Skip to main content

hatchery_node/
server.rs

1use crate::git_worktree::{
2    create_worktree as create_git_worktree,
3    create_worktree_with_timeout as create_git_worktree_with_timeout,
4    list_worktrees as list_git_worktrees,
5    list_worktrees_with_deadline as list_git_worktrees_with_deadline,
6    paths_equal as worktree_paths_equal, remove_worktree as remove_git_worktree,
7    removal_lookup_path as normalize_worktree_removal_target,
8    resolve_base_commit_with_timeout,
9    run_git_read_bounded, GitCommandOutput, GitWorktreeError, GitWorktreeErrorKind,
10    NativeGitWorktreeSnapshot,
11};
12use crate::host_directory_browser::{
13    browse_host_directories, HostDirectoryBrowseError, HostDirectoryBrowseErrorKind,
14};
15use crate::environment_profiles::{
16    EnvironmentProfileBinding, NodeEnvironmentProfile, MAX_NODE_ENVIRONMENT_PROFILES,
17};
18use crate::bundle_catalog::{BundleCatalog, NodeBundle};
19use self::bundle_delivery::{DeliveryStore, DeliveryStoreError};
20use self::context_pack_store::ContextPackStore;
21use crate::bundle_provider::{
22    bundle_launch_arguments, validate_bundle_binding, BundleProviderLayout,
23};
24use crate::context_pack::{
25    ContextPackCatalog, ContextPackRepository, ContextPackRepositoryFileSource,
26    ContextPackSelectedFileSkipReason, NodeContextPack, CONTEXT_PACK_SELECTED_FILES,
27};
28use crate::harness_mcp_proxy::{
29    HarnessMcpProxyError, HarnessMcpProxyRegistry, PreparedHarnessMcpSpawn,
30    ReviewedHarnessMcpProgram,
31};
32use crate::session_environment::{
33    MaterializationId, MaterializationOwner, MaterializationOwnershipRecord,
34    MaterializationState, NodeSecretResolver, NodeSessionMaterializationProfile,
35    SessionEnvironmentMaterializeError, SessionEnvironmentMaterializer,
36};
37use crate::worktree_service::{
38    exact_created_worktree, exact_owned_worktree, ManagedWorktreeLeaseRecord,
39    ManagedWorktreeProfile, ManagedWorktreeRegistry, ManagedWorktreeSessionHolder,
40};
41use crate::session_registry::{
42    self, validate_display_name, LoadedNodeState, ManagedWorktreeSpawnReplayRecordV10,
43    ManagedWorktreeSpawnReplayStateV10, MAX_MANAGED_SESSION_RECORDS,
44    MAX_MANAGED_WORKTREE_SPAWN_REPLAYS,
45};
46#[cfg(unix)]
47use crate::workspace_file_unix::{
48    create_workspace_directory as create_workspace_directory_on_disk,
49    create_workspace_file as create_workspace_file_on_disk,
50    read_workspace_file as read_workspace_file_from_disk,
51    write_workspace_file as write_workspace_file_to_disk, WorkspaceFileBytes,
52    WorkspaceFileReadError, WorkspaceFileReadErrorKind,
53    WORKSPACE_ENTRY_CREATE_CANCELED, WORKSPACE_ENTRY_CREATE_COMMITTING,
54    WORKSPACE_ENTRY_CREATE_PENDING,
55};
56#[cfg(windows)]
57use crate::workspace_file_windows::{
58    create_workspace_directory as create_workspace_directory_on_disk,
59    create_workspace_file as create_workspace_file_on_disk,
60    read_workspace_file as read_workspace_file_from_disk,
61    write_workspace_file as write_workspace_file_to_disk, WorkspaceFileBytes,
62    WorkspaceFileReadError, WorkspaceFileReadErrorKind,
63    WORKSPACE_ENTRY_CREATE_CANCELED, WORKSPACE_ENTRY_CREATE_COMMITTING,
64    WORKSPACE_ENTRY_CREATE_PENDING,
65};
66use crate::platform;
67use crate::provider_runtime::{
68    require_policy, ProviderRuntimeAdmissionError, ProviderRuntimeMonitor,
69    ProviderRuntimeRequirement,
70};
71use crate::spawn_spec::SpawnProfileRegistry;
72use crate::standalone_workspace::{
73    prepare_standalone_workspace, StandaloneWorkspaceError, StandaloneWorkspaceErrorKind,
74};
75use crate::protocol::{
76    read_json_frame_limited_body_timeout, write_json_frame, write_json_frame_limited,
77    validate_node_negotiated_handshake_capacity, validate_provider_contract_manifest,
78    provider_id_is_legacy, AdapterContractRevision, AgentStreamChunkKindV1, AgentStreamChunkV1,
79    AgentStreamInteractionOptionV1, AgentStreamNamedIdV1,
80    CapabilityId, ClientFrame, ClientRole,
81    ControllerState, DeliveryBlobChunkHexV1, DeliveryBlobDigestV1,
82    DeliveryBundleManifestV2, DeliveryCommitReceiptV1, DeliveryStageId,
83    AgentProgressAttentionKindV1, AgentProgressAttentionV1, AgentProgressCurrentV1,
84    AgentProgressEventKindV1, AgentProgressUsageV1, AgentProgressV1, FrameError,
85    GitCommitDetails, GitDiff, GitDiffMode, GitDiffRequest, GitHistoryPage,
86    GitObjectId, GitCommitSummary, GitSignatureStatus, GitSnapshot, GitStatusEntry,
87    GitWorktreeSnapshot, HarnessMcpActivationDigest, HarnessMcpReservationId,
88    HostDirectoryListing,
89    NodeCompatibilitySupport, NodeEvent,
90    NodeEventEnvelope, NodeFailure, NodeFailureCode, NodeHello, NodeId, NodeIncarnationId,
91    NegotiatedNodeCompatibility, NodeRequest, NodeResponse, NodeSnapshot, OpaqueHostPath,
92    ProtocolRange,
93    ProviderAdapterContractSupport,
94    ProviderContractRevision, ProviderContractSupport, RepositoryPath, RequestEnvelope,
95    ProviderRuntimeStatuses, ResolvedBundleReceipt, ResolvedEnvironmentProfileReceipt,
96    ContextPackLineageReceipt, ContextPackBytesRead, ResolvedContextPackReceipt,
97    ResolvedHarnessMcpProxyReceiptV1, ResolvedSpawnReceipt,
98    ResolvedSpawnSpec, ResponseEnvelope,
99    ServerChallenge, ServerFrame, SessionAddress, SessionAgentProgress, SessionKey, SessionMode,
100    ManagedSessionRecord,
101    ManagedSessionState, ManagedWorktreeCleanupFailure, ManagedWorktreeLeaseId,
102    SessionRecordRetentionConfig,
103    ManagedWorktreeLeaseSnapshot, ManagedWorktreeLeaseState, ManagedWorktreeRetention,
104    ManagedWorktreeSpawnReceipt, ManagedWorktreeSpawnRequest,
105    ManagedWorktreeSpawnRequestV2, SessionRecordId,
106    NativeSessionCatalogRoute, NativeSessionSelection, SessionTaskBindingV1,
107    SessionTaskTargetV1, TaskId,
108    BlockAuthorityV1, HarnessMcpLaunchV1, SessionHistorySummaryV1, MAX_ACP_BLOCKED_HELP_BYTES, MAX_ACP_BLOCKED_REASON_BYTES,
109    StateSchemaSupport, WorkspaceEntry, WorkspaceEntryKind, WorktreeProfileId,
110    SpawnContextId, SpawnEnvironmentProfileId, SpawnIdempotencyKey,
111    SpawnProfileDefaults, SpawnRequiredCapabilities, SpawnSpec, SpawnSpecResolveError,
112    WorkspaceFileContent, WorkspaceFileRevision,
113    WorkspaceFileRead, WorkspaceId, WorkspaceInspection, WorkspaceInspectionTruncationV1,
114    WorkspaceSnapshot,
115    WorktreeServiceMode,
116    LaunchInventory, ManagedWorktreeProfileSummary, SpawnProfileSummary,
117    WorktreeProfileInventory, MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
118    DEFAULT_CONTROLLER_LEASE_MS,
119    MAX_CONTROLLER_LEASE_MS, MIN_CONTROLLER_LEASE_MS, NODE_COMPATIBILITY_METADATA_CAPABILITY,
120    CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
121    NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY, NODE_REPOSITORY_PATH_CAPABILITY,
122    NODE_PROVIDER_ID_OPEN_CAPABILITY,
123    NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
124    NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY, NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
125    NODE_HISTORY_CONTEXT_PACK_CAPABILITY, NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
126    NODE_NATIVE_SESSION_CATALOG_CAPABILITY, NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
127    NODE_NATIVE_SESSION_INDEX_CAPABILITY, NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
128    NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
129    NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY,
130    NODE_HARNESS_MCP_READ_PROXY_CAPABILITY,
131    NODE_SESSION_TASK_CORRELATION_CAPABILITY,
132    NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY,
133    NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
134    NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
135    NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
136    NODE_SPAWN_PROFILE_REVISION_CAPABILITY, NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
137    NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
138    NODE_AGENT_STREAM_EVENTS_CAPABILITY, NODE_ACP_CONTROL_CAPABILITY,
139    NODE_GIT_READ_CAPABILITY, NODE_WORKSPACE_FILE_READ_CAPABILITY,
140    NODE_WORKSPACE_FILE_WRITE_CAPABILITY, NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
141    NODE_WORKTREE_SELECTION_CAPABILITY,
142    BUILD_STAMP, MAX_NODE_CLIENT_FRAME_BYTES, MAX_NODE_HELLO_FRAME_BYTES,
143    MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS, MAX_AGENT_PROGRESS_ENTRY_BYTES,
144    MAX_NODE_TERMINAL_BYTES, MAX_NODE_TEXT_BYTES,
145    MAX_REPOSITORY_PATH_BYTES,
146    MAX_GIT_DIFF_BYTES, MAX_GIT_HISTORY_COMMITS, MAX_WORKSPACE_FILE_BYTES,
147    MAX_HARNESS_MCP_PENDING_CALLS_PER_NODE,
148    MAX_WORKSPACE_ROOT_BYTES, NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10,
149    SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY, SPAWN_RUNTIME_RAW_PTY_LIFECYCLE,
150    SPAWN_RUNTIME_SEMANTIC_READINESS, SPAWN_RUNTIME_SEMANTIC_RESUME,
151    SPAWN_RUNTIME_STRUCTURED_PROMPT,
152};
153#[cfg(test)]
154use crate::protocol::WorktreeProfileRevision;
155use ring::digest::{digest, SHA256};
156use crate::protocol::correlation::{
157    interaction_correlation, sanitize_progress_tool_label, tool_class_label, tool_correlation,
158    truncate_text,
159};
160use gate4agent_catalog::{builtin_registry, AgentRegistry};
161use gate4agent_handle::{EventSubscription, Gate4AgentHandle, PortDispatchError};
162use gate4agent_runtime_native::{
163    NativeMcpServerLaunchOverlay, NativeInstanceLaunchOverlay,
164    NativeLaunchEnvironmentOverlay,
165    NativeHistoryConfig, NativeLaunchProfileControl, NativeRuntime, NativeRuntimeConfig,
166    NativeSessionCatalogAuthority, NativeSessionCatalogError, NativeSessionPreviewError,
167    ScopedNativeSessionCatalogEntry,
168};
169use gate4agent_runtime_native::shell_efficiency::ShellEfficiencyProfile;
170use std::sync::OnceLock;
171use gate4agent_runtime_native::tick_profile::TickProfileSnapshot;
172use drive_loop_profile::DriveLoopProfiler;
173#[cfg(test)]
174use gate4agent_runtime_native::{HistorySourceLayout, NativeHistoryRoot};
175use gate4agent_node_wire::{
176    auth_proof, negotiated_auth_proof, proofs_match, random_incarnation_id, random_nonce,
177    AuthDirection, OwnerOnlyLocalListener,
178};
179use gate4agent_types::{
180    AdapterBinding, AdapterFamily, AgentAdapterCapabilities, AgentCapabilities, AgentId,
181    AgentInstanceId, AgentReadinessSpec, AgentSpec, AgentTransportCapabilities, ApprovalLevel,
182    DetectionSpec, InitialPromptMode, LaunchSpec, ProcessMatcher, PromptSpec, SpecVerification,
183    CommandEnvelope, CommandId,
184    validate_candidate_id, ControlCommand, ControlEvent, ControlEventKind,
185    HistoryCandidateSummary, HistoryOperation, HistoryQuery, HistorySessionRecord,
186    HostDecisionAuthority as ProviderHostDecisionAuthority,
187    HostRequestDecision as ProviderHostRequestDecision,
188    InputAction,
189    PromptFraming, PromptPayload, ResumeLaunchRequest,
190    ProviderActivity, ProviderEvent, ProviderInteractionKind, ProviderInteractionStatus,
191    ProviderRuntimeCapability, ProviderRuntimePolicy, ProviderSessionIdentity, ProviderSessionKey,
192    ProviderSnapshot, ProviderStopReason,
193    ResumeTarget,
194    SessionGeneration, StartRequest,
195    TerminalControl, TerminalText,
196    SessionStatus, TerminalFrame, TransportKind, CONTROL_SESSIONS_MAX,
197    HISTORY_DISCOVERY_LIMIT_MAX, WORKING_DIRECTORY_MAX_BYTES,
198};
199use std::collections::{BTreeMap, VecDeque};
200use std::ffi::OsString;
201use std::io;
202use std::path::{Path, PathBuf};
203use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, AtomicUsize, Ordering};
204use std::sync::{Arc, Mutex, RwLock};
205use std::time::{Duration, Instant};
206use thiserror::Error;
207use tokio::sync::{broadcast, mpsc, Mutex as AsyncMutex, Notify, OwnedSemaphorePermit, Semaphore};
208use tokio::task::{AbortHandle, JoinSet};
209use tokio::time::{sleep, timeout};
210
211mod http_api;
212pub mod node_wake;
213
214mod browser_bridge;
215
216use gate4agent_node_core::drive_loop_profile;
217
218#[path = "bundle_delivery.rs"]
219mod bundle_delivery;
220
221#[path = "context_pack_store.rs"]
222mod context_pack_store;
223
224#[cfg(windows)]
225pub use crate::platform::DEFAULT_NODE_ENDPOINT;
226
227const NODE_EVENT_HISTORY_MAX: usize = 4_096;
228const NODE_BROADCAST_CAPACITY: usize = 1_024;
229const NODE_TERMINAL_BROADCAST_CAPACITY: usize = 1;
230/// Unlike `NODE_TERMINAL_BROADCAST_CAPACITY`'s single replaceable slot (a
231/// terminal frame is a full-screen snapshot; only the latest ever matters),
232/// an `AgentStreamChunkV1` is one delta in an ordered sequence -- collapsing
233/// two pending sends into one would silently drop content a subscriber can
234/// never recover. This needs headroom for a burst instead.
235const NODE_AGENT_STREAM_BROADCAST_CAPACITY: usize = 256;
236/// The node never has more than `MAX_HARNESS_MCP_PENDING_CALLS_PER_NODE`
237/// `NodeEvent::HarnessMcpReadCall`s in flight at once (`begin_call`'s own
238/// admission check), and `publish_transient` sends each exactly once as a
239/// single-envelope batch -- so this many slots covers the entire pending
240/// set without ever lagging the one subscriber that matters, the harness's
241/// own connection, the way sharing the durable `event_tx`'s bus could.
242const NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY: usize = MAX_HARNESS_MCP_PENDING_CALLS_PER_NODE;
243const NODE_CONNECTION_EVENT_BURST_MAX: usize = 16;
244const CONTROL_EVENT_SUBSCRIPTION_CAPACITY: usize = 1_024;
245const MAX_PREAUTH_CONNECTIONS: usize = 32;
246const MAX_AUTHENTICATED_CONNECTIONS: usize = 16;
247const AUTH_FRAME_TIMEOUT_MS: u64 = 5_000;
248const FRAME_BODY_TIMEOUT_MS: u64 = 5_000;
249const CONNECTION_SHUTDOWN_GRACE_MS: u64 = 250;
250const SPAWN_DISPATCH_TIMEOUT_MS: u64 = 2_000;
251/// Fresh Spawn stays provisional until HQ successfully attaches (first Resize /
252/// TerminalBytes). This is an *attach-never-arrived* timeout from spawn time —
253/// not the disconnect reconnect grace (HQ owns that, starting only on a real
254/// client WS close). A provider conversation id is a session; a PTY with no
255/// attached HQ view is still a draft and is reaped here if attach never lands.
256const PROVISIONAL_SPAWN_GRACE_MS: u64 = 30_000;
257/// How often `drive_runtime_until_shutdown`'s loop re-evaluates the session
258/// record retention policy (`NodeServerConfig::session_record_retention`).
259/// A sweep cadence, not a policy value -- the policy itself lives entirely
260/// in the operator-configured age/keep-N flags; this just bounds how stale
261/// the in-memory view of dead `Unavailable` records can get between sweeps
262/// on an otherwise-idle node, mirroring the once-per-startup sweep that
263/// also runs alongside `reconcile_managed_worktrees`.
264const SESSION_RECORD_RETENTION_SWEEP_PERIOD: Duration = Duration::from_secs(5 * 60);
265/// TTL for `NodeShared::rejected_command_reasons` entries -- see that
266/// field's own doc for why a spawn waiter needs this at all. Must outlive
267/// the longest a waiter can still be polling for this exact command's
268/// outcome: `gate4agent_node_protocol::MAX_SPAWN_DEADLINE_MS` (120s) is the
269/// largest `deadline_ms` any caller may request, and the margin on top
270/// absorbs the waiter's own poll cadence plus clock skew between the drive
271/// loop (writer) and the waiter (reader).
272const REJECTED_COMMAND_REASON_TTL_MS: u64 = 150_000;
273/// Bounds `NodeShared::rejected_command_reasons` against an unbounded
274/// backlog of asynchronously-rejected commands nobody ever collects (a
275/// waiter that stops polling, or a burst of always-rejected commands from a
276/// misbehaving caller): once at capacity, the oldest entry is evicted to
277/// make room for the newest, mirroring the eviction rule already used by
278/// `spawn_idempotency`.
279const REJECTED_COMMAND_REASON_MAX_ENTRIES: usize = 256;
280/// How long `wait_until_removed` waits between two `Remove` re-dispatches
281/// for the same session. The presence poll runs far faster (2ms); this
282/// only paces the sending, so an accepted `Remove` still in flight is not
283/// piled on by a dozen more that land on an already-dropped instance.
284const REMOVE_REDISPATCH_INTERVAL_MS: u64 = 100;
285const SPAWN_IDEMPOTENCY_MAX_ENTRIES: usize = 256;
286const SPAWN_IDEMPOTENCY_TTL_MS: u64 = 15 * 60 * 1_000;
287const PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS: u64 = 2_500;
288const MUTATION_SETTLE_TIMEOUT_MS: u64 = 5_000;
289const READINESS_SETTLE_HEADROOM_MS: u64 = 2_000;
290const MANAGED_RESUME_SETTLE_TIMEOUT_MS: u64 = 30_000;
291const WORKSPACE_INSPECTION_MAX_CONCURRENCY: usize = 4;
292const WORKSPACE_FILE_READ_TIMEOUT_MS: u64 = 2_000;
293const HOST_DIRECTORY_BROWSE_TIMEOUT_MS: u64 = 2_000;
294const NATIVE_SESSION_CATALOG_TIMEOUT_MS: u64 = 30_000;
295const WORKSPACE_TREE_MAX_DEPTH: usize = 6;
296const WORKSPACE_TREE_MAX_ENTRIES: usize = 512;
297/// Default total wall-clock budget for `inspect_workspace`'s walk+git
298/// chain, overridable via `GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS`.
299const WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT: u64 = 8_000;
300/// The harness relay enforces its own flat response deadline
301/// (`HOST_RUN_READ_RESPONSE_DEADLINE`, `hatchery-harness-service::runtime`,
302/// 12s) above this node-side budget. The two constants live in different
303/// crates with no shared definition, so clamp any override here comfortably
304/// below that external ceiling — a misconfigured value above it would
305/// reopen the exact deadline race this budget exists to close.
306const WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX: u64 = 11_000;
307/// Default cap on directory entries visited (not just returned) by one
308/// walk, overridable via `GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP`.
309const WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT: usize = 50_000;
310const WORKSPACE_INSPECTION_ENTRY_CAP_MAX: usize = 1_000_000;
311const GIT_STATUS_MAX_ENTRIES: usize = 128;
312const GIT_COMMIT_MAX_ENTRIES: usize = 12;
313const GIT_OUTPUT_MAX_BYTES: usize = 64 * 1_024;
314const GIT_DIAGNOSTIC_MAX_BYTES: usize = 1_024;
315const WORKSPACE_FILE_WRITE_TIMEOUT_MS: u64 = 5_000;
316const WORKSPACE_ENTRY_CREATE_TIMEOUT_MS: u64 = 5_000;
317const GIT_COMMAND_TIMEOUT_MS: u64 = 1_500;
318const WORKSPACE_UNAVAILABLE_ERROR: &str = "workspace-unavailable";
319const PROVIDER_SESSION_SCOPE_CONFLICT_ERROR: &str = "provider-session-scope-conflict";
320const PROVIDER_SESSION_LIVE_CONFLICT_ERROR: &str = "provider-session-live-conflict";
321const MANAGED_SESSION_CAPACITY_ERROR: &str = "managed-session-capacity";
322const PROVIDER_IDENTITY_ALLOCATION_ERROR: &str =
323    "provider-session-identity-allocation-failed";
324const PROVIDER_RESUME_REJECTED_ERROR: &str = "provider-resume-rejected";
325const DURABLE_STATE_COMMIT_FAILED_ERROR: &str = "durable-state-commit-failed";
326const DURABLE_STATE_LOCK_FAILED_ERROR: &str = "durable-state-lock-failed";
327const DURABLE_STATE_LOAD_FAILED_ERROR: &str = "durable-state-load-failed";
328const DURABLE_STATE_CONFLICT_ERROR: &str = "durable-state-conflict";
329const DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR: &str = "durable-state-schema-unsupported";
330const DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR: &str =
331    "durable-state-path-semantics-unsupported";
332
333fn opaque_windows_path(value: String) -> OpaqueHostPath {
334    OpaqueHostPath::utf8(value).expect("validated Windows node path must remain wire-safe")
335}
336
337fn agent_progress_from_provider_snapshot(
338    address: SessionAddress,
339    provider: &ProviderSnapshot,
340) -> Option<SessionAgentProgress> {
341    let mut truncated = false;
342    let active_tool_count = bounded_progress_count(provider.active_tools.len(), &mut truncated);
343    let mut active_tool_labels = Vec::with_capacity(
344        provider
345            .active_tools
346            .len()
347            .min(MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS),
348    );
349    for tool in &provider.active_tools {
350        if active_tool_labels.len() == MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS {
351            truncated = true;
352            break;
353        }
354        if let Some(label) = sanitize_progress_tool_label(&tool.name, &mut truncated) {
355            active_tool_labels.push(label);
356        }
357    }
358    let attention = provider
359        .interactions
360        .iter()
361        .find(|interaction| {
362            matches!(
363                interaction.status,
364                ProviderInteractionStatus::Pending | ProviderInteractionStatus::Resolving { .. }
365            )
366        })
367        .map(|interaction| AgentProgressAttentionV1 {
368            kind: match interaction.interaction_kind {
369                ProviderInteractionKind::Approval => AgentProgressAttentionKindV1::Approval,
370                ProviderInteractionKind::Question => AgentProgressAttentionKindV1::Question,
371            },
372            tool_label: sanitize_progress_tool_label(&interaction.tool_name, &mut truncated),
373        });
374    let subagent_count = bounded_progress_count(provider.subagents.len(), &mut truncated);
375    let usage = (provider.completed_turns > 0
376        || provider.usage.input_tokens > 0
377        || provider.usage.output_tokens > 0
378        || provider.usage.cache_read_tokens > 0
379        || provider.usage.cache_write_tokens > 0
380        || provider.usage.reasoning_tokens > 0)
381        .then_some(AgentProgressUsageV1 {
382            input_tokens: provider.usage.input_tokens,
383            output_tokens: provider.usage.output_tokens,
384            cache_read_tokens: provider.usage.cache_read_tokens,
385            cache_write_tokens: provider.usage.cache_write_tokens,
386            reasoning_tokens: provider.usage.reasoning_tokens,
387        });
388    let progress = AgentProgressV1 {
389        provider_sequence: provider.sequence,
390        activity: provider.activity,
391        completed_turns: provider.completed_turns,
392        usage,
393        current: AgentProgressCurrentV1::from(provider.activity),
394        active_tool_labels,
395        active_tool_count,
396        attention,
397        subagent_count,
398        last_event_kind: provider.last_event.as_ref().and_then(agent_progress_event_kind),
399        gap_count: provider.gap_count,
400        stale: provider.stale,
401        truncated,
402    };
403    let entry = SessionAgentProgress { address, progress };
404    serde_json::to_vec(&entry)
405        .ok()
406        .filter(|encoded| encoded.len() <= MAX_AGENT_PROGRESS_ENTRY_BYTES)
407        .map(|_| entry)
408}
409
410fn bounded_progress_count(count: usize, truncated: &mut bool) -> u32 {
411    match u32::try_from(count) {
412        Ok(count) => count,
413        Err(_) => {
414            *truncated = true;
415            u32::MAX
416        }
417    }
418}
419
420fn agent_progress_event_kind(event: &ProviderEvent) -> Option<AgentProgressEventKindV1> {
421    if matches!(event, ProviderEvent::ContextWindowUsage { .. }) {
422        return None;
423    }
424    Some(match event {
425        ProviderEvent::SessionStarted { .. } => AgentProgressEventKindV1::SessionStarted,
426        ProviderEvent::SessionIdentityObserved { .. } => {
427            AgentProgressEventKindV1::SessionIdentityObserved
428        }
429        ProviderEvent::TurnStarted { .. } => AgentProgressEventKindV1::TurnStarted,
430        ProviderEvent::WorkingObserved => AgentProgressEventKindV1::WorkingObserved,
431        ProviderEvent::Text { .. } => AgentProgressEventKindV1::Text,
432        ProviderEvent::Thinking { .. } => AgentProgressEventKindV1::Thinking,
433        ProviderEvent::ToolStarted { .. } => AgentProgressEventKindV1::ToolStarted,
434        ProviderEvent::ToolCompleted { .. } => AgentProgressEventKindV1::ToolCompleted,
435        ProviderEvent::TurnCompleted { .. } => AgentProgressEventKindV1::TurnCompleted,
436        ProviderEvent::TurnInterrupted => AgentProgressEventKindV1::TurnInterrupted,
437        ProviderEvent::SessionEnded { .. } => AgentProgressEventKindV1::SessionEnded,
438        ProviderEvent::Error { .. } => AgentProgressEventKindV1::Error,
439        ProviderEvent::Ready => AgentProgressEventKindV1::Ready,
440        ProviderEvent::InteractionRequested { .. } => {
441            AgentProgressEventKindV1::InteractionRequested
442        }
443        ProviderEvent::InteractionResolved { .. } => AgentProgressEventKindV1::InteractionResolved,
444        ProviderEvent::SubagentStarted { .. } => AgentProgressEventKindV1::SubagentStarted,
445        ProviderEvent::SubagentStopped { .. } => AgentProgressEventKindV1::SubagentStopped,
446        ProviderEvent::RateLimited { .. } => AgentProgressEventKindV1::RateLimited,
447        ProviderEvent::HostRequestObserved { .. } => AgentProgressEventKindV1::HostRequestObserved,
448        ProviderEvent::UnrecognizedNotification { .. } => {
449            AgentProgressEventKindV1::UnrecognizedNotification
450        }
451        ProviderEvent::ContextWindowUsage { .. } => unreachable!("handled above"),
452        // ACP session/update coverage beyond text/tool/turn streaming
453        // (`plan`, `available_commands_update`, `current_mode_update`,
454        // `session_info_update`, `usage_update`, `config_option_update`,
455        // `user_message_chunk`). `AgentProgressEventKindV1` is a versioned
456        // wire enum without a `#[serde(other)]` fallback; minting new
457        // variants for it is a deliberate wire-contract decision for
458        // whoever owns this protocol, not a side effect of parsing more of
459        // ACP's own wire -- until that decision is made, these fold into
460        // the same "we saw something, no dedicated kind for it yet" bucket
461        // as `UnrecognizedNotification`.
462        ProviderEvent::UserMessage { .. }
463        | ProviderEvent::Plan { .. }
464        | ProviderEvent::AvailableCommandsUpdated { .. }
465        | ProviderEvent::ModeChanged { .. }
466        | ProviderEvent::SessionInfoUpdated { .. }
467        | ProviderEvent::UsageUpdated { .. }
468        | ProviderEvent::ConfigOptionsUpdated { .. } => {
469            AgentProgressEventKindV1::UnrecognizedNotification
470        }
471    })
472}
473
474/// Maps this harness's own `HostDecisionAuthority` onto the wider
475/// Maps a host request decider onto the `BlockAuthorityV1` a `Blocked` stream
476/// chunk carries -- the four authorities a `Denied` decision is ever made
477/// under, one-for-one. The remaining `BlockAuthorityV1` variants
478/// (`ProviderClassifier`, `ProviderPermissionRule`, `ProviderSandbox`,
479/// `ProviderRefusal`, `UserRejected`, `ProviderQuota`, `Unknown`) describe a
480/// block the PROVIDER side of the wire reported -- this function never
481/// produces one, because `ProviderHostDecisionAuthority` has no vocabulary for
482/// them; the provider-side blocks below mint those directly from typed fields.
483/// Whether a provider source is a live session transport -- the only kind
484/// whose events reach the agent stream. History, resume, session-option and
485/// capability-probe sources are not sessions and stream nothing.
486fn provider_family_is_live(family: AdapterFamily) -> bool {
487    match family {
488        AdapterFamily::PtySemantic
489        | AdapterFamily::Hook
490        | AdapterFamily::ManagedHook
491        | AdapterFamily::Pipe
492        | AdapterFamily::OneShot
493        | AdapterFamily::Acp => true,
494        AdapterFamily::History
495        | AdapterFamily::Resume
496        | AdapterFamily::SessionOptions
497        | AdapterFamily::CapabilityProbe => false,
498    }
499}
500
501fn block_authority(by: ProviderHostDecisionAuthority) -> BlockAuthorityV1 {
502    match by {
503        ProviderHostDecisionAuthority::Gate => BlockAuthorityV1::HostGate,
504        ProviderHostDecisionAuthority::Policy => BlockAuthorityV1::HostPolicy,
505        ProviderHostDecisionAuthority::Operator => BlockAuthorityV1::Operator,
506        ProviderHostDecisionAuthority::DeadlinePolicy => BlockAuthorityV1::HostDeadline,
507    }
508}
509
510/// The `Blocked::reason` for a `Denied` decision this build's own
511/// `AgentEvent::RpcIncomingRequest::reason` did not attach text to --
512/// reachable today for `Operator`/`HostDeadline`: a deferred `session/
513/// request_permission` request resolved by `AcpSession::
514/// resolve_pending_request`/`expire_deadlines` (`src/acp/session.rs`),
515/// neither of which computes free text (an operator answers approve/reject,
516/// never a comment; an unattended deadline asks nobody anything). `reason`
517/// is a REQUIRED field on the wire (see `AgentStreamChunkKindV1::Blocked`)
518/// -- a categorical fallback naming the authority is the
519/// honest minimum, never a placeholder standing in for detail nobody
520/// reported.
521fn action_blocked_fallback_reason(authority: BlockAuthorityV1) -> &'static str {
522    match authority {
523        BlockAuthorityV1::HostGate => "denied by the dangerous-command gate",
524        BlockAuthorityV1::HostPolicy => "denied by host policy",
525        BlockAuthorityV1::HostDeadline => {
526            "denied by host policy after the operator deadline expired"
527        }
528        BlockAuthorityV1::Operator => "declined by the operator",
529        BlockAuthorityV1::ProviderClassifier => "denied by the provider's classifier",
530        BlockAuthorityV1::ProviderPermissionRule => "denied by a provider permission rule",
531        BlockAuthorityV1::ProviderSandbox => "denied by the provider's sandbox",
532        BlockAuthorityV1::ProviderRefusal => "refused by the provider",
533        BlockAuthorityV1::UserRejected => "rejected by the user",
534        BlockAuthorityV1::ProviderQuota => "the provider's quota or rate limit was exhausted",
535        BlockAuthorityV1::Unknown => "blocked; no reason was reported",
536    }
537}
538
539/// Computes `AgentStreamChunkKindV1::Blocked`'s `tool_class`/`authority`/`reason`
540/// for a `HostRequestObserved` `Denied` decision.
541fn host_request_denied_block(
542    method: &str,
543    by: ProviderHostDecisionAuthority,
544    reason: Option<&str>,
545) -> (String, BlockAuthorityV1, String) {
546    let authority = block_authority(by);
547    let reason_source = reason.unwrap_or_else(|| action_blocked_fallback_reason(authority));
548    // `AgentStreamChunkKindV1::validate` REJECTS an over-long reason outright
549    // rather than truncating -- this cut happens here, at the producer, before
550    // the validator ever sees the text.
551    let (reason_text, _) =
552        truncate_text(reason_source, MAX_ACP_BLOCKED_REASON_BYTES);
553    (tool_class_label(method), authority, reason_text)
554}
555
556// ---------------------------------------------------------------------------
557// K2 -- provider-side blocks read off ACP `tool_call_update._meta.
558// nonExecutionKind` (`ProviderEvent::ToolCompleted::non_execution_kind`) and
559// `SessionEnded::stop_reason` (`stopReason: "refusal"`, or a `session/
560// prompt` RPC error whose vendor code names a quota/limit/rate
561// exhaustion). See
562// `docs/gate4agent/plans/gate4agent-blocked-action-event-2026-09-02.md`
563// §3 slice K2.
564// ---------------------------------------------------------------------------
565
566/// Classifies ACP's `nonExecutionKind` into the typed authority a block
567/// this build mints under, or `None` when the tool truly did not run for a
568/// reason that is NOT a block -- `"interrupted"`/`"cancelled"` mean the
569/// TURN stopped (session closed, the operator cancelled), not that a
570/// policy/classifier/rule refused the call, so those (and any unrecognized
571/// future value) stay a plain `ToolCompleted { success: false }` only, per
572/// this plan's own rule that a tool failure is never inflated into a block
573/// this build cannot actually attribute.
574fn provider_tool_block_authority(kind: Option<&str>) -> Option<(BlockAuthorityV1, &'static str)> {
575    match kind? {
576        "user-rejected" => Some((BlockAuthorityV1::UserRejected, "user-rejected")),
577        "permission-rule" => Some((BlockAuthorityV1::ProviderPermissionRule, "permission-rule")),
578        _ => None,
579    }
580}
581
582/// Splits a provider's own denial sentence into `reason`/`help`, when it
583/// matches the fixed shape Claude Code's classifier/permission-rule denial
584/// uses -- verified live, the owner's sample fixture
585/// (`docs/gate4agent/research/gate4agent-blocked-action-signals-2026-09-02.md`
586/// appendix): "...denied by <authority>. Reason: <reason>. <help...>".
587/// `authority` is NEVER derived from this text (see this module's own rule
588/// that authority comes only from a typed field) -- only `reason`/`help`
589/// are. Falls back to the full text as `reason` with no `help` when the
590/// shape does not match (a provider whose text does not carry this exact
591/// wording) -- the honest minimum: the tool truly did not run, so SOME
592/// reason is always reported, never fabricated detail beyond what arrived.
593fn split_provider_denial_text(text: &str) -> (String, Option<String>) {
594    let trimmed = text.trim();
595    let Some((_, after_reason)) = trimmed.split_once("Reason:") else {
596        return (trimmed.to_owned(), None);
597    };
598    let tail = after_reason.trim_start();
599    let Some((reason, help)) = tail.split_once(". ") else {
600        // "Reason:" present but no ". " to split help off -- the whole
601        // tail is the reason, e.g. "Reason: Blocked by classifier." with
602        // no help text following on some providers/versions.
603        let reason = tail.strip_suffix('.').unwrap_or(tail).trim();
604        return (reason.to_owned(), None);
605    };
606    let help = help.trim();
607    if help.is_empty() {
608        (reason.trim().to_owned(), None)
609    } else {
610        (reason.trim().to_owned(), Some(help.to_owned()))
611    }
612}
613
614/// `tool_class` for a provider-side `ActionBlocked` this build has no typed
615/// tool name for -- `ProviderEvent::ToolCompleted` carries only `id`, never
616/// `name` (see `ProviderEvent::ToolStarted`, which does, but a tool's own
617/// name is never threaded through to its completion). Reads the class off
618/// the guidance tail's own "add a <Class> permission rule" sentence when
619/// present (the shape the owner's sample fixture uses) -- text matching
620/// fills `tool_class` here, same as `reason`/`help`, NEVER `authority` (see
621/// this module's own rule). Falls back to the same generic `"Tool"` class
622/// `ToolCompleted`'s own observation already uses when nothing names one.
623fn tool_class_from_denial_help(help: Option<&str>) -> String {
624    help.and_then(|text| text.split("add a ").nth(1))
625        .and_then(|tail| tail.split(' ').next())
626        .filter(|word| !word.is_empty())
627        .map(str::to_owned)
628        .unwrap_or_else(|| "Tool".to_owned())
629}
630
631/// Whether a `session/prompt` RPC error's vendor code (or, absent one, its
632/// message) names a quota/rate-limit exhaustion -- Codex's
633/// `data.codexErrorInfo: "usageLimitExceeded"` (measured live 2026-09-05)
634/// contains "limit", the word this build actually keys on; "quota"/"rate"
635/// are kept for whichever OTHER vendor's own code names one of those
636/// instead, never confirmed live, but the honest generalization of the
637/// one confirmed sample.
638fn provider_error_is_quota(vendor_code: Option<&str>, message: &str) -> bool {
639    fn names_quota(s: &str) -> bool {
640        let lower = s.to_ascii_lowercase();
641        lower.contains("limit") || lower.contains("quota") || lower.contains("rate")
642    }
643    vendor_code.is_some_and(names_quota) || names_quota(message)
644}
645
646/// The `Blocked` chunk this build mints when a turn stopped via a
647/// typed provider fact rather than a tool call -- `stopReason: "refusal"`
648/// (`authority: ProviderRefusal`) or a `session/prompt` RPC error whose
649/// vendor code (or message) names a quota/limit/rate exhaustion
650/// (`authority: ProviderQuota`, `reason` = the error message verbatim,
651/// `reason_kind` = the vendor code). `None` for every other stop --
652/// `end_turn`, `max_tokens`, `max_turn_requests`, `cancelled`, an untagged
653/// `Other`, or an RPC error whose vendor code/message names none of the
654/// three words: `SessionEnded` stays a plain `Exited`/turn-end fact for
655/// those, never inflated into a block this build cannot actually
656/// attribute. `ProviderRefusal`'s `reason` has no source text to read on
657/// `SessionEnded` itself (ACP's `refusal` stopReason carries none) -- the
658/// honest minimum names the fact that happened, never invented detail; a
659/// later slice that threads the turn's last agent text chunk through can
660/// replace it without changing this function's contract.
661fn provider_session_end_block(
662    stop_reason: &ProviderStopReason,
663) -> Option<(BlockAuthorityV1, String, String)> {
664    match stop_reason {
665        ProviderStopReason::Refusal => Some((
666            BlockAuthorityV1::ProviderRefusal,
667            "refusal".to_owned(),
668            "the provider ended the turn with stopReason: refusal".to_owned(),
669        )),
670        ProviderStopReason::ProviderError { message, vendor_code, .. }
671            if provider_error_is_quota(vendor_code.as_deref(), message) =>
672        {
673            let reason_kind = vendor_code.clone().unwrap_or_else(|| "provider-error".to_owned());
674            Some((BlockAuthorityV1::ProviderQuota, reason_kind, message.clone()))
675        }
676        _ => None,
677    }
678}
679
680/// Builds the `AgentStreamChunkV1` `event`'s `ProviderEvent` carries onto the
681/// `agent-stream-events-v1` channel -- see
682/// `docs/gate4agent/plans/gate4agent-acp-control-plane-on-the-wire-2026-09-02.md`
683/// §5. Two invariants keep the chunk correlatable with the `NodeEvent::Control`
684/// event it derives from, so a client joining the two sees one id and one
685/// ordering:
686///
687/// - `source_sequence` on the chunk is the `source_sequence` the provider
688///   event itself carries.
689/// - `InteractionPrompt::correlation_id` is minted by
690///   `correlation::interaction_correlation` with the
691///   `(instance_id, generation, provider_sequence)` triple -- never the
692///   provider's own `request_id`, whose opacity on the wire is pinned by a
693///   dedicated test.
694///
695/// Returns `None` for every `ControlEventKind` other than `ProviderEvent`
696/// (nothing else carries stream content), for a source that is not a live
697/// session transport, and for the `ProviderEvent` variants below with no
698/// declared chunk kind to carry them, or whose declared chunk kind this build
699/// has no source for yet:
700///
701/// - `ModelCatalog { current, available }` has no `ProviderEvent` source at
702///   all -- grok's vendor model switch is not bridged into `ProviderEvent`
703///   yet, the same gap the plan names explicitly.
704/// - `UserMessage { text, is_delta }` is an echo of the OPERATOR's own
705///   prompt, replayed on session resume -- `Text`/`Thinking` are documented
706///   as "what the agent is saying", so mapping the operator's words onto
707///   `Text` would misrepresent whose content it is. No dedicated
708///   user-message kind is declared, so this stays unproduced rather than
709///   mislabeled.
710///
711/// `HostRequestObserved { .. }` is its own arm below: a `Granted`/`Deferred`
712/// decision returns `None` (nothing for the operator to react to), a `Denied`
713/// decision returns `Some(Blocked { .. })`.
714///
715/// `ToolCompleted { .. }`/`SessionEnded { .. }` are likewise their own arms
716/// below (K2): each returns `None` unless
717/// `provider_tool_block_authority`/`provider_session_end_block` classifies the
718/// event as an actual block (ACP's `_meta.nonExecutionKind`, or `stopReason`/an
719/// RPC error naming a quota).
720fn agent_stream_chunk(event: &ControlEvent) -> Option<AgentStreamChunkV1> {
721    let ControlEventKind::ProviderEvent {
722        sequence: provider_sequence,
723        source,
724        source_sequence,
725        event: provider_event,
726    } = &event.event
727    else {
728        return None;
729    };
730    if !provider_family_is_live(source.family) {
731        return None;
732    }
733    let kind = match provider_event {
734        ProviderEvent::Text { text, is_delta } => AgentStreamChunkKindV1::Text {
735            text: text.clone(),
736            is_delta: *is_delta,
737        },
738        ProviderEvent::Thinking { text } => {
739            AgentStreamChunkKindV1::Thinking { text: text.clone() }
740        }
741        ProviderEvent::InteractionRequested {
742            interaction_kind,
743            tool_name,
744            title,
745            prompt,
746            options,
747            ..
748        } => AgentStreamChunkKindV1::InteractionPrompt {
749            correlation_id: interaction_correlation(
750                event.instance_id,
751                event.generation,
752                *provider_sequence,
753            ),
754            interaction_kind: *interaction_kind,
755            tool_name: tool_name.clone(),
756            // `title`/`options` are carried straight through from
757            // `ProviderEvent::InteractionRequested`, as the agent itself
758            // gave them on `session/request_permission`'s `PermissionTool
759            // Call.title`/`options` (see `gate4agent-shell-native`'s
760            // `provider_event`, the only ACP-transport producer). A PTY- or
761            // hook-sourced interaction carries `title: None` and
762            // `options: []` from its own producer because that source has
763            // none to offer, never because this mapping dropped one.
764            title: title.clone(),
765            prompt: prompt.clone(),
766            options: options
767                .iter()
768                .map(|option| AgentStreamInteractionOptionV1 {
769                    option_id: option.option_id.clone(),
770                    name: option.name.clone(),
771                    kind: option.kind.clone(),
772                })
773                .collect(),
774        },
775        ProviderEvent::ConfigOptionsUpdated { options } => {
776            AgentStreamChunkKindV1::ConfigOptions { options: options.clone() }
777        }
778        // `available` is the mode catalogue `AcpSession::available_modes()`
779        // read back at `session/new` and threaded through by
780        // `gate4agent-shell-native`'s `provider_event` (see that crate's
781        // `ProviderEvent::ModeChanged` arm) -- an empty list here is the
782        // agent's own honest "no modes announced", never "not read": ACP
783        // orders `session/new` before any `session/update`, and the
784        // catalogue is fixed at handshake, so a `ModeChanged` event cannot
785        // exist before the same session object's catalogue is already
786        // populated (see `gate4agent_types::ProviderEvent::ModeChanged`'s
787        // own doc comment for the ordering argument in full).
788        ProviderEvent::ModeChanged { mode_id, available } => AgentStreamChunkKindV1::ModeCatalog {
789            current: Some(mode_id.clone()),
790            available: available
791                .iter()
792                .map(|mode| AgentStreamNamedIdV1 {
793                    id: mode.id.clone(),
794                    name: mode.name.clone(),
795                    description: mode.description.clone(),
796                })
797                .collect(),
798        },
799        // A denied host request is a block -- see this function's own doc
800        // comment for why `HostRequestObserved` is not in the exclusion list
801        // below.
802        ProviderEvent::HostRequestObserved { method, decision, reason, .. } => {
803            let ProviderHostRequestDecision::Denied { by } = decision else {
804                return None;
805            };
806            let (tool_class, authority, reason_text) =
807                host_request_denied_block(method, *by, reason.as_deref());
808            AgentStreamChunkKindV1::Blocked {
809                correlation_id: None,
810                tool_class,
811                authority,
812                reason_kind: None,
813                reason: reason_text,
814                help: None,
815            }
816        }
817        // Same relationship as `HostRequestObserved` above, driven by ACP's
818        // own `_meta.nonExecutionKind` instead of the host's
819        // `HostRequestDecision`.
820        ProviderEvent::ToolCompleted { id, output, non_execution_kind, .. } => {
821            let (authority, reason_kind) =
822                provider_tool_block_authority(non_execution_kind.as_deref())?;
823            let (reason, help) = split_provider_denial_text(output);
824            let (reason, _) =
825                truncate_text(&reason, MAX_ACP_BLOCKED_REASON_BYTES);
826            let help = help.map(|help| {
827                truncate_text(&help, MAX_ACP_BLOCKED_HELP_BYTES).0
828            });
829            AgentStreamChunkKindV1::Blocked {
830                correlation_id: Some(tool_correlation(event, source, id)),
831                tool_class: tool_class_from_denial_help(help.as_deref()),
832                authority,
833                reason_kind: Some(reason_kind.to_owned()),
834                reason,
835                help,
836            }
837        }
838        // Same relationship, driven by `stopReason: "refusal"` or a
839        // `session/prompt` RPC error naming a quota/limit/rate exhaustion
840        // instead of a tool-level fact.
841        ProviderEvent::SessionEnded { stop_reason, .. } => {
842            let (authority, reason_kind, reason) =
843                stop_reason.as_ref().and_then(provider_session_end_block)?;
844            let (reason, _) =
845                truncate_text(&reason, MAX_ACP_BLOCKED_REASON_BYTES);
846            AgentStreamChunkKindV1::Blocked {
847                correlation_id: None,
848                tool_class: "Session".to_owned(),
849                authority,
850                reason_kind: Some(reason_kind),
851                reason,
852                help: None,
853            }
854        }
855        ProviderEvent::SessionStarted { .. }
856        | ProviderEvent::SessionIdentityObserved { .. }
857        | ProviderEvent::TurnStarted { .. }
858        | ProviderEvent::WorkingObserved
859        | ProviderEvent::ToolStarted { .. }
860        | ProviderEvent::TurnCompleted { .. }
861        | ProviderEvent::ContextWindowUsage { .. }
862        | ProviderEvent::TurnInterrupted
863        | ProviderEvent::Error { .. }
864        | ProviderEvent::Ready
865        | ProviderEvent::InteractionResolved { .. }
866        | ProviderEvent::SubagentStarted { .. }
867        | ProviderEvent::SubagentStopped { .. }
868        | ProviderEvent::RateLimited { .. }
869        | ProviderEvent::UnrecognizedNotification { .. }
870        | ProviderEvent::UserMessage { .. }
871        | ProviderEvent::Plan { .. }
872        | ProviderEvent::AvailableCommandsUpdated { .. }
873        | ProviderEvent::SessionInfoUpdated { .. }
874        | ProviderEvent::UsageUpdated { .. } => return None,
875    };
876    Some(AgentStreamChunkV1 { source_sequence: *source_sequence, kind })
877}
878
879async fn run_native_session_catalog_operation<T, E, F>(
880    catalog: Arc<Mutex<NativeSessionCatalogAuthority>>,
881    operation: &'static str,
882    execute: F,
883) -> Result<T, NodeFailure>
884where
885    T: Send + 'static,
886    E: std::fmt::Display + Send + 'static,
887    F: FnOnce(&mut NativeSessionCatalogAuthority) -> Result<T, E> + Send + 'static,
888{
889    let task = tokio::task::spawn_blocking(move || {
890        let mut catalog = match catalog.try_lock() {
891            Ok(catalog) => catalog,
892            Err(std::sync::TryLockError::Poisoned(poisoned)) => poisoned.into_inner(),
893            Err(std::sync::TryLockError::WouldBlock) => {
894                return Err(failure(
895                    NodeFailureCode::BackendBusy,
896                    "native session catalog operation is already running",
897                ));
898            }
899        };
900        execute(&mut catalog).map_err(|error| {
901            failure(
902                NodeFailureCode::BackendOperationFailed,
903                &format!("{operation} failed: {error}"),
904            )
905        })
906    });
907    timeout(Duration::from_millis(NATIVE_SESSION_CATALOG_TIMEOUT_MS), task)
908        .await
909        .map_err(|_| {
910            failure(
911                NodeFailureCode::BackendBusy,
912                &format!("{operation} exceeded its bounded deadline"),
913            )
914        })?
915        .map_err(|_| {
916            failure(
917                NodeFailureCode::BackendOperationFailed,
918                &format!("{operation} task failed"),
919            )
920        })?
921}
922
923fn windows_path_text(path: &OpaqueHostPath) -> &str {
924    path.as_utf8()
925        .expect("Windows node only creates UTF-8 opaque host paths")
926}
927
928fn require_windows_path(path: OpaqueHostPath) -> Result<String, NodeFailure> {
929    path.as_utf8()
930        .map(str::to_owned)
931        .ok_or_else(|| failure(
932            NodeFailureCode::InvalidRequest,
933            "this node requires UTF-8 host paths",
934        ))
935}
936
937fn protocol_worktree(mut worktree: NativeGitWorktreeSnapshot) -> GitWorktreeSnapshot {
938    GitWorktreeSnapshot {
939        path: opaque_windows_path(worktree.path),
940        head: worktree.head,
941        branch: worktree.branch,
942        is_bare: worktree.is_bare,
943        is_main: worktree.is_main,
944        locked: worktree.locked,
945        lock_reason: worktree.lock_reason,
946        prunable: worktree.prunable,
947        prunable_reason: worktree.prunable_reason,
948        workspace_id: worktree.workspace_id.take(),
949    }
950}
951
952#[derive(Clone, Debug, Eq, PartialEq)]
953pub struct WorkspaceConfig {
954    workspace_id: WorkspaceId,
955    canonical_root: String,
956    worktree_service_mode: WorktreeServiceMode,
957    managed_worktree_profiles: BTreeMap<WorktreeProfileId, ManagedWorktreeProfile>,
958}
959
960impl WorkspaceConfig {
961    pub fn new(
962        workspace_id: WorkspaceId,
963        root: impl AsRef<Path>,
964    ) -> Result<Self, NodeServerError> {
965        let root = root.as_ref();
966        if !root.is_absolute() {
967            return Err(NodeServerError::InvalidWorkspaceRoot {
968                workspace_id,
969                path: root.to_string_lossy().into_owned(),
970                message: "path is not absolute".to_owned(),
971            });
972        }
973        let canonical = std::fs::canonicalize(root).map_err(|error| {
974            NodeServerError::InvalidWorkspaceRoot {
975                workspace_id: workspace_id.clone(),
976                path: root.to_string_lossy().into_owned(),
977                message: error.to_string(),
978            }
979        })?;
980        if !canonical.is_dir() {
981            return Err(NodeServerError::InvalidWorkspaceRoot {
982                workspace_id,
983                path: root.to_string_lossy().into_owned(),
984                message: "path is not a directory".to_owned(),
985            });
986        }
987        let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
988            NodeServerError::InvalidWorkspaceRoot {
989                workspace_id: workspace_id.clone(),
990                path: path.to_string_lossy().into_owned(),
991                message: "canonical path is not valid Unicode".to_owned(),
992            }
993        })?;
994        let canonical_root = platform::normalize_canonical_root(canonical_root);
995        validate_workspace_root(&workspace_id, &canonical_root)?;
996        Ok(Self {
997            workspace_id,
998            canonical_root,
999            worktree_service_mode: WorktreeServiceMode::Manual,
1000            managed_worktree_profiles: BTreeMap::new(),
1001        })
1002    }
1003
1004    pub fn workspace_id(&self) -> &WorkspaceId {
1005        &self.workspace_id
1006    }
1007
1008    pub fn canonical_root(&self) -> &str {
1009        &self.canonical_root
1010    }
1011
1012    pub fn with_worktree_service_mode(mut self, mode: WorktreeServiceMode) -> Self {
1013        self.worktree_service_mode = mode;
1014        self
1015    }
1016
1017    pub fn with_managed_worktree_profile(
1018        mut self,
1019        profile: ManagedWorktreeProfile,
1020    ) -> Result<Self, NodeServerError> {
1021        profile.validate_for_workspace(&self.canonical_root).map_err(|message| {
1022            NodeServerError::InvalidManagedWorktreeProfile {
1023                workspace_id: self.workspace_id.clone(),
1024                profile_id: profile.profile_id().clone(),
1025                message,
1026            }
1027        })?;
1028        if self.managed_worktree_profiles.contains_key(profile.profile_id()) {
1029            return Err(NodeServerError::DuplicateManagedWorktreeProfile {
1030                workspace_id: self.workspace_id,
1031                profile_id: profile.profile_id().clone(),
1032            });
1033        }
1034        if self.managed_worktree_profiles.len()
1035            == MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE
1036        {
1037            return Err(NodeServerError::ManagedWorktreeProfileCapacity {
1038                workspace_id: self.workspace_id,
1039                max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
1040            });
1041        }
1042        self.managed_worktree_profiles
1043            .insert(profile.profile_id().clone(), profile);
1044        Ok(self)
1045    }
1046}
1047
1048#[derive(Clone)]
1049pub struct NodeServerConfig {
1050    pub endpoint: String,
1051    api_listen: Option<std::net::SocketAddr>,
1052    /// Opt-in node-envelope HTTP+WS browser bridge (separate from `--api-listen`
1053    /// ops HTTP and from framed C2). Loopback only; default `None` = off.
1054    bridge_listen: Option<std::net::SocketAddr>,
1055    /// Optional bridge shared secret (`GATE4AGENT_BRIDGE_TOKEN`). Distinct from
1056    /// NODE_TOKEN; never logged. `None` = loopback bind is the only gate.
1057    bridge_token: Option<String>,
1058    /// Tip 6: optional mesh underlay UDP bind that relays to local `--bridge-listen`.
1059    /// Requires bridge_listen + bridge_token. HQ never accepts (NodePeer).
1060    bridge_underlay: Option<browser_bridge::BridgeUnderlayAccept>,
1061    /// A relay to CALL, rather than wait to be called by. `None` keeps the
1062    /// node purely a listener, which is what it has always been.
1063    call_home: Option<std::net::SocketAddr>,
1064    /// When set, call-home authenticates by signing the relay's challenge with this
1065    /// identity (enrolled node) and falls back to the token handshake against a relay
1066    /// that sends no challenge.
1067    call_home_identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
1068    pub node_id: NodeId,
1069    pub workspaces: Vec<WorkspaceConfig>,
1070    access_token: String,
1071    pub runtime: NativeRuntimeConfig,
1072    session_record_retention: SessionRecordRetentionConfig,
1073    state_path: Option<PathBuf>,
1074    spawn_profiles: SpawnProfileRegistry,
1075    session_environment: Option<NodeSessionEnvironmentConfig>,
1076    history: Option<NativeHistoryConfig>,
1077    harness_mcp_helper: Option<ReviewedHarnessMcpProgram>,
1078    /// Station network allowlist catalog (opaque ids). Empty default.
1079    /// Loaded at startup from `--network-allowlist-catalog` / env
1080    /// `GATE4AGENT_NODE_NETWORK_ALLOWLIST_CATALOG` file. Never secrets.
1081    network_allowlist_catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog,
1082    #[cfg(feature = "fixture")]
1083    fixture_raw_pty_runtime: bool,
1084}
1085
1086#[derive(Clone)]
1087struct NodeSessionEnvironmentConfig {
1088    root: PathBuf,
1089    resolver: Arc<dyn NodeSecretResolver>,
1090}
1091
1092impl NodeServerConfig {
1093    pub fn new(
1094        endpoint: impl Into<String>,
1095        access_token: impl Into<String>,
1096        node_id: NodeId,
1097        workspaces: impl IntoIterator<Item = WorkspaceConfig>,
1098    ) -> Result<Self, NodeServerError> {
1099        let endpoint = endpoint.into();
1100        let access_token = access_token.into();
1101        if !platform::validate_endpoint(&endpoint) {
1102            return Err(NodeServerError::InvalidEndpoint);
1103        }
1104        if access_token.is_empty() || access_token.len() > 4_096 {
1105            return Err(NodeServerError::InvalidAccessToken);
1106        }
1107        let workspaces = workspaces.into_iter().collect::<Vec<_>>();
1108        if workspaces.is_empty() {
1109            return Err(NodeServerError::NoWorkspaces);
1110        }
1111        let mut workspace_ids = BTreeMap::new();
1112        let mut workspace_roots = BTreeMap::new();
1113        for workspace in &workspaces {
1114            if workspace.worktree_service_mode == WorktreeServiceMode::Managed
1115                && workspace.managed_worktree_profiles.is_empty()
1116            {
1117                return Err(NodeServerError::ManagedWorktreeProfileRequired(
1118                    workspace.workspace_id.clone(),
1119                ));
1120            }
1121            if workspace.worktree_service_mode != WorktreeServiceMode::Managed
1122                && !workspace.managed_worktree_profiles.is_empty()
1123            {
1124                return Err(NodeServerError::ManagedWorktreeProfileModeMismatch(
1125                    workspace.workspace_id.clone(),
1126                ));
1127            }
1128            if workspace_ids
1129                .insert(workspace.workspace_id.clone(), ())
1130                .is_some()
1131            {
1132                return Err(NodeServerError::DuplicateWorkspaceId(
1133                    workspace.workspace_id.clone(),
1134                ));
1135            }
1136            let root_key = platform::root_identity(&workspace.canonical_root);
1137            if let Some(existing) = workspace_roots.insert(
1138                root_key,
1139                workspace.workspace_id.clone(),
1140            ) {
1141                return Err(NodeServerError::DuplicateWorkspaceRoot {
1142                    first: existing,
1143                    second: workspace.workspace_id.clone(),
1144                    root: workspace.canonical_root.clone(),
1145                });
1146            }
1147        }
1148        Ok(Self {
1149            endpoint,
1150            api_listen: None,
1151            bridge_listen: None,
1152            bridge_token: None,
1153            bridge_underlay: None,
1154            call_home: None,
1155            call_home_identity: None,
1156            node_id,
1157            workspaces,
1158            access_token,
1159            // PTY output is polled by the runtime's effect worker; the upstream
1160            // default (20ms) added ~10-20ms to every keystroke echo.
1161            runtime: NativeRuntimeConfig {
1162                worker_poll_interval_ms: 4,
1163                ..NativeRuntimeConfig::default()
1164            },
1165            session_record_retention: SessionRecordRetentionConfig::default(),
1166            state_path: None,
1167            spawn_profiles: SpawnProfileRegistry::default(),
1168            session_environment: None,
1169            history: None,
1170            harness_mcp_helper: None,
1171            network_allowlist_catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog::new(),
1172            #[cfg(feature = "fixture")]
1173            fixture_raw_pty_runtime: false,
1174        })
1175    }
1176
1177    pub fn with_api_listen(
1178        mut self,
1179        api_listen: std::net::SocketAddr,
1180    ) -> Result<Self, NodeServerError> {
1181        if !api_listen.ip().is_loopback() {
1182            return Err(NodeServerError::InvalidApiListen(api_listen));
1183        }
1184        self.api_listen = Some(api_listen);
1185        Ok(self)
1186    }
1187
1188    /// Opt-in node-envelope HTTP+WS bridge listener. Loopback only (same spirit
1189    /// as `with_api_listen`). Does not enable by default — libraries and the
1190    /// binary leave this `None` until `--bridge-listen` / `with_bridge_listen`.
1191    pub fn with_bridge_listen(
1192        mut self,
1193        bridge_listen: std::net::SocketAddr,
1194    ) -> Result<Self, NodeServerError> {
1195        if !bridge_listen.ip().is_loopback() {
1196            return Err(NodeServerError::InvalidBridgeListen(bridge_listen));
1197        }
1198        self.bridge_listen = Some(bridge_listen);
1199        Ok(self)
1200    }
1201
1202    /// Optional bridge shared secret, distinct from the C2/node access token.
1203    /// Never log the value. Empty / oversize refused.
1204    pub fn with_bridge_token(
1205        mut self,
1206        bridge_token: impl Into<String>,
1207    ) -> Result<Self, NodeServerError> {
1208        let bridge_token = bridge_token.into();
1209        if bridge_token.is_empty() || bridge_token.len() > 4_096 {
1210            return Err(NodeServerError::InvalidBridgeToken);
1211        }
1212        self.bridge_token = Some(bridge_token);
1213        Ok(self)
1214    }
1215
1216    /// Tip 6: point bridge reachability over mesh underlay (UDP+AEAD relay to
1217    /// local loopback `--bridge-listen`). Requires bridge listen + bridge token
1218    /// already configured. Transport key is 32 raw bytes (never logged).
1219    /// Underlay auth token is distinct from BRIDGE_TOKEN (crypto ≠ auth).
1220    pub fn with_bridge_underlay(
1221        mut self,
1222        bind: std::net::SocketAddr,
1223        transport_key: [u8; 32],
1224        underlay_token: impl Into<String>,
1225    ) -> Result<Self, NodeServerError> {
1226        if self.bridge_listen.is_none() {
1227            return Err(NodeServerError::BridgeUnderlayRequiresBridgeListen);
1228        }
1229        if self.bridge_token.is_none() {
1230            return Err(NodeServerError::BridgeUnderlayRequiresBridgeToken);
1231        }
1232        let underlay_token = underlay_token.into();
1233        if underlay_token.is_empty() || underlay_token.len() > 4_096 {
1234            return Err(NodeServerError::InvalidBridgeUnderlayToken);
1235        }
1236        // Port 0 is allowed (ephemeral). Role lock (no HQ accept) lives in underlay.
1237        self.bridge_underlay = Some(browser_bridge::BridgeUnderlayAccept {
1238            bind,
1239            transport_key,
1240            underlay_token,
1241        });
1242        Ok(self)
1243    }
1244
1245    /// Also dial `relay` and serve this wire over the connection, in
1246    /// addition to accepting on the local endpoint.
1247    ///
1248    /// Who opens the socket and who is the protocol's server are separate
1249    /// questions, and this is the node's half of separating them. The node
1250    /// stays the server either way -- it answers `NodeRequest`s and emits
1251    /// `NodeEvent`s exactly as it does for a relay that dialled it. All
1252    /// that changes is that a node with no address anyone can reach makes
1253    /// the connection itself, and names itself once it arrives (see
1254    /// `gate4agent_node_wire::write_call_home_announce`).
1255    ///
1256    /// Loopback only, and that is not an oversight to be flagged and
1257    /// worked around. This wire carries terminal contents, keystrokes and
1258    /// file bytes as plaintext JSON; its mutual challenge-response
1259    /// authenticates both ends and encrypts nothing. Every listener in
1260    /// this stack is loopback-guarded for that reason, and a dialler must
1261    /// hold the same line -- a node calling a public relay would put the
1262    /// whole control plane on the wire in the clear. Reaching a relay on
1263    /// another host is a transport question, and it stays unanswered here
1264    /// on purpose: this change is about direction, not distance.
1265    pub fn with_call_home(
1266        mut self,
1267        relay: std::net::SocketAddr,
1268    ) -> Result<Self, NodeServerError> {
1269        // Loopback, or the concrete C2 WireGuard tunnel address. Unspecified
1270        // and port 0 refused — plaintext wire must not dial every interface.
1271        if relay.port() == 0 || relay.ip().is_unspecified() {
1272            return Err(NodeServerError::InvalidCallHome(relay));
1273        }
1274        self.call_home = Some(relay);
1275        Ok(self)
1276    }
1277
1278    /// Authenticate call-home with a node-held identity instead of a standing token.
1279    pub fn with_call_home_identity(mut self, identity: Arc<hatchery_enroll::NodeIdentity>) -> Self {
1280        self.call_home_identity = Some(identity);
1281        self
1282    }
1283
1284    pub fn with_state_path(
1285        mut self,
1286        state_path: impl Into<PathBuf>,
1287    ) -> Result<Self, NodeServerError> {
1288        let state_path = state_path.into();
1289        if !state_path.is_absolute() || state_path.file_name().is_none() {
1290            return Err(NodeServerError::InvalidStatePath(
1291                state_path.to_string_lossy().into_owned(),
1292            ));
1293        }
1294        self.state_path = Some(state_path);
1295        Ok(self)
1296    }
1297
1298    pub fn with_spawn_profiles(mut self, spawn_profiles: SpawnProfileRegistry) -> Self {
1299        self.spawn_profiles = spawn_profiles;
1300        self
1301    }
1302
1303    /// Sets the age/keep-N policy the drive loop's retention sweep uses to
1304    /// retire dead `ManagedSessionState::Unavailable` records. Both fields
1305    /// default to `0` (disabled) via `SessionRecordRetentionConfig::default`
1306    /// when this is never called -- a node never deletes a durable record
1307    /// until an operator opts in with a real value.
1308    pub fn with_session_record_retention(
1309        mut self,
1310        retention: SessionRecordRetentionConfig,
1311    ) -> Self {
1312        self.session_record_retention = retention;
1313        self
1314    }
1315
1316    pub fn with_session_environment_materialization(
1317        mut self,
1318        root: impl Into<PathBuf>,
1319        resolver: Arc<dyn NodeSecretResolver>,
1320    ) -> Result<Self, NodeServerError> {
1321        let root = root.into();
1322        if !root.is_absolute() {
1323            return Err(NodeServerError::InvalidSessionEnvironmentRoot);
1324        }
1325        self.session_environment = Some(NodeSessionEnvironmentConfig { root, resolver });
1326        Ok(self)
1327    }
1328
1329    /// Enables bounded provider-history discovery and loading from explicit
1330    /// host-owned roots. Ambient vendor homes are never inferred here.
1331    pub fn with_history(mut self, history: NativeHistoryConfig) -> Self {
1332        self.history = Some(history);
1333        self
1334    }
1335
1336    /// Enables the H3B read proxy with one exact operator-reviewed helper file.
1337    /// Linux/macOS use owner-only UDS; Windows uses owner-only named pipes.
1338    /// Both platforms review the helper the same way (absolute regular file +
1339    /// identity hash) — the old `cfg(not(windows))` hard-refuse was a leftover
1340    /// gate after Unix listener/peer-cred support landed.
1341    pub fn with_harness_mcp_helper(
1342        mut self,
1343        helper_program: impl Into<PathBuf>,
1344    ) -> Result<Self, NodeServerError> {
1345        self.harness_mcp_helper = Some(
1346            ReviewedHarnessMcpProgram::review(helper_program.into())
1347                .map_err(|_| NodeServerError::InvalidHarnessMcpHelper)?,
1348        );
1349        Ok(self)
1350    }
1351
1352    /// Install an in-memory station network allowlist catalog (opaque ids +
1353    /// optional node-local permits / provider-native mapping). Empty remains
1354    /// the default — unknown spawn ids refuse at resolve. Never cookies /
1355    /// OAuth / proxy credentials. C2 / inventory still list ids only.
1356    pub fn with_network_allowlist_catalog(
1357        mut self,
1358        catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog,
1359    ) -> Result<Self, NodeServerError> {
1360        if catalog.len() > crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES {
1361            return Err(NodeServerError::NetworkAllowlistCatalogCapacity {
1362                max: crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES,
1363            });
1364        }
1365        self.network_allowlist_catalog = catalog;
1366        Ok(self)
1367    }
1368
1369    /// Load station network allowlist catalog from an absolute regular file
1370    /// (v1 id-list or v2 JSON schema). Unset path is not used here — callers
1371    /// that want env fallback use `resolve_network_allowlist_catalog`.
1372    pub fn with_network_allowlist_catalog_file(
1373        self,
1374        path: impl AsRef<std::path::Path>,
1375    ) -> Result<Self, NodeServerError> {
1376        let catalog = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(path)
1377            .map_err(NodeServerError::from)?;
1378        self.with_network_allowlist_catalog(catalog)
1379    }
1380}
1381
1382pub fn default_state_path(node_id: &NodeId) -> Result<PathBuf, NodeServerError> {
1383    platform::default_state_path(node_id.as_str())
1384        .ok_or(NodeServerError::LocalStateDirectoryUnavailable)
1385}
1386
1387pub fn default_node_endpoint() -> Result<PathBuf, NodeServerError> {
1388    platform::default_node_endpoint().ok_or(NodeServerError::LocalRuntimeDirectoryUnavailable)
1389}
1390
1391fn delivery_store_root_for_state_path(state_path: &Path) -> Option<PathBuf> {
1392    let parent = state_path.parent()?;
1393    let mut name = state_path.file_name()?.to_os_string();
1394    name.push(".delivery-store");
1395    Some(parent.join(name))
1396}
1397
1398fn context_pack_store_root_for_state_path(state_path: &Path) -> Option<PathBuf> {
1399    let parent = state_path.parent()?;
1400    let mut name = state_path.file_name()?.to_os_string();
1401    name.push(".context-pack-store");
1402    Some(parent.join(name))
1403}
1404
1405pub struct NodeServer {
1406    config: NodeServerConfig,
1407    runtime: NativeRuntime,
1408    shared: Arc<NodeShared>,
1409    events: EventSubscription,
1410    state_path_lock: Option<session_registry::StatePathLock>,
1411}
1412
1413#[cfg(feature = "fixture")]
1414#[derive(Clone, Default)]
1415pub struct SpawnManagedWorktreeV2FailureProbe {
1416    latest_code: Arc<Mutex<Option<NodeFailureCode>>>,
1417}
1418
1419#[cfg(feature = "fixture")]
1420impl SpawnManagedWorktreeV2FailureProbe {
1421    pub fn latest_code(&self) -> Option<NodeFailureCode> {
1422        *self
1423            .latest_code
1424            .lock()
1425            .unwrap_or_else(|poisoned| poisoned.into_inner())
1426    }
1427
1428    fn record(&self, code: Option<NodeFailureCode>) {
1429        *self
1430            .latest_code
1431            .lock()
1432            .unwrap_or_else(|poisoned| poisoned.into_inner()) = code;
1433    }
1434}
1435
1436#[cfg(all(feature = "fixture", windows))]
1437struct IsolatedCodexHomeFixtureEnvironment;
1438
1439#[cfg(all(feature = "fixture", windows))]
1440impl gate4agent_runtime_native::NativeChildEnvironmentResolver
1441    for IsolatedCodexHomeFixtureEnvironment
1442{
1443    fn resolve_child_environment(
1444        &self,
1445    ) -> Result<
1446        Vec<gate4agent_catalog::EnvMutation>,
1447        gate4agent_runtime_native::NativeChildEnvironmentResolveError,
1448    > {
1449        Ok(vec![gate4agent_catalog::EnvMutation {
1450            key: OsString::from("USERPROFILE"),
1451            value: None,
1452        }])
1453    }
1454}
1455
1456impl NodeServer {
1457    /// The letter-wake handle for the door, when m4a accounts are on. `token` is what callers
1458    /// must present.
1459    pub fn wake_handle(&self, token: String, policy: Arc<crate::wake_pipeline::WakePolicy>) -> Option<node_wake::NodeWake> {
1460        self.shared.m4a_accounts.as_ref()?;
1461        Some(node_wake::NodeWake::new(Arc::clone(&self.shared), token, policy))
1462    }
1463
1464    pub fn new(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1465        let catalog = active_registry()?;
1466        Self::new_with_registry(config, catalog)
1467    }
1468
1469    #[cfg(feature = "fixture")]
1470    pub fn new_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1471        let mut spec = gate4agent_testkit::interactive_agent_spec();
1472        spec.id = AgentId::new("claude").map_err(|error| NodeServerError::Registry(error.to_string()))?;
1473        spec.display_name = "Controlled Claude PTY fixture".to_owned();
1474        Self::new_fixture_with_spec(config, spec)
1475    }
1476
1477    #[cfg(feature = "fixture")]
1478    pub fn new_clean_exit_fixture(
1479        config: NodeServerConfig,
1480        fixture_root: PathBuf,
1481        started_marker: PathBuf,
1482        release_signal: PathBuf,
1483    ) -> Result<Self, NodeServerError> {
1484        let mut spec = gate4agent_testkit::controlled_clean_exit_agent_spec(
1485            &fixture_root,
1486            &started_marker,
1487            &release_signal,
1488        )
1489        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1490        spec.id = AgentId::new("claude")
1491            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1492        spec.display_name = "Controlled Claude clean-exit fixture".to_owned();
1493        Self::new_fixture_with_spec(config, spec)
1494    }
1495
1496    /// A `claude` source whose session establishes a verified provider
1497    /// identity via an early `SessionStart` Hook post (so it is eligible for
1498    /// auto-export-at-exit and its native history is independently
1499    /// loadable), waits for `release_signal`, then exits cleanly — paired
1500    /// on the same Node with a plain `codex` target that never validates
1501    /// anything about what it receives. Built for A2 durable ContextPack
1502    /// E2E coverage: the test itself inspects materialized files and
1503    /// managed-session-record fields directly, rather than having the
1504    /// target script self-validate.
1505    #[cfg(all(feature = "fixture", windows))]
1506    pub fn new_durable_context_pack_clean_exit_fixture(
1507        config: NodeServerConfig,
1508        fixture_root: PathBuf,
1509        started_marker: PathBuf,
1510        release_signal: PathBuf,
1511        session_id: &str,
1512    ) -> Result<Self, NodeServerError> {
1513        let mut source_spec = gate4agent_testkit::identified_clean_exit_agent_spec(
1514            &fixture_root,
1515            &started_marker,
1516            &release_signal,
1517            session_id,
1518        )
1519        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1520        let source_id = AgentId::new("claude")
1521            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1522        let source_provider = builtin_registry().get(&source_id).ok_or_else(|| {
1523            NodeServerError::Registry(
1524                "durable context pack clean-exit fixture source adapter is unavailable"
1525                    .to_owned(),
1526            )
1527        })?;
1528        source_spec.capabilities.adapters.history =
1529            source_provider.capabilities.adapters.history.clone();
1530        source_spec.id = source_id;
1531        source_spec.display_name =
1532            "Controlled Claude durable context-pack clean-exit fixture".to_owned();
1533        // Both specs launch via `powershell.exe`; the registry's ambiguity
1534        // check keys on `detection.command` (+ aliases), not `launch.program`
1535        // -- give each a distinct detection identity, same precedent as
1536        // `context_pack_fixture_catalog`'s per-provider
1537        // `gate4agent-{provider}-context-fixture.cmd` commands.
1538        source_spec.detection.command =
1539            "gate4agent-claude-durable-context-pack-fixture.cmd".to_owned();
1540        source_spec.detection.aliases.clear();
1541
1542        let mut target_spec = gate4agent_testkit::interactive_agent_spec();
1543        target_spec.id = AgentId::new("codex")
1544            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1545        target_spec.display_name =
1546            "Controlled Codex durable context-pack target fixture".to_owned();
1547        target_spec.detection.command =
1548            "gate4agent-codex-durable-context-pack-fixture.cmd".to_owned();
1549        target_spec.detection.aliases.clear();
1550
1551        let catalog = AgentRegistry::new([source_spec, target_spec])
1552            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1553        let mut server = Self::new_with_registry(config, catalog)?;
1554        Arc::get_mut(&mut server.shared)
1555            .expect("fixture Node shared state must remain uniquely owned before run")
1556            .fixture_semantic_hook_policy = true;
1557        Ok(server)
1558    }
1559
1560    #[cfg(feature = "fixture")]
1561    pub fn new_monitoring_hook_fixture(
1562        config: NodeServerConfig,
1563    ) -> Result<Self, NodeServerError> {
1564        let mut spec = gate4agent_testkit::monitoring_hook_agent_spec();
1565        spec.id = AgentId::new("claude")
1566            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1567        let mut server = Self::new_fixture_with_spec(config, spec)?;
1568        Arc::get_mut(&mut server.shared)
1569            .expect("fixture Node shared state must remain uniquely owned before run")
1570            .fixture_semantic_hook_policy = true;
1571        Ok(server)
1572    }
1573
1574    #[cfg(all(feature = "fixture", windows))]
1575    pub fn new_provider_bundle_argv_fixture(
1576        config: NodeServerConfig,
1577        agent_id: AgentId,
1578        proof_path: PathBuf,
1579    ) -> Result<Self, NodeServerError> {
1580        let spec = Self::provider_bundle_argv_fixture_spec(agent_id, proof_path, None)?;
1581        Self::new_fixture_with_spec(config, spec)
1582    }
1583
1584    #[cfg(all(feature = "fixture", windows))]
1585    pub fn new_provider_bundle_argv_hold_fixture(
1586        config: NodeServerConfig,
1587        agent_id: AgentId,
1588        proof_path: PathBuf,
1589        release_signal: PathBuf,
1590    ) -> Result<Self, NodeServerError> {
1591        let spec = Self::provider_bundle_argv_fixture_spec(
1592            agent_id,
1593            proof_path,
1594            Some(release_signal),
1595        )?;
1596        Self::new_fixture_with_spec(config, spec)
1597    }
1598
1599    #[cfg(all(feature = "fixture", windows))]
1600    fn provider_bundle_argv_fixture_spec(
1601        agent_id: AgentId,
1602        proof_path: PathBuf,
1603        release_signal: Option<PathBuf>,
1604    ) -> Result<gate4agent_types::AgentSpec, NodeServerError> {
1605        if !matches!(agent_id.as_str(), "claude" | "codex" | "kimi") {
1606            return Err(NodeServerError::Registry(
1607                "provider bundle argv fixture requires Claude, Codex, or Kimi".to_owned(),
1608            ));
1609        }
1610        let mut spec = gate4agent_testkit::interactive_agent_spec();
1611        spec.id = agent_id.clone();
1612        spec.display_name = format!("Controlled {agent_id} bundle argv fixture");
1613        let provider = builtin_registry()
1614            .get(&agent_id)
1615            .ok_or_else(|| {
1616                NodeServerError::Registry(
1617                    "provider bundle argv fixture adapter is unavailable".to_owned(),
1618                )
1619            })?;
1620        spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1621        if !proof_path.is_absolute()
1622            || !proof_path
1623                .parent()
1624                .is_some_and(|parent| parent.is_dir())
1625        {
1626            return Err(NodeServerError::Registry(
1627                "provider bundle argv proof path requires an existing absolute parent"
1628                    .to_owned(),
1629            ));
1630        }
1631        if let Some(release_signal) = release_signal.as_ref() {
1632            if !release_signal.is_absolute()
1633                || !release_signal
1634                    .parent()
1635                    .is_some_and(|parent| parent.is_dir())
1636            {
1637                return Err(NodeServerError::Registry(
1638                    "provider bundle argv release path requires an existing absolute parent"
1639                        .to_owned(),
1640                ));
1641            }
1642            if release_signal.exists() {
1643                return Err(NodeServerError::Registry(
1644                    "provider bundle argv release path must not exist".to_owned(),
1645                ));
1646            }
1647            let proof_parent = std::fs::canonicalize(
1648                proof_path
1649                    .parent()
1650                    .expect("validated provider bundle proof parent"),
1651            )
1652            .map_err(|_| {
1653                NodeServerError::Registry(
1654                    "provider bundle argv proof parent is unavailable".to_owned(),
1655                )
1656            })?;
1657            let release_parent = std::fs::canonicalize(
1658                release_signal
1659                    .parent()
1660                    .expect("validated provider bundle release parent"),
1661            )
1662            .map_err(|_| {
1663                NodeServerError::Registry(
1664                    "provider bundle argv release parent is unavailable".to_owned(),
1665                )
1666            })?;
1667            if proof_parent != release_parent {
1668                return Err(NodeServerError::Registry(
1669                    "provider bundle argv release path must share the proof parent".to_owned(),
1670                ));
1671            }
1672        }
1673        let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1674            NodeServerError::Registry(
1675                "provider bundle argv proof path is not valid Unicode".to_owned(),
1676            )
1677        })?;
1678        let release_signal = release_signal.map(|release_signal| {
1679            release_signal.into_os_string().into_string().map_err(|_| {
1680                NodeServerError::Registry(
1681                    "provider bundle argv release path is not valid Unicode".to_owned(),
1682                )
1683            })
1684        }).transpose()?;
1685        let script = spec
1686            .launch
1687            .fixed_args
1688            .pop()
1689            .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
1690        let bundle_validation = if agent_id.as_str() == "codex" {
1691            "if ($bundleArgs.Count -ne 0) { exit 91 }; $codexHome = [Environment]::GetEnvironmentVariable('CODEX_HOME', 'Process'); $cwd = (Get-Location).ProviderPath; if ([string]::IsNullOrEmpty($codexHome)) { [IO.File]::WriteAllLines($proofPath, [string[]]@('unbundled', $cwd)); [Console]::Out.WriteLine('F63_CODEX_UNBUNDLED_VALIDATED') } elseif (-not [IO.Path]::IsPathRooted($codexHome) -or -not (Test-Path -LiteralPath $codexHome -PathType Container)) { exit 92 } else { $skillPath = Join-Path $codexHome 'skills/review-code/SKILL.md'; if (-not (Test-Path -LiteralPath $skillPath -PathType Leaf)) { exit 93 }; $files = @(Get-ChildItem -LiteralPath $codexHome -Recurse -Force -File); if ($files.Count -ne 1 -or [IO.Path]::GetFullPath($files[0].FullName) -ine [IO.Path]::GetFullPath($skillPath)) { exit 94 }; $sha = [Security.Cryptography.SHA256]::Create(); try { $skillHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($skillPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }; if ($skillHash -cne 'd78fe03be106b673fcf5415c8359e99f0298b5071cd11822c58ebcb27e52a68d') { exit 95 }; [IO.File]::WriteAllLines($proofPath, [string[]]@('bundled', $codexHome, $cwd, $skillHash)); [Console]::Out.WriteLine('F63_CODEX_BUNDLE_HOME_VALIDATED') }"
1692        } else if agent_id.as_str() == "claude" {
1693            "if ($bundleArgs.Count -eq 0) { [IO.File]::WriteAllLines($proofPath, [string[]]@()); [Console]::Out.WriteLine('F62_NO_BUNDLE_ARGV') } elseif ($bundleArgs.Count -eq 2 -and $bundleArgs[0] -ceq '--plugin-dir' -and [IO.Path]::IsPathRooted($bundleArgs[1]) -and (Test-Path -LiteralPath $bundleArgs[1] -PathType Container)) { if (-not (Test-Path -LiteralPath (Join-Path $bundleArgs[1] '.claude-plugin/plugin.json') -PathType Leaf)) { exit 92 }; [IO.File]::WriteAllLines($proofPath, [string[]]@($bundleArgs[0], $bundleArgs[1])); [Console]::Out.WriteLine('F62_BUNDLE_ARGV_VALIDATED') } else { exit 91 }"
1694        } else {
1695            "if ($bundleArgs.Count -eq 0) { [IO.File]::WriteAllLines($proofPath, [string[]]@()); [Console]::Out.WriteLine('F62_NO_BUNDLE_ARGV') } elseif ($bundleArgs.Count -eq 2 -and $bundleArgs[0] -ceq '--skills-dir' -and [IO.Path]::IsPathRooted($bundleArgs[1]) -and (Test-Path -LiteralPath $bundleArgs[1] -PathType Container)) { if ((Split-Path -Leaf $bundleArgs[1]) -ne 'skills') { exit 93 }; [IO.File]::WriteAllLines($proofPath, [string[]]@($bundleArgs[0], $bundleArgs[1])); [Console]::Out.WriteLine('F62_BUNDLE_ARGV_VALIDATED') } else { exit 91 }"
1696        };
1697        if let Some(release_signal) = release_signal {
1698            spec.launch.fixed_args.push(format!(
1699                "& {{ param([string]$proofPath, [string]$releaseSignal, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {bundle_validation}; $deadline = [DateTime]::UtcNow.AddSeconds(45); while (-not (Test-Path -LiteralPath $releaseSignal -PathType Leaf)) {{ if ([DateTime]::UtcNow -ge $deadline) {{ exit 96 }}; Start-Sleep -Milliseconds 20 }}; {script} }}",
1700            ));
1701            spec.launch.fixed_args.push(proof_path);
1702            spec.launch.fixed_args.push(release_signal);
1703        } else {
1704            spec.launch.fixed_args.push(format!(
1705                "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {bundle_validation}; {script} }}",
1706            ));
1707            spec.launch.fixed_args.push(proof_path);
1708        }
1709        Ok(spec)
1710    }
1711
1712    #[cfg(all(feature = "fixture", windows))]
1713    pub fn new_context_pack_fixture(
1714        config: NodeServerConfig,
1715        proof_path: PathBuf,
1716    ) -> Result<Self, NodeServerError> {
1717        let catalog = Self::context_pack_fixture_catalog(proof_path, None, false)?;
1718        Self::new_with_registry(config, catalog)
1719    }
1720
1721    #[cfg(all(feature = "fixture", windows))]
1722    pub fn new_monitoring_context_pack_fixture(
1723        config: NodeServerConfig,
1724        proof_path: PathBuf,
1725    ) -> Result<Self, NodeServerError> {
1726        let catalog = Self::context_pack_fixture_catalog(proof_path, None, true)?;
1727        let mut server = Self::new_with_registry(config, catalog)?;
1728        Arc::get_mut(&mut server.shared)
1729            .expect("fixture Node shared state must remain uniquely owned before run")
1730            .fixture_semantic_hook_policy = true;
1731        Ok(server)
1732    }
1733
1734    #[cfg(all(feature = "fixture", windows))]
1735    pub fn install_isolated_codex_home_environment_fixture(
1736        &mut self,
1737        profile_id: SpawnEnvironmentProfileId,
1738        profile_revision: crate::protocol::SpawnEnvironmentProfileRevision,
1739    ) -> Result<(), NodeServerError> {
1740        let provider = AgentId::new("codex")
1741            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1742        let native_profile = gate4agent_runtime_native::NativeLaunchProfile::new(
1743            gate4agent_runtime_native::NativeLaunchProfileId::new(format!(
1744                "{}-pty",
1745                profile_id.as_str(),
1746            ))
1747            .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1748            provider.clone(),
1749            TransportKind::Pty,
1750            vec![OsString::from("USERPROFILE")],
1751            Arc::new(IsolatedCodexHomeFixtureEnvironment),
1752        )
1753        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1754        let materialization = NodeSessionMaterializationProfile::new(
1755            Vec::new(),
1756            vec![crate::session_environment::NodeSessionPathBinding::new(
1757                "CODEX_HOME",
1758                crate::session_environment::NodeSessionPathClass::ProviderHome,
1759            )
1760            .map_err(|error| NodeServerError::Registry(error.to_string()))?],
1761            Vec::new(),
1762        )
1763        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1764        self.install_environment_profile(
1765            NodeEnvironmentProfile::new_with_materialization(
1766                profile_id,
1767                profile_revision,
1768                provider,
1769                [native_profile],
1770                Some(materialization),
1771            )
1772            .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1773        )
1774    }
1775
1776    #[cfg(all(feature = "fixture", windows))]
1777    pub fn new_context_only_proof_fixture(
1778        config: NodeServerConfig,
1779        target_provider: AgentId,
1780        proof_path: PathBuf,
1781    ) -> Result<Self, NodeServerError> {
1782        if target_provider.as_str() != "kimi" {
1783            return Err(NodeServerError::Registry(
1784                "context-only proof fixture requires Kimi".to_owned(),
1785            ));
1786        }
1787        let catalog = Self::context_pack_fixture_catalog(
1788            proof_path,
1789            Some(&target_provider),
1790            false,
1791        )?;
1792        Self::new_with_registry(config, catalog)
1793    }
1794
1795    #[cfg(all(feature = "fixture", windows))]
1796    fn context_pack_fixture_catalog(
1797        proof_path: PathBuf,
1798        context_only_target: Option<&AgentId>,
1799        monitoring_claude_source: bool,
1800    ) -> Result<AgentRegistry, NodeServerError> {
1801        if !proof_path.is_absolute()
1802            || !proof_path
1803                .parent()
1804                .is_some_and(|parent| parent.is_dir())
1805        {
1806            return Err(NodeServerError::Registry(
1807                "context pack proof path requires an existing absolute parent".to_owned(),
1808            ));
1809        }
1810        let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1811            NodeServerError::Registry(
1812                "context pack proof path is not valid Unicode".to_owned(),
1813            )
1814        })?;
1815        let providers = ["claude", "codex", "grok", "kimi"];
1816        let builtins = builtin_registry();
1817        let mut specs = Vec::with_capacity(providers.len());
1818        for provider_id in providers {
1819            let agent_id = AgentId::new(provider_id)
1820                .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1821            let provider = builtins.get(&agent_id).ok_or_else(|| {
1822                NodeServerError::Registry(
1823                    "context pack fixture provider adapter is unavailable".to_owned(),
1824                )
1825            })?;
1826            let monitored_provider =
1827                monitoring_claude_source && matches!(provider_id, "claude" | "codex");
1828            let mut spec = if monitored_provider {
1829                gate4agent_testkit::monitoring_hook_agent_spec()
1830            } else {
1831                gate4agent_testkit::interactive_agent_spec()
1832            };
1833            spec.id = agent_id.clone();
1834            spec.display_name = format!("Controlled {agent_id} context pack fixture");
1835            spec.detection.command = format!("gate4agent-{provider_id}-context-fixture.cmd");
1836            spec.detection.aliases.clear();
1837            spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1838            if provider_id == "codex" {
1839                let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1840                    NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1841                })?;
1842                let validation = r#"
1843$bundleArgs = @($bundleArgs)
1844if ($bundleArgs.Count -ne 0) { exit 91 }
1845$codexHome = [Environment]::GetEnvironmentVariable('CODEX_HOME', 'Process')
1846$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1847$cwd = (Get-Location).ProviderPath
1848if ([string]::IsNullOrEmpty($codexHome) -or -not [IO.Path]::IsPathRooted($codexHome) -or -not (Test-Path -LiteralPath $codexHome -PathType Container)) { exit 92 }
1849$skillPath = Join-Path $codexHome 'skills/review-code/SKILL.md'
1850if (-not (Test-Path -LiteralPath $skillPath -PathType Leaf)) { exit 93 }
1851$homeFiles = @(Get-ChildItem -LiteralPath $codexHome -Recurse -Force -File)
1852if ($homeFiles.Count -ne 1 -or [IO.Path]::GetFullPath($homeFiles[0].FullName) -ine [IO.Path]::GetFullPath($skillPath)) { exit 94 }
1853$sha = [Security.Cryptography.SHA256]::Create()
1854try { $skillHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($skillPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1855if ($skillHash -cne 'd78fe03be106b673fcf5415c8359e99f0298b5071cd11822c58ebcb27e52a68d') { exit 95 }
1856if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 96 }
1857$contextPath = Join-Path $contextRoot 'context-pack.json'
1858if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 97 }
1859$contextFiles = @(Get-ChildItem -LiteralPath $contextRoot -Recurse -Force -File)
1860if ($contextFiles.Count -ne 1 -or [IO.Path]::GetFullPath($contextFiles[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 98 }
1861$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1862$codexFull = [IO.Path]::GetFullPath($codexHome).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1863$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1864$separator = [IO.Path]::DirectorySeparatorChar
1865if ($contextFull.Equals($codexFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($codexFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 99 }
1866if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 100 }
1867$rawContext = [IO.File]::ReadAllText($contextPath)
1868if ($rawContext.Contains('g4a-private-provider-session-canary') -or $rawContext.Contains('private-provider-login-identity')) { exit 101 }
1869try { $document = $rawContext | ConvertFrom-Json -ErrorAction Stop } catch { exit 102 }
1870$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')
1871$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')
1872foreach ($property in @($document.PSObject.Properties.Name)) {
1873    if ($allowedTopLevel -cnotcontains [string]$property) { exit 103 }
1874}
1875foreach ($property in $requiredTopLevel) {
1876    if ($null -eq $document.PSObject.Properties[$property]) { exit 104 }
1877}
1878if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 105 }
1879if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 106 }
1880$messages = @($document.retained_messages)
1881if ($messages.Count -eq 0) { exit 107 }
1882foreach ($message in $messages) {
1883    $messageProperties = @($message.PSObject.Properties.Name)
1884    foreach ($property in $messageProperties) {
1885        if (@('role', 'text') -cnotcontains [string]$property) { exit 108 }
1886    }
1887    if ($messageProperties.Count -ne 2 -or @('user', 'assistant') -cnotcontains [string]$message.role -or [string]::IsNullOrEmpty([string]$message.text)) { exit 109 }
1888}
1889$sha = [Security.Cryptography.SHA256]::Create()
1890try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1891[IO.File]::WriteAllLines($proofPath, [string[]]@('contextual', $codexHome, $cwd, $skillHash, $contextRoot, $contextHash, [string]$document.schema, [string]$document.source_provider, [string]$messages.Count, [string]$messages[0].text, [string]$messages[$messages.Count - 1].text))
1892$rawProof = [IO.File]::ReadAllText($proofPath)
1893if ($rawProof.Contains('g4a-private-provider-session-canary') -or $rawProof.Contains('private-provider-login-identity')) { exit 110 }
1894[Console]::Out.WriteLine('F7_CODEX_BUNDLE_CONTEXT_VALIDATED')
1895"#;
1896                spec.launch.fixed_args.push(format!(
1897                    "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1898                ));
1899                spec.launch.fixed_args.push(proof_path.clone());
1900            } else if context_only_target.map(AgentId::as_str) == Some(provider_id) {
1901                let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1902                    NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1903                })?;
1904                let validation = r#"
1905$bundleArgs = @($bundleArgs)
1906if ($bundleArgs.Count -ne 0) { exit 91 }
1907$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1908$cwd = (Get-Location).ProviderPath
1909if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 92 }
1910$contextPath = Join-Path $contextRoot 'context-pack.json'
1911if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 93 }
1912$entries = @(Get-ChildItem -LiteralPath $contextRoot -Force)
1913if ($entries.Count -ne 1 -or $entries[0] -isnot [IO.FileInfo] -or [IO.Path]::GetFullPath($entries[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 94 }
1914$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1915$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1916$separator = [IO.Path]::DirectorySeparatorChar
1917if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase) -or $cwdFull.StartsWith($contextFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 95 }
1918try { $document = [IO.File]::ReadAllText($contextPath) | ConvertFrom-Json -ErrorAction Stop } catch { exit 96 }
1919if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 97 }
1920if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 98 }
1921$messages = @($document.retained_messages)
1922if ($messages.Count -eq 0) { exit 99 }
1923$hasUser = $false
1924$hasAssistant = $false
1925foreach ($message in $messages) {
1926    if ([string]::IsNullOrWhiteSpace([string]$message.text)) { exit 100 }
1927    if ([string]$message.role -ceq 'user') { $hasUser = $true }
1928    elseif ([string]$message.role -ceq 'assistant') { $hasAssistant = $true }
1929    else { exit 101 }
1930}
1931if (-not $hasUser -or -not $hasAssistant) { exit 102 }
1932$sha = [Security.Cryptography.SHA256]::Create()
1933try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1934[IO.File]::WriteAllLines($proofPath, [string[]]@('context-only', $contextRoot, $cwd, $contextHash, [string]$document.schema, [string]$document.source_provider, [string]$messages.Count))
1935[Console]::Out.WriteLine('F7_KIMI_CONTEXT_ONLY_VALIDATED')
1936"#;
1937                spec.launch.fixed_args.push(format!(
1938                    "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1939                ));
1940                spec.launch.fixed_args.push(proof_path.clone());
1941            }
1942            specs.push(spec);
1943        }
1944        let catalog = AgentRegistry::new(specs)
1945            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1946        Ok(catalog)
1947    }
1948
1949    #[cfg(feature = "fixture")]
1950    pub fn new_exact_launcher_fixture(
1951        config: NodeServerConfig,
1952        agent_id: AgentId,
1953        launcher: String,
1954    ) -> Result<Self, NodeServerError> {
1955        if !matches!(agent_id.as_str(), "claude" | "codex" | "grok" | "kimi") {
1956            return Err(NodeServerError::Registry(
1957                "exact launcher fixture requires a supported provider".to_owned(),
1958            ));
1959        }
1960        if launcher.contains('\0') {
1961            return Err(NodeServerError::Registry(
1962                "exact launcher fixture path contains NUL".to_owned(),
1963            ));
1964        }
1965        let launcher_path = Path::new(&launcher);
1966        if !launcher_path.is_absolute() {
1967            return Err(NodeServerError::Registry(
1968                "exact launcher fixture path must be absolute".to_owned(),
1969            ));
1970        }
1971        let metadata = std::fs::metadata(launcher_path).map_err(|_| {
1972            NodeServerError::Registry("exact launcher fixture path is unavailable".to_owned())
1973        })?;
1974        if !metadata.is_file() {
1975            return Err(NodeServerError::Registry(
1976                "exact launcher fixture path must be a regular file".to_owned(),
1977            ));
1978        }
1979        let mut spec = builtin_registry()
1980            .get(&agent_id)
1981            .ok_or_else(|| {
1982                NodeServerError::Registry(
1983                    "exact launcher fixture provider is unavailable".to_owned(),
1984                )
1985            })?
1986            .clone();
1987        spec.launch.program = launcher;
1988        Self::new_fixture_with_spec(config, spec)
1989    }
1990
1991    /// Shared H3B validation and spec construction for
1992    /// `new_harness_mcp_proxy_fixture` and
1993    /// `new_harness_mcp_acp_launcher_fixture`: an absolute, exact-regular-
1994    /// file provider launcher plus at most 32 Unicode, NUL-free fixed
1995    /// arguments (65,536 bytes total), replacing `spec.launch` on a clone of
1996    /// the named builtin catalog entry -- detection command and expected
1997    /// process matcher rebased on the launcher's own basename, exactly the
1998    /// same shape `new_harness_mcp_proxy_fixture` always built.
1999    #[cfg(feature = "fixture")]
2000    fn h3b_provider_spec_with_program_and_args(
2001        agent_id: AgentId,
2002        provider_program: PathBuf,
2003        fixed_args: Vec<OsString>,
2004    ) -> Result<AgentSpec, NodeServerError> {
2005        if !provider_program.is_absolute() || fixed_args.len() > 32 {
2006            return Err(NodeServerError::Registry(
2007                "H3B fixture requires an absolute provider and at most 32 arguments".to_owned(),
2008            ));
2009        }
2010        let metadata = std::fs::symlink_metadata(&provider_program).map_err(|_| {
2011            NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
2012        })?;
2013        if !metadata.file_type().is_file() || metadata.file_type().is_symlink() {
2014            return Err(NodeServerError::Registry(
2015                "H3B fixture provider must be an exact regular file".to_owned(),
2016            ));
2017        }
2018        let program = provider_program.into_os_string().into_string().map_err(|_| {
2019            NodeServerError::Registry("H3B fixture provider path is not Unicode".to_owned())
2020        })?;
2021        let fixed_args = fixed_args.into_iter().map(|argument| {
2022            argument.into_string().map_err(|_| NodeServerError::Registry(
2023                "H3B fixture argument is not Unicode".to_owned(),
2024            ))
2025        }).collect::<Result<Vec<_>, _>>()?;
2026        let argument_bytes = fixed_args.iter()
2027            .try_fold(0usize, |total, argument| total.checked_add(argument.len()));
2028        if program.contains('\0')
2029            || fixed_args.iter().any(|argument| argument.contains('\0'))
2030            || !matches!(argument_bytes, Some(total) if total <= 65_536)
2031        {
2032            return Err(NodeServerError::Registry(
2033                "H3B fixture provider or arguments are invalid".to_owned(),
2034            ));
2035        }
2036        let mut spec = builtin_registry().get(&agent_id).ok_or_else(|| {
2037            NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
2038        })?.clone();
2039        let process_name = Path::new(&program).file_name().and_then(|name| name.to_str())
2040            .ok_or_else(|| NodeServerError::Registry(
2041                "H3B fixture provider basename is unavailable".to_owned(),
2042            ))?.to_owned();
2043        spec.detection.command = process_name.clone();
2044        spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
2045            name: process_name,
2046        }];
2047        spec.launch.program = program;
2048        spec.launch.fixed_args = fixed_args;
2049        Ok(spec)
2050    }
2051
2052    #[cfg(feature = "fixture")]
2053    pub fn new_harness_mcp_proxy_fixture(
2054        config: NodeServerConfig,
2055        provider_program: PathBuf,
2056        fixed_args: Vec<OsString>,
2057        harness_mcp_program: PathBuf,
2058    ) -> Result<Self, NodeServerError> {
2059        let mut config = config.with_harness_mcp_helper(harness_mcp_program)?;
2060        let agent_id = AgentId::new("codex")
2061            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2062        let mut spec =
2063            Self::h3b_provider_spec_with_program_and_args(agent_id, provider_program, fixed_args)?;
2064        spec.display_name = "Controlled H3B MCP proxy fixture".to_owned();
2065        config.fixture_raw_pty_runtime = true;
2066        Self::new_fixture_with_spec(config, spec)
2067    }
2068
2069    /// The ACP-mode sibling of `new_harness_mcp_proxy_fixture`: the same
2070    /// `--harness-mcp-helper` wiring and the same absolute-program-plus-
2071    /// fixed-args launcher replacement as `new_exact_launcher_fixture`, but
2072    /// admitting `ProviderRuntimeRequirement::Acp` the honest way instead of
2073    /// hardcoding it. `new_harness_mcp_proxy_fixture` forces `config.
2074    /// fixture_raw_pty_runtime = true`, which tears out the live
2075    /// `ProviderRuntimeMonitor` entirely (`NodeServer::new_with_registry`)
2076    /// and makes `NodeShared::admit_provider_runtime`'s no-monitor branch
2077    /// hardcode `acp_transport = false` for every provider, refusing
2078    /// `ProviderRuntimeRequirement::Acp` unconditionally regardless of what
2079    /// the catalog declares. This constructor leaves `fixture_raw_pty_
2080    /// runtime` at its default `false`, so a real `ProviderRuntimeMonitor`
2081    /// is built from the (cloned, launcher-replaced) catalog spec, and
2082    /// `ProviderRuntimeMonitor::supports_acp_transport` reads the honest,
2083    /// unmodified catalog fact: every builtin provider this accepts
2084    /// (`claude`, `codex`, `grok`, `kimi`) already declares `capabilities.
2085    /// transports.acp` in `gate4agent-catalog::builtin`, so no capability is
2086    /// invented here -- this constructor only removes the fixture-only
2087    /// bypass standing in its way. `provider_runtime::policy_for_transport`'s
2088    /// `TransportKind::Acp` arm (applied unconditionally at spawn-prep time
2089    /// for every ACP session, fixture or not) is what actually grants the
2090    /// ACP-shaped `ProviderRuntimePolicy` downstream of that admission --
2091    /// this constructor never calls it directly, and does not need to.
2092    #[cfg(feature = "fixture")]
2093    pub fn new_harness_mcp_acp_launcher_fixture(
2094        config: NodeServerConfig,
2095        helper_program: PathBuf,
2096        provider: AgentId,
2097        program: PathBuf,
2098        args: Vec<OsString>,
2099    ) -> Result<Self, NodeServerError> {
2100        if !matches!(provider.as_str(), "claude" | "codex" | "grok" | "kimi") {
2101            return Err(NodeServerError::Registry(
2102                "ACP harness-MCP launcher fixture requires a supported provider".to_owned(),
2103            ));
2104        }
2105        let config = config.with_harness_mcp_helper(helper_program)?;
2106        let mut spec = Self::h3b_provider_spec_with_program_and_args(provider, program, args)?;
2107        let launch = spec.launch.clone();
2108        let acp = spec.capabilities.transports.acp.as_mut().ok_or_else(|| {
2109            NodeServerError::Registry(
2110                "ACP harness-MCP launcher fixture provider does not declare an ACP transport"
2111                    .to_owned(),
2112            )
2113        })?;
2114        // Routes the ACP spawn itself through the exact replaced launcher
2115        // (`AcpSession::spawn_with_launch` -> a bare `Command::new(&launch.
2116        // program)`, `gate4agent`'s `src/acp/spawn.rs`) instead of the
2117        // catalog's own vendor-npm resolution (`acp_command`, same file) --
2118        // without this the ACP transport ignores `spec.launch` entirely and
2119        // tries to spawn the real vendor CLI (e.g. `npx -y @agentclientprotocol/
2120        // codex-acp@1.10.0`), which this fixture must never reach.
2121        acp.launch_override = Some(launch);
2122        spec.display_name = "Controlled ACP harness-MCP launcher fixture".to_owned();
2123        Self::new_fixture_with_spec(config, spec)
2124    }
2125
2126    #[cfg(feature = "fixture")]
2127    pub fn new_resume_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
2128        let mut spec = gate4agent_testkit::interactive_agent_spec();
2129        let claude_id = AgentId::new("claude")
2130            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2131        let claude = builtin_registry()
2132            .get(&claude_id)
2133            .ok_or_else(|| NodeServerError::Registry("Claude fixture adapter is unavailable".to_owned()))?;
2134        spec.id = claude_id;
2135        spec.display_name = "Resumable Claude PTY fixture".to_owned();
2136        spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
2137            name: "claude".to_owned(),
2138        }];
2139        spec.capabilities.transports.pty_adapter = claude
2140            .capabilities
2141            .transports
2142            .pty_adapter
2143            .clone();
2144        spec.capabilities.adapters.resume = claude
2145            .capabilities
2146            .adapters
2147            .resume
2148            .clone();
2149        let script = spec
2150            .launch
2151            .fixed_args
2152            .pop()
2153            .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
2154        spec.launch.fixed_args.push(format!(
2155            "& {{ param([Parameter(ValueFromRemainingArguments=$true)][string[]]$ignored) {script} }}",
2156        ));
2157        Self::new_fixture_with_spec(config, spec)
2158    }
2159
2160    #[cfg(feature = "fixture")]
2161    pub fn new_cmd_cwd_fixture(
2162        config: NodeServerConfig,
2163        command: String,
2164    ) -> Result<Self, NodeServerError> {
2165        let mut spec = gate4agent_testkit::interactive_agent_spec();
2166        spec.id = AgentId::new("claude")
2167            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2168        spec.display_name = "Controlled cmd cwd fixture".to_owned();
2169        spec.detection.command = "gate4agent-cwd-fixture.cmd".to_owned();
2170        spec.launch.program = command;
2171        spec.launch.fixed_args.clear();
2172        Self::new_fixture_with_spec(config, spec)
2173    }
2174
2175    #[cfg(feature = "fixture")]
2176    fn new_fixture_with_spec(
2177        config: NodeServerConfig,
2178        spec: gate4agent_types::AgentSpec,
2179    ) -> Result<Self, NodeServerError> {
2180        let catalog = AgentRegistry::new([spec])
2181            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2182        Self::new_with_registry(config, catalog)
2183    }
2184
2185    fn new_with_registry(
2186        config: NodeServerConfig,
2187        catalog: AgentRegistry,
2188    ) -> Result<Self, NodeServerError> {
2189        for profile in config.spawn_profiles.iter() {
2190            if catalog.get(&profile.provider).is_none() {
2191                return Err(NodeServerError::Registry(
2192                    "spawn profile references an unavailable provider".to_owned(),
2193                ));
2194            }
2195        }
2196        let input_settle_timeout_ms = catalog
2197            .iter()
2198            .map(|spec| spec.readiness.timeout_ms)
2199            .max()
2200            .unwrap_or(MUTATION_SETTLE_TIMEOUT_MS)
2201            .saturating_add(READINESS_SETTLE_HEADROOM_MS)
2202            .max(MUTATION_SETTLE_TIMEOUT_MS);
2203        let (provider_contracts, provider_adapter_contracts) =
2204            provider_contract_manifest(&catalog)?;
2205        let compatibility_support = node_compatibility_support_for_manifest(
2206            &provider_contracts,
2207            &provider_adapter_contracts,
2208        )?;
2209        validate_node_negotiated_handshake_capacity(&compatibility_support)
2210            .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2211        let enabled_providers = provider_contracts
2212            .iter()
2213            .map(|contract| contract.provider.clone())
2214            .collect();
2215        #[cfg(feature = "fixture")]
2216        let fixture_raw_pty_runtime = config.fixture_raw_pty_runtime;
2217        #[cfg(not(feature = "fixture"))]
2218        let fixture_raw_pty_runtime = false;
2219        let (provider_runtime_monitor, provider_runtime_statuses) = if fixture_raw_pty_runtime {
2220            let statuses = ProviderRuntimeStatuses::new(catalog.iter().map(|spec| {
2221                crate::protocol::ProviderRuntimeStatus::raw_passthrough(spec.id.clone(), None)
2222            })).expect("fixture provider catalog remains bounded");
2223            (None, statuses)
2224        } else {
2225            let monitor = Arc::new(ProviderRuntimeMonitor::new(&catalog));
2226            let statuses = monitor.collect();
2227            (Some(monitor), statuses)
2228        };
2229        let state_path_lock =
2230            session_registry::StatePathLock::acquire(config.state_path.as_deref())
2231                .map_err(|error| {
2232                    durable_state_server_error(error, DURABLE_STATE_LOCK_FAILED_ERROR)
2233                })?;
2234        let (delivery_store, delivered_bundles) = match config.state_path.as_ref() {
2235            Some(state_path) => {
2236                let delivery_root = delivery_store_root_for_state_path(state_path)
2237                    .ok_or_else(|| {
2238                        NodeServerError::DeliveryStore(
2239                            "delivery store root could not be derived from state path".to_owned(),
2240                        )
2241                    })?;
2242                let (store, bundles) = DeliveryStore::open(delivery_root).map_err(|error| {
2243                    let detail = match &error {
2244                        DeliveryStoreError::Storage(io_error) => {
2245                            format!("{error}: {io_error}")
2246                        }
2247                        _ => error.to_string(),
2248                    };
2249                    NodeServerError::DeliveryStore(detail)
2250                })?;
2251                (Some(store), bundles)
2252            }
2253            None => (None, Vec::new()),
2254        };
2255        let delivered_catalog = BundleCatalog::new(delivered_bundles).map_err(|error| {
2256            NodeServerError::DeliveryStore(format!("bundle catalog: {error}"))
2257        })?;
2258        let (context_pack_store, durable_context_packs) = match config.state_path.as_ref() {
2259            Some(state_path) => {
2260                let context_pack_root = context_pack_store_root_for_state_path(state_path)
2261                    .ok_or(NodeServerError::ContextPackStore)?;
2262                let (store, packs) = ContextPackStore::open(context_pack_root)
2263                    .map_err(|_| NodeServerError::ContextPackStore)?;
2264                (Some(store), packs)
2265            }
2266            None => (None, Vec::new()),
2267        };
2268        let mut durable_context_catalog = ContextPackCatalog::default();
2269        for pack in durable_context_packs {
2270            durable_context_catalog
2271                .insert(pack)
2272                .map_err(|_| NodeServerError::ContextPackStore)?;
2273        }
2274        let session_environment_materializer = config
2275            .session_environment
2276            .as_ref()
2277            .map(|environment| {
2278                SessionEnvironmentMaterializer::new(
2279                    environment.root.clone(),
2280                    Arc::clone(&environment.resolver),
2281                )
2282                .map_err(|_| NodeServerError::SessionEnvironmentMaterializer)
2283            })
2284            .transpose()?;
2285        // Session launchers go through the unprivileged `exec-as` wrapper when run-as is set
2286        // (the provider version probe above already used the original catalog).
2287        #[cfg(unix)]
2288        let catalog = crate::isolation::wrap_registry(catalog).map_err(NodeServerError::Registry)?;
2289        let native_session_catalog = config.history.clone().map(|history| {
2290            Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)))
2291        });
2292        let (handle, runtime) = match config.history.clone() {
2293            Some(history) => NativeRuntime::new_with_history(catalog, config.runtime, history),
2294            None => NativeRuntime::new(catalog, config.runtime),
2295        };
2296        let native_launch_profile_control = runtime.native_launch_profile_control();
2297        let harness_mcp_registry = config
2298            .harness_mcp_helper
2299            .clone()
2300            .map(HarnessMcpProxyRegistry::new);
2301        let events = handle.subscribe(CONTROL_EVENT_SUBSCRIPTION_CAPACITY);
2302        let incarnation_id = random_incarnation_id()
2303            .map_err(NodeServerError::IncarnationIdentity)?;
2304        let mut loaded = session_registry::load(config.state_path.as_deref(), &config.node_id)
2305            .map_err(durable_state_load_error)?;
2306        let managed_worktrees = std::mem::take(&mut loaded.managed_worktrees);
2307        let managed_worktree_tombstones =
2308            std::mem::take(&mut loaded.managed_worktree_tombstones);
2309        let materializations = std::mem::take(&mut loaded.materializations);
2310        let managed_spawn_replays = std::mem::take(&mut loaded.managed_spawn_replays);
2311        let (workspaces, records, persistence_warning) =
2312            merge_durable_state(&config.workspaces, loaded)?;
2313        let mut shared = NodeShared::new_with_incarnation(
2314            handle,
2315            config.access_token.clone(),
2316            config.node_id.clone(),
2317            incarnation_id,
2318            workspaces,
2319            enabled_providers,
2320            provider_runtime_statuses,
2321            provider_runtime_monitor,
2322            provider_contracts,
2323            provider_adapter_contracts,
2324            config.spawn_profiles.clone(),
2325            Some(native_launch_profile_control),
2326            native_session_catalog,
2327            session_environment_materializer,
2328            config.state_path.clone(),
2329            records,
2330            managed_worktrees,
2331            managed_worktree_tombstones,
2332            materializations,
2333            persistence_warning,
2334            input_settle_timeout_ms,
2335        );
2336        shared.harness_mcp_registry = harness_mcp_registry.clone();
2337        shared.session_record_retention = config.session_record_retention;
2338        shared.network_allowlist_catalog =
2339            RwLock::new(config.network_allowlist_catalog.clone());
2340        shared.bundle_catalog = RwLock::new(delivered_catalog);
2341        shared.delivery_store = Mutex::new(delivery_store);
2342        shared.context_catalog = RwLock::new(durable_context_catalog);
2343        shared.context_pack_store = Mutex::new(context_pack_store);
2344        shared.managed_spawn_replays = Mutex::new(
2345            managed_spawn_replays
2346                .into_iter()
2347                .map(|record| (record.idempotency_key.clone(), record))
2348                .collect(),
2349        );
2350        let shared = Arc::new(shared);
2351        if let Some(registry) = harness_mcp_registry {
2352            let weak = Arc::downgrade(&shared);
2353            registry.set_event_sink(Arc::new(move |event| match weak.upgrade() {
2354                Some(shared) => shared.publish_transient(event),
2355                None => false,
2356            }));
2357        }
2358        if shared.persistence_error().is_none() {
2359            shared.persist_state().map_err(|error| {
2360                durable_state_server_error(error, DURABLE_STATE_COMMIT_FAILED_ERROR)
2361            })?;
2362        }
2363        Ok(Self {
2364            config,
2365            runtime,
2366            shared,
2367            events,
2368            state_path_lock,
2369        })
2370    }
2371
2372    pub fn shutdown_handle(&self) -> NodeShutdownHandle {
2373        NodeShutdownHandle {
2374            shared: Arc::clone(&self.shared),
2375        }
2376    }
2377
2378    #[cfg(feature = "fixture")]
2379    pub fn spawn_managed_worktree_v2_failure_probe_fixture(
2380        &self,
2381    ) -> SpawnManagedWorktreeV2FailureProbe {
2382        self.shared
2383            .fixture_spawn_managed_worktree_v2_failure_probe
2384            .clone()
2385    }
2386
2387    /// Installs one immutable host-local environment profile before the server runs.
2388    ///
2389    /// Native resolvers remain inside the runtime; the shared node registry keeps
2390    /// only public IDs, revisions, provider bindings, and opaque native profile IDs.
2391    pub fn install_environment_profile(
2392        &mut self,
2393        profile: NodeEnvironmentProfile,
2394    ) -> Result<(), NodeServerError> {
2395        if !self.shared.enabled_providers.contains(profile.provider()) {
2396            return Err(NodeServerError::EnvironmentProfileProviderUnavailable(
2397                profile.provider().clone(),
2398            ));
2399        }
2400        let (binding, native_profiles, materialization) = profile.into_parts();
2401        if materialization.is_some() && self.shared.session_environment_materializer.is_none() {
2402            return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2403        }
2404        let mut profiles = self
2405            .shared
2406            .environment_profiles
2407            .write()
2408            .unwrap_or_else(|poisoned| poisoned.into_inner());
2409        if profiles.contains_key(&binding.id) {
2410            return Err(NodeServerError::DuplicateEnvironmentProfile(
2411                binding.id.clone(),
2412            ));
2413        }
2414        if profiles.len() >= MAX_NODE_ENVIRONMENT_PROFILES {
2415            return Err(NodeServerError::EnvironmentProfileCapacity {
2416                max: MAX_NODE_ENVIRONMENT_PROFILES,
2417            });
2418        }
2419        for native_id in binding.native_profile_ids() {
2420            if profiles.values().any(|current| {
2421                current.native_profile_ids().any(|current_id| current_id == native_id)
2422            }) {
2423                return Err(NodeServerError::DuplicateNativeEnvironmentProfile(
2424                    native_id.to_string(),
2425                ));
2426            }
2427        }
2428
2429        let mut installed = Vec::with_capacity(native_profiles.len());
2430        for native_profile in native_profiles {
2431            let native_id = native_profile.id().clone();
2432            if let Err(error) = self.runtime.upsert_native_launch_profile(native_profile) {
2433                for installed_id in installed.into_iter().rev() {
2434                    let _ = self.runtime.remove_native_launch_profile(&installed_id);
2435                }
2436                return Err(NodeServerError::NativeEnvironmentProfile(error.to_string()));
2437            }
2438            installed.push(native_id);
2439        }
2440        if let Some(materialization) = materialization {
2441            self.shared
2442                .environment_materialization_profiles
2443                .write()
2444                .unwrap_or_else(|poisoned| poisoned.into_inner())
2445                .insert(binding.id.clone(), materialization);
2446        }
2447        profiles.insert(binding.id.clone(), binding);
2448        Ok(())
2449    }
2450
2451    /// Installs one immutable host-local bundle before the server runs.
2452    pub fn install_bundle(&mut self, bundle: NodeBundle) -> Result<(), NodeServerError> {
2453        if self.shared.session_environment_materializer.is_none() {
2454            return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2455        }
2456        let mut catalog = self
2457            .shared
2458            .bundle_catalog
2459            .write()
2460            .unwrap_or_else(|poisoned| poisoned.into_inner());
2461        catalog
2462            .insert_idempotent(bundle)
2463            .map_err(|error| NodeServerError::BundleCatalog(error.to_string()))?;
2464        Ok(())
2465    }
2466
2467    pub async fn run_until_ctrl_signal(self) -> Result<(), NodeServerError> {
2468        let shutdown = self.shutdown_handle();
2469        let run = self.run();
2470        tokio::pin!(run);
2471        tokio::select! {
2472            result = &mut run => result,
2473            signal = wait_for_ctrl_signal() => {
2474                signal?;
2475                let shutdown_result = shutdown.request_shutdown().await;
2476                let run_result = run.await;
2477                shutdown_result.and(run_result)
2478            }
2479        }
2480    }
2481
2482    pub async fn run(self) -> Result<(), NodeServerError> {
2483        let NodeServer {
2484            config,
2485            mut runtime,
2486            shared,
2487            events,
2488            state_path_lock: _state_path_lock,
2489        } = self;
2490        shared.reconcile_materializations();
2491        shared.reconcile_managed_worktrees().await;
2492        shared.retire_unavailable_session_records().await;
2493        shared.reconcile_context_pack_exports().await;
2494        // Lifecycle hooks are retired (owner ruling 2026-09-25): the node
2495        // observes sessions through ACP where a provider has it; there is no
2496        // hook ingress.
2497        let endpoint = config.endpoint.clone();
2498        let call_home = config.call_home;
2499        let call_home_identity = config.call_home_identity.clone();
2500        let api_listen = config.api_listen;
2501        let bridge_listen = config.bridge_listen;
2502        let bridge_underlay = config.bridge_underlay;
2503        let bridge_auth = browser_bridge::BridgeAuth::from_optional(config.bridge_token)
2504            .map_err(|_| NodeServerError::InvalidBridgeToken)?;
2505        let accept_shared = Arc::clone(&shared);
2506        let api_shared = Arc::clone(&shared);
2507        let bridge_shared = Arc::clone(&shared);
2508        let shutdown_shared = Arc::clone(&shared);
2509        let shutdown_timeout = Duration::from_millis(
2510            config.runtime.provider_shutdown_timeout_ms.max(1),
2511        );
2512        let result = {
2513            let runtime_loop = drive_runtime_until_shutdown(
2514                &mut runtime,
2515                events,
2516                Arc::clone(&shared),
2517                shutdown_timeout,
2518            );
2519            let accept_loop = accept_connections(&endpoint, call_home, call_home_identity, accept_shared);
2520            let api_loop = http_api::run(api_listen, api_shared);
2521            let bridge_loop = browser_bridge::run(bridge_listen, bridge_auth, bridge_shared, bridge_underlay);
2522            tokio::pin!(runtime_loop);
2523            tokio::pin!(accept_loop);
2524            tokio::pin!(api_loop);
2525            tokio::pin!(bridge_loop);
2526            tokio::select! {
2527                runtime_result = &mut runtime_loop => {
2528                    let shutdown_result = shutdown_shared
2529                        .begin_shutdown()
2530                        .await
2531                        .map_err(NodeServerError::ShutdownDispatch);
2532                    let (accept_result, api_result, bridge_result) =
2533                        tokio::join!(&mut accept_loop, &mut api_loop, &mut bridge_loop);
2534                    match runtime_result {
2535                        Err(error) => Err(error),
2536                        Ok(()) => shutdown_result
2537                            .and(accept_result)
2538                            .and(api_result.map_err(NodeServerError::HttpApi))
2539                            .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2540                    }
2541                }
2542                accept_result = &mut accept_loop => {
2543                    let shutdown_result = shutdown_shared
2544                        .begin_shutdown()
2545                        .await
2546                        .map_err(NodeServerError::ShutdownDispatch);
2547                    let (runtime_result, api_result, bridge_result) =
2548                        tokio::join!(&mut runtime_loop, &mut api_loop, &mut bridge_loop);
2549                    match accept_result {
2550                        Err(error) => Err(error),
2551                        Ok(()) => shutdown_result
2552                            .and(runtime_result)
2553                            .and(api_result.map_err(NodeServerError::HttpApi))
2554                            .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2555                    }
2556                }
2557                api_result = &mut api_loop => {
2558                    let shutdown_result = shutdown_shared
2559                        .begin_shutdown()
2560                        .await
2561                        .map_err(NodeServerError::ShutdownDispatch);
2562                    let (runtime_result, accept_result, bridge_result) =
2563                        tokio::join!(&mut runtime_loop, &mut accept_loop, &mut bridge_loop);
2564                    match api_result {
2565                        Err(error) => Err(NodeServerError::HttpApi(error)),
2566                        Ok(()) => shutdown_result
2567                            .and(runtime_result)
2568                            .and(accept_result)
2569                            .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2570                    }
2571                }
2572                bridge_result = &mut bridge_loop => {
2573                    let shutdown_result = shutdown_shared
2574                        .begin_shutdown()
2575                        .await
2576                        .map_err(NodeServerError::ShutdownDispatch);
2577                    let (runtime_result, accept_result, api_result) =
2578                        tokio::join!(&mut runtime_loop, &mut accept_loop, &mut api_loop);
2579                    match bridge_result {
2580                        Err(error) => Err(NodeServerError::BridgeApi(error)),
2581                        Ok(()) => shutdown_result
2582                            .and(runtime_result)
2583                            .and(accept_result)
2584                            .and(api_result.map_err(NodeServerError::HttpApi)),
2585                    }
2586                }
2587            }
2588        };
2589        // Persistence gap fix: durable DeliveryStore / ContextPackStore hold
2590        // exclusive flock (Unix) / share-mode locks (Windows) inside NodeShared.
2591        // NodeShutdownHandle also clones that Arc, so without an explicit release
2592        // here a supervisor that keeps the shutdown handle cannot reopen the same
2593        // state path after run() returns (EAGAIN / Resource temporarily unavailable).
2594        // StatePathLock already drops with this stack frame (`_state_path_lock`).
2595        if let Ok(mut guard) = shared.delivery_store.lock() {
2596            *guard = None;
2597        }
2598        if let Ok(mut guard) = shared.context_pack_store.lock() {
2599            *guard = None;
2600        }
2601        result
2602    }
2603}
2604
2605async fn drive_runtime_until_shutdown(
2606    runtime: &mut NativeRuntime,
2607    events: EventSubscription,
2608    shared: Arc<NodeShared>,
2609    shutdown_timeout: Duration,
2610) -> Result<(), NodeServerError> {
2611    // Published once, never per iteration. The worker loops inside
2612    // `NativeRuntime` fold into this profile continuously; turning it into
2613    // percentiles is what costs, and that is `GET /metrics`'s bill to pay.
2614    let _ = shared
2615        .shell_efficiency_profile
2616        .set(runtime.shell_efficiency_profile());
2617    let mut shutdown_started = None;
2618    let mut last_retention_sweep = Instant::now();
2619    let mut last_provisional_reap = Instant::now();
2620    loop {
2621        // Every phase below is timed unconditionally -- see
2622        // `drive_loop_profile`'s own doc comment for why an always-on,
2623        // allocation-free ring is cheap enough to never need a feature
2624        // flag. This is the only place these numbers can be taken: the
2625        // phases are calls made directly from this loop, not something a
2626        // callee could time on our behalf.
2627        let runtime_tick_start = Instant::now();
2628        let tick_result = runtime.tick().await;
2629        let runtime_tick_elapsed = runtime_tick_start.elapsed();
2630
2631        let event_drain_start = Instant::now();
2632        // Every command the kernel refused, named. `NativeRuntimeTick`
2633        // has carried these outcomes all along and nothing read them, so a
2634        // rejected command -- a hook event denied by runtime policy, a
2635        // stale generation, an unsupported capability -- vanished at the
2636        // last step of the loop that produced it. A refusal the operator
2637        // cannot see is indistinguishable from an event that never arrived,
2638        // and telling those two apart is most of debugging this stack.
2639        for outcome in &tick_result.command_outcomes {
2640            if let Err(error) = &outcome.result {
2641                shared.rejected_commands_total.fetch_add(1, Ordering::Relaxed);
2642                tracing::warn!(
2643                    command_id = outcome.command_id.0,
2644                    cause = %error,
2645                    "control command rejected"
2646                );
2647                shared.record_rejected_command(
2648                    outcome.command_id,
2649                    error.to_string(),
2650                    error.is_unsupported_transport(),
2651                );
2652            }
2653        }
2654        let mut events_drained: u64 = 0;
2655        while let Ok(event) = events.try_recv() {
2656            events_drained += 1;
2657            let clean_exit = matches!(
2658                event.event,
2659                ControlEventKind::Exited { exit_code: Some(0), forced: false },
2660            );
2661            let instance_id = event.instance_id;
2662            let generation = event.generation;
2663            // Resolve and spawn before publish_control: publish_control's own
2664            // managed-record bookkeeping (reconcile_managed_record) downgrades
2665            // this exact record from Live to Dormant and clears
2666            // active_session for every Exited event, inside the same
2667            // synchronous call. Capturing (record_id, session) here — before
2668            // that runs — is the only way to observe them still Live and
2669            // still populated; the export itself must still run on its own
2670            // detached task (never awaited inline on this loop) because it
2671            // needs NativeRuntime::tick() to keep running to ever settle, and
2672            // tick() is only ever driven by this same loop. See
2673            // reconcile_context_pack_export_for_record's doc comment.
2674            if clean_exit {
2675                if let Some((record_id, session)) =
2676                    shared.managed_record_export_target_for_instance(instance_id, generation)
2677                {
2678                    let export_shared = Arc::clone(&shared);
2679                    tokio::spawn(async move {
2680                        export_shared
2681                            .reconcile_context_pack_export_for_record(&record_id, session)
2682                            .await;
2683                    });
2684                }
2685            }
2686            shared.publish_control(event);
2687        }
2688        let event_drain_elapsed = event_drain_start.elapsed();
2689
2690        let publish_terminal_frames_start = Instant::now();
2691        shared.publish_terminal_frames();
2692        let publish_terminal_frames_elapsed = publish_terminal_frames_start.elapsed();
2693
2694        // Physical PTY count and the runtime's own per-phase tick timings
2695        // both live inside `NativeRuntime`, which `GET /metrics` (reading
2696        // only `NodeShared`) cannot reach directly -- this loop is the one
2697        // place holding `&mut NativeRuntime`, so it is the only place that
2698        // can copy either out.
2699        shared
2700            .native_session_gauge
2701            .store(runtime.active_native_sessions(), Ordering::Relaxed);
2702        *shared
2703            .runtime_tick_profile
2704            .lock()
2705            .unwrap_or_else(|poisoned| poisoned.into_inner()) = runtime.tick_profile_snapshot();
2706
2707        let remainder_start = Instant::now();
2708        if shared.shutdown.load(Ordering::Acquire) {
2709            let started = *shutdown_started.get_or_insert_with(Instant::now);
2710            let snapshot = shared.handle.snapshot();
2711            let sessions_settled = snapshot.sessions.iter().all(|session| {
2712                !matches!(
2713                    session.status,
2714                    gate4agent_types::SessionStatus::Starting
2715                        | gate4agent_types::SessionStatus::Running
2716                        | gate4agent_types::SessionStatus::Stopping
2717                )
2718            });
2719            if runtime.active_native_sessions() == 0 && sessions_settled {
2720                return Ok(());
2721            }
2722            if started.elapsed() >= shutdown_timeout {
2723                return Err(NodeServerError::ShutdownTimedOut {
2724                    active_native_sessions: runtime.active_native_sessions(),
2725                });
2726            }
2727        }
2728        let remainder_elapsed = remainder_start.elapsed();
2729
2730        {
2731            let mut profile = shared
2732                .drive_loop_profile
2733                .lock()
2734                .unwrap_or_else(|poisoned| poisoned.into_inner());
2735            profile.record_runtime_tick(runtime_tick_elapsed);
2736            profile.record_event_drain(event_drain_elapsed);
2737            profile.record_publish_terminal_frames(publish_terminal_frames_elapsed);
2738            profile.record_remainder(remainder_elapsed);
2739            profile.note_iteration(Instant::now());
2740        }
2741
2742        // "Produced nothing" means this iteration's `NativeRuntime::tick`
2743        // applied zero observations (`tick_result.observations_applied`,
2744        // which already folds in any terminal frames collected -- see
2745        // `NativeEffectDispatcher::drain_observations`) AND the
2746        // control-event subscription drained above forwarded zero events.
2747        // Command ingress and dispatched effects are deliberately NOT part
2748        // of this definition: an idle node still runs `step_control_plane`
2749        // and every provider supervisor's `tick` regardless, every 10ms --
2750        // so "nothing" here means nothing OBSERVABLE left this iteration,
2751        // matching what a reader would assume from `drive_loop_iterations`'s
2752        // own field name, not that the tick itself was free of work.
2753        shared.drive_loop_iterations_total.fetch_add(1, Ordering::Relaxed);
2754        if tick_result.observations_applied == 0 && events_drained == 0 {
2755            shared.drive_loop_iterations_idle.fetch_add(1, Ordering::Relaxed);
2756        }
2757
2758        // A sweep cadence, not a policy value -- see
2759        // `SESSION_RECORD_RETENTION_SWEEP_PERIOD`'s own doc comment. The
2760        // policy itself (whether this does anything at all) lives entirely
2761        // in `NodeShared::session_record_retention`.
2762        if last_retention_sweep.elapsed() >= SESSION_RECORD_RETENTION_SWEEP_PERIOD {
2763            shared.retire_unavailable_session_records().await;
2764            last_retention_sweep = Instant::now();
2765        }
2766        // Provisional Spawn grace: kill PTYs that never got an HQ attach.
2767        if last_provisional_reap.elapsed() >= Duration::from_secs(2) {
2768            for address in shared.take_due_provisional_spawns() {
2769                shared.reap_provisional_spawn(address).await;
2770            }
2771            last_provisional_reap = Instant::now();
2772        }
2773
2774        // Fast-poll for 300ms after PTY input so its echo is read and published
2775        // within ~1ms; otherwise nap 10ms but wake at once on new input.
2776        let hot = shared
2777            .pty_input_marks
2778            .lock()
2779            .unwrap_or_else(|poisoned| poisoned.into_inner())
2780            .values()
2781            .any(|(at, _)| at.elapsed() < Duration::from_millis(300));
2782        if hot {
2783            sleep(Duration::from_millis(1)).await;
2784        } else {
2785            tokio::select! {
2786                _ = sleep(Duration::from_millis(10)) => {}
2787                _ = shared.pty_input_wake.notified() => {}
2788            }
2789        }
2790    }
2791}
2792
2793#[derive(Clone)]
2794pub struct NodeShutdownHandle {
2795    shared: Arc<NodeShared>,
2796}
2797
2798impl NodeShutdownHandle {
2799    pub async fn request_shutdown(&self) -> Result<(), NodeServerError> {
2800        self.shared
2801            .begin_shutdown()
2802            .await
2803            .map_err(NodeServerError::ShutdownDispatch)
2804    }
2805}
2806
2807async fn wait_for_ctrl_signal() -> Result<(), NodeServerError> {
2808    #[cfg(windows)]
2809    {
2810    let mut ctrl_c = tokio::signal::windows::ctrl_c()?;
2811    let mut ctrl_break = tokio::signal::windows::ctrl_break()?;
2812    return tokio::select! {
2813        signal = ctrl_c.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2814        signal = ctrl_break.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2815    };
2816    }
2817    #[cfg(unix)]
2818    {
2819        let mut interrupt = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())?;
2820        let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
2821        tokio::select! {
2822            signal = interrupt.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2823            signal = terminate.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2824        }
2825    }
2826}
2827
2828/// The providers this node actually runs.
2829///
2830/// `builtin_registry()` is a reference catalog of everything the project has
2831/// ever described; the fleet is this list, and it is deliberately short. A
2832/// provider only belongs here if it can be exercised for real -- there is a
2833/// subscription behind it and a failure in it can be reproduced and
2834/// debugged. Carrying one that cannot is carrying code nobody can verify,
2835/// which is worse than not supporting it: it looks supported.
2836///
2837/// A provider dropped from the fleet by this rule is removed outright, not
2838/// carried dark: no catalog entry, no adapter, no dead code claiming support
2839/// for something nobody can verify runs.
2840/// Host interactive shell for HQ terminal attach (no provider CLI, no network).
2841fn shell_host_spec() -> AgentSpec {
2842    AgentSpec {
2843        id: AgentId::new("shell").expect("shell agent id"),
2844        revision: "hatchery:shell-pty-1".to_owned(),
2845        display_name: "Host shell".to_owned(),
2846        detection: DetectionSpec {
2847            command: "bash".to_owned(),
2848            aliases: vec!["sh".to_owned(), "shell".to_owned()],
2849            required_commands: Vec::new(),
2850            unsupported_platforms: Vec::new(),
2851        },
2852        launch: LaunchSpec {
2853            // Absolute path so admission does not depend on PATH.
2854            program: "/bin/bash".to_owned(),
2855            fixed_args: vec!["-i".to_owned()],
2856        },
2857        expected_processes: vec![ProcessMatcher::Exact {
2858            name: "bash".to_owned(),
2859        }],
2860        prompt: PromptSpec {
2861            initial: InitialPromptMode::None,
2862            native_draft: None,
2863        },
2864        readiness: AgentReadinessSpec::default(),
2865        capabilities: AgentCapabilities {
2866            agent_commands: None,
2867            transports: AgentTransportCapabilities {
2868                pty: true,
2869                pty_adapter: None,
2870                pipe: None,
2871                acp: None,
2872            },
2873            adapters: AgentAdapterCapabilities::default(),
2874        },
2875        verification: SpecVerification::Gate4AgentVerified,
2876    }
2877}
2878
2879fn active_registry() -> Result<AgentRegistry, NodeServerError> {
2880    let mut specs = vec![shell_host_spec()];
2881    specs.extend(
2882        builtin_registry()
2883            .iter()
2884            .filter(|spec| matches!(spec.id.as_str(), "claude" | "codex" | "grok" | "kimi"))
2885            .cloned(),
2886    );
2887    AgentRegistry::new(specs).map_err(|error| NodeServerError::Registry(error.to_string()))
2888}
2889
2890fn provider_contract_manifest(
2891    catalog: &AgentRegistry,
2892) -> Result<
2893    (
2894        Vec<ProviderContractSupport>,
2895        Vec<ProviderAdapterContractSupport>,
2896    ),
2897    NodeServerError,
2898> {
2899    let mut provider_contracts = Vec::new();
2900    let mut provider_adapter_contracts = Vec::new();
2901    for spec in catalog.iter() {
2902        let provider = spec.id.clone();
2903        let revision = ProviderContractRevision::new(spec.revision.clone())
2904            .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2905        provider_contracts.push(ProviderContractSupport {
2906            provider: provider.clone(),
2907            revision,
2908        });
2909        for (family, binding) in declared_adapter_bindings(spec)? {
2910            let revision = AdapterContractRevision::new(binding.revision.clone())
2911                .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2912            provider_adapter_contracts.push(ProviderAdapterContractSupport {
2913                provider: provider.clone(),
2914                family,
2915                adapter_id: binding.id.clone(),
2916                revision,
2917            });
2918        }
2919    }
2920    validate_provider_contract_manifest(&provider_contracts, &provider_adapter_contracts)
2921        .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2922    Ok((provider_contracts, provider_adapter_contracts))
2923}
2924
2925fn declared_adapter_bindings(
2926    spec: &AgentSpec,
2927) -> Result<Vec<(AdapterFamily, &AdapterBinding)>, NodeServerError> {
2928    let mut bindings = Vec::new();
2929    push_declared_binding(
2930        &mut bindings,
2931        AdapterFamily::PtySemantic,
2932        spec.capabilities.transports.pty_adapter.as_ref(),
2933    )?;
2934    push_declared_binding(
2935        &mut bindings,
2936        AdapterFamily::Pipe,
2937        spec.capabilities.adapters.pty_sidecar.as_ref(),
2938    )?;
2939    let pipe_transport = spec.capabilities.transports.pipe.as_ref();
2940    let pipe_family = pipe_transport.map(|transport| match transport.protocol {
2941        gate4agent_types::PipeProtocol::SemanticNdjson
2942        | gate4agent_types::PipeProtocol::StructuredJsonl => AdapterFamily::Pipe,
2943        gate4agent_types::PipeProtocol::OneShotText => AdapterFamily::OneShot,
2944    });
2945    push_declared_binding(
2946        &mut bindings,
2947        AdapterFamily::Pipe,
2948        pipe_transport
2949            .filter(|_| pipe_family == Some(AdapterFamily::Pipe))
2950            .map(|transport| &transport.adapter),
2951    )?;
2952    push_declared_binding(
2953        &mut bindings,
2954        AdapterFamily::Acp,
2955        spec.capabilities
2956            .transports
2957            .acp
2958            .as_ref()
2959            .map(|transport| &transport.adapter),
2960    )?;
2961    push_declared_binding(
2962        &mut bindings,
2963        AdapterFamily::Hook,
2964        spec.capabilities.adapters.hook.as_ref(),
2965    )?;
2966    push_declared_binding(
2967        &mut bindings,
2968        AdapterFamily::ManagedHook,
2969        spec.capabilities.adapters.managed_hook.as_ref(),
2970    )?;
2971    push_declared_binding(
2972        &mut bindings,
2973        AdapterFamily::OneShot,
2974        pipe_transport
2975            .filter(|_| pipe_family == Some(AdapterFamily::OneShot))
2976            .map(|transport| &transport.adapter),
2977    )?;
2978    push_declared_binding(
2979        &mut bindings,
2980        AdapterFamily::OneShot,
2981        spec.capabilities.adapters.one_shot.as_ref(),
2982    )?;
2983    push_declared_binding(
2984        &mut bindings,
2985        AdapterFamily::History,
2986        spec.capabilities.adapters.history.as_ref(),
2987    )?;
2988    push_declared_binding(
2989        &mut bindings,
2990        AdapterFamily::Resume,
2991        spec.capabilities.adapters.resume.as_ref(),
2992    )?;
2993    push_declared_binding(
2994        &mut bindings,
2995        AdapterFamily::SessionOptions,
2996        spec.capabilities.adapters.session_options.as_ref(),
2997    )?;
2998    push_declared_binding(
2999        &mut bindings,
3000        AdapterFamily::CapabilityProbe,
3001        spec.capabilities.adapters.capability_probe.as_ref(),
3002    )?;
3003    Ok(bindings)
3004}
3005
3006fn push_declared_binding<'a>(
3007    bindings: &mut Vec<(AdapterFamily, &'a AdapterBinding)>,
3008    family: AdapterFamily,
3009    binding: Option<&'a AdapterBinding>,
3010) -> Result<(), NodeServerError> {
3011    let Some(binding) = binding else {
3012        return Ok(());
3013    };
3014    if let Some((_, existing)) = bindings
3015        .iter()
3016        .find(|(existing_family, _)| *existing_family == family)
3017    {
3018        if *existing == binding {
3019            return Ok(());
3020        }
3021        return Err(NodeServerError::ProviderContractManifest(format!(
3022            "provider declares conflicting {family:?} adapter bindings"
3023        )));
3024    }
3025    bindings.push((family, binding));
3026    Ok(())
3027}
3028
3029fn merge_durable_state(
3030    configured: &[WorkspaceConfig],
3031    mut loaded: LoadedNodeState,
3032) -> Result<(Vec<WorkspaceConfig>, Vec<ManagedSessionRecord>, Option<String>), NodeServerError> {
3033    let mut workspaces = configured
3034        .iter()
3035        .cloned()
3036        .map(|workspace| (workspace.workspace_id.clone(), workspace))
3037        .collect::<BTreeMap<_, _>>();
3038    let mut roots = workspaces
3039        .values()
3040        .map(|workspace| {
3041            (
3042                platform::root_identity(&workspace.canonical_root),
3043                workspace.workspace_id.clone(),
3044            )
3045        })
3046        .collect::<BTreeMap<_, _>>();
3047    let mut warning = loaded
3048        .warning
3049        .take()
3050        .map(|message| session_registry::sanitized_persistence_summary(&message));
3051
3052    for (workspace_id, canonical_root) in loaded.workspaces {
3053        if let Some(existing) = workspaces.get(&workspace_id) {
3054            if !platform::roots_equal(&existing.canonical_root, &canonical_root) {
3055                return Err(NodeServerError::DurableState(io::Error::new(
3056                    io::ErrorKind::InvalidData,
3057                    DURABLE_STATE_CONFLICT_ERROR,
3058                )));
3059            }
3060            continue;
3061        }
3062        if roots.get(&platform::root_identity(&canonical_root)).is_some() {
3063            return Err(NodeServerError::DurableState(io::Error::new(
3064                io::ErrorKind::InvalidData,
3065                DURABLE_STATE_CONFLICT_ERROR,
3066            )));
3067        }
3068        if !Path::new(&canonical_root).is_dir() {
3069            warning.get_or_insert_with(|| {
3070                session_registry::DURABLE_STATE_WORKSPACE_WARNING.to_owned()
3071            });
3072            continue;
3073        }
3074        let workspace = WorkspaceConfig::new(workspace_id.clone(), &canonical_root).map_err(|_| {
3075            NodeServerError::DurableState(io::Error::new(
3076                io::ErrorKind::InvalidData,
3077                DURABLE_STATE_CONFLICT_ERROR,
3078            ))
3079        })?;
3080        roots.insert(
3081            platform::root_identity(&workspace.canonical_root),
3082            workspace.workspace_id.clone(),
3083        );
3084        workspaces.insert(workspace_id, workspace);
3085    }
3086
3087    for record in &mut loaded.records {
3088        match workspaces.get(&record.workspace_id) {
3089            Some(workspace)
3090                if platform::roots_equal(
3091                    &workspace.canonical_root,
3092                    windows_path_text(&record.canonical_root),
3093                ) => {}
3094            Some(_) => {
3095                return Err(NodeServerError::DurableState(io::Error::new(
3096                    io::ErrorKind::InvalidData,
3097                    DURABLE_STATE_CONFLICT_ERROR,
3098                )));
3099            }
3100            None => {
3101                record.state = ManagedSessionState::Unavailable;
3102                record.active_session = None;
3103                record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
3104            }
3105        }
3106    }
3107    Ok((workspaces.into_values().collect(), loaded.records, warning))
3108}
3109
3110/// One entry of `NodeShared::rejected_command_reasons` -- see that field's
3111/// own doc for the mechanism this exists to close.
3112struct RejectedCommandReason {
3113    message: String,
3114    /// True when the kernel refused this command specifically because the
3115    /// agent does not declare the requested transport (`KernelCommandError::
3116    /// UnsupportedTransport`) -- lets the reader choose the named
3117    /// `NodeFailureCode::UnsupportedTransport` over the generic
3118    /// `BackendOperationFailed` bucket, so this one well-known rejection
3119    /// keeps its identity all the way to the operator wire.
3120    unsupported_transport: bool,
3121    expires_at: Instant,
3122}
3123
3124struct NodeShared {
3125    handle: Gate4AgentHandle,
3126    access_token: String,
3127    node_id: NodeId,
3128    incarnation_id: NodeIncarnationId,
3129    started_at_unix_ms: u64,
3130    workspaces: RwLock<BTreeMap<WorkspaceId, String>>,
3131    worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode>,
3132    managed_worktree_profiles:
3133        BTreeMap<WorkspaceId, BTreeMap<WorktreeProfileId, ManagedWorktreeProfile>>,
3134    managed_worktrees: Mutex<ManagedWorktreeRegistry>,
3135    enabled_providers: Vec<AgentId>,
3136    provider_runtime_statuses: ProviderRuntimeStatuses,
3137    provider_runtime_status_updates:
3138        RwLock<BTreeMap<AgentId, crate::protocol::ProviderRuntimeStatus>>,
3139    provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
3140    provider_contracts: Vec<ProviderContractSupport>,
3141    provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
3142    spawn_profiles: SpawnProfileRegistry,
3143    environment_profiles:
3144        RwLock<BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>>,
3145    environment_materialization_profiles:
3146        RwLock<BTreeMap<SpawnEnvironmentProfileId, NodeSessionMaterializationProfile>>,
3147    /// Station **network** allowlist catalog (opaque policy ids only).
3148    /// Empty by default — any `network_allowlist` on spawn must be registered
3149    /// here or resolve refuses with `UnknownNetworkAllowlist`. Optional
3150    /// startup load via `NodeServerConfig::with_network_allowlist_catalog(_file)`
3151    /// / env `GATE4AGENT_NODE_NETWORK_ALLOWLIST_CATALOG`. Plan
3152    /// `station-network-and-browser-profile-knobs-2026-10-02.md` §2.1 / §4.
3153    /// Never holds cookies / OAuth / proxy credentials.
3154    network_allowlist_catalog: RwLock<crate::network_allowlist_catalog::NetworkAllowlistCatalog>,
3155    /// Node-local exclusive dig2browser station profile leases (feature
3156    /// `dig2-station-probe`). Opaque `browser_profile_id` ↔ session holder;
3157    /// cleared on session end. Never ImportSession / cookies on C2.
3158    #[cfg(feature = "dig2-station-probe")]
3159    browser_station_leases: crate::dig2_station_lease::BrowserStationLeaseTable,
3160    bundle_catalog: RwLock<BundleCatalog>,
3161    delivery_store: Mutex<Option<DeliveryStore>>,
3162    context_pack_store: Mutex<Option<ContextPackStore>>,
3163    context_catalog: RwLock<ContextPackCatalog>,
3164    native_launch_profile_control: Option<NativeLaunchProfileControl>,
3165    harness_mcp_registry: Option<HarnessMcpProxyRegistry>,
3166    native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
3167    session_environment_materializer: Option<SessionEnvironmentMaterializer>,
3168    /// Per-session m4a accounts assigned at spawn (see [`crate::m4a_accounts`]).
3169    m4a_accounts: Option<crate::m4a_accounts::M4aSessionAccounts>,
3170    /// nick -> the session that holds that m4a account (for letter wakes).
3171    m4a_live: Mutex<BTreeMap<String, (AgentInstanceId, AgentId)>>,
3172    materializations: Mutex<BTreeMap<MaterializationId, MaterializationOwnershipRecord>>,
3173    spawn_idempotency: Mutex<SpawnIdempotencyCache>,
3174    managed_spawn_replays:
3175        Mutex<BTreeMap<SpawnIdempotencyKey, ManagedWorktreeSpawnReplayRecordV10>>,
3176    controller: Mutex<Option<ControllerLease>>,
3177    /// Fresh spawns waiting for their first successful HQ attach. Removed on
3178    /// Resize/TerminalBytes (sessionize) or when the grace reaper stops them.
3179    provisional_spawns: Mutex<BTreeMap<AgentInstanceId, (SessionAddress, Instant)>>,
3180    history: Mutex<NodeEventHistory>,
3181    event_tx: broadcast::Sender<NodeEventEnvelope>,
3182    terminal_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3183    /// The outbound `NodeEvent::AgentStream` push channel -- its own
3184    /// dedicated, capability-gated broadcast, mirroring `terminal_event_tx`
3185    /// exactly rather than riding the durable `event_tx` history (see
3186    /// `publish`'s own assertion): a subscriber who never negotiated
3187    /// `NODE_AGENT_STREAM_EVENTS_CAPABILITY` must never receive one of
3188    /// these, the same guarantee `include_terminal_frame_events` already
3189    /// gives terminal frames.
3190    agent_stream_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3191    /// The outbound `NodeEvent::HarnessMcpReadCall` push channel -- mirrors
3192    /// `agent_stream_event_tx` exactly, its own dedicated, capability-gated
3193    /// broadcast rather than a ride on the durable `event_tx` history. A
3194    /// `HarnessMcpReadCall` is a request/response call a live proxy call is
3195    /// waiting on (`harness_mcp_proxy.rs`'s `begin_call`); the shared bus's
3196    /// finite capacity and its willingness to silently evict a lagging
3197    /// subscriber's backlog is exactly how the call vanished with neither
3198    /// side ever logging it (see `publish_transient`'s own doc).
3199    harness_mcp_proxy_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3200    terminal_frame_watermarks:
3201        Mutex<BTreeMap<AgentInstanceId, (SessionAddress, u64)>>,
3202    /// Wakes the drive loop as soon as PTY input is queued (no 10ms nap).
3203    pty_input_wake: tokio::sync::Notify,
3204    /// Per session: when the last PTY input was dispatched (latency probe +
3205    /// a short fast-poll window so the echo is published within ~1ms).
3206    pty_input_marks: Mutex<BTreeMap<AgentInstanceId, (Instant, bool)>>,
3207    next_connection_id: AtomicU64,
3208    next_instance_id: AtomicU64,
3209    next_command_id: AtomicU64,
3210    shutdown: AtomicBool,
3211    shutdown_notify: Notify,
3212    preauth_slots: Arc<Semaphore>,
3213    authenticated_slots: Arc<Semaphore>,
3214    inspection_slots: Arc<Semaphore>,
3215    mutation_gate: AsyncMutex<()>,
3216    session_bindings: Mutex<BTreeMap<AgentInstanceId, SessionBinding>>,
3217    session_records: Mutex<SessionRecords>,
3218    state_path: Option<PathBuf>,
3219    persistence_error: RwLock<Option<String>>,
3220    state_transaction: Mutex<()>,
3221    input_settle_timeout_ms: u64,
3222    /// Drive-loop phase/rate timings -- see `drive_loop_profile`'s own doc
3223    /// comment. Written once per iteration by
3224    /// `drive_runtime_until_shutdown`, read by `GET /metrics`.
3225    drive_loop_profile: Mutex<DriveLoopProfiler>,
3226    /// Latest copy of `NativeRuntime::tick_profile_snapshot()` -- `Copy`,
3227    /// so an overwrite each iteration is cheap. The runtime itself is not
3228    /// reachable from here (it is owned locally by
3229    /// `drive_runtime_until_shutdown`), so this is how its own per-phase
3230    /// tick timings reach `GET /metrics`.
3231    runtime_tick_profile: Mutex<TickProfileSnapshot>,
3232    /// The live shell-efficiency profile, shared with the per-instance
3233    /// worker loops that fold into it.
3234    ///
3235    /// Deliberately NOT the per-iteration snapshot copy `runtime_tick_
3236    /// profile` above keeps: producing a snapshot sorts every ring behind
3237    /// it, and that sort belongs on the read. A `OnceLock` rather than a
3238    /// field set at construction because `NodeShared` is built before the
3239    /// `NativeRuntime` that owns the profile exists -- the drive loop
3240    /// publishes it once, before its first iteration. Unset means the
3241    /// runtime has not started driving yet, which `GET /metrics` reports as
3242    /// empty series rather than as zeroes it never measured.
3243    shell_efficiency_profile: OnceLock<Arc<Mutex<ShellEfficiencyProfile>>>,
3244    /// Physical PTY sessions the native runtime currently owns
3245    /// (`NativeRuntime::active_native_sessions`), refreshed once per drive
3246    /// loop iteration -- deliberately a separate count from
3247    /// `handle.snapshot().sessions.len()` (the control-plane kernel's own
3248    /// session bookkeeping), since the two can disagree and the gap is
3249    /// itself a diagnostic signal `GET /metrics` exposes.
3250    native_session_gauge: AtomicUsize,
3251    /// Lifetime totals for the per-connection serve loop, summed across
3252    /// every live connection.
3253    ///
3254    /// The drive loop's own phase timings account for about ten
3255    /// microseconds an iteration on an idle node, yet the process burns a
3256    /// quarter of a core — and burns it ONLY while a c2 is attached (0.5%
3257    /// with none, 23% with two, measured). That gap is somewhere in
3258    /// serving a connection, and there was no way to see it: iterations
3259    /// says whether the loop is spinning rather than sleeping, and events
3260    /// says whether it is spinning over real work or over nothing.
3261    connection_loop_iterations: AtomicU64,
3262    connection_events_sent: AtomicU64,
3263    /// Lifetime totals for `drive_runtime_until_shutdown`'s own iterations
3264    /// -- `drive_loop_iterations_idle` counts iterations that applied zero
3265    /// `NativeRuntime::tick` observations AND forwarded zero control
3266    /// events; see the exact "produced nothing" definition at the site in
3267    /// `drive_runtime_until_shutdown` that increments these. Read twice
3268    /// and divide by the interval, same rule as `connections` above --
3269    /// `idle / total` is backlog item 5's "wake on work" number.
3270    /// Lifetime count of control commands the kernel refused.
3271    rejected_commands_total: AtomicU64,
3272    /// The named reason behind a recent kernel rejection, by the
3273    /// `CommandId` `dispatch_bounded` handed back when it enqueued that
3274    /// command -- populated by `drive_runtime_until_shutdown` for every
3275    /// `Err` in a tick's `command_outcomes`, and drained by whichever
3276    /// spawn-dispatch waiter recognizes its own `Register`/`Start`
3277    /// `CommandId` among them.
3278    ///
3279    /// A `ControlCommand` only ever fails synchronously (a `NodeFailure`
3280    /// out of `dispatch`/`dispatch_bounded` itself) for a port-level ingress
3281    /// problem -- full or disconnected -- never for the kernel refusing the
3282    /// command's content (an unsupported transport, an unknown agent, ...);
3283    /// that verdict exists only once `NativeRuntime::tick` has processed the
3284    /// command, one drive-loop iteration later, in a different task than
3285    /// whichever caller is awaiting the spawn. Without this map, a spawn
3286    /// wait loop that polls `self.handle.snapshot()` for a session that
3287    /// will now never appear has no way to learn why -- it can only spin
3288    /// until its own commit deadline and report a generic "did not commit"
3289    /// timeout, discarding a reason the kernel already named precisely (see
3290    /// `KernelCommandError`'s variants, e.g. `UnsupportedTransport`).
3291    rejected_command_reasons: Mutex<BTreeMap<CommandId, RejectedCommandReason>>,
3292    drive_loop_iterations_total: AtomicU64,
3293    drive_loop_iterations_idle: AtomicU64,
3294    /// The operator-chosen age/keep-N policy the retention sweep applies to
3295    /// `ManagedSessionState::Unavailable` records. Both `0` by default --
3296    /// see `SessionRecordRetentionConfig`'s own doc comment.
3297    session_record_retention: SessionRecordRetentionConfig,
3298    /// Lifetime count of managed session records the retention sweep has
3299    /// retired. Same tier as `rejected_commands_total` above: a live,
3300    /// process-lifetime instrument, never persisted and never reloaded from
3301    /// `state-v1.json` -- a restart legitimately resets it, matching every
3302    /// other `*_total` counter here.
3303    retired_records_total: AtomicU64,
3304    #[cfg(feature = "fixture")]
3305    fixture_semantic_hook_policy: bool,
3306    #[cfg(feature = "fixture")]
3307    fixture_spawn_managed_worktree_v2_failure_probe: SpawnManagedWorktreeV2FailureProbe,
3308}
3309
3310impl Drop for NodeShared {
3311    fn drop(&mut self) {
3312        SessionLifecycleHook::on_shutdown(self);
3313    }
3314}
3315
3316#[derive(Clone, Debug, Eq, PartialEq)]
3317struct SessionBinding {
3318    workspace_id: WorkspaceId,
3319    generation: SessionGeneration,
3320    runtime_policy: ProviderRuntimePolicy,
3321    pending_resume: Option<(SessionGeneration, CommandId, ProviderRuntimePolicy)>,
3322    record_id: Option<SessionRecordId>,
3323    managed_worktree_lease_id: Option<ManagedWorktreeLeaseId>,
3324    environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
3325    bundle: Option<ResolvedBundleReceipt>,
3326    context: Option<ResolvedContextPackReceipt>,
3327    materialization_id: Option<MaterializationId>,
3328}
3329
3330#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3331enum SpawnRecordPolicy {
3332    ProviderIdentityOnly,
3333    Always,
3334}
3335
3336struct NativeEnvironmentSelectionGuard {
3337    control: Option<NativeLaunchProfileControl>,
3338    instance_id: AgentInstanceId,
3339}
3340
3341struct NativeInstanceOverlayGuard {
3342    control: Option<NativeLaunchProfileControl>,
3343    instance_id: AgentInstanceId,
3344}
3345
3346struct NativeHarnessMcpOverlayGuard {
3347    control: NativeLaunchProfileControl,
3348    instance_id: AgentInstanceId,
3349}
3350
3351impl NativeHarnessMcpOverlayGuard {
3352    fn retain(self) { std::mem::forget(self); }
3353}
3354
3355impl Drop for NativeHarnessMcpOverlayGuard {
3356    fn drop(&mut self) {
3357        self.control
3358            .clear_native_mcp_server_launch_overlay(self.instance_id);
3359    }
3360}
3361
3362impl NativeInstanceOverlayGuard {
3363    fn retain(mut self) {
3364        self.control = None;
3365    }
3366}
3367
3368impl Drop for NativeInstanceOverlayGuard {
3369    fn drop(&mut self) {
3370        if let Some(control) = self.control.as_ref() {
3371            control.clear_native_instance_launch_overlay(self.instance_id);
3372        }
3373    }
3374}
3375
3376enum PreparedNativeLaunchOverlay {
3377    Environment(NativeLaunchEnvironmentOverlay),
3378    Instance(NativeInstanceLaunchOverlay),
3379}
3380
3381impl NativeEnvironmentSelectionGuard {
3382    fn retain(mut self) {
3383        self.control = None;
3384    }
3385}
3386
3387impl Drop for NativeEnvironmentSelectionGuard {
3388    fn drop(&mut self) {
3389        if let Some(control) = self.control.as_ref() {
3390            control.clear_native_launch_profile_selection(self.instance_id);
3391        }
3392    }
3393}
3394
3395struct SessionMaterializationGuard<'a> {
3396    shared: &'a NodeShared,
3397    id: Option<MaterializationId>,
3398    /// Absolute ProviderHome (`home/`) under the session materialization root.
3399    /// Used for Claude station network `--settings` sidecar writes.
3400    provider_home: Option<PathBuf>,
3401}
3402
3403impl SessionMaterializationGuard<'_> {
3404    fn id(&self) -> Option<&MaterializationId> {
3405        self.id.as_ref()
3406    }
3407
3408    fn provider_home(&self) -> Option<&Path> {
3409        self.provider_home.as_deref()
3410    }
3411
3412    fn retain(mut self) {
3413        self.id = None;
3414    }
3415}
3416
3417impl Drop for SessionMaterializationGuard<'_> {
3418    fn drop(&mut self) {
3419        if let Some(id) = self.id.take() {
3420            let _ = self.shared.cleanup_materialization(&id);
3421        }
3422    }
3423}
3424
3425
3426/// Port: product-side reactions to session lifecycle transitions
3427/// (station lease, harness MCP overlay/registry, environment launch overlays).
3428trait SessionLifecycleHook {
3429    /// A binding was removed (unbind or spawn rollback).
3430    fn on_binding_removed(&self, address: &SessionAddress, removed: Option<&SessionBinding>);
3431    /// A session reached a terminal control event that revokes its harness MCP door.
3432    fn on_session_ended(&self, address: &SessionAddress);
3433    /// Node shutdown (or drop): revoke every harness MCP reservation.
3434    fn on_shutdown(&self);
3435    /// A harness MCP reservation was aborted before activation.
3436    fn on_harness_reservation_aborted(&self, instance_id: AgentInstanceId);
3437    /// Release session-owned materialization for a removed binding.
3438    fn cleanup_session_materialization(
3439        &self,
3440        address: &SessionAddress,
3441        binding: &SessionBinding,
3442    ) -> Result<(), NodeFailure>;
3443}
3444
3445impl SessionLifecycleHook for NodeShared {
3446    fn on_session_ended(&self, address: &SessionAddress) {
3447        if let Some(registry) = self.harness_mcp_registry.as_ref() {
3448            registry.revoke_session(address);
3449        }
3450        if let Some(control) = self.native_launch_profile_control.as_ref() {
3451            control.clear_native_mcp_server_launch_overlay(address.session.instance_id);
3452        }
3453    }
3454
3455    fn on_shutdown(&self) {
3456        if let Some(registry) = self.harness_mcp_registry.as_ref() {
3457            let instances = registry.shutdown();
3458            if let Some(control) = self.native_launch_profile_control.as_ref() {
3459                for instance_id in instances {
3460                    control.clear_native_mcp_server_launch_overlay(instance_id);
3461                }
3462            }
3463        }
3464    }
3465
3466    fn on_harness_reservation_aborted(&self, instance_id: AgentInstanceId) {
3467        if let Some(control) = self.native_launch_profile_control.as_ref() {
3468            control.clear_native_mcp_server_launch_overlay(instance_id);
3469        }
3470    }
3471
3472    fn cleanup_session_materialization(
3473        &self,
3474        address: &SessionAddress,
3475        binding: &SessionBinding,
3476    ) -> Result<(), NodeFailure> {
3477        self.cleanup_session_owned_materialization(address, binding)
3478    }
3479
3480    fn on_binding_removed(&self, address: &SessionAddress, removed: Option<&SessionBinding>) {
3481        // Exclusive dig2browser station profile lease is node-local; clear
3482        // on every session unbind / rollback. Never ImportSession/cookies.
3483        #[cfg(feature = "dig2-station-probe")]
3484        {
3485            crate::dig2_station_lease::release_lease_for_holder(
3486                &self.browser_station_leases,
3487                address.session.instance_id,
3488            );
3489        }
3490        if let Some(control) = self.native_launch_profile_control.as_ref() {
3491            control.clear_native_mcp_server_launch_overlay(
3492                address.session.instance_id,
3493            );
3494        }
3495        if let Some(registry) = self.harness_mcp_registry.as_ref() {
3496            registry.revoke_session(address);
3497        }
3498        if removed
3499            .is_some_and(|binding| {
3500                binding.environment_profile.is_some()
3501                    || binding.bundle.is_some()
3502                    || binding.context.is_some()
3503            })
3504        {
3505            if let Some(control) = self.native_launch_profile_control.as_ref() {
3506                control.clear_native_launch_profile_selection(
3507                    address.session.instance_id,
3508                );
3509                control.clear_native_instance_launch_overlay(
3510                    address.session.instance_id,
3511                );
3512            }
3513        }
3514    }
3515}
3516
3517struct SpawnPrepareRequest<'r> {
3518    instance_id: AgentInstanceId,
3519    address: &'r SessionAddress,
3520    provider: &'r AgentId,
3521    mode: SessionMode,
3522    environment_profile: Option<&'r ResolvedEnvironmentProfileReceipt>,
3523    bundle: Option<&'r ResolvedBundleReceipt>,
3524    context: Option<&'r ResolvedContextPackReceipt>,
3525    managed_authority: Option<&'r ManagedWorktreeLeaseId>,
3526    harness_mcp: Option<&'r PreparedHarnessMcpSpawn>,
3527    approval_level: ApprovalLevel,
3528    network_allowlist: Option<&'r crate::protocol::SpawnNetworkAllowlistId>,
3529    browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
3530}
3531
3532/// Guards held between "session identity minted" and "launch committed".
3533/// Field order is the drop order and matches the pre-refactor local order
3534/// (instance overlay, environment selection, materialization, harness, station lease).
3535struct PreparedSpawn<'a> {
3536    instance_overlay: Option<NativeInstanceOverlayGuard>,
3537    environment_selection: Option<NativeEnvironmentSelectionGuard>,
3538    materialization_guard: Option<SessionMaterializationGuard<'a>>,
3539    harness_mcp_overlay: Option<NativeHarnessMcpOverlayGuard>,
3540    #[cfg(feature = "dig2-station-probe")]
3541    browser_station_lease_guard: Option<crate::dig2_station_lease::BrowserStationLeaseGuard>,
3542    materialization_id: Option<MaterializationId>,
3543}
3544
3545impl PreparedSpawn<'_> {
3546    /// Session-owned launch state now belongs to the binding.
3547    fn retain_session_guards(&mut self) {
3548        if let Some(selection) = self.environment_selection.take() {
3549            selection.retain();
3550        }
3551        if let Some(overlay) = self.instance_overlay.take() {
3552            overlay.retain();
3553        }
3554        if let Some(guard) = self.materialization_guard.take() {
3555            guard.retain();
3556        }
3557    }
3558
3559    /// Launch committed: keep harness overlay and station lease for the session lifetime.
3560    fn retain_launch_guards(&mut self) {
3561        if let Some(overlay) = self.harness_mcp_overlay.take() {
3562            overlay.retain();
3563        }
3564        #[cfg(feature = "dig2-station-probe")]
3565        if let Some(guard) = self.browser_station_lease_guard.take() {
3566            guard.retain();
3567        }
3568    }
3569}
3570
3571/// Port: everything product-side that must be prepared before a session is
3572/// bound and started (station lease, harness MCP overlay, materialization,
3573/// environment selection, network overlays, launch overlay).
3574trait SpawnPreparer {
3575    fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure>;
3576    /// Select the native launch profile for an environment-profile session.
3577    fn select_environment(
3578        &self,
3579        instance_id: AgentInstanceId,
3580        provider: &AgentId,
3581        mode: SessionMode,
3582        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3583    ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure>;
3584    /// Install a prepared launch overlay for the instance.
3585    fn install_launch_overlay(
3586        &self,
3587        instance_id: AgentInstanceId,
3588        overlay: PreparedNativeLaunchOverlay,
3589    ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure>;
3590}
3591
3592impl SpawnPreparer for NodeShared {
3593    fn select_environment(
3594        &self,
3595        instance_id: AgentInstanceId,
3596        provider: &AgentId,
3597        mode: SessionMode,
3598        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3599    ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
3600        self.select_environment_profile(instance_id, provider, mode, environment_profile)
3601    }
3602
3603    fn install_launch_overlay(
3604        &self,
3605        instance_id: AgentInstanceId,
3606        overlay: PreparedNativeLaunchOverlay,
3607    ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
3608        self.install_prepared_launch_overlay(instance_id, overlay)
3609    }
3610
3611    fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure> {
3612        let SpawnPrepareRequest {
3613            instance_id,
3614            address,
3615            provider,
3616            mode,
3617            environment_profile,
3618            bundle,
3619            context,
3620            managed_authority,
3621            harness_mcp,
3622            approval_level,
3623            network_allowlist,
3624            browser_profile_id,
3625        } = request;
3626        // Track A follow-on: exclusive node-local BrowserStationLease after
3627        // dig2 probe (feature dig2-station-probe). Opaque profile id only —
3628        // never ImportSession / cookies on C2. Linux/feature-off: probe
3629        // already refused or stub; this block is cfg'd out when feature off.
3630        #[cfg(feature = "dig2-station-probe")]
3631        let mut browser_station_lease_guard = if let Some(profile_id) = browser_profile_id {
3632            use crate::dig2_station_lease::{acquire_lease, BrowserStationLeaseError};
3633            match acquire_lease(
3634                &self.browser_station_leases,
3635                profile_id,
3636                instance_id,
3637                unix_time_ms(),
3638            ) {
3639                Ok(guard) => Some(guard),
3640                Err(BrowserStationLeaseError::ProfileBusy { .. }) => {
3641                    return Err(failure(
3642                        NodeFailureCode::BrowserStationProfileBusy,
3643                        "dig2browser station profile is already leased by another session",
3644                    ));
3645                }
3646                Err(BrowserStationLeaseError::CapacityExceeded) => {
3647                    return Err(failure(
3648                        NodeFailureCode::BrowserStationProfileBusy,
3649                        "dig2browser station profile lease capacity exhausted",
3650                    ));
3651                }
3652                Err(BrowserStationLeaseError::HolderAlreadyLeased) => {
3653                    return Err(failure(
3654                        NodeFailureCode::BrowserStationProfileBusy,
3655                        "session already holds a dig2browser station profile lease",
3656                    ));
3657                }
3658            }
3659        } else {
3660            None
3661        };
3662        #[cfg(not(feature = "dig2-station-probe"))]
3663        let _ = browser_profile_id;
3664        let mut harness_mcp_overlay = if let Some(prepared) = harness_mcp {
3665            // Slice A(ii) of gate4agent-arc-mailbox-and-task-layer: the door
3666            // exists for PTY (env vars) and ACP (`session/new.mcpServers`,
3667            // `gate4agent-shell-native`'s `mcp_server_acp_entry`) alike --
3668            // both read the identical overlay resolved below, one as child
3669            // environment, the other translated into one stdio MCP server
3670            // entry. `Inline`/`Pipe` stays refused: this arc's proof is ACP
3671            // only, and nothing downstream reads a harness-MCP overlay for
3672            // that transport yet.
3673            if !matches!(mode, SessionMode::Pty | SessionMode::Acp) || prepared.provider != *provider
3674            {
3675                return Err(failure(
3676                    NodeFailureCode::BindingMismatch,
3677                    "harness MCP reservation does not match the exact PTY/ACP provider",
3678                ));
3679            }
3680            prepared.verify_helper_program().map_err(harness_mcp_failure)?;
3681            let control = self.native_launch_profile_control.clone().ok_or_else(|| {
3682                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP launch control is unavailable")
3683            })?;
3684            // Every name below is the caller's, carried in the reservation's
3685            // launch description: which environment variables receive the
3686            // endpoint, token and helper program, what the MCP server is called,
3687            // which argv it gets and which stale variables are erased.
3688            let launch = prepared.launch();
3689            let mut env_entries = vec![
3690                (
3691                    OsString::from(launch.endpoint_env.as_str()),
3692                    prepared.endpoint().as_os_str().to_os_string(),
3693                ),
3694                (
3695                    OsString::from(launch.token_env.as_str()),
3696                    prepared.token().expose().into(),
3697                ),
3698                (
3699                    OsString::from(launch.program_env.as_str()),
3700                    prepared.helper_program().as_os_str().to_os_string(),
3701                ),
3702            ];
3703            if let Some(trace_entry) = harness_mcp_trace_env_entry(launch) {
3704                env_entries.push(trace_entry);
3705            }
3706            if let Some(door) = crate::door::door_listen_addr() {
3707                env_entries.push((
3708                    OsString::from("HATCHERY_NODE_DOOR"),
3709                    OsString::from(door.to_string()),
3710                ));
3711                // Same key shape as HQ browser/draft ids:
3712                // `pty-{incarnation}-{workspace}-{instance}` — never reuse a
3713                // slot across node boots when the instance counter resets.
3714                let session_key = format!(
3715                    "pty-{}-{}-{}",
3716                    self.incarnation_id,
3717                    address.workspace_id.as_str(),
3718                    address.session.instance_id.0
3719                );
3720                env_entries.push((
3721                    OsString::from("HATCHERY_LOCAL_SERVER_SESSION"),
3722                    OsString::from(session_key),
3723                ));
3724            }
3725            let overlay = NativeMcpServerLaunchOverlay::new(
3726                provider.clone(),
3727                launch.server_name.as_str(),
3728                prepared.helper_program().as_os_str().to_os_string(),
3729                launch.args.iter().map(OsString::from).collect(),
3730                env_entries,
3731                launch.scrub_env.iter().map(OsString::from).collect(),
3732            ).map_err(|_| failure(
3733                NodeFailureCode::HarnessMcpUnavailable,
3734                "harness MCP launch overlay is unavailable",
3735            ))?;
3736            control
3737                .install_native_mcp_server_launch_overlay(instance_id, overlay)
3738                .map_err(|_| failure(
3739                    NodeFailureCode::HarnessMcpUnavailable,
3740                    "harness MCP launch overlay could not be installed",
3741                ))?;
3742            // State-change line: the overlay is installed and will be read
3743            // back by the PTY child's environment or, for ACP,
3744            // `gate4agent-shell-native`'s `mcp_server_acp_entry` at spawn
3745            // time -- no secrets here, only which instance/provider/mode
3746            // now carries a harness-MCP door.
3747            tracing::info!(
3748                node_id = %self.node_id,
3749                instance_id = ?instance_id,
3750                provider = %provider,
3751                mode = ?mode,
3752                "harness MCP overlay installed for spawn",
3753            );
3754            Some(NativeHarnessMcpOverlayGuard { control, instance_id })
3755        } else {
3756            None
3757        };
3758        let prepared_materialization = self.prepare_session_materialization(
3759            &address,
3760            &provider,
3761            mode,
3762            environment_profile,
3763            bundle,
3764            context,
3765            managed_authority.cloned(),
3766        )?;
3767        let (environment_overlay, mut materialization_guard) = match prepared_materialization {
3768            Some((overlay, guard)) => (Some(overlay), Some(guard)),
3769            None => (None, None),
3770        };
3771        let materialization_id = materialization_guard
3772            .as_ref()
3773            .and_then(SessionMaterializationGuard::id)
3774            .cloned();
3775        let mut environment_selection = SpawnPreparer::select_environment(self, 
3776            instance_id,
3777            &provider,
3778            mode,
3779            environment_profile,
3780        )?;
3781        // Station network overlays: Codex `-c network_access` and/or Claude
3782        // ProviderHome `--settings` sidecar (permits → allowedDomains).
3783        let provider_home = materialization_guard
3784            .as_ref()
3785            .and_then(SessionMaterializationGuard::provider_home);
3786        let network_overlay_args = self.resolve_network_allowlist_overlay_args(
3787            &provider,
3788            mode,
3789            approval_level,
3790            network_allowlist,
3791            provider_home,
3792        )?;
3793        let environment_overlay = match (environment_overlay.flatten(), network_overlay_args) {
3794            (prepared, args) if args.is_empty() => prepared,
3795            (Some(PreparedNativeLaunchOverlay::Instance(mut overlay)), args) => {
3796                overlay.append_extra_args(args).map_err(|_| {
3797                    failure(
3798                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3799                        "network allowlist overlay could not merge with instance launch args",
3800                    )
3801                })?;
3802                Some(PreparedNativeLaunchOverlay::Instance(overlay))
3803            }
3804            (Some(PreparedNativeLaunchOverlay::Environment(overlay)), args) => {
3805                Some(PreparedNativeLaunchOverlay::Instance(
3806                    overlay
3807                        .into_instance_with_extra_args(args)
3808                        .map_err(|_| {
3809                            failure(
3810                                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3811                                "network allowlist overlay could not merge with environment overlay",
3812                            )
3813                        })?,
3814                ))
3815            }
3816            (None, args) => Some(PreparedNativeLaunchOverlay::Instance(
3817                NativeInstanceLaunchOverlay::new(
3818                    provider.clone(),
3819                    match mode {
3820                        SessionMode::Pty => TransportKind::Pty,
3821                        SessionMode::Acp => TransportKind::Acp,
3822                        SessionMode::Inline => TransportKind::Pipe,
3823                    },
3824                    Vec::new(),
3825                    args,
3826                )
3827                .map_err(|_| {
3828                    failure(
3829                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3830                        "network allowlist overlay is invalid for this transport",
3831                    )
3832                })?,
3833            )),
3834        };
3835        // Per-session m4a account: own nick + capability for this PTY child only, on its own
3836        // overlay or appended to the overlay the spawn already carries.
3837        let environment_overlay = match (mode, self.m4a_accounts.as_ref(), crate::m4a_accounts::m4a_provider(provider.as_str())) {
3838            (SessionMode::Pty, Some(accounts), Some(m4a_provider)) => {
3839                let session_key = format!("{}-{}", self.incarnation_id, instance_id.0);
3840                match accounts.provision(m4a_provider, &session_key) {
3841                    Ok(account) => {
3842                        let identity = account.env_mutations();
3843                        let merged = match environment_overlay {
3844                            None => NativeInstanceLaunchOverlay::new_session_identity(provider.clone(), identity)
3845                                .map(PreparedNativeLaunchOverlay::Instance),
3846                            Some(PreparedNativeLaunchOverlay::Instance(mut overlay)) => {
3847                                overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
3848                            }
3849                            Some(PreparedNativeLaunchOverlay::Environment(mut overlay)) => {
3850                                overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Environment(overlay))
3851                            }
3852                        }
3853                        .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a session account overlay is invalid"))?;
3854                        tracing::info!(provider = %provider, instance_id = ?instance_id, nick = %account.nick, "m4a session account assigned at spawn");
3855                        self.m4a_live
3856                            .lock()
3857                            .unwrap_or_else(|p| p.into_inner())
3858                            .insert(account.nick.clone(), (instance_id, provider.clone()));
3859                        Some(merged)
3860                    }
3861                    Err(error) => {
3862                        tracing::warn!(provider = %provider, instance_id = ?instance_id, %error, "m4a session account not assigned");
3863                        environment_overlay
3864                    }
3865                }
3866            }
3867            (SessionMode::Pty, Some(_), None) => {
3868                tracing::warn!(provider = %provider, instance_id = ?instance_id, "m4a session account not assigned: provider has no m4a mapping");
3869                environment_overlay
3870            }
3871            _ => environment_overlay,
3872        };
3873        let mut instance_overlay = environment_overlay
3874            .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
3875            .transpose()?
3876            .flatten();
3877        Ok(PreparedSpawn {
3878            instance_overlay,
3879            environment_selection,
3880            materialization_guard,
3881            harness_mcp_overlay,
3882            #[cfg(feature = "dig2-station-probe")]
3883            browser_station_lease_guard,
3884            materialization_id,
3885        })
3886    }
3887}
3888
3889struct SessionRecords {
3890    records: BTreeMap<SessionRecordId, ManagedSessionRecord>,
3891}
3892
3893struct SpawnIdempotencyCache {
3894    entries: BTreeMap<SpawnIdempotencyKey, SpawnIdempotencyEntry>,
3895}
3896
3897struct SpawnIdempotencyEntry {
3898    value: SpawnIdempotencyValue,
3899    expires_at: Instant,
3900}
3901
3902enum SpawnIdempotencyValue {
3903    Standard {
3904        spec: SpawnSpec,
3905        result: Result<ResolvedSpawnReceipt, NodeFailure>,
3906    },
3907    Managed {
3908        request: ManagedWorktreeSpawnRequest,
3909        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
3910    },
3911    HarnessMcp,
3912}
3913
3914enum DurableManagedSpawnReplayDecision {
3915    Reserved,
3916    PendingLinked,
3917    Committed(ManagedWorktreeSpawnReceipt),
3918}
3919
3920impl SpawnIdempotencyValue {
3921    fn references_context(&self, context_id: &SpawnContextId) -> bool {
3922        match self {
3923            Self::Standard { result, .. } => result
3924                .as_ref()
3925                .ok()
3926                .and_then(|receipt| receipt.context.as_ref()),
3927            Self::Managed { result, .. } => result
3928                .as_ref()
3929                .ok()
3930                .and_then(|receipt| receipt.spawn.context.as_ref()),
3931            Self::HarnessMcp => None,
3932        }
3933        .is_some_and(|receipt| &receipt.id == context_id)
3934    }
3935}
3936
3937#[derive(Clone, Copy)]
3938struct ControllerLease {
3939    connection_id: u64,
3940    expires_at: Instant,
3941}
3942
3943struct NodeEventHistory {
3944    last_sequence: u64,
3945    replay_floor_sequence: u64,
3946    events: VecDeque<NodeEventEnvelope>,
3947    record_providers: BTreeMap<SessionRecordId, AgentId>,
3948    removed_record_providers: BTreeMap<u64, AgentId>,
3949}
3950
3951impl NodeEventHistory {
3952    fn new(record_providers: BTreeMap<SessionRecordId, AgentId>) -> Self {
3953        Self {
3954            last_sequence: 0,
3955            replay_floor_sequence: 1,
3956            events: VecDeque::with_capacity(NODE_EVENT_HISTORY_MAX),
3957            record_providers,
3958            removed_record_providers: BTreeMap::new(),
3959        }
3960    }
3961}
3962
3963impl NodeShared {
3964    fn new_with_incarnation(
3965        handle: Gate4AgentHandle,
3966        access_token: String,
3967        node_id: NodeId,
3968        incarnation_id: NodeIncarnationId,
3969        workspaces: Vec<WorkspaceConfig>,
3970        enabled_providers: Vec<AgentId>,
3971        provider_runtime_statuses: ProviderRuntimeStatuses,
3972        provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
3973        provider_contracts: Vec<ProviderContractSupport>,
3974        provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
3975        spawn_profiles: SpawnProfileRegistry,
3976        native_launch_profile_control: Option<NativeLaunchProfileControl>,
3977        native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
3978        session_environment_materializer: Option<SessionEnvironmentMaterializer>,
3979        state_path: Option<PathBuf>,
3980        records: Vec<ManagedSessionRecord>,
3981        managed_worktrees: Vec<ManagedWorktreeLeaseRecord>,
3982        managed_worktree_tombstones: Vec<ManagedWorktreeLeaseRecord>,
3983        materializations: Vec<MaterializationOwnershipRecord>,
3984        persistence_warning: Option<String>,
3985        input_settle_timeout_ms: u64,
3986    ) -> Self {
3987        let (event_tx, _) = broadcast::channel(NODE_BROADCAST_CAPACITY);
3988        let (terminal_event_tx, _) = broadcast::channel(NODE_TERMINAL_BROADCAST_CAPACITY);
3989        let (agent_stream_event_tx, _) = broadcast::channel(NODE_AGENT_STREAM_BROADCAST_CAPACITY);
3990        let (harness_mcp_proxy_event_tx, _) =
3991            broadcast::channel(NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY);
3992        let record_providers = records
3993            .iter()
3994            .map(|record| (record.record_id.clone(), record.provider.clone()))
3995            .collect();
3996        let mut worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode> = workspaces
3997            .iter()
3998            .map(|workspace| {
3999                (workspace.workspace_id.clone(), workspace.worktree_service_mode)
4000            })
4001            .collect();
4002        for lease in &managed_worktrees {
4003            worktree_service_modes.insert(lease.workspace_id.clone(), WorktreeServiceMode::Off);
4004        }
4005        let managed_worktree_profiles = workspaces.iter()
4006            .map(|workspace| (
4007                workspace.workspace_id.clone(),
4008                workspace.managed_worktree_profiles.clone(),
4009            ))
4010            .collect();
4011        let mut managed_worktrees = ManagedWorktreeRegistry::from_records(
4012            managed_worktrees,
4013            managed_worktree_tombstones,
4014        ).expect("durable managed worktree registry was validated while loading");
4015        managed_worktrees.clear_stale_session_holders(incarnation_id, unix_time_ms());
4016        managed_worktrees.reattach_record_holders(
4017            &managed_worktree_record_holders(&records),
4018            unix_time_ms(),
4019        );
4020        Self {
4021            handle,
4022            access_token,
4023            node_id,
4024            incarnation_id,
4025            started_at_unix_ms: std::time::SystemTime::now()
4026                .duration_since(std::time::UNIX_EPOCH)
4027                .unwrap_or_default()
4028                .as_millis()
4029                .min(u64::MAX as u128) as u64,
4030            workspaces: RwLock::new(
4031                workspaces
4032                    .into_iter()
4033                    .map(|workspace| (workspace.workspace_id, workspace.canonical_root))
4034                    .collect(),
4035            ),
4036            worktree_service_modes,
4037            managed_worktree_profiles,
4038            managed_worktrees: Mutex::new(managed_worktrees),
4039            enabled_providers,
4040            provider_runtime_statuses,
4041            provider_runtime_status_updates: RwLock::new(BTreeMap::new()),
4042            provider_runtime_monitor,
4043            provider_contracts,
4044            provider_adapter_contracts,
4045            spawn_profiles,
4046            environment_profiles: RwLock::new(BTreeMap::new()),
4047            environment_materialization_profiles: RwLock::new(BTreeMap::new()),
4048            network_allowlist_catalog: RwLock::new(crate::network_allowlist_catalog::NetworkAllowlistCatalog::new()),
4049            #[cfg(feature = "dig2-station-probe")]
4050            browser_station_leases: crate::dig2_station_lease::new_lease_table(),
4051            bundle_catalog: RwLock::new(BundleCatalog::default()),
4052            delivery_store: Mutex::new(None),
4053            context_pack_store: Mutex::new(None),
4054            context_catalog: RwLock::new(ContextPackCatalog::default()),
4055            native_launch_profile_control,
4056            harness_mcp_registry: None,
4057            native_session_catalog,
4058            session_environment_materializer,
4059            m4a_accounts: crate::m4a_accounts::M4aSessionAccounts::from_env(),
4060            m4a_live: Mutex::new(BTreeMap::new()),
4061            materializations: Mutex::new(
4062                materializations
4063                    .into_iter()
4064                    .map(|record| (record.id().clone(), record))
4065                    .collect(),
4066            ),
4067            spawn_idempotency: Mutex::new(SpawnIdempotencyCache {
4068                entries: BTreeMap::new(),
4069            }),
4070            managed_spawn_replays: Mutex::new(BTreeMap::new()),
4071            controller: Mutex::new(None),
4072            provisional_spawns: Mutex::new(BTreeMap::new()),
4073            history: Mutex::new(NodeEventHistory::new(record_providers)),
4074            event_tx,
4075            terminal_event_tx,
4076            agent_stream_event_tx,
4077            harness_mcp_proxy_event_tx,
4078            terminal_frame_watermarks: Mutex::new(BTreeMap::new()),
4079            pty_input_wake: tokio::sync::Notify::new(),
4080            pty_input_marks: Mutex::new(BTreeMap::new()),
4081            next_connection_id: AtomicU64::new(1),
4082            next_instance_id: AtomicU64::new(1),
4083            next_command_id: AtomicU64::new(1),
4084            shutdown: AtomicBool::new(false),
4085            shutdown_notify: Notify::new(),
4086            preauth_slots: Arc::new(Semaphore::new(MAX_PREAUTH_CONNECTIONS)),
4087            authenticated_slots: Arc::new(Semaphore::new(MAX_AUTHENTICATED_CONNECTIONS)),
4088            inspection_slots: Arc::new(Semaphore::new(WORKSPACE_INSPECTION_MAX_CONCURRENCY)),
4089            mutation_gate: AsyncMutex::new(()),
4090            session_bindings: Mutex::new(BTreeMap::new()),
4091            session_records: Mutex::new(SessionRecords {
4092                records: records
4093                    .into_iter()
4094                    .map(|record| (record.record_id.clone(), record))
4095                    .collect(),
4096            }),
4097            state_path,
4098            persistence_error: RwLock::new(
4099                persistence_warning
4100                    .map(|message| session_registry::sanitized_persistence_summary(&message)),
4101            ),
4102            state_transaction: Mutex::new(()),
4103            input_settle_timeout_ms,
4104            drive_loop_profile: Mutex::new(DriveLoopProfiler::default()),
4105            runtime_tick_profile: Mutex::new(TickProfileSnapshot::default()),
4106            shell_efficiency_profile: OnceLock::new(),
4107            native_session_gauge: AtomicUsize::new(0),
4108            connection_loop_iterations: AtomicU64::new(0),
4109            connection_events_sent: AtomicU64::new(0),
4110            rejected_commands_total: AtomicU64::new(0),
4111            rejected_command_reasons: Mutex::new(BTreeMap::new()),
4112            drive_loop_iterations_total: AtomicU64::new(0),
4113            drive_loop_iterations_idle: AtomicU64::new(0),
4114            session_record_retention: SessionRecordRetentionConfig::default(),
4115            retired_records_total: AtomicU64::new(0),
4116            #[cfg(feature = "fixture")]
4117            fixture_semantic_hook_policy: false,
4118            #[cfg(feature = "fixture")]
4119            fixture_spawn_managed_worktree_v2_failure_probe:
4120                SpawnManagedWorktreeV2FailureProbe::default(),
4121        }
4122    }
4123
4124    #[cfg(test)]
4125    fn new(
4126        handle: Gate4AgentHandle,
4127        access_token: String,
4128        node_id: NodeId,
4129        workspaces: Vec<WorkspaceConfig>,
4130        enabled_providers: Vec<AgentId>,
4131    ) -> Self {
4132        Self::new_with_incarnation(
4133            handle,
4134            access_token,
4135            node_id,
4136            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
4137            workspaces,
4138            enabled_providers,
4139            ProviderRuntimeStatuses::default(),
4140            None,
4141            Vec::new(),
4142            Vec::new(),
4143            SpawnProfileRegistry::default(),
4144            None,
4145            None,
4146            None,
4147            None,
4148            Vec::new(),
4149            Vec::new(),
4150            Vec::new(),
4151            Vec::new(),
4152            None,
4153            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
4154        )
4155    }
4156
4157    fn persistence_error(&self) -> Option<String> {
4158        self.persistence_error
4159            .read()
4160            .unwrap_or_else(|poisoned| poisoned.into_inner())
4161            .clone()
4162    }
4163
4164    async fn admit_provider_runtime(
4165        &self,
4166        provider: &AgentId,
4167        requirement: ProviderRuntimeRequirement,
4168    ) -> Result<ProviderRuntimePolicy, NodeFailure> {
4169        let admission = if let Some(monitor) = self.provider_runtime_monitor.clone() {
4170            let pipe_transport = monitor.supports_pipe_transport(provider);
4171            let acp_transport = monitor.supports_acp_transport(provider);
4172            let provider = provider.clone();
4173            let refresh = tokio::task::spawn_blocking(move || monitor.evaluate(&provider));
4174            let (status, admission) = timeout(
4175                Duration::from_millis(PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS),
4176                refresh,
4177            )
4178            .await
4179            .map_err(|_| failure(
4180                NodeFailureCode::BackendBusy,
4181                "provider runtime probe exceeded its bounded deadline",
4182            ))?
4183            .map_err(|_| failure(
4184                NodeFailureCode::BackendOperationFailed,
4185                "provider runtime probe task failed",
4186            ))?;
4187            if let Some(status) = status {
4188                self
4189                    .provider_runtime_status_updates
4190                    .write()
4191                    .unwrap_or_else(|poisoned| poisoned.into_inner())
4192                    .insert(status.provider().clone(), status);
4193            }
4194            admission.and_then(|policy| {
4195                require_policy(policy, pipe_transport, acp_transport, requirement).map(|()| policy)
4196            })
4197        } else {
4198            // No live monitor means no catalog to consult -- there is
4199            // nothing here to confirm this provider declares a Pipe or ACP
4200            // transport, so Inline/Acp are rejected the same conservative
4201            // way an undeclared provider always was, rather than admitted
4202            // on trust.
4203            crate::provider_runtime::admit_status(
4204                &self.provider_runtime_statuses,
4205                provider,
4206                false,
4207                false,
4208                requirement,
4209            )
4210        };
4211        let admission = admission.map(|policy| self.admit_declared_hook_semantics(provider, policy));
4212        admission.map_err(|error| match error {
4213            ProviderRuntimeAdmissionError::LauncherUnavailable => failure(
4214                NodeFailureCode::BackendOperationFailed,
4215                "provider launcher is unavailable",
4216            ),
4217            ProviderRuntimeAdmissionError::SemanticCapabilityUnverified => failure(
4218                NodeFailureCode::UnsupportedCapability,
4219                "provider semantic capability is not verified",
4220            ),
4221            ProviderRuntimeAdmissionError::ProbeBusy => failure(
4222                NodeFailureCode::BackendBusy,
4223                "provider runtime probe is already in progress",
4224            ),
4225        })
4226    }
4227
4228    /// Whether the catalog declares a Pipe transport for `provider` --
4229    /// the fact `require_policy` needs to admit or reject
4230    /// `ProviderRuntimeRequirement::Inline`. With no live monitor there is no
4231    /// catalog to consult, so this stays conservative and rejects, matching
4232    /// `admit_provider_runtime`'s own no-monitor branch.
4233    fn provider_supports_pipe_transport(&self, provider: &AgentId) -> bool {
4234        match self.provider_runtime_monitor.as_deref() {
4235            Some(monitor) => monitor.supports_pipe_transport(provider),
4236            None => false,
4237        }
4238    }
4239
4240    /// Whether the catalog declares an ACP transport for `provider` -- the
4241    /// fact `require_policy` needs to admit or reject
4242    /// `ProviderRuntimeRequirement::Acp`. Mirrors
4243    /// `provider_supports_pipe_transport` exactly, including the
4244    /// conservative no-monitor default.
4245    fn provider_supports_acp_transport(&self, provider: &AgentId) -> bool {
4246        match self.provider_runtime_monitor.as_deref() {
4247            Some(monitor) => monitor.supports_acp_transport(provider),
4248            None => false,
4249        }
4250    }
4251
4252    /// Grants hook ingestion to a provider whose catalog entry declares a
4253    /// Hook adapter, whatever runtime mode its launcher probe landed in.
4254    ///
4255    /// `admit_status`'s `RawPassthrough` arm hands back
4256    /// `ProviderRuntimePolicy::raw_pty()`, which grants nothing semantic --
4257    /// correct for everything it was written to answer, and wrong for hooks
4258    /// specifically. A hook event is not inferred from terminal behaviour,
4259    /// so it has nothing to do with whether a launcher probe verified this
4260    /// CLI version's PTY contract; the authority is the catalog declaring
4261    /// the adapter plus the ingress authenticating the route. Without this
4262    /// the node installs a provider's hooks, the CLI faithfully calls them,
4263    /// and every event is refused -- which is exactly the state grok was in.
4264    ///
4265    /// Applied to both admission paths so the monitor's own derivation and
4266    /// this coarse fallback cannot disagree about the same provider.
4267    fn admit_declared_hook_semantics(
4268        &self,
4269        provider: &AgentId,
4270        policy: ProviderRuntimePolicy,
4271    ) -> ProviderRuntimePolicy {
4272        if policy.hook_semantics || !policy.raw_pty_lifecycle {
4273            return policy;
4274        }
4275        let declares_hook = self.provider_adapter_contracts.iter().any(|contract| {
4276            contract.provider == *provider && contract.family == AdapterFamily::Hook
4277        });
4278        if !declares_hook {
4279            return policy;
4280        }
4281        ProviderRuntimePolicy::new(
4282            policy.raw_pty_lifecycle,
4283            policy.semantic_readiness,
4284            policy.structured_prompt,
4285            policy.provider_session_identity,
4286            policy.semantic_resume,
4287            true,
4288        )
4289        .expect("granting hook semantics over a raw-PTY policy is internally valid")
4290    }
4291
4292    /// Station network allowlist → provider launch overlay argv.
4293    ///
4294    /// - **Codex**: first-slice `-c sandbox_workspace_write.network_access` from
4295    ///   `provider_native` under Moderate (PTY or ACP).
4296    /// - **Claude**: catalog `permits` → ProviderHome sidecar settings JSON +
4297    ///   documented `--settings <path>` on **PTY** only (Linux/macOS/WSL2).
4298    ///   ACP refuses (no measured ACP settings-path surface). Native Windows
4299    ///   refuses (no vendor Bash sandbox). Membership-only (empty permits) =
4300    ///   no overlay.
4301    /// - **Kimi / Grok**: no first-party settings/argv network surface here —
4302    ///   empty overlay when no `provider_native`; invented native keys already
4303    ///   refuse at load / resolve.
4304    ///
4305    /// Empty when no allowlist / nothing to map. Never cookies / secrets.
4306    fn resolve_network_allowlist_overlay_args(
4307        &self,
4308        provider: &AgentId,
4309        mode: SessionMode,
4310        approval_level: ApprovalLevel,
4311        network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4312        provider_home: Option<&Path>,
4313    ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4314        let Some(allowlist_id) = network_allowlist else {
4315            return Ok(Vec::new());
4316        };
4317        let catalog = self
4318            .network_allowlist_catalog
4319            .read()
4320            .unwrap_or_else(|poisoned| poisoned.into_inner());
4321        let Some(entry) = catalog.get(allowlist_id) else {
4322            return Err(failure(
4323                NodeFailureCode::UnknownNetworkAllowlist,
4324                "spawn network allowlist is unavailable on this node",
4325            ));
4326        };
4327
4328        // Claude: settings-shaped overlay from permits (not provider_native).
4329        if provider.as_str() == "claude" && !entry.permits.is_empty() {
4330            crate::network_allowlist_catalog::claude_bash_sandbox_network_os_supported().map_err(
4331                |message| {
4332                    failure(
4333                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4334                        &message,
4335                    )
4336                },
4337            )?;
4338            if !matches!(mode, SessionMode::Pty) {
4339                return Err(failure(
4340                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4341                    "Claude sandbox.network --settings overlay requires PTY transport; ACP has no measured settings-path surface (refuse rather than invent RPC)",
4342                ));
4343            }
4344            let Some(home) = provider_home else {
4345                return Err(failure(
4346                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4347                    "Claude sandbox.network --settings overlay requires ProviderHome materialization (CLAUDE_CONFIG_DIR)",
4348                ));
4349            };
4350            let domains =
4351                crate::network_allowlist_catalog::claude_allowed_domains_from_permits(&entry.permits);
4352            if domains.is_empty() {
4353                return Err(failure(
4354                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4355                    "Claude sandbox.network overlay could not map catalog permits to allowedDomains",
4356                ));
4357            }
4358            let settings_path = home.join(
4359                crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
4360            );
4361            let json =
4362                crate::network_allowlist_catalog::claude_station_network_settings_json(&domains);
4363            crate::session_environment::secure_create_file(&settings_path, json.as_bytes()).map_err(
4364                |_| {
4365                    failure(
4366                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4367                        "Claude station network settings sidecar could not be written under ProviderHome",
4368                    )
4369                },
4370            )?;
4371            let overlay =
4372                crate::network_allowlist_catalog::claude_settings_network_overlay_args(&settings_path);
4373            return Ok(overlay.into_iter().map(std::ffi::OsString::from).collect());
4374        }
4375
4376        let Some(native) = entry.provider_native.as_ref() else {
4377            return Ok(Vec::new());
4378        };
4379        let overlay = crate::network_allowlist_catalog::resolve_provider_native_launch_overlay(
4380            provider.as_str(),
4381            approval_level,
4382            native,
4383        )
4384        .map_err(|message| {
4385            failure(
4386                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4387                &message,
4388            )
4389        })?;
4390        if overlay.is_empty() {
4391            return Ok(Vec::new());
4392        }
4393        if !matches!(mode, SessionMode::Pty | SessionMode::Acp) {
4394            return Err(failure(
4395                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4396                "Codex network_access -c overlay requires PTY or ACP transport",
4397            ));
4398        }
4399        Ok(overlay.into_iter().map(std::ffi::OsString::from).collect())
4400    }
4401
4402    /// Backward-compatible alias used by Codex-focused unit tests.
4403    fn resolve_codex_network_access_overlay_args(
4404        &self,
4405        provider: &AgentId,
4406        mode: SessionMode,
4407        approval_level: ApprovalLevel,
4408        network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4409    ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4410        self.resolve_network_allowlist_overlay_args(
4411            provider,
4412            mode,
4413            approval_level,
4414            network_allowlist,
4415            None,
4416        )
4417    }
4418
4419    fn resolve_environment_profile(
4420        &self,
4421        resolved: &ResolvedSpawnSpec,
4422    ) -> Result<Option<ResolvedEnvironmentProfileReceipt>, NodeFailure> {
4423        // Station network allowlist catalog enforce (plan
4424        // station-network-and-browser-profile-knobs §2.1 / §4): empty-default
4425        // catalog — unknown ids refuse rather than silent ambient. Runs even
4426        // when no environment_profile_id is present so a bare allowlist still
4427        // cannot bypass the catalog. Never cookies / OAuth / proxy credentials.
4428        if let Some(allowlist_id) = resolved.network_allowlist.as_ref() {
4429            let catalog = self
4430                .network_allowlist_catalog
4431                .read()
4432                .unwrap_or_else(|poisoned| poisoned.into_inner());
4433            let Some(entry) = catalog.get(allowlist_id) else {
4434                return Err(failure(
4435                    NodeFailureCode::UnknownNetworkAllowlist,
4436                    "spawn network allowlist is unavailable on this node",
4437                ));
4438            };
4439            // Track B: refuse provider-native mappings this provider cannot
4440            // honor (e.g. codex_network_access on Claude). Never silent ambient.
4441            if let Some(native) = entry.provider_native.as_ref() {
4442                if let Err(message) = crate::network_allowlist_catalog::provider_native_mapping_supported(
4443                    resolved.provider.as_str(),
4444                    resolved.approval_level,
4445                    native,
4446                ) {
4447                    return Err(failure(
4448                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4449                        &message,
4450                    ));
4451                }
4452            }
4453        }
4454        // Station knobs Track A (dig2browser-station-probe-and-network-permit-set):
4455        // when feature `dig2-station-probe` is on and browser_profile_id is set,
4456        // cheap named-pipe path reachability must succeed (Windows
4457        // `\\.\pipe\{suffix}`); platform-unsupported or unreachable → clear
4458        // NodeFailureCode. Runs even without environment_profile_id (same
4459        // placement spirit as allowlist catalog). Feature off keeps stub echo.
4460        // Never cookies / OAuth / proxy credentials. Exclusive lease is
4461        // acquired later in spawn_session_with_deadline (after instance id).
4462        #[cfg(feature = "dig2-station-probe")]
4463        if resolved.browser_profile_id.is_some() {
4464            use crate::dig2_station_probe::{probe_station_reachable, StationProbeError};
4465            match probe_station_reachable() {
4466                Ok(()) => {}
4467                Err(StationProbeError::UnsupportedPlatform)
4468                | Err(StationProbeError::InvalidPipeSuffix) => {
4469                    return Err(failure(
4470                        NodeFailureCode::BrowserStationProbeUnavailable,
4471                        "dig2browser station probe unavailable on this platform",
4472                    ));
4473                }
4474                Err(StationProbeError::Unreachable) => {
4475                    return Err(failure(
4476                        NodeFailureCode::BrowserStationUnreachable,
4477                        "dig2browser station named-pipe is unreachable",
4478                    ));
4479                }
4480            }
4481        }
4482        let Some(profile_id) = resolved.environment_profile_id.as_ref() else {
4483            return Ok(None);
4484        };
4485        let profiles = self
4486            .environment_profiles
4487            .read()
4488            .unwrap_or_else(|poisoned| poisoned.into_inner());
4489        let binding = profiles.get(profile_id).ok_or_else(|| {
4490            failure(
4491                NodeFailureCode::UnknownEnvironmentProfile,
4492                "spawn environment profile is unavailable on this node",
4493            )
4494        })?;
4495        if binding.provider != resolved.provider
4496            || binding.native_profile_id(resolved.mode).is_none()
4497        {
4498            return Err(failure(
4499                NodeFailureCode::EnvironmentProfileBindingMismatch,
4500                "spawn environment profile does not match the provider and mode",
4501            ));
4502        }
4503        // Echo opaque network_allowlist / browser_profile_id onto the receipt
4504        // after catalog + optional dig2 probe above. Empty/whitespace ids
4505        // already refuse at Spawn*Id construction.
4506        Ok(Some(ResolvedEnvironmentProfileReceipt {
4507            profile_id: binding.id.clone(),
4508            profile_revision: binding.revision.clone(),
4509            network_allowlist: resolved.network_allowlist.clone(),
4510            browser_profile_id: resolved.browser_profile_id.clone(),
4511        }))
4512    }
4513
4514    fn resolve_bundle(
4515        &self,
4516        resolved: &ResolvedSpawnSpec,
4517        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4518    ) -> Result<Option<ResolvedBundleReceipt>, NodeFailure> {
4519        let Some(bundle_id) = resolved.bundle_id.as_ref() else {
4520            return Ok(None);
4521        };
4522        let catalog = self.bundle_catalog
4523            .read()
4524            .unwrap_or_else(|poisoned| poisoned.into_inner());
4525        let bundle = catalog.get(bundle_id).ok_or_else(|| {
4526                failure(
4527                    NodeFailureCode::UnknownBundle,
4528                    "spawn bundle is unavailable on this node",
4529                )
4530            })?;
4531        self.bundle_layout(
4532            &resolved.provider,
4533            resolved.mode,
4534            environment_profile,
4535            bundle,
4536        )?;
4537        Ok(Some(bundle.receipt()))
4538    }
4539
4540    fn begin_delivery_stage(
4541        &self,
4542        manifest: DeliveryBundleManifestV2,
4543    ) -> Result<NodeResponse, NodeFailure> {
4544        let mut store = self
4545            .delivery_store
4546            .lock()
4547            .unwrap_or_else(|poisoned| poisoned.into_inner());
4548        let store = store.as_mut().ok_or_else(|| {
4549            failure(
4550                NodeFailureCode::DeliveryStageStorageFailed,
4551                "node delivery store is unavailable",
4552            )
4553        })?;
4554        let begun = store.begin(manifest).map_err(delivery_failure)?;
4555        Ok(NodeResponse::DeliveryStageBegun {
4556            stage_id: begun.stage_id,
4557            manifest_digest: begun.manifest_digest,
4558            missing_blobs: begun.missing_blobs,
4559        })
4560    }
4561
4562    fn put_delivery_blob_chunk(
4563        &self,
4564        stage_id: DeliveryStageId,
4565        blob_digest: DeliveryBlobDigestV1,
4566        offset: u64,
4567        chunk_hex: DeliveryBlobChunkHexV1,
4568    ) -> Result<NodeResponse, NodeFailure> {
4569        let mut store = self
4570            .delivery_store
4571            .lock()
4572            .unwrap_or_else(|poisoned| poisoned.into_inner());
4573        let store = store.as_mut().ok_or_else(|| {
4574            failure(
4575                NodeFailureCode::DeliveryStageStorageFailed,
4576                "node delivery store is unavailable",
4577            )
4578        })?;
4579        let accepted = store
4580            .put_chunk(&stage_id, &blob_digest, offset, &chunk_hex.decode())
4581            .map_err(delivery_failure)?;
4582        Ok(NodeResponse::DeliveryBlobChunkAccepted {
4583            stage_id: accepted.stage_id,
4584            blob_digest: accepted.blob_digest,
4585            next_offset: accepted.next_offset,
4586        })
4587    }
4588
4589    fn commit_delivery_stage(
4590        &self,
4591        stage_id: DeliveryStageId,
4592    ) -> Result<DeliveryCommitReceiptV1, NodeFailure> {
4593        let mut store = self
4594            .delivery_store
4595            .lock()
4596            .unwrap_or_else(|poisoned| poisoned.into_inner());
4597        let store = store.as_mut().ok_or_else(|| {
4598            failure(
4599                NodeFailureCode::DeliveryStageStorageFailed,
4600                "node delivery store is unavailable",
4601            )
4602        })?;
4603        let prepared = store.prepare_commit(&stage_id).map_err(delivery_failure)?;
4604        let mut catalog = self
4605            .bundle_catalog
4606            .write()
4607            .unwrap_or_else(|poisoned| poisoned.into_inner());
4608        let mut next_catalog = catalog.clone();
4609        next_catalog
4610            .insert_idempotent(prepared.bundle().clone())
4611            .map_err(|_| {
4612                failure(
4613                    NodeFailureCode::DeliveryStageConflict,
4614                    "delivery bundle identity conflicts with installed content",
4615                )
4616            })?;
4617        let receipt = store.publish_commit(prepared).map_err(delivery_failure)?;
4618        *catalog = next_catalog;
4619        Ok(receipt)
4620    }
4621
4622    fn abort_delivery_stage(
4623        &self,
4624        stage_id: &DeliveryStageId,
4625    ) -> Result<(), NodeFailure> {
4626        let mut store = self
4627            .delivery_store
4628            .lock()
4629            .unwrap_or_else(|poisoned| poisoned.into_inner());
4630        let store = store.as_mut().ok_or_else(|| {
4631            failure(
4632                NodeFailureCode::DeliveryStageStorageFailed,
4633                "node delivery store is unavailable",
4634            )
4635        })?;
4636        store.abort(stage_id).map_err(delivery_failure)
4637    }
4638
4639    fn resolve_context(
4640        &self,
4641        resolved: &ResolvedSpawnSpec,
4642    ) -> Result<Option<ResolvedContextPackReceipt>, NodeFailure> {
4643        let Some(context_id) = resolved.context_id.as_ref() else {
4644            return Ok(None);
4645        };
4646        self.context_catalog
4647            .read()
4648            .unwrap_or_else(|poisoned| poisoned.into_inner())
4649            .get(context_id)
4650            .map(|context| context.receipt().clone())
4651            .ok_or_else(|| {
4652                failure(
4653                    NodeFailureCode::UnknownContextPack,
4654                    "spawn context pack is unavailable on this node",
4655                )
4656            })
4657            .map(Some)
4658    }
4659
4660    fn bundle_layout(
4661        &self,
4662        provider: &AgentId,
4663        mode: SessionMode,
4664        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4665        bundle: &NodeBundle,
4666    ) -> Result<BundleProviderLayout, NodeFailure> {
4667        let layout = validate_bundle_binding(provider, mode, bundle).map_err(|_| {
4668            failure(
4669                NodeFailureCode::BundleBindingMismatch,
4670                "spawn bundle does not match the provider and mode",
4671            )
4672        })?;
4673        if layout != BundleProviderLayout::Codex {
4674            return Ok(layout);
4675        }
4676        let profile = environment_profile.and_then(|receipt| {
4677            let profiles = self
4678                .environment_profiles
4679                .read()
4680                .unwrap_or_else(|poisoned| poisoned.into_inner());
4681            let binding = profiles.get(&receipt.profile_id)?;
4682            if binding.revision != receipt.profile_revision
4683                || &binding.provider != provider
4684                || binding.native_profile_id(mode).is_none()
4685            {
4686                return None;
4687            }
4688            self.environment_materialization_profiles
4689                .read()
4690                .unwrap_or_else(|poisoned| poisoned.into_inner())
4691                .get(&receipt.profile_id)
4692                .cloned()
4693        });
4694        if !profile.is_some_and(|profile| profile.supports_bundle_layout(layout)) {
4695            return Err(failure(
4696                NodeFailureCode::BundleBindingMismatch,
4697                "Codex bundle requires an exact isolated CODEX_HOME profile",
4698            ));
4699        }
4700        Ok(layout)
4701    }
4702
4703    fn select_environment_profile(
4704        &self,
4705        instance_id: AgentInstanceId,
4706        provider: &AgentId,
4707        mode: SessionMode,
4708        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4709    ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
4710        let Some(environment_profile) = environment_profile else {
4711            return Ok(None);
4712        };
4713        let native_profile_id = {
4714            let profiles = self
4715                .environment_profiles
4716                .read()
4717                .unwrap_or_else(|poisoned| poisoned.into_inner());
4718            let binding = profiles
4719                .get(&environment_profile.profile_id)
4720                .ok_or_else(|| {
4721                    failure(
4722                        NodeFailureCode::UnknownEnvironmentProfile,
4723                        "session environment profile is unavailable on this node",
4724                    )
4725                })?;
4726            if &binding.revision != &environment_profile.profile_revision
4727                || &binding.provider != provider
4728            {
4729                return Err(failure(
4730                    NodeFailureCode::EnvironmentProfileBindingMismatch,
4731                    "session environment profile revision or provider changed",
4732                ));
4733            }
4734            binding.native_profile_id(mode).cloned().ok_or_else(|| {
4735                failure(
4736                    NodeFailureCode::EnvironmentProfileBindingMismatch,
4737                    "session environment profile does not support this mode",
4738                )
4739            })?
4740        };
4741        let control = self.native_launch_profile_control.clone().ok_or_else(|| {
4742            failure(
4743                NodeFailureCode::BackendOperationFailed,
4744                "native environment profile controller is unavailable",
4745            )
4746        })?;
4747        control
4748            .select_native_launch_profile(instance_id, native_profile_id)
4749            .map_err(|_| {
4750                failure(
4751                    NodeFailureCode::BackendOperationFailed,
4752                    "native environment profile selection failed",
4753                )
4754            })?;
4755        Ok(Some(NativeEnvironmentSelectionGuard {
4756            control: Some(control),
4757            instance_id,
4758        }))
4759    }
4760
4761    fn install_prepared_launch_overlay(
4762        &self,
4763        instance_id: AgentInstanceId,
4764        overlay: PreparedNativeLaunchOverlay,
4765    ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
4766        let control = self.native_launch_profile_control.clone().ok_or_else(|| {
4767            failure(
4768                NodeFailureCode::BackendOperationFailed,
4769                "native launch profile controller is unavailable",
4770            )
4771        })?;
4772        match overlay {
4773            PreparedNativeLaunchOverlay::Environment(overlay) => {
4774                control
4775                    .install_native_launch_environment_overlay(instance_id, overlay)
4776                    .map_err(|_| failure(
4777                        NodeFailureCode::BackendOperationFailed,
4778                        "session environment overlay installation failed",
4779                    ))?;
4780                Ok(None)
4781            }
4782            PreparedNativeLaunchOverlay::Instance(overlay) => {
4783                control
4784                    .install_native_instance_launch_overlay(instance_id, overlay)
4785                    .map_err(|_| failure(
4786                        NodeFailureCode::BundleBindingMismatch,
4787                        "session bundle launch overlay installation failed",
4788                    ))?;
4789                Ok(Some(NativeInstanceOverlayGuard {
4790                    control: Some(control),
4791                    instance_id,
4792                }))
4793            }
4794        }
4795    }
4796
4797    fn environment_materialization_profile(
4798        &self,
4799        environment_profile: &ResolvedEnvironmentProfileReceipt,
4800    ) -> Option<NodeSessionMaterializationProfile> {
4801        self.environment_materialization_profiles
4802            .read()
4803            .unwrap_or_else(|poisoned| poisoned.into_inner())
4804            .get(&environment_profile.profile_id)
4805            .cloned()
4806    }
4807
4808    fn materialization_profile(
4809        &self,
4810        provider: &AgentId,
4811        mode: SessionMode,
4812        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4813        bundle: Option<&ResolvedBundleReceipt>,
4814        context: Option<&ResolvedContextPackReceipt>,
4815    ) -> Result<Option<(
4816        NodeSessionMaterializationProfile,
4817        bool,
4818        Option<BundleProviderLayout>,
4819    )>, NodeFailure> {
4820        let environment = environment_profile
4821            .and_then(|receipt| self.environment_materialization_profile(receipt));
4822        let (bundle, bundle_layout) = match bundle {
4823            Some(receipt) => {
4824                let catalog = self.bundle_catalog.read()
4825                    .unwrap_or_else(|poisoned| poisoned.into_inner());
4826                let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
4827                    NodeFailureCode::UnknownBundle,
4828                    "session bundle is unavailable on this node",
4829                ))?;
4830                if installed.receipt() != *receipt {
4831                    return Err(failure(
4832                        NodeFailureCode::BundleBindingMismatch,
4833                        "session bundle revision or digest changed",
4834                    ));
4835                }
4836                let layout = self.bundle_layout(
4837                    provider,
4838                    mode,
4839                    environment_profile,
4840                    installed,
4841                )?;
4842                (Some(installed.clone()), Some(layout))
4843            }
4844            None => (None, None),
4845        };
4846        let has_environment_overlay = environment.is_some() || context.is_some();
4847        let profile = match (environment, bundle.as_ref(), bundle_layout) {
4848            (None, None, None) => None,
4849            (Some(profile), None, None) => Some(Ok(profile)),
4850            (None, Some(bundle), Some(layout)) => {
4851                Some(NodeSessionMaterializationProfile::from_bundle(bundle, layout))
4852            }
4853            (Some(profile), Some(bundle), Some(layout)) => {
4854                Some(profile.with_bundle(bundle, layout))
4855            }
4856            _ => {
4857                return Err(failure(
4858                    NodeFailureCode::BundleBindingMismatch,
4859                    "session bundle provider layout is unavailable",
4860                ));
4861            }
4862        };
4863        let profile = profile
4864            .transpose()
4865            .map_err(|_| failure(
4866                NodeFailureCode::BundleMaterializationFailed,
4867                "session bundle materialization profile is invalid",
4868            ))?;
4869        let profile = match (profile, context) {
4870            (Some(profile), Some(receipt)) => {
4871                let catalog = self.context_catalog
4872                    .read()
4873                    .unwrap_or_else(|poisoned| poisoned.into_inner());
4874                let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
4875                    NodeFailureCode::UnknownContextPack,
4876                    "session context pack is unavailable on this node",
4877                ))?;
4878                if pack.receipt() != receipt {
4879                    return Err(failure(
4880                        NodeFailureCode::ContextPackMaterializationFailed,
4881                        "session context pack identity changed",
4882                    ));
4883                }
4884                profile.with_context(pack)
4885            }
4886            (None, Some(receipt)) => {
4887                let catalog = self.context_catalog
4888                    .read()
4889                    .unwrap_or_else(|poisoned| poisoned.into_inner());
4890                let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
4891                    NodeFailureCode::UnknownContextPack,
4892                    "session context pack is unavailable on this node",
4893                ))?;
4894                if pack.receipt() != receipt {
4895                    return Err(failure(
4896                        NodeFailureCode::ContextPackMaterializationFailed,
4897                        "session context pack identity changed",
4898                    ));
4899                }
4900                NodeSessionMaterializationProfile::from_context(pack)
4901            }
4902            (Some(profile), None) => Ok(profile),
4903            (None, None) => return Ok(None),
4904        }
4905        .map_err(|_| failure(
4906            NodeFailureCode::ContextPackMaterializationFailed,
4907            "session context pack materialization profile is invalid",
4908        ))?;
4909        Ok(Some((profile, has_environment_overlay, bundle_layout)))
4910    }
4911
4912    fn prepare_session_materialization<'a>(
4913        &'a self,
4914        address: &SessionAddress,
4915        provider: &AgentId,
4916        mode: SessionMode,
4917        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4918        bundle: Option<&ResolvedBundleReceipt>,
4919        context: Option<&ResolvedContextPackReceipt>,
4920        managed_lease_id: Option<ManagedWorktreeLeaseId>,
4921    ) -> Result<Option<(Option<PreparedNativeLaunchOverlay>, SessionMaterializationGuard<'a>)>, NodeFailure> {
4922        let Some((profile, has_environment_overlay, bundle_layout)) =
4923            self.materialization_profile(provider, mode, environment_profile, bundle, context)?
4924        else {
4925            return Ok(None);
4926        };
4927        let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
4928            failure(
4929                NodeFailureCode::BackendOperationFailed,
4930                "session environment materializer is unavailable",
4931            )
4932        })?;
4933        let materialization_failure_code = if context.is_some() {
4934            NodeFailureCode::ContextPackMaterializationFailed
4935        } else if bundle.is_some() {
4936            NodeFailureCode::BundleMaterializationFailed
4937        } else {
4938            NodeFailureCode::BackendOperationFailed
4939        };
4940        let id = MaterializationId::new(format!(
4941            "mat-{}-{}-{}",
4942            self.incarnation_id,
4943            address.session.instance_id.0,
4944            address.session.generation.0,
4945        ))
4946        .map_err(|_| {
4947            failure(
4948                NodeFailureCode::BackendOperationFailed,
4949                "session environment materialization identity failed",
4950            )
4951        })?;
4952        let owner = MaterializationOwner::Session {
4953            incarnation_id: self.incarnation_id,
4954            instance_id: address.session.instance_id,
4955            generation: address.session.generation,
4956        };
4957        let mut ownership = materializer
4958            .begin(
4959                id.clone(),
4960                environment_profile.cloned(),
4961                bundle.cloned(),
4962                context.cloned(),
4963                owner,
4964                managed_lease_id,
4965                &profile,
4966                unix_time_ms(),
4967            )
4968            .map_err(|_| {
4969                failure(
4970                    materialization_failure_code,
4971                    "session materialization preparation failed",
4972                )
4973            })?;
4974        {
4975            let _transaction = self
4976                .state_transaction
4977                .lock()
4978                .unwrap_or_else(|poisoned| poisoned.into_inner());
4979            let duplicate = self
4980                .materializations
4981                .lock()
4982                .unwrap_or_else(|poisoned| poisoned.into_inner())
4983                .insert(id.clone(), ownership.clone());
4984            if duplicate.is_some() {
4985                return Err(failure(
4986                    NodeFailureCode::BackendOperationFailed,
4987                    "session environment materialization identity conflict",
4988                ));
4989            }
4990            if let Err(error) = self.persist_state_locked() {
4991                self.materializations
4992                    .lock()
4993                    .unwrap_or_else(|poisoned| poisoned.into_inner())
4994                    .remove(&id);
4995                return Err(persistence_failure(error));
4996            }
4997        }
4998        let environment = match materializer.materialize(&mut ownership, &profile, unix_time_ms()) {
4999            Ok(environment) => environment,
5000            Err(_) => {
5001                let transaction = self
5002                    .state_transaction
5003                    .lock()
5004                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5005                self.materializations
5006                    .lock()
5007                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5008                    .insert(id.clone(), ownership.clone());
5009                if self.persist_state_locked().is_err() {
5010                    self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5011                }
5012                drop(transaction);
5013                if ownership.state() == MaterializationState::CleanupRequired {
5014                    let _ = self.cleanup_materialization(&id);
5015                }
5016                return Err(failure(
5017                    materialization_failure_code,
5018                    "session materialization failed",
5019                ));
5020            }
5021        };
5022        {
5023            let _transaction = self
5024                .state_transaction
5025                .lock()
5026                .unwrap_or_else(|poisoned| poisoned.into_inner());
5027            self.materializations
5028                .lock()
5029                .unwrap_or_else(|poisoned| poisoned.into_inner())
5030                .insert(id.clone(), ownership.clone());
5031            if let Err(error) = self.persist_state_locked() {
5032                drop(_transaction);
5033                let _ = self.cleanup_materialization(&id);
5034                return Err(persistence_failure(error));
5035            }
5036        }
5037        let guard = SessionMaterializationGuard {
5038            shared: self,
5039            id: Some(id),
5040            provider_home: Some(ownership.provider_home().to_path_buf()),
5041        };
5042        let bundle_arguments = match bundle {
5043            Some(receipt) => {
5044                let catalog = self.bundle_catalog.read()
5045                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5046                let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
5047                    NodeFailureCode::UnknownBundle,
5048                    "session bundle is unavailable on this node",
5049                ))?;
5050                if installed.receipt() != *receipt {
5051                    return Err(failure(
5052                        NodeFailureCode::BundleBindingMismatch,
5053                        "session bundle revision or digest changed",
5054                    ));
5055                }
5056                let layout = bundle_layout.ok_or_else(|| failure(
5057                    NodeFailureCode::BundleBindingMismatch,
5058                    "materialized bundle provider layout is unavailable",
5059                ))?;
5060                materializer.revalidate_bundle(&ownership, installed, layout).map_err(|_| {
5061                    failure(
5062                        NodeFailureCode::BundleMaterializationFailed,
5063                        "session bundle byte revalidation failed",
5064                    )
5065                })?;
5066                Some(bundle_launch_arguments(
5067                    layout,
5068                    ownership.bundle_root(),
5069                    ownership.provider_home(),
5070                ).map_err(|_| failure(
5071                    NodeFailureCode::BundleBindingMismatch,
5072                    "session bundle launch binding is invalid",
5073                ))?)
5074            }
5075            None => None,
5076        };
5077        if let Some(receipt) = context {
5078            materializer
5079                .revalidate_context(&ownership, receipt)
5080                .map_err(|_| failure(
5081                    NodeFailureCode::ContextPackMaterializationFailed,
5082                    "session context pack byte revalidation failed",
5083                ))?;
5084        }
5085        let transport = match mode {
5086            SessionMode::Pty => TransportKind::Pty,
5087            SessionMode::Inline => TransportKind::Pipe,
5088            SessionMode::Acp => TransportKind::Acp,
5089        };
5090        let overlay = if let Some(extra_args) = bundle_arguments {
5091            Some(PreparedNativeLaunchOverlay::Instance(
5092                NativeInstanceLaunchOverlay::new(
5093                    provider.clone(),
5094                    transport,
5095                    if has_environment_overlay {
5096                        environment
5097                    } else {
5098                        Vec::new()
5099                    },
5100                    extra_args,
5101                )
5102                .map_err(|_| {
5103                    failure(
5104                        NodeFailureCode::BundleBindingMismatch,
5105                        "session bundle launch overlay is invalid",
5106                    )
5107                })?,
5108            ))
5109        } else if has_environment_overlay {
5110            let context_only = environment_profile.is_none()
5111                && bundle.is_none()
5112                && context.is_some();
5113            let overlay = if context_only {
5114                NativeLaunchEnvironmentOverlay::new_context_root(
5115                    provider.clone(),
5116                    transport,
5117                    environment,
5118                )
5119                .map_err(|_| {
5120                    failure(
5121                        NodeFailureCode::ContextPackMaterializationFailed,
5122                        "context-only launch overlay is invalid",
5123                    )
5124                })?
5125            } else {
5126                NativeLaunchEnvironmentOverlay::new(
5127                    provider.clone(),
5128                    transport,
5129                    environment,
5130                )
5131                .map_err(|_| {
5132                    failure(
5133                        NodeFailureCode::BackendOperationFailed,
5134                        "session environment overlay is invalid",
5135                    )
5136                })?
5137            };
5138            Some(PreparedNativeLaunchOverlay::Environment(overlay))
5139        } else {
5140            None
5141        };
5142        Ok(Some((overlay, guard)))
5143    }
5144
5145    fn cleanup_materialization(&self, id: &MaterializationId) -> Result<(), NodeFailure> {
5146        let Some(mut ownership) = self
5147            .materializations
5148            .lock()
5149            .unwrap_or_else(|poisoned| poisoned.into_inner())
5150            .get(id)
5151            .cloned()
5152        else {
5153            return Ok(());
5154        };
5155        if ownership.state() != MaterializationState::CleanupRequired {
5156            ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5157                failure(
5158                    NodeFailureCode::BackendOperationFailed,
5159                    "session environment cleanup state is invalid",
5160                )
5161            })?;
5162        }
5163        {
5164            let _transaction = self
5165                .state_transaction
5166                .lock()
5167                .unwrap_or_else(|poisoned| poisoned.into_inner());
5168            let previous = self
5169                .materializations
5170                .lock()
5171                .unwrap_or_else(|poisoned| poisoned.into_inner())
5172                .insert(id.clone(), ownership.clone());
5173            if let Err(error) = self.persist_state_locked() {
5174                let mut materializations = self
5175                    .materializations
5176                    .lock()
5177                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5178                if let Some(previous) = previous {
5179                    materializations.insert(id.clone(), previous);
5180                }
5181                return Err(persistence_failure(error));
5182            }
5183        }
5184        let cleanup = self
5185            .session_environment_materializer
5186            .as_ref()
5187            .ok_or(())
5188            .and_then(|materializer| materializer.cleanup(&ownership).map_err(|_| ()));
5189        if cleanup.is_err() {
5190            let _ = ownership.mark_recovery_required(unix_time_ms());
5191            self.materializations
5192                .lock()
5193                .unwrap_or_else(|poisoned| poisoned.into_inner())
5194                .insert(id.clone(), ownership);
5195            if self.persist_state().is_err() {
5196                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5197            }
5198            return Err(failure(
5199                NodeFailureCode::BackendOperationFailed,
5200                "session environment cleanup requires recovery",
5201            ));
5202        }
5203        let _transaction = self
5204            .state_transaction
5205            .lock()
5206            .unwrap_or_else(|poisoned| poisoned.into_inner());
5207        self.materializations
5208            .lock()
5209            .unwrap_or_else(|poisoned| poisoned.into_inner())
5210            .remove(id);
5211        if let Err(error) = self.persist_state_locked() {
5212            self.materializations
5213                .lock()
5214                .unwrap_or_else(|poisoned| poisoned.into_inner())
5215                .insert(id.clone(), ownership);
5216            return Err(persistence_failure(error));
5217        }
5218        Ok(())
5219    }
5220
5221    fn resolve_record_materialization(
5222        &self,
5223        record_id: &SessionRecordId,
5224        provider: &AgentId,
5225        mode: SessionMode,
5226        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5227        bundle: Option<&ResolvedBundleReceipt>,
5228        context: Option<&ResolvedContextPackReceipt>,
5229    ) -> Result<Option<(MaterializationId, Option<PreparedNativeLaunchOverlay>)>, NodeFailure> {
5230        let ownership = {
5231            let materializations = self
5232                .materializations
5233                .lock()
5234                .unwrap_or_else(|poisoned| poisoned.into_inner());
5235            let mut matches = materializations.values().filter(|record| {
5236                record.owner()
5237                    == &MaterializationOwner::Record {
5238                        record_id: record_id.clone(),
5239                    }
5240            });
5241            let ownership = matches.next().cloned();
5242            if matches.next().is_some() {
5243                return Err(failure(
5244                    NodeFailureCode::BackendOperationFailed,
5245                    "managed session has conflicting environment ownership",
5246                ));
5247            }
5248            ownership
5249        };
5250        if let (Some(ownership), Some(receipt)) = (ownership.as_ref(), context) {
5251            let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5252                failure(
5253                    NodeFailureCode::ContextPackMaterializationFailed,
5254                    "session context materializer is unavailable",
5255                )
5256            })?;
5257            let pack = materializer
5258                .revalidate_context(ownership, receipt)
5259                .map_err(|_| {
5260                    self.mark_materialization_recovery_required(ownership.id());
5261                    failure(
5262                        NodeFailureCode::ContextPackMaterializationFailed,
5263                        "managed session context pack revalidation failed",
5264                    )
5265                })?;
5266            self.context_catalog
5267                .write()
5268                .unwrap_or_else(|poisoned| poisoned.into_inner())
5269                .insert(pack)
5270                .map_err(|_| failure(
5271                    NodeFailureCode::ContextPackMaterializationFailed,
5272                    "managed session context pack catalog restore failed",
5273                ))?;
5274        }
5275        let materialization = self.materialization_profile(
5276            provider,
5277            mode,
5278            environment_profile,
5279            bundle,
5280            context,
5281        )?;
5282        match (ownership, materialization) {
5283            (None, None) => Ok(None),
5284            (None, Some(_)) | (Some(_), None) => Err(failure(
5285                NodeFailureCode::BackendOperationFailed,
5286                "managed session materialization is unavailable",
5287            )),
5288            (Some(ownership), Some((profile, has_environment_materialization, bundle_layout))) => {
5289                if ownership.environment_profile() != environment_profile
5290                    || ownership.bundle() != bundle
5291                    || ownership.context() != context
5292                    || ownership.state() != MaterializationState::Ready
5293                {
5294                    self.mark_materialization_recovery_required(ownership.id());
5295                    return Err(failure(
5296                        NodeFailureCode::BackendOperationFailed,
5297                        "managed session environment ownership requires recovery",
5298                    ));
5299                }
5300                let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5301                    failure(
5302                        NodeFailureCode::BackendOperationFailed,
5303                        "session environment materializer is unavailable",
5304                    )
5305                })?;
5306                if materializer.revalidate(&ownership).is_err() {
5307                    self.mark_materialization_recovery_required(ownership.id());
5308                    return Err(failure(
5309                        NodeFailureCode::BackendOperationFailed,
5310                        "managed session environment revalidation failed",
5311                    ));
5312                }
5313                let bundle_arguments = if let Some(bundle_receipt) = bundle {
5314                    let catalog = self.bundle_catalog.read()
5315                        .unwrap_or_else(|poisoned| poisoned.into_inner());
5316                    let Some(installed) = catalog.get(&bundle_receipt.id) else {
5317                        return Err(failure(
5318                            NodeFailureCode::UnknownBundle,
5319                            "managed session bundle is unavailable",
5320                        ));
5321                    };
5322                    let layout = bundle_layout.ok_or_else(|| failure(
5323                        NodeFailureCode::BundleBindingMismatch,
5324                        "record bundle provider layout is unavailable",
5325                    ))?;
5326                    if materializer.revalidate_bundle(&ownership, installed, layout).is_err() {
5327                        drop(catalog);
5328                        self.mark_materialization_recovery_required(ownership.id());
5329                        return Err(failure(
5330                            NodeFailureCode::BundleMaterializationFailed,
5331                            "managed session bundle revalidation failed",
5332                        ));
5333                    }
5334                    Some(bundle_launch_arguments(
5335                        layout,
5336                        ownership.bundle_root(),
5337                        ownership.provider_home(),
5338                    ).map_err(|_| failure(
5339                        NodeFailureCode::BundleBindingMismatch,
5340                        "managed session bundle launch binding is invalid",
5341                    ))?)
5342                } else {
5343                    None
5344                };
5345                if !has_environment_materialization {
5346                    let overlay = bundle_arguments.map(|extra_args| {
5347                        NativeInstanceLaunchOverlay::new(
5348                            provider.clone(),
5349                            TransportKind::Pty,
5350                            Vec::new(),
5351                            extra_args,
5352                        )
5353                        .map(PreparedNativeLaunchOverlay::Instance)
5354                        .map_err(|_| failure(
5355                            NodeFailureCode::BundleBindingMismatch,
5356                            "managed session bundle launch overlay is invalid",
5357                        ))
5358                    }).transpose()?;
5359                    return Ok(Some((ownership.id().clone(), overlay)));
5360                }
5361                let environment = match materializer.resolve_environment(&ownership, &profile) {
5362                    Ok(environment) => environment,
5363                    Err(
5364                        SessionEnvironmentMaterializeError::SecretUnavailable
5365                        | SessionEnvironmentMaterializeError::SecretDenied
5366                        | SessionEnvironmentMaterializeError::InvalidSecretValue,
5367                    ) => {
5368                        return Err(failure(
5369                            NodeFailureCode::BackendOperationFailed,
5370                            "managed session environment resolution failed",
5371                        ));
5372                    }
5373                    Err(
5374                        SessionEnvironmentMaterializeError::InvalidRoot
5375                        | SessionEnvironmentMaterializeError::OwnershipMismatch
5376                        | SessionEnvironmentMaterializeError::Filesystem(_)
5377                        | SessionEnvironmentMaterializeError::Record(_),
5378                    ) => {
5379                        self.mark_materialization_recovery_required(ownership.id());
5380                        return Err(failure(
5381                            NodeFailureCode::BackendOperationFailed,
5382                            "managed session environment resolution failed",
5383                        ));
5384                    }
5385                };
5386                let transport = match mode {
5387                    SessionMode::Pty => TransportKind::Pty,
5388                    SessionMode::Inline => TransportKind::Pipe,
5389                    SessionMode::Acp => TransportKind::Acp,
5390                };
5391                let overlay = match bundle_arguments {
5392                    Some(extra_args) => PreparedNativeLaunchOverlay::Instance(
5393                        NativeInstanceLaunchOverlay::new(
5394                            provider.clone(),
5395                            transport,
5396                            environment,
5397                            extra_args,
5398                        )
5399                        .map_err(|_| failure(
5400                            NodeFailureCode::BundleBindingMismatch,
5401                            "managed session bundle launch overlay is invalid",
5402                        ))?,
5403                    ),
5404                    None => PreparedNativeLaunchOverlay::Environment(
5405                        NativeLaunchEnvironmentOverlay::new(
5406                            provider.clone(),
5407                            transport,
5408                            environment,
5409                        )
5410                        .map_err(|_| {
5411                            failure(
5412                                NodeFailureCode::BackendOperationFailed,
5413                                "managed session environment overlay is invalid",
5414                            )
5415                        })?,
5416                    ),
5417                };
5418                Ok(Some((ownership.id().clone(), Some(overlay))))
5419            }
5420        }
5421    }
5422
5423    fn destroy_record_materialization(
5424        &self,
5425        record_id: &SessionRecordId,
5426    ) -> Result<bool, NodeFailure> {
5427        let ids = self
5428            .materializations
5429            .lock()
5430            .unwrap_or_else(|poisoned| poisoned.into_inner())
5431            .values()
5432            .filter_map(|record| {
5433                (record.owner()
5434                    == &MaterializationOwner::Record {
5435                        record_id: record_id.clone(),
5436                    })
5437                .then(|| record.id().clone())
5438            })
5439            .collect::<Vec<_>>();
5440        if ids.len() > 1 {
5441            return Err(failure(
5442                NodeFailureCode::BackendOperationFailed,
5443                "managed session has conflicting environment ownership",
5444            ));
5445        }
5446        let Some(id) = ids.first().cloned() else {
5447            return Ok(false);
5448        };
5449        let (terminal_record, mut cleanup_ownership) = {
5450            let _transaction = self
5451                .state_transaction
5452                .lock()
5453                .unwrap_or_else(|poisoned| poisoned.into_inner());
5454            let original_record = self.record(record_id)?;
5455            let mut terminal_record = original_record.clone();
5456            terminal_record.provider_session = None;
5457            terminal_record.active_session = None;
5458            terminal_record.state = ManagedSessionState::Unavailable;
5459            terminal_record.last_error = Some("environment-profile-unavailable".to_owned());
5460            terminal_record.updated_at_unix_ms = unix_time_ms();
5461            let original_ownership = self
5462                .materializations
5463                .lock()
5464                .unwrap_or_else(|poisoned| poisoned.into_inner())
5465                .get(&id)
5466                .cloned()
5467                .ok_or_else(|| {
5468                    failure(
5469                        NodeFailureCode::BackendOperationFailed,
5470                        "managed session environment ownership disappeared during cleanup",
5471                    )
5472                })?;
5473            let mut cleanup_ownership = original_ownership.clone();
5474            if cleanup_ownership.state() != MaterializationState::CleanupRequired {
5475                cleanup_ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5476                    failure(
5477                        NodeFailureCode::BackendOperationFailed,
5478                        "managed session environment cleanup state is invalid",
5479                    )
5480                })?;
5481            }
5482            self.session_records
5483                .lock()
5484                .unwrap_or_else(|poisoned| poisoned.into_inner())
5485                .records
5486                .insert(record_id.clone(), terminal_record.clone());
5487            self.materializations
5488                .lock()
5489                .unwrap_or_else(|poisoned| poisoned.into_inner())
5490                .insert(id.clone(), cleanup_ownership.clone());
5491            if let Err(error) = self.persist_state_locked() {
5492                self.session_records
5493                    .lock()
5494                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5495                    .records
5496                    .insert(record_id.clone(), original_record);
5497                self.materializations
5498                    .lock()
5499                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5500                    .insert(id.clone(), original_ownership);
5501                return Err(persistence_failure(error));
5502            }
5503            (terminal_record, cleanup_ownership)
5504        };
5505        let cleanup = self
5506            .session_environment_materializer
5507            .as_ref()
5508            .ok_or(())
5509            .and_then(|materializer| materializer.cleanup(&cleanup_ownership).map_err(|_| ()));
5510        if cleanup.is_err() {
5511            let _ = cleanup_ownership.mark_recovery_required(unix_time_ms());
5512            self.materializations
5513                .lock()
5514                .unwrap_or_else(|poisoned| poisoned.into_inner())
5515                .insert(id.clone(), cleanup_ownership);
5516            if self.persist_state().is_err() {
5517                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5518            }
5519            return Err(failure(
5520                NodeFailureCode::BackendOperationFailed,
5521                "managed session environment cleanup requires recovery",
5522            ));
5523        }
5524        let _transaction = self
5525            .state_transaction
5526            .lock()
5527            .unwrap_or_else(|poisoned| poisoned.into_inner());
5528        self.remove_record_memory(record_id);
5529        self.materializations
5530            .lock()
5531            .unwrap_or_else(|poisoned| poisoned.into_inner())
5532            .remove(&id);
5533        if let Err(error) = self.persist_state_locked() {
5534            self.session_records
5535                .lock()
5536                .unwrap_or_else(|poisoned| poisoned.into_inner())
5537                .records
5538                .insert(record_id.clone(), terminal_record);
5539            self.materializations
5540                .lock()
5541                .unwrap_or_else(|poisoned| poisoned.into_inner())
5542                .insert(id, cleanup_ownership);
5543            return Err(persistence_failure(error));
5544        }
5545        Ok(true)
5546    }
5547
5548    fn mark_materialization_recovery_required(&self, id: &MaterializationId) {
5549        let changed = {
5550            let mut materializations = self
5551                .materializations
5552                .lock()
5553                .unwrap_or_else(|poisoned| poisoned.into_inner());
5554            let Some(ownership) = materializations.get_mut(id) else {
5555                return;
5556            };
5557            ownership.mark_recovery_required(unix_time_ms()).is_ok()
5558        };
5559        if changed && self.persist_state().is_err() {
5560            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5561        }
5562    }
5563
5564    fn reconcile_materializations(&self) {
5565        let records = self
5566            .materializations
5567            .lock()
5568            .unwrap_or_else(|poisoned| poisoned.into_inner())
5569            .values()
5570            .cloned()
5571            .collect::<Vec<_>>();
5572        for mut ownership in records {
5573            let result = match (ownership.state(), ownership.owner()) {
5574                (MaterializationState::Preparing, _)
5575                | (MaterializationState::CleanupRequired, _) => {
5576                    self.cleanup_materialization(ownership.id())
5577                }
5578                (MaterializationState::Ready, MaterializationOwner::Session { .. }) => {
5579                    if ownership.mark_recovery_required(unix_time_ms()).is_ok() {
5580                        self.materializations
5581                            .lock()
5582                            .unwrap_or_else(|poisoned| poisoned.into_inner())
5583                            .insert(ownership.id().clone(), ownership.clone());
5584                        self.persist_state().map_err(persistence_failure)
5585                    } else {
5586                        Err(failure(
5587                            NodeFailureCode::BackendOperationFailed,
5588                            "session environment reconciliation failed",
5589                        ))
5590                    }
5591                }
5592                (MaterializationState::Ready, MaterializationOwner::Record { record_id }) => self
5593                    .session_environment_materializer
5594                    .as_ref()
5595                    .ok_or_else(|| {
5596                        failure(
5597                            NodeFailureCode::BackendOperationFailed,
5598                            "session environment materializer is unavailable",
5599                        )
5600                    })
5601                    .and_then(|materializer| {
5602                        materializer.revalidate(&ownership).map_err(|_| failure(
5603                            NodeFailureCode::BackendOperationFailed,
5604                            "session materialization revalidation failed",
5605                        ))?;
5606                        let record = self.record(record_id)?;
5607                        if record.environment_profile.as_ref() != ownership.environment_profile()
5608                            || record.bundle.as_ref() != ownership.bundle()
5609                            || record.context.as_ref() != ownership.context()
5610                        {
5611                            return Err(failure(
5612                                NodeFailureCode::BackendOperationFailed,
5613                                "session materialization ownership changed after restart",
5614                            ));
5615                        }
5616                        if let Some(receipt) = ownership.bundle() {
5617                            let catalog = self.bundle_catalog.read()
5618                                .unwrap_or_else(|poisoned| poisoned.into_inner());
5619                            let bundle = catalog.get(&receipt.id).ok_or_else(|| failure(
5620                                NodeFailureCode::UnknownBundle,
5621                                "session bundle is unavailable after restart",
5622                            ))?;
5623                            if bundle.receipt() != *receipt {
5624                                return Err(failure(
5625                                    NodeFailureCode::BundleBindingMismatch,
5626                                    "session bundle changed after restart",
5627                                ));
5628                            }
5629                            let layout = self.bundle_layout(
5630                                &record.provider,
5631                                record.mode,
5632                                record.environment_profile.as_ref(),
5633                                bundle,
5634                            )?;
5635                            materializer
5636                                .revalidate_bundle(&ownership, bundle, layout)
5637                                .map_err(|_| failure(
5638                                    NodeFailureCode::BundleMaterializationFailed,
5639                                    "session bundle revalidation failed",
5640                                ))?;
5641                        }
5642                        if let Some(receipt) = ownership.context() {
5643                            let pack = materializer
5644                                .revalidate_context(&ownership, receipt)
5645                                .map_err(|_| {
5646                                    failure(
5647                                        NodeFailureCode::ContextPackMaterializationFailed,
5648                                        "session context revalidation failed",
5649                                    )
5650                                })?;
5651                            self.context_catalog
5652                                .write()
5653                                .unwrap_or_else(|poisoned| poisoned.into_inner())
5654                                .insert(pack)
5655                                .map_err(|_| {
5656                                    failure(
5657                                        NodeFailureCode::ContextPackMaterializationFailed,
5658                                        "session context catalog restore failed",
5659                                    )
5660                                })?;
5661                        }
5662                        Ok(())
5663                    }),
5664                (MaterializationState::RecoveryRequired, _) => Ok(()),
5665            };
5666            if result.is_err() && ownership.state() != MaterializationState::RecoveryRequired {
5667                let _ = ownership.mark_recovery_required(unix_time_ms());
5668                self.materializations
5669                    .lock()
5670                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5671                    .insert(ownership.id().clone(), ownership);
5672                if self.persist_state().is_err() {
5673                    self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5674                }
5675            }
5676        }
5677        let materialized_profiles = self
5678            .environment_materialization_profiles
5679            .read()
5680            .unwrap_or_else(|poisoned| poisoned.into_inner())
5681            .keys()
5682            .cloned()
5683            .collect::<Vec<_>>();
5684        let ownership = self
5685            .materializations
5686            .lock()
5687            .unwrap_or_else(|poisoned| poisoned.into_inner())
5688            .values()
5689            .cloned()
5690            .collect::<Vec<_>>();
5691        let changed = {
5692            let mut session_records = self
5693                .session_records
5694                .lock()
5695                .unwrap_or_else(|poisoned| poisoned.into_inner());
5696            let mut changed = false;
5697            for record in session_records.records.values_mut() {
5698                let environment_profile = record.environment_profile.as_ref();
5699                if environment_profile.is_none()
5700                    && record.bundle.is_none()
5701                    && record.context.is_none()
5702                {
5703                    continue;
5704                }
5705                if record.bundle.is_none()
5706                    && environment_profile.is_some_and(|profile| {
5707                        !materialized_profiles.contains(&profile.profile_id)
5708                    })
5709                {
5710                    continue;
5711                }
5712                let has_exact_ownership = ownership.iter().any(|candidate| {
5713                    candidate.state() == MaterializationState::Ready
5714                        && candidate.environment_profile() == environment_profile
5715                        && candidate.bundle() == record.bundle.as_ref()
5716                        && candidate.context() == record.context.as_ref()
5717                        && candidate.owner()
5718                            == &MaterializationOwner::Record {
5719                                record_id: record.record_id.clone(),
5720                            }
5721                });
5722                if !has_exact_ownership {
5723                    record.provider_session = None;
5724                    record.active_session = None;
5725                    record.state = ManagedSessionState::Unavailable;
5726                    record.last_error = Some(if record.bundle.is_some() {
5727                        "bundle-unavailable".to_owned()
5728                    } else if record.context.is_some() {
5729                        "context-unavailable".to_owned()
5730                    } else {
5731                        "environment-profile-unavailable".to_owned()
5732                    });
5733                    record.updated_at_unix_ms = unix_time_ms();
5734                    changed = true;
5735                }
5736            }
5737            changed
5738        };
5739        if changed && self.persist_state().is_err() {
5740            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5741        }
5742    }
5743
5744    fn replay_spawn_spec(
5745        &self,
5746        spec: &SpawnSpec,
5747        now: Instant,
5748    ) -> Result<Option<Result<ResolvedSpawnReceipt, NodeFailure>>, NodeFailure> {
5749        let mut cache = self
5750            .spawn_idempotency
5751            .lock()
5752            .unwrap_or_else(|poisoned| poisoned.into_inner());
5753        cache.entries.retain(|_, entry| entry.expires_at > now);
5754        if let Some(entry) = cache.entries.get(&spec.idempotency_key) {
5755            let SpawnIdempotencyValue::Standard { spec: existing, result } = &entry.value else {
5756                return Err(failure(
5757                    NodeFailureCode::SpawnIdempotencyConflict,
5758                    "spawn idempotency key was reused across spawn kinds",
5759                ));
5760            };
5761            if existing != spec {
5762                return Err(failure(
5763                    NodeFailureCode::SpawnIdempotencyConflict,
5764                    "spawn idempotency key was reused with a different specification",
5765                ));
5766            }
5767            return Ok(Some(result.clone()));
5768        }
5769        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5770            return Err(failure(
5771                NodeFailureCode::SpawnIdempotencyCapacity,
5772                "spawn idempotency cache reached its bounded capacity",
5773            ));
5774        }
5775        Ok(None)
5776    }
5777
5778    fn remember_spawn_spec(
5779        &self,
5780        spec: SpawnSpec,
5781        result: Result<ResolvedSpawnReceipt, NodeFailure>,
5782        accepted_at: Instant,
5783    ) {
5784        self
5785            .spawn_idempotency
5786            .lock()
5787            .unwrap_or_else(|poisoned| poisoned.into_inner())
5788            .entries
5789            .insert(
5790                spec.idempotency_key.clone(),
5791                SpawnIdempotencyEntry {
5792                    value: SpawnIdempotencyValue::Standard { spec, result },
5793                    expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5794                },
5795            );
5796    }
5797
5798    fn claim_harness_mcp_spawn(
5799        &self,
5800        _reservation_id: &HarnessMcpReservationId,
5801        _activation_digest: &HarnessMcpActivationDigest,
5802        spec: &SpawnSpec,
5803        now: Instant,
5804    ) -> Result<(), NodeFailure> {
5805        let mut cache = self.spawn_idempotency.lock()
5806            .unwrap_or_else(|poisoned| poisoned.into_inner());
5807        cache.entries.retain(|_, entry| entry.expires_at > now);
5808        if cache.entries.contains_key(&spec.idempotency_key) {
5809            return Err(failure(
5810                NodeFailureCode::SpawnIdempotencyConflict,
5811                "spawn idempotency key was reused across spawn kinds",
5812            ));
5813        }
5814        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5815            return Err(failure(
5816                NodeFailureCode::SpawnIdempotencyCapacity,
5817                "spawn idempotency cache reached its bounded capacity",
5818            ));
5819        }
5820        cache.entries.insert(spec.idempotency_key.clone(), SpawnIdempotencyEntry {
5821            value: SpawnIdempotencyValue::HarnessMcp,
5822            expires_at: now + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5823        });
5824        Ok(())
5825    }
5826
5827    fn replay_managed_spawn(
5828        &self,
5829        request: &ManagedWorktreeSpawnRequest,
5830        now: Instant,
5831    ) -> Result<Option<Result<ManagedWorktreeSpawnReceipt, NodeFailure>>, NodeFailure> {
5832        let mut cache = self.spawn_idempotency.lock()
5833            .unwrap_or_else(|poisoned| poisoned.into_inner());
5834        cache.entries.retain(|_, entry| entry.expires_at > now);
5835        if let Some(entry) = cache.entries.get(&request.spawn_spec.idempotency_key) {
5836            let SpawnIdempotencyValue::Managed { request: existing, result } = &entry.value else {
5837                return Err(failure(
5838                    NodeFailureCode::SpawnIdempotencyConflict,
5839                    "spawn idempotency key was reused across spawn kinds",
5840                ));
5841            };
5842            if existing != request {
5843                return Err(failure(
5844                    NodeFailureCode::SpawnIdempotencyConflict,
5845                    "spawn idempotency key was reused with a different managed specification",
5846                ));
5847            }
5848            return Ok(Some(result.clone()));
5849        }
5850        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5851            return Err(failure(
5852                NodeFailureCode::SpawnIdempotencyCapacity,
5853                "spawn idempotency cache reached its bounded capacity",
5854            ));
5855        }
5856        Ok(None)
5857    }
5858
5859    fn remember_managed_spawn(
5860        &self,
5861        request: ManagedWorktreeSpawnRequest,
5862        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
5863        accepted_at: Instant,
5864    ) {
5865        self.spawn_idempotency.lock()
5866            .unwrap_or_else(|poisoned| poisoned.into_inner())
5867            .entries
5868            .insert(
5869                request.spawn_spec.idempotency_key.clone(),
5870                SpawnIdempotencyEntry {
5871                    value: SpawnIdempotencyValue::Managed { request, result },
5872                    expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5873                },
5874            );
5875    }
5876
5877    fn remember_managed_spawn_attempt(
5878        &self,
5879        durable_v2_key: Option<&SpawnIdempotencyKey>,
5880        request: ManagedWorktreeSpawnRequest,
5881        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
5882        accepted_at: Instant,
5883    ) {
5884        if durable_v2_key.is_none() {
5885            self.remember_managed_spawn(request, result, accepted_at);
5886        }
5887    }
5888
5889    fn acquire_durable_managed_spawn_v2(
5890        &self,
5891        request: &ManagedWorktreeSpawnRequestV2,
5892        request_digest: String,
5893    ) -> Result<DurableManagedSpawnReplayDecision, NodeFailure> {
5894        if self.state_path.is_none() {
5895            return Err(failure(
5896                NodeFailureCode::BackendOperationFailed,
5897                "managed worktree V2 requires durable node state",
5898            ));
5899        }
5900        let _transaction = self.state_transaction
5901            .lock()
5902            .unwrap_or_else(|poisoned| poisoned.into_inner());
5903        let key = request.spawn_spec.idempotency_key.clone();
5904        let mut previous = None;
5905        {
5906            let mut replays = self.managed_spawn_replays
5907                .lock()
5908                .unwrap_or_else(|poisoned| poisoned.into_inner());
5909            if let Some(record) = replays.get_mut(&key) {
5910                if record.request_digest != request_digest {
5911                    return Err(failure(
5912                        NodeFailureCode::SpawnIdempotencyConflict,
5913                        "managed worktree V2 idempotency key was reused with a different request",
5914                    ));
5915                }
5916                match &record.state {
5917                    ManagedWorktreeSpawnReplayStateV10::Pending {
5918                        owner_incarnation_id,
5919                        lease_id: None,
5920                    } if owner_incarnation_id != &self.incarnation_id => {
5921                        previous = Some(record.clone());
5922                        record.state = ManagedWorktreeSpawnReplayStateV10::Pending {
5923                            owner_incarnation_id: self.incarnation_id,
5924                            lease_id: None,
5925                        };
5926                        record.updated_at_unix_ms = unix_time_ms();
5927                    }
5928                    ManagedWorktreeSpawnReplayStateV10::Pending {
5929                        lease_id: None,
5930                        ..
5931                    } => {
5932                        return Err(failure(
5933                            NodeFailureCode::BackendBusy,
5934                            "managed worktree V2 reservation is already active",
5935                        ));
5936                    }
5937                    ManagedWorktreeSpawnReplayStateV10::Pending {
5938                        lease_id: Some(_),
5939                        ..
5940                    } => {
5941                        return Ok(DurableManagedSpawnReplayDecision::PendingLinked);
5942                    }
5943                    ManagedWorktreeSpawnReplayStateV10::Committed { receipt } => {
5944                        return Ok(DurableManagedSpawnReplayDecision::Committed(
5945                            receipt.clone(),
5946                        ));
5947                    }
5948                }
5949            } else if replays.len() >= MAX_MANAGED_WORKTREE_SPAWN_REPLAYS {
5950                return Err(failure(
5951                    NodeFailureCode::SpawnIdempotencyCapacity,
5952                    "durable managed worktree replay ledger reached its bounded capacity",
5953                ));
5954            } else {
5955                let now = unix_time_ms();
5956                replays.insert(key.clone(), ManagedWorktreeSpawnReplayRecordV10 {
5957                    idempotency_key: key.clone(),
5958                    request_digest,
5959                    source_workspace_id: request.spawn_spec.target.workspace_id.clone(),
5960                    profile_id: request.worktree_profile_id.clone(),
5961                    expected_profile_revision: request.expected_profile_revision.clone(),
5962                    state: ManagedWorktreeSpawnReplayStateV10::Pending {
5963                        owner_incarnation_id: self.incarnation_id,
5964                        lease_id: None,
5965                    },
5966                    created_at_unix_ms: now,
5967                    updated_at_unix_ms: now,
5968                });
5969            }
5970        }
5971        if let Err(error) = self.persist_state_locked() {
5972            let mut replays = self.managed_spawn_replays
5973                .lock()
5974                .unwrap_or_else(|poisoned| poisoned.into_inner());
5975            match previous {
5976                Some(previous) => {
5977                    replays.insert(key, previous);
5978                }
5979                None => {
5980                    replays.remove(&key);
5981                }
5982            }
5983            return Err(persistence_failure(error));
5984        }
5985        Ok(DurableManagedSpawnReplayDecision::Reserved)
5986    }
5987
5988    fn link_durable_managed_spawn_v2_lease_locked(
5989        &self,
5990        key: &SpawnIdempotencyKey,
5991        lease_id: &ManagedWorktreeLeaseId,
5992    ) -> Result<ManagedWorktreeSpawnReplayRecordV10, NodeFailure> {
5993        let mut replays = self.managed_spawn_replays
5994            .lock()
5995            .unwrap_or_else(|poisoned| poisoned.into_inner());
5996        let record = replays.get_mut(key).ok_or_else(|| {
5997            failure(
5998                NodeFailureCode::ManagedWorktreeRecoveryRequired,
5999                "managed worktree V2 reservation disappeared before allocation",
6000            )
6001        })?;
6002        let previous = record.clone();
6003        match &mut record.state {
6004            ManagedWorktreeSpawnReplayStateV10::Pending {
6005                lease_id: current,
6006                ..
6007            }
6008                if current.is_none() =>
6009            {
6010                *current = Some(lease_id.clone());
6011                record.updated_at_unix_ms = unix_time_ms();
6012                Ok(previous)
6013            }
6014            _ => Err(failure(
6015                NodeFailureCode::ManagedWorktreeRecoveryRequired,
6016                "managed worktree V2 reservation is not allocation-ready",
6017            )),
6018        }
6019    }
6020
6021    fn restore_durable_managed_spawn_v2_locked(
6022        &self,
6023        key: &SpawnIdempotencyKey,
6024        previous: ManagedWorktreeSpawnReplayRecordV10,
6025    ) {
6026        self.managed_spawn_replays
6027            .lock()
6028            .unwrap_or_else(|poisoned| poisoned.into_inner())
6029            .insert(key.clone(), previous);
6030    }
6031
6032    fn commit_durable_managed_spawn_v2(
6033        &self,
6034        request: &ManagedWorktreeSpawnRequestV2,
6035        receipt: ManagedWorktreeSpawnReceipt,
6036    ) -> Result<(), NodeFailure> {
6037        let _transaction = self.state_transaction
6038            .lock()
6039            .unwrap_or_else(|poisoned| poisoned.into_inner());
6040        let key = &request.spawn_spec.idempotency_key;
6041        let previous = {
6042            let mut replays = self.managed_spawn_replays
6043                .lock()
6044                .unwrap_or_else(|poisoned| poisoned.into_inner());
6045            let record = replays.get_mut(key).ok_or_else(|| {
6046                failure(
6047                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
6048                    "managed worktree V2 reservation disappeared before commit",
6049                )
6050            })?;
6051            let previous = record.clone();
6052            match &record.state {
6053                ManagedWorktreeSpawnReplayStateV10::Pending {
6054                    lease_id: Some(lease_id),
6055                    ..
6056                } if lease_id == &receipt.lease.lease_id => {}
6057                _ => {
6058                    return Err(failure(
6059                        NodeFailureCode::ManagedWorktreeRecoveryRequired,
6060                        "managed worktree V2 reservation is not linked to the spawned lease",
6061                    ));
6062                }
6063            }
6064            record.state = ManagedWorktreeSpawnReplayStateV10::Committed { receipt };
6065            record.updated_at_unix_ms = unix_time_ms();
6066            previous
6067        };
6068        if let Err(error) = self.persist_state_locked() {
6069            self.restore_durable_managed_spawn_v2_locked(key, previous);
6070            return Err(persistence_failure(error));
6071        }
6072        Ok(())
6073    }
6074
6075    fn remove_unallocated_durable_managed_spawn_v2(
6076        &self,
6077        request: &ManagedWorktreeSpawnRequestV2,
6078    ) -> Result<(), NodeFailure> {
6079        let _transaction = self.state_transaction
6080            .lock()
6081            .unwrap_or_else(|poisoned| poisoned.into_inner());
6082        let key = &request.spawn_spec.idempotency_key;
6083        let removed = {
6084            let mut replays = self.managed_spawn_replays
6085                .lock()
6086                .unwrap_or_else(|poisoned| poisoned.into_inner());
6087            match replays.get(key).map(|record| &record.state) {
6088                Some(ManagedWorktreeSpawnReplayStateV10::Pending {
6089                    lease_id: None,
6090                    ..
6091                }) => {
6092                    replays.remove(key)
6093                }
6094                _ => None,
6095            }
6096        };
6097        let Some(removed) = removed else {
6098            return Ok(());
6099        };
6100        if let Err(error) = self.persist_state_locked() {
6101            self.managed_spawn_replays
6102                .lock()
6103                .unwrap_or_else(|poisoned| poisoned.into_inner())
6104                .insert(key.clone(), removed);
6105            return Err(persistence_failure(error));
6106        }
6107        Ok(())
6108    }
6109
6110    fn resolve_spawn_spec(&self, spec: &SpawnSpec) -> Result<ResolvedSpawnSpec, NodeFailure> {
6111        if spec.target.node_id != self.node_id {
6112            return Err(failure(
6113                NodeFailureCode::SpawnTargetMismatch,
6114                "spawn target does not match this node",
6115            ));
6116        }
6117        let defaults = self.spawn_profiles.get(&spec.profile_id).ok_or_else(|| {
6118            failure(
6119                NodeFailureCode::UnknownSpawnProfile,
6120                "spawn profile is unavailable on this node",
6121            )
6122        })?;
6123        let resolved = spec.resolve(defaults).map_err(|error| match error {
6124            SpawnSpecResolveError::ProfileRevisionMismatch { .. } => failure(
6125                NodeFailureCode::SpawnProfileRevisionMismatch,
6126                "spawn profile revision does not match the loaded profile",
6127            ),
6128            _ => failure(
6129                NodeFailureCode::InvalidRequest,
6130                "spawn specification could not be resolved",
6131            ),
6132        })?;
6133        if resolved.target.worktree_id.is_some() {
6134            self.require_worktree_service(&resolved.target.workspace_id)?;
6135        }
6136        let environment_profile = self.resolve_environment_profile(&resolved)?;
6137        self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6138        self.resolve_context(&resolved)?;
6139        Ok(resolved)
6140    }
6141
6142    async fn resolve_spawn_workspace(
6143        &self,
6144        resolved: &ResolvedSpawnSpec,
6145        deadline: Instant,
6146    ) -> Result<WorkspaceId, NodeFailure> {
6147        let Some(worktree_id) = resolved.target.worktree_id.as_ref() else {
6148            return Ok(resolved.target.workspace_id.clone());
6149        };
6150        if self.managed_worktrees.lock()
6151            .unwrap_or_else(|poisoned| poisoned.into_inner())
6152            .lease_for_workspace(worktree_id)
6153            .is_some()
6154        {
6155            return Err(failure(
6156                NodeFailureCode::ManagedWorktreeOwnershipConflict,
6157                "managed worktree targets may only be spawned through their lease request",
6158            ));
6159        }
6160        self.require_worktree_service(&resolved.target.workspace_id)?;
6161        let source_root = self.workspace_root(&resolved.target.workspace_id)?;
6162        let selected_root = self.workspace_root(worktree_id)?;
6163        let remaining = spawn_deadline_remaining(deadline)?;
6164        let worktrees = timeout(remaining, list_git_worktrees(&source_root))
6165            .await
6166            .map_err(|_| {
6167                failure(
6168                    NodeFailureCode::SpawnDeadlineExceeded,
6169                    "Git worktree selection exceeded the spawn deadline",
6170                )
6171            })?
6172            .map_err(git_worktree_failure)?;
6173        validate_selected_worktree(&source_root, &selected_root, &worktrees)?;
6174        Ok(worktree_id.clone())
6175    }
6176
6177    async fn spawn_from_spec(
6178        &self,
6179        spec: SpawnSpec,
6180    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6181        self.spawn_from_spec_with_proxy(spec, None).await
6182    }
6183
6184    async fn spawn_from_spec_with_proxy(
6185        &self,
6186        spec: SpawnSpec,
6187        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
6188    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6189        let accepted_at = Instant::now();
6190        if harness_mcp.is_none() {
6191            if let Some(replayed) = self.replay_spawn_spec(&spec, accepted_at)? {
6192                return replayed;
6193            }
6194        }
6195        let resolved = self.resolve_spawn_spec(&spec)?;
6196        let environment_profile = self.resolve_environment_profile(&resolved)?;
6197        let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6198        let context = self.resolve_context(&resolved)?;
6199        let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6200        let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6201        let spawn_workspace_id = self.resolve_spawn_workspace(&resolved, deadline).await;
6202        let spawn_workspace_id = match spawn_workspace_id {
6203            Ok(workspace_id) => workspace_id,
6204            Err(error) => {
6205                let result = Err(error);
6206                if harness_mcp.is_none() {
6207                    self.remember_spawn_spec(spec, result.clone(), accepted_at);
6208                }
6209                return result;
6210            }
6211        };
6212        if self.managed_worktrees.lock()
6213            .unwrap_or_else(|poisoned| poisoned.into_inner())
6214            .lease_for_workspace(&spawn_workspace_id)
6215            .is_some()
6216        {
6217            let result = Err(failure(
6218                NodeFailureCode::ManagedWorktreeOwnershipConflict,
6219                "managed worktree targets may only be spawned through their lease request",
6220            ));
6221            if harness_mcp.is_none() {
6222                self.remember_spawn_spec(spec, result.clone(), accepted_at);
6223            }
6224            return result;
6225        }
6226        let result = self
6227            .spawn_session_with_deadline(
6228                spawn_workspace_id,
6229                resolved.provider.clone(),
6230                resolved.mode,
6231                resolved.terminal_size,
6232                resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
6233                environment_profile.clone(),
6234                bundle.clone(),
6235                context.clone(),
6236                None,
6237                None,
6238                SpawnRecordPolicy::Always,
6239                Some(deadline),
6240                &required_capabilities,
6241                harness_mcp,
6242                resolved.approval_level,
6243                resolved.network_allowlist.clone(),
6244                resolved.browser_profile_id.clone(),
6245            )
6246            .await
6247            .map(|(session, _runtime_policy)| {
6248                resolved.receipt_with_materialization(
6249                    self.incarnation_id,
6250                    session,
6251                    environment_profile,
6252                    bundle,
6253                    context,
6254                )
6255            });
6256        if harness_mcp.is_none() {
6257            self.remember_spawn_spec(spec, result.clone(), accepted_at);
6258        }
6259        result
6260    }
6261
6262    async fn spawn_from_spec_with_harness_mcp(
6263        &self,
6264        reservation_id: HarnessMcpReservationId,
6265        activation_digest: HarnessMcpActivationDigest,
6266        spec: SpawnSpec,
6267        deadline_unix_ms: u64,
6268    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6269        let registry = self.harness_mcp_registry.as_ref().ok_or_else(|| {
6270            failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
6271        })?;
6272        if let Some(receipt) = registry
6273            .replay_spawn(&reservation_id, &activation_digest, &spec)
6274            .map_err(harness_mcp_failure)?
6275        {
6276            return Ok(receipt);
6277        }
6278        let resolved = self.resolve_spawn_spec(&spec)?;
6279        let prepared = registry
6280            .prepare_spawn(
6281                &reservation_id,
6282                &activation_digest,
6283                &spec,
6284                deadline_unix_ms,
6285                resolved.provider.clone(),
6286            )
6287            .map_err(harness_mcp_failure)?;
6288        self.claim_harness_mcp_spawn(
6289            &reservation_id,
6290            &activation_digest,
6291            &spec,
6292            Instant::now(),
6293        )?;
6294        let spawn = self.spawn_from_spec_with_proxy(spec, Some(&prepared)).await;
6295        let mut receipt = match spawn {
6296            Ok(receipt) => receipt,
6297            Err(error) => {
6298                let _ = registry.abort(&reservation_id, &activation_digest);
6299                return Err(error);
6300            }
6301        };
6302        let record_id = self
6303            .session_bindings
6304            .lock()
6305            .unwrap_or_else(|poisoned| poisoned.into_inner())
6306            .get(&receipt.session.session.instance_id)
6307            .and_then(|binding| binding.record_id.clone());
6308        let record_id = match record_id {
6309            Some(record_id) => record_id,
6310            None => {
6311                let rollback = self.remove_session(&receipt.session).await;
6312                let _ = registry.abort(&reservation_id, &activation_digest);
6313                if let Err(rollback_error) = rollback {
6314                    return Err(rollback_error);
6315                }
6316                return Err(failure(
6317                    NodeFailureCode::BindingMismatch,
6318                    "harness MCP spawn did not retain its exact managed record",
6319                ));
6320            }
6321        };
6322        receipt.harness_mcp_proxy = Some(ResolvedHarnessMcpProxyReceiptV1 {
6323            reservation_id: reservation_id.clone(),
6324            activation_digest: activation_digest.clone(),
6325        });
6326        if let Err(error) = registry.mark_spawned(
6327            &prepared,
6328            receipt.session.clone(),
6329            record_id,
6330            receipt.clone(),
6331        ) {
6332            let rollback = self.remove_session(&receipt.session).await;
6333            let _ = registry.abort(&reservation_id, &activation_digest);
6334            if let Err(rollback_error) = rollback {
6335                return Err(rollback_error);
6336            }
6337            return Err(harness_mcp_failure(error));
6338        }
6339        Ok(receipt)
6340    }
6341
6342    async fn spawn_managed_worktree(
6343        &self,
6344        request: ManagedWorktreeSpawnRequest,
6345    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6346        self.spawn_managed_worktree_inner(request, None).await
6347    }
6348
6349    async fn spawn_managed_worktree_inner(
6350        &self,
6351        request: ManagedWorktreeSpawnRequest,
6352        durable_v2_key: Option<&SpawnIdempotencyKey>,
6353    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6354        let accepted_at = Instant::now();
6355        if durable_v2_key.is_none() {
6356            if let Some(replayed) = self.replay_managed_spawn(&request, accepted_at)? {
6357                return replayed;
6358            }
6359        }
6360        let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6361        if request.spawn_spec.target.worktree_id.is_some() {
6362            let result = Err(failure(
6363                NodeFailureCode::InvalidRequest,
6364                "managed worktree spawn must not select a caller-provided worktree",
6365            ));
6366            self.remember_managed_spawn_attempt(
6367                durable_v2_key,
6368                request,
6369                result.clone(),
6370                accepted_at,
6371            );
6372            return result;
6373        }
6374        let environment_profile = self.resolve_environment_profile(&resolved)?;
6375        let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6376        let context = self.resolve_context(&resolved)?;
6377        let source_workspace_id = resolved.target.workspace_id.clone();
6378        let profile = self.managed_profile(&source_workspace_id, &request.worktree_profile_id)?;
6379        let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6380        let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6381        let runtime_requirement = match (resolved.mode, resolved.prompt.is_some()) {
6382            (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
6383            (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
6384            (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
6385            (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
6386        };
6387        let runtime_policy = timeout(
6388            spawn_deadline_remaining(deadline)?,
6389            self.admit_provider_runtime(&resolved.provider, runtime_requirement),
6390        ).await.map_err(|_| failure(
6391            NodeFailureCode::SpawnDeadlineExceeded,
6392            "provider admission exceeded the managed spawn deadline",
6393        ))??;
6394        if required_capabilities.iter().any(|capability| !runtime_policy.admits(*capability)) {
6395            return Err(failure(
6396                NodeFailureCode::UnsupportedSpawnCapability,
6397                "provider runtime does not admit a required spawn capability",
6398            ));
6399        }
6400        self.ensure_binding_capacity()?;
6401        let source_root = self.workspace_root(&source_workspace_id)?;
6402        profile.validate_for_workspace(&source_root)
6403            .map_err(|message| failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message))?;
6404        let base_timeout_ms = u64::try_from(
6405            spawn_deadline_remaining(deadline)?.as_millis(),
6406        ).unwrap_or(u64::MAX).max(1);
6407        let base_commit = resolve_base_commit_with_timeout(
6408            &source_root,
6409            profile.base(),
6410            base_timeout_ms,
6411        ).await.map_err(|error| {
6412            if Instant::now() >= deadline || error.message.contains("timed out") {
6413                failure(
6414                    NodeFailureCode::SpawnDeadlineExceeded,
6415                    "managed worktree base resolution exceeded the spawn deadline",
6416                )
6417            } else {
6418                managed_git_worktree_failure(error)
6419            }
6420        })?;
6421
6422        let lease = {
6423            let _transaction = self.state_transaction.lock()
6424                .unwrap_or_else(|poisoned| poisoned.into_inner());
6425            let previous = self.managed_worktrees.lock()
6426                .unwrap_or_else(|poisoned| poisoned.into_inner()).clone();
6427            let lease = self.managed_worktrees.lock()
6428                .unwrap_or_else(|poisoned| poisoned.into_inner())
6429                .allocate(source_workspace_id.clone(), &profile, base_commit.clone(), unix_time_ms())
6430                .map_err(|message| failure(NodeFailureCode::BackendBusy, &message))?;
6431            let previous_replay = match durable_v2_key
6432                .map(|key| self.link_durable_managed_spawn_v2_lease_locked(key, &lease.lease_id))
6433                .transpose()
6434            {
6435                Ok(previous) => previous,
6436                Err(error) => {
6437                    *self.managed_worktrees.lock()
6438                        .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6439                    return Err(error);
6440                }
6441            };
6442            if let Err(error) = self.persist_state_locked() {
6443                *self.managed_worktrees.lock()
6444                    .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6445                if let (Some(key), Some(previous_replay)) =
6446                    (durable_v2_key, previous_replay)
6447                {
6448                    self.restore_durable_managed_spawn_v2_locked(key, previous_replay);
6449                }
6450                return Err(persistence_failure(error));
6451            }
6452            lease
6453        };
6454        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() });
6455        if let Err(message) = profile.validate_target_authority(&lease.lease_id, &lease.target_root) {
6456            self.managed_worktrees.lock()
6457                .unwrap_or_else(|poisoned| poisoned.into_inner())
6458                .remove_unmutated(&lease.lease_id);
6459            if self.persist_state().is_err() {
6460                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6461            }
6462            let result = Err(failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message));
6463            self.remember_managed_spawn_attempt(
6464                durable_v2_key,
6465                request,
6466                result.clone(),
6467                accepted_at,
6468            );
6469            return result;
6470        }
6471        let mutation_timeout_ms = match spawn_deadline_remaining(deadline) {
6472            Ok(remaining) => u64::try_from(remaining.as_millis()).unwrap_or(u64::MAX).max(1),
6473            Err(error) => {
6474                self.reconcile_failed_allocation(&lease, &source_root).await;
6475                let result = Err(error);
6476                self.remember_managed_spawn_attempt(
6477                    durable_v2_key,
6478                    request,
6479                    result.clone(),
6480                    accepted_at,
6481                );
6482                return result;
6483            }
6484        };
6485        let created = match create_git_worktree_with_timeout(
6486                &source_root,
6487                &lease.target_root,
6488                &lease.branch,
6489                Some(&base_commit),
6490                mutation_timeout_ms,
6491            ).await {
6492            Ok(created) => created,
6493            Err(error) => {
6494                self.reconcile_failed_allocation(&lease, &source_root).await;
6495                let result = Err(if Instant::now() >= deadline
6496                    || error.message.contains("timed out")
6497                    || error.message.contains("deadline")
6498                {
6499                    failure(
6500                        NodeFailureCode::SpawnDeadlineExceeded,
6501                        "managed worktree creation exceeded the spawn deadline",
6502                    )
6503                } else {
6504                    managed_git_worktree_failure(error)
6505                });
6506                self.remember_managed_spawn_attempt(
6507                    durable_v2_key,
6508                    request,
6509                    result.clone(),
6510                    accepted_at,
6511                );
6512                return result;
6513            }
6514        };
6515        if !exact_created_worktree(&lease, &created.path, created.branch.as_deref(), &created.head) {
6516            self.mark_managed_recovery_required(&lease.lease_id, ManagedWorktreeCleanupFailure::OwnershipConflict);
6517            let result = Err(failure(
6518                NodeFailureCode::ManagedWorktreeRecoveryRequired,
6519                "created worktree identity did not match its durable lease",
6520            ));
6521            self.remember_managed_spawn_attempt(
6522                durable_v2_key,
6523                request,
6524                result.clone(),
6525                accepted_at,
6526            );
6527            return result;
6528        }
6529        {
6530            let mut registry = self.managed_worktrees.lock()
6531                .unwrap_or_else(|poisoned| poisoned.into_inner());
6532            let current = registry.get_mut(&lease.lease_id)
6533                .expect("allocated managed worktree lease remains present");
6534            current.expected_head = Some(created.head.clone());
6535            current.state = ManagedWorktreeLeaseState::Ready;
6536            current.updated_at_unix_ms = unix_time_ms();
6537        }
6538        if let Err(error) = self.persist_state() {
6539            self.mark_managed_recovery_required(
6540                &lease.lease_id,
6541                ManagedWorktreeCleanupFailure::Backend,
6542            );
6543            let result = Err(persistence_failure(error));
6544            self.remember_managed_spawn_attempt(
6545                durable_v2_key,
6546                request,
6547                result.clone(),
6548                accepted_at,
6549            );
6550            return result;
6551        }
6552        let ready = self.managed_worktrees.lock()
6553            .unwrap_or_else(|poisoned| poisoned.into_inner())
6554            .get(&lease.lease_id)
6555            .expect("ready managed worktree lease remains present")
6556            .snapshot();
6557        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: ready });
6558        if let Err(error) = self.register_workspace(lease.workspace_id.clone(), created.path.clone()).await {
6559            let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
6560            let result = Err(error);
6561            self.remember_managed_spawn_attempt(
6562                durable_v2_key,
6563                request,
6564                result.clone(),
6565                accepted_at,
6566            );
6567            return result;
6568        }
6569        let spawn = self.spawn_session_with_deadline(
6570            lease.workspace_id.clone(),
6571            resolved.provider.clone(),
6572            resolved.mode,
6573            resolved.terminal_size,
6574            resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
6575            environment_profile.clone(),
6576            bundle.clone(),
6577            context.clone(),
6578            Some(lease.lease_id.clone()),
6579            Some(runtime_policy),
6580            SpawnRecordPolicy::Always,
6581            Some(deadline),
6582            &required_capabilities,
6583            None,
6584            resolved.approval_level,
6585            resolved.network_allowlist.clone(),
6586            resolved.browser_profile_id.clone(),
6587        ).await;
6588        let (session, effective_runtime_policy) = match spawn {
6589            Ok(spawned) => spawned,
6590            Err(error) => {
6591                if self
6592                    .cleanup_managed_worktree(&lease.lease_id, true)
6593                    .await
6594                    .is_err()
6595                {
6596                    self.mark_managed_recovery_required(
6597                        &lease.lease_id,
6598                        ManagedWorktreeCleanupFailure::Backend,
6599                    );
6600                }
6601                let result = Err(error);
6602                self.remember_managed_spawn_attempt(
6603                    durable_v2_key,
6604                    request,
6605                    result.clone(),
6606                    accepted_at,
6607                );
6608                return result;
6609            }
6610        };
6611        let (record_id, raw_inventory_record_id) = {
6612            let mut bindings = self.session_bindings.lock()
6613                .unwrap_or_else(|poisoned| poisoned.into_inner());
6614            let binding = bindings.get_mut(&session.session.instance_id)
6615                .expect("successful spawn retains its session binding");
6616            binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
6617            managed_spawn_record_ownership(binding, effective_runtime_policy)
6618        };
6619        let bound = self.managed_worktrees.lock()
6620            .unwrap_or_else(|poisoned| poisoned.into_inner())
6621            .bind_session(
6622                &lease.lease_id,
6623                ManagedWorktreeSessionHolder {
6624                    incarnation_id: self.incarnation_id,
6625                    instance_id: session.session.instance_id,
6626                    generation: session.session.generation,
6627                },
6628                record_id,
6629                unix_time_ms(),
6630            )
6631            .map_err(|message| failure(NodeFailureCode::ManagedWorktreeRecoveryRequired, &message));
6632        let snapshot = match bound {
6633            Ok(snapshot) => snapshot,
6634            Err(error) => {
6635                if raw_inventory_record_id
6636                    .as_ref()
6637                    .is_some_and(|record_id| self.discard_record(record_id).is_err())
6638                {
6639                    self.mark_managed_recovery_required(
6640                        &lease.lease_id,
6641                        ManagedWorktreeCleanupFailure::Backend,
6642                    );
6643                }
6644                let _ = self.remove_session(&session).await;
6645                self.mark_managed_recovery_required(
6646                    &lease.lease_id,
6647                    ManagedWorktreeCleanupFailure::Backend,
6648                );
6649                let result = Err(error);
6650                self.remember_managed_spawn_attempt(
6651                    durable_v2_key,
6652                    request,
6653                    result.clone(),
6654                    accepted_at,
6655                );
6656                return result;
6657            }
6658        };
6659        if let Err(error) = self.persist_state() {
6660            if raw_inventory_record_id
6661                .as_ref()
6662                .is_some_and(|record_id| self.discard_record(record_id).is_err())
6663            {
6664                self.mark_managed_recovery_required(
6665                    &lease.lease_id,
6666                    ManagedWorktreeCleanupFailure::Backend,
6667                );
6668            }
6669            let _ = self.remove_session(&session).await;
6670            if self
6671                .cleanup_managed_worktree(&lease.lease_id, true)
6672                .await
6673                .is_err()
6674            {
6675                self.mark_managed_recovery_required(
6676                    &lease.lease_id,
6677                    ManagedWorktreeCleanupFailure::Backend,
6678                );
6679            }
6680            let result = Err(persistence_failure(error));
6681            self.remember_managed_spawn_attempt(
6682                durable_v2_key,
6683                request,
6684                result.clone(),
6685                accepted_at,
6686            );
6687            return result;
6688        }
6689        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
6690        let receipt = managed_spawn_receipt(
6691            &resolved,
6692            self.incarnation_id,
6693            session,
6694            snapshot,
6695            environment_profile,
6696            bundle,
6697            context,
6698        );
6699        let result = Ok(receipt);
6700        self.remember_managed_spawn_attempt(
6701            durable_v2_key,
6702            request,
6703            result.clone(),
6704            accepted_at,
6705        );
6706        result
6707    }
6708
6709    async fn spawn_managed_worktree_v2(
6710        &self,
6711        request: ManagedWorktreeSpawnRequestV2,
6712    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6713        if request.spawn_spec.target.node_id != self.node_id {
6714            return Err(failure(
6715                NodeFailureCode::SpawnTargetMismatch,
6716                "spawn target does not match this node",
6717            ));
6718        }
6719        if request.spawn_spec.target.worktree_id.is_some() {
6720            return Err(failure(
6721                NodeFailureCode::InvalidRequest,
6722                "managed worktree spawn must not select a caller-provided worktree",
6723            ));
6724        }
6725        let request_digest = managed_spawn_request_digest_v2(&request)?;
6726        let replay = self.acquire_durable_managed_spawn_v2(&request, request_digest)?;
6727        match replay {
6728            DurableManagedSpawnReplayDecision::Committed(receipt) => return Ok(receipt),
6729            DurableManagedSpawnReplayDecision::PendingLinked => {
6730                return Err(failure(
6731                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
6732                    "managed worktree V2 reservation has an incomplete durable allocation",
6733                ));
6734            }
6735            DurableManagedSpawnReplayDecision::Reserved => {}
6736        }
6737        let validated = (|| {
6738            let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6739            let profile = self.managed_profile(
6740                &resolved.target.workspace_id,
6741                &request.worktree_profile_id,
6742            )?;
6743            if profile.revision() != &request.expected_profile_revision {
6744                return Err(failure(
6745                    NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
6746                    "managed worktree profile revision does not match the loaded profile",
6747                ));
6748            }
6749            Ok(())
6750        })();
6751        match validated {
6752            Ok(()) => {}
6753            Err(error) => {
6754                self.remove_unallocated_durable_managed_spawn_v2(&request)?;
6755                return Err(error);
6756            }
6757        }
6758        let legacy = ManagedWorktreeSpawnRequest {
6759            spawn_spec: request.spawn_spec.clone(),
6760            worktree_profile_id: request.worktree_profile_id.clone(),
6761        };
6762        match self
6763            .spawn_managed_worktree_inner(
6764                legacy,
6765                Some(&request.spawn_spec.idempotency_key),
6766            )
6767            .await
6768        {
6769            Ok(receipt) => {
6770                self.commit_durable_managed_spawn_v2(&request, receipt.clone())?;
6771                Ok(receipt)
6772            }
6773            Err(error) => {
6774                self.remove_unallocated_durable_managed_spawn_v2(&request)?;
6775                Err(error)
6776            }
6777        }
6778    }
6779
6780    fn managed_profile(
6781        &self,
6782        workspace_id: &WorkspaceId,
6783        profile_id: &WorktreeProfileId,
6784    ) -> Result<ManagedWorktreeProfile, NodeFailure> {
6785        if self.worktree_service_modes.get(workspace_id).copied()
6786            != Some(WorktreeServiceMode::Managed)
6787        {
6788            return Err(failure(
6789                NodeFailureCode::UnsupportedSpawnCapability,
6790                "workspace does not enable managed worktrees",
6791            ));
6792        }
6793        self.managed_worktree_profiles.get(workspace_id)
6794            .and_then(|profiles| profiles.get(profile_id))
6795            .cloned()
6796            .ok_or_else(|| failure(
6797                NodeFailureCode::InvalidRequest,
6798                "managed worktree profile is unavailable for this workspace",
6799            ))
6800    }
6801
6802    async fn reconcile_failed_allocation(
6803        &self,
6804        lease: &ManagedWorktreeLeaseRecord,
6805        source_root: &str,
6806    ) {
6807        match list_git_worktrees(source_root).await {
6808            Ok(worktrees) => {
6809                match worktrees.iter().find(|item| worktree_paths_equal(&item.path, &lease.target_root)) {
6810                    None => {
6811                        match std::fs::symlink_metadata(&lease.target_root) {
6812                            Err(error) if error.kind() == io::ErrorKind::NotFound => {
6813                                self.managed_worktrees.lock()
6814                                    .unwrap_or_else(|poisoned| poisoned.into_inner())
6815                                    .remove_unmutated(&lease.lease_id);
6816                                let _ = self.persist_state();
6817                            }
6818                            _ => self.mark_managed_recovery_required(
6819                                &lease.lease_id,
6820                                ManagedWorktreeCleanupFailure::OwnershipConflict,
6821                            ),
6822                        }
6823                    }
6824                    Some(item) if exact_owned_worktree(
6825                        lease,
6826                        &item.path,
6827                        item.branch.as_deref(),
6828                        &item.head,
6829                    ) => {
6830                        let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
6831                    }
6832                    Some(_) => self.mark_managed_recovery_required(
6833                        &lease.lease_id,
6834                        ManagedWorktreeCleanupFailure::OwnershipConflict,
6835                    ),
6836                }
6837            }
6838            Err(_) => self.mark_managed_recovery_required(
6839                &lease.lease_id,
6840                ManagedWorktreeCleanupFailure::Backend,
6841            ),
6842        }
6843    }
6844
6845    async fn reconcile_managed_worktrees(&self) {
6846        let leases = self.managed_worktrees.lock()
6847            .unwrap_or_else(|poisoned| poisoned.into_inner())
6848            .active_records()
6849            .cloned()
6850            .collect::<Vec<_>>();
6851        for lease in leases {
6852            let profile = match self.managed_profile(&lease.source_workspace_id, &lease.profile_id) {
6853                Ok(profile) if profile.revision() == &lease.profile_revision => profile,
6854                _ => {
6855                    self.mark_managed_recovery_required(
6856                        &lease.lease_id,
6857                        ManagedWorktreeCleanupFailure::OwnershipConflict,
6858                    );
6859                    continue;
6860                }
6861            };
6862            if profile.validate_target_authority(&lease.lease_id, &lease.target_root).is_err() {
6863                self.mark_managed_recovery_required(
6864                    &lease.lease_id,
6865                    ManagedWorktreeCleanupFailure::OwnershipConflict,
6866                );
6867                continue;
6868            }
6869            let source_root = match self.workspace_root(&lease.source_workspace_id) {
6870                Ok(root) => root,
6871                Err(_) => {
6872                    self.mark_managed_recovery_required(
6873                        &lease.lease_id,
6874                        ManagedWorktreeCleanupFailure::OwnershipConflict,
6875                    );
6876                    continue;
6877                }
6878            };
6879            let worktrees = match list_git_worktrees(&source_root).await {
6880                Ok(worktrees) => worktrees,
6881                Err(_) => {
6882                    self.mark_managed_recovery_required(
6883                        &lease.lease_id,
6884                        ManagedWorktreeCleanupFailure::Backend,
6885                    );
6886                    continue;
6887                }
6888            };
6889            let listed = worktrees.iter()
6890                .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
6891            let Some(listed) = listed else {
6892                match std::fs::symlink_metadata(&lease.target_root) {
6893                    Err(error) if error.kind() == io::ErrorKind::NotFound
6894                        && lease.state == ManagedWorktreeLeaseState::Allocating
6895                        && !lease.has_holders() => {
6896                            let removed = self.managed_worktrees.lock()
6897                                .unwrap_or_else(|poisoned| poisoned.into_inner())
6898                                .tombstone(&lease.lease_id, unix_time_ms());
6899                            if self.persist_state().is_ok() && removed.is_some() {
6900                                self.publish(NodeEvent::ManagedWorktreeRemoved {
6901                                    lease_id: lease.lease_id.clone(),
6902                                });
6903                            }
6904                        }
6905                    _ => self.mark_managed_recovery_required(
6906                        &lease.lease_id,
6907                        ManagedWorktreeCleanupFailure::OwnershipConflict,
6908                    ),
6909                }
6910                continue;
6911            };
6912            if listed.is_main || listed.is_bare || !exact_owned_worktree(
6913                &lease,
6914                &listed.path,
6915                listed.branch.as_deref(),
6916                &listed.head,
6917            ) {
6918                self.mark_managed_recovery_required(
6919                    &lease.lease_id,
6920                    ManagedWorktreeCleanupFailure::OwnershipConflict,
6921                );
6922                continue;
6923            }
6924            if !self.workspaces.read()
6925                .unwrap_or_else(|poisoned| poisoned.into_inner())
6926                .contains_key(&lease.workspace_id)
6927            {
6928                if self.register_workspace(lease.workspace_id.clone(), listed.path.clone()).await.is_err() {
6929                    self.mark_managed_recovery_required(
6930                        &lease.lease_id,
6931                        ManagedWorktreeCleanupFailure::Backend,
6932                    );
6933                    continue;
6934                }
6935            }
6936            let snapshot = {
6937                let mut registry = self.managed_worktrees.lock()
6938                    .unwrap_or_else(|poisoned| poisoned.into_inner());
6939                let current = registry.get_mut(&lease.lease_id)
6940                    .expect("reconciled managed lease remains present");
6941                current.expected_head = Some(listed.head.clone());
6942                current.cleanup_failure = None;
6943                current.state = if current.has_holders() {
6944                    ManagedWorktreeLeaseState::InUse
6945                } else if current.retention == ManagedWorktreeRetention::Retain {
6946                    ManagedWorktreeLeaseState::Retained
6947                } else {
6948                    ManagedWorktreeLeaseState::Ready
6949                };
6950                current.updated_at_unix_ms = unix_time_ms();
6951                current.snapshot()
6952            };
6953            if self.persist_state().is_err() {
6954                self.mark_managed_recovery_required(
6955                    &lease.lease_id,
6956                    ManagedWorktreeCleanupFailure::Backend,
6957                );
6958                continue;
6959            }
6960            self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
6961            if snapshot.state == ManagedWorktreeLeaseState::Ready {
6962                let _ = self.cleanup_managed_worktree(&lease.lease_id, false).await;
6963            }
6964        }
6965    }
6966
6967    /// Crash-recovery startup sweep: durably capture the pack for every
6968    /// `Live` managed record whose most recent session has not yet produced
6969    /// one (recovers a session whose `Exited{0}` event was observed but the
6970    /// Node crashed before the export finished). The reactive fast path for
6971    /// a single just-exited record does not go through this — see
6972    /// `drive_runtime_until_shutdown`, which resolves and spawns directly so
6973    /// it can capture the exact `SessionAddress` before `publish_control`
6974    /// clears it, rather than re-deriving it from (by then likely already
6975    /// `Dormant`) record state the way a rescan naturally would.
6976    ///
6977    /// Returns as soon as every eligible record's export has been *spawned*,
6978    /// not completed — each one runs on its own detached task (see
6979    /// `reconcile_context_pack_export_for_record`'s own doc comment for why:
6980    /// `NativeRuntime::tick()` has not even started its own loop yet at the
6981    /// point this is called from `NodeServer::run()`, so nothing could ever
6982    /// drain the history round trip if this awaited it inline here either).
6983    /// Best-effort throughout: never panics, no unwrap/expect; every failure
6984    /// is a silent skip for THIS record — an unusable source or a racing
6985    /// record is not surfaced to any caller.
6986    async fn reconcile_context_pack_exports(self: &Arc<Self>) {
6987        let candidates: Vec<(SessionRecordId, SessionAddress)> = self
6988            .session_records
6989            .lock()
6990            .unwrap_or_else(|poisoned| poisoned.into_inner())
6991            .records
6992            .values()
6993            .filter(|record| {
6994                record.state == ManagedSessionState::Live
6995                    && record.exported_context.is_none()
6996                    && record.provider_session.is_some()
6997            })
6998            .filter_map(|record| {
6999                record
7000                    .active_session
7001                    .clone()
7002                    .map(|session| (record.record_id.clone(), session))
7003            })
7004            .collect();
7005        for (record_id, session) in candidates {
7006            let shared = Arc::clone(self);
7007            tokio::spawn(async move {
7008                shared
7009                    .reconcile_context_pack_export_for_record(&record_id, session)
7010                    .await;
7011            });
7012        }
7013    }
7014
7015    /// Runs on its own detached task (`tokio::spawn`, see both call sites),
7016    /// never inline on `drive_runtime_until_shutdown`'s own task: the history
7017    /// round trip this performs (`discover_history`/`load_history`, via
7018    /// `export_context_pack_for_session_record`) can only ever settle once
7019    /// `NativeRuntime::tick()` runs again to drain the authority worker's
7020    /// completion — and `tick()` is exclusively owned by, and only ever
7021    /// called from, that same drive-loop task. Awaiting this inline there
7022    /// would starve `tick()` and this call could never observe its own
7023    /// command complete.
7024    ///
7025    /// Because this now genuinely races the drive loop's own
7026    /// `publish_control` (which downgrades this exact record from `Live` to
7027    /// `Dormant`, clearing `active_session`, for the very `Exited` event that
7028    /// triggered this reconcile), the record legitimately may already be
7029    /// `Dormant` by the time this reads it, or may transition mid-flight —
7030    /// `allow_clean_detachment: true` on the downstream export call is what
7031    /// tolerates exactly that transition (and only that transition; see
7032    /// `session_record_export_target_matches`).
7033    async fn reconcile_context_pack_export_for_record(
7034        &self,
7035        record_id: &SessionRecordId,
7036        session: SessionAddress,
7037    ) {
7038        let Ok(record) = self.record(record_id) else {
7039            return;
7040        };
7041        if !matches!(
7042            record.state,
7043            ManagedSessionState::Live | ManagedSessionState::Dormant
7044        ) || record.exported_context.is_some()
7045            || record.provider_session.is_none()
7046        {
7047            return;
7048        }
7049        // Pack bytes are already durably committed inside this call, via
7050        // commit_context_pack_for_session_record's own durable
7051        // write-through (context_pack_store) — no separate step here.
7052        // Unclean exits (non-zero/forced/no loadable history) are already
7053        // rejected by context_pack_source_status_is_usable inside
7054        // materialize_context_pack: this simply returns Err and is skipped.
7055        let Ok(receipt) = self
7056            .export_context_pack_for_session_record(&record.record_id, &session, true)
7057            .await
7058        else {
7059            return;
7060        };
7061        self.set_exported_context(&record.record_id, receipt);
7062    }
7063
7064    /// Records the record's most recent clean-exit pack. No-op (not an
7065    /// error) if the record disappeared, or if it already carries an
7066    /// `exported_context` — first-write-wins, matching the harness-side
7067    /// idempotent reconciliation that consumes this field.
7068    fn set_exported_context(&self, record_id: &SessionRecordId, receipt: ResolvedContextPackReceipt) {
7069        let transaction = self
7070            .state_transaction
7071            .lock()
7072            .unwrap_or_else(|poisoned| poisoned.into_inner());
7073        let (previous, updated) = {
7074            let mut records = self
7075                .session_records
7076                .lock()
7077                .unwrap_or_else(|poisoned| poisoned.into_inner());
7078            let Some(record) = records.records.get_mut(record_id) else {
7079                return;
7080            };
7081            if record.exported_context.is_some() {
7082                return;
7083            }
7084            let previous = record.clone();
7085            record.exported_context = Some(receipt);
7086            record.updated_at_unix_ms = unix_time_ms().max(record.updated_at_unix_ms);
7087            (previous, record.clone())
7088        };
7089        if self.persist_state_locked().is_err() {
7090            self.session_records
7091                .lock()
7092                .unwrap_or_else(|poisoned| poisoned.into_inner())
7093                .records
7094                .insert(record_id.clone(), previous);
7095            return;
7096        }
7097        drop(transaction);
7098        self.publish_record(updated);
7099    }
7100
7101    /// Resolves the managed record and its exact `SessionAddress` currently
7102    /// bound to `instance_id` at exactly `generation`, used by the reactive
7103    /// `Exited{0}` hook in `drive_runtime_until_shutdown` to target a single
7104    /// record without a full rescan. Mirrors the lookup already inlined in
7105    /// `publish_control`. Captured synchronously, before `publish_control`
7106    /// for the same event clears the record's own `active_session` — the
7107    /// caller passes the resolved address through to a detached
7108    /// reconciliation task rather than re-deriving it from the record later,
7109    /// after that race has necessarily already happened.
7110    fn managed_record_export_target_for_instance(
7111        &self,
7112        instance_id: AgentInstanceId,
7113        generation: SessionGeneration,
7114    ) -> Option<(SessionRecordId, SessionAddress)> {
7115        let bindings = self
7116            .session_bindings
7117            .lock()
7118            .unwrap_or_else(|poisoned| poisoned.into_inner());
7119        let binding = bindings.get(&instance_id).filter(|binding| binding.generation == generation)?;
7120        let record_id = binding.record_id.clone()?;
7121        Some((
7122            record_id,
7123            SessionAddress {
7124                workspace_id: binding.workspace_id.clone(),
7125                session: SessionKey { instance_id, generation },
7126            },
7127        ))
7128    }
7129
7130    fn mark_managed_recovery_required(
7131        &self,
7132        lease_id: &ManagedWorktreeLeaseId,
7133        failure_kind: ManagedWorktreeCleanupFailure,
7134    ) {
7135        let snapshot = {
7136            let mut registry = self.managed_worktrees.lock()
7137                .unwrap_or_else(|poisoned| poisoned.into_inner());
7138            let Some(lease) = registry.get_mut(lease_id) else { return };
7139            lease.state = ManagedWorktreeLeaseState::RecoveryRequired;
7140            lease.cleanup_failure = Some(failure_kind);
7141            lease.updated_at_unix_ms = unix_time_ms();
7142            lease.snapshot()
7143        };
7144        let _ = self.persist_state();
7145        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
7146    }
7147
7148    fn set_persistence_error(&self, error: Option<String>) {
7149        *self
7150            .persistence_error
7151            .write()
7152            .unwrap_or_else(|poisoned| poisoned.into_inner()) = error
7153            .map(|message| session_registry::sanitized_persistence_summary(&message));
7154    }
7155
7156    fn persist_state(&self) -> io::Result<()> {
7157        let _transaction = self
7158            .state_transaction
7159            .lock()
7160            .unwrap_or_else(|poisoned| poisoned.into_inner());
7161        self.persist_state_locked()
7162    }
7163
7164    fn persist_state_locked(&self) -> io::Result<()> {
7165        let workspaces = self
7166            .workspaces
7167            .read()
7168            .unwrap_or_else(|poisoned| poisoned.into_inner())
7169            .clone();
7170        let records = self
7171            .session_records
7172            .lock()
7173            .unwrap_or_else(|poisoned| poisoned.into_inner())
7174            .records
7175            .values()
7176            .cloned()
7177            .collect::<Vec<_>>();
7178        let mut managed = self.managed_worktrees
7179            .lock()
7180            .unwrap_or_else(|poisoned| poisoned.into_inner());
7181        managed.reattach_record_holders(
7182            &managed_worktree_record_holders(&records),
7183            unix_time_ms(),
7184        );
7185        let materializations = self
7186            .materializations
7187            .lock()
7188            .unwrap_or_else(|poisoned| poisoned.into_inner())
7189            .values()
7190            .cloned()
7191            .collect::<Vec<_>>();
7192        let managed_spawn_replays = self.managed_spawn_replays
7193            .lock()
7194            .unwrap_or_else(|poisoned| poisoned.into_inner())
7195            .values()
7196            .cloned()
7197            .collect::<Vec<_>>();
7198        let result = session_registry::save_v10(
7199            self.state_path.as_deref(),
7200            &self.node_id,
7201            &workspaces,
7202            &records,
7203            &managed.records(),
7204            &managed.tombstones(),
7205            &materializations,
7206            &managed_spawn_replays,
7207        );
7208        match &result {
7209            Ok(warning) => self.set_persistence_error(warning.clone()),
7210            Err(_) => self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned())),
7211        }
7212        result.map(|_| ())
7213    }
7214
7215    async fn begin_shutdown(&self) -> Result<(), NodeFailure> {
7216        let _mutation_guard = self.mutation_gate.lock().await;
7217        self.begin_shutdown_locked().await
7218    }
7219
7220    async fn begin_shutdown_locked(&self) -> Result<(), NodeFailure> {
7221        if self.shutdown.swap(true, Ordering::AcqRel) {
7222            return Ok(());
7223        }
7224        SessionLifecycleHook::on_shutdown(self);
7225        let deadline = Instant::now() + Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
7226        let mut first_error = None;
7227        for session in &self.handle.snapshot().sessions {
7228            let remaining = deadline.saturating_duration_since(Instant::now());
7229            if remaining.is_zero() {
7230                first_error.get_or_insert_with(|| {
7231                    failure(NodeFailureCode::BackendBusy, "stop-all dispatch exceeded its bounded deadline")
7232                });
7233                break;
7234            }
7235            if let Err(error) = self
7236                .dispatch_bounded(
7237                    ControlCommand::Stop {
7238                        instance_id: session.instance_id,
7239                        force: true,
7240                    },
7241                    remaining,
7242                )
7243                .await
7244            {
7245                first_error.get_or_insert(error);
7246            }
7247        }
7248        self.shutdown_notify.notify_waiters();
7249        match first_error {
7250            Some(error) => Err(error),
7251            None => Ok(()),
7252        }
7253    }
7254
7255    fn ensure_binding_capacity(&self) -> Result<(), NodeFailure> {
7256        let bindings = self
7257            .session_bindings
7258            .lock()
7259            .unwrap_or_else(|poisoned| poisoned.into_inner());
7260        if bindings.len() >= CONTROL_SESSIONS_MAX {
7261            Err(failure(
7262                NodeFailureCode::BackendBusy,
7263                "node session-binding capacity is full; remove an existing session first",
7264            ))
7265        } else {
7266            Ok(())
7267        }
7268    }
7269
7270    /// One-owner workspace/env collision guard: a live session on
7271    /// `workspace_id` with a different `environment_profile` (option
7272    /// equality on the resolved receipt, including Some vs None) blocks
7273    /// another spawn on that same cwd. Same profile may still share the
7274    /// workspace; distinct profiles need distinct workspace ids (managed
7275    /// worktree).
7276    fn ensure_workspace_environment_profile_compatible(
7277        &self,
7278        workspace_id: &WorkspaceId,
7279        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
7280    ) -> Result<(), NodeFailure> {
7281        let bindings = self
7282            .session_bindings
7283            .lock()
7284            .unwrap_or_else(|poisoned| poisoned.into_inner());
7285        for binding in bindings.values() {
7286            if &binding.workspace_id != workspace_id {
7287                continue;
7288            }
7289            if binding.environment_profile.as_ref() != environment_profile {
7290                return Err(failure(
7291                    NodeFailureCode::WorkspaceBusy,
7292                    "workspace already has a live session with a different environment profile; use a managed worktree for concurrent distinct ProviderHome/env bindings on one repo",
7293                ));
7294            }
7295        }
7296        Ok(())
7297    }
7298
7299    #[cfg(test)]
7300    fn bind_session(
7301        &self,
7302        instance_id: AgentInstanceId,
7303        workspace_id: WorkspaceId,
7304        generation: SessionGeneration,
7305    ) {
7306        self.bind_session_with_policy(
7307            &SessionAddress {
7308                workspace_id,
7309                session: SessionKey { instance_id, generation },
7310            },
7311            ProviderRuntimePolicy::raw_pty(),
7312            None,
7313        );
7314    }
7315
7316    #[cfg(test)]
7317    fn bind_session_with_policy(
7318        &self,
7319        address: &SessionAddress,
7320        runtime_policy: ProviderRuntimePolicy,
7321        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7322    ) {
7323        self.bind_session_with_materialization(
7324            address,
7325            runtime_policy,
7326            environment_profile,
7327            None,
7328            None,
7329            None,
7330        );
7331    }
7332
7333    fn bind_session_with_materialization(
7334        &self,
7335        address: &SessionAddress,
7336        runtime_policy: ProviderRuntimePolicy,
7337        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7338        bundle: Option<ResolvedBundleReceipt>,
7339        context: Option<ResolvedContextPackReceipt>,
7340        materialization_id: Option<MaterializationId>,
7341    ) {
7342        let mut bindings = self
7343            .session_bindings
7344            .lock()
7345            .unwrap_or_else(|poisoned| poisoned.into_inner());
7346        debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7347        debug_assert!(!bindings.contains_key(&address.session.instance_id));
7348        bindings.insert(address.session.instance_id, SessionBinding {
7349            workspace_id: address.workspace_id.clone(),
7350            generation: address.session.generation,
7351            runtime_policy,
7352            pending_resume: None,
7353            record_id: None,
7354            managed_worktree_lease_id: None,
7355            environment_profile,
7356            bundle,
7357            context,
7358            materialization_id,
7359        });
7360    }
7361
7362    #[cfg(test)]
7363    fn bind_managed_session(
7364        &self,
7365        address: &SessionAddress,
7366        record_id: SessionRecordId,
7367        runtime_policy: ProviderRuntimePolicy,
7368        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7369    ) {
7370        self.bind_managed_session_with_materialization(
7371            address,
7372            record_id,
7373            runtime_policy,
7374            environment_profile,
7375            None,
7376            None,
7377            None,
7378        );
7379    }
7380
7381    fn bind_managed_session_with_materialization(
7382        &self,
7383        address: &SessionAddress,
7384        record_id: SessionRecordId,
7385        runtime_policy: ProviderRuntimePolicy,
7386        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7387        bundle: Option<ResolvedBundleReceipt>,
7388        context: Option<ResolvedContextPackReceipt>,
7389        materialization_id: Option<MaterializationId>,
7390    ) {
7391        let mut bindings = self
7392            .session_bindings
7393            .lock()
7394            .unwrap_or_else(|poisoned| poisoned.into_inner());
7395        debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7396        debug_assert!(!bindings.contains_key(&address.session.instance_id));
7397        bindings.insert(
7398            address.session.instance_id,
7399            SessionBinding {
7400                workspace_id: address.workspace_id.clone(),
7401                generation: address.session.generation,
7402                runtime_policy,
7403                pending_resume: None,
7404                record_id: Some(record_id),
7405                managed_worktree_lease_id: None,
7406                environment_profile,
7407                bundle,
7408                context,
7409                materialization_id,
7410            },
7411        );
7412    }
7413
7414    #[cfg(test)]
7415    fn bind_spawn_session(
7416        &self,
7417        address: &SessionAddress,
7418        provider: AgentId,
7419        mode: SessionMode,
7420        runtime_policy: ProviderRuntimePolicy,
7421        record_policy: SpawnRecordPolicy,
7422        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7423    ) -> Result<Option<SessionRecordId>, NodeFailure> {
7424        self.bind_spawn_session_with_materialization(
7425            address,
7426            provider,
7427            mode,
7428            runtime_policy,
7429            record_policy,
7430            environment_profile,
7431            None,
7432            None,
7433            None,
7434        )
7435    }
7436
7437    fn bind_spawn_session_with_materialization(
7438        &self,
7439        address: &SessionAddress,
7440        provider: AgentId,
7441        mode: SessionMode,
7442        runtime_policy: ProviderRuntimePolicy,
7443        record_policy: SpawnRecordPolicy,
7444        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7445        bundle: Option<ResolvedBundleReceipt>,
7446        context: Option<ResolvedContextPackReceipt>,
7447        materialization_id: Option<MaterializationId>,
7448    ) -> Result<Option<SessionRecordId>, NodeFailure> {
7449        if record_policy == SpawnRecordPolicy::ProviderIdentityOnly
7450            && !runtime_policy.provider_session_identity
7451        {
7452            self.bind_session_with_materialization(
7453                address,
7454                runtime_policy,
7455                environment_profile,
7456                bundle,
7457                context,
7458                materialization_id,
7459            );
7460            return Ok(None);
7461        }
7462        let record_owns_lifecycle = runtime_policy.provider_session_identity;
7463        let record = self.new_record(
7464            address,
7465            provider,
7466            mode,
7467            active_record_state(runtime_policy.provider_session_identity, false),
7468            None,
7469            record_owns_lifecycle.then(|| environment_profile.clone()).flatten(),
7470            record_owns_lifecycle.then(|| bundle.clone()).flatten(),
7471            record_owns_lifecycle.then(|| context.clone()).flatten(),
7472        )?;
7473        let record_id = record.record_id.clone();
7474        let transaction = self
7475            .state_transaction
7476            .lock()
7477            .unwrap_or_else(|poisoned| poisoned.into_inner());
7478        self.insert_record(record.clone())?;
7479        let previous_materialization = if record_owns_lifecycle {
7480            if let Some(materialization_id) = materialization_id.as_ref() {
7481                let mut materializations = self
7482                    .materializations
7483                    .lock()
7484                    .unwrap_or_else(|poisoned| poisoned.into_inner());
7485                let Some(ownership) = materializations.get_mut(materialization_id) else {
7486                    drop(materializations);
7487                    self.remove_record_memory(&record_id);
7488                    return Err(failure(
7489                        NodeFailureCode::BackendOperationFailed,
7490                        "session environment ownership disappeared before binding",
7491                    ));
7492                };
7493                let previous = ownership.clone();
7494                if ownership
7495                    .transfer_to_record(record_id.clone(), unix_time_ms())
7496                    .is_err()
7497                {
7498                    drop(materializations);
7499                    self.remove_record_memory(&record_id);
7500                    return Err(failure(
7501                        NodeFailureCode::BackendOperationFailed,
7502                        "session environment ownership transfer failed",
7503                    ));
7504                }
7505                Some(previous)
7506            } else {
7507                None
7508            }
7509        } else {
7510            None
7511        };
7512        self.bind_managed_session_with_materialization(
7513            address,
7514            record_id.clone(),
7515            runtime_policy,
7516            environment_profile,
7517            bundle,
7518            context,
7519            materialization_id.clone(),
7520        );
7521        if let Err(error) = self.persist_state_locked() {
7522            self.remove_binding(address);
7523            self.remove_record_memory(&record_id);
7524            if let (Some(materialization_id), Some(previous)) =
7525                (materialization_id.as_ref(), previous_materialization)
7526            {
7527                self.materializations
7528                    .lock()
7529                    .unwrap_or_else(|poisoned| poisoned.into_inner())
7530                    .insert(materialization_id.clone(), previous);
7531            }
7532            return Err(persistence_failure(error));
7533        }
7534        drop(transaction);
7535        self.publish_record(record);
7536        Ok(Some(record_id))
7537    }
7538
7539    fn allocate_record_id(&self) -> Result<SessionRecordId, NodeFailure> {
7540        for _ in 0..8 {
7541            let bytes = random_nonce().map_err(|error| {
7542                failure(
7543                    NodeFailureCode::BackendOperationFailed,
7544                    &format!("session record identity generation failed: {error}"),
7545                )
7546            })?;
7547            let mut value = String::with_capacity(27);
7548            value.push_str("sr-");
7549            for byte in &bytes[..12] {
7550                use std::fmt::Write as _;
7551                let _ = write!(&mut value, "{byte:02x}");
7552            }
7553            let record_id = SessionRecordId::new(value).map_err(|error| {
7554                failure(NodeFailureCode::BackendOperationFailed, &error.to_string())
7555            })?;
7556            let records = self
7557                .session_records
7558                .lock()
7559                .unwrap_or_else(|poisoned| poisoned.into_inner());
7560            if !records.records.contains_key(&record_id) {
7561                return Ok(record_id);
7562            }
7563        }
7564        Err(failure(
7565            NodeFailureCode::BackendBusy,
7566            "could not allocate a unique session record ID",
7567        ))
7568    }
7569
7570    fn allocate_task_id(&self) -> Result<TaskId, NodeFailure> {
7571        for _ in 0..8 {
7572            let bytes = random_nonce().map_err(|error| {
7573                failure(
7574                    NodeFailureCode::BackendOperationFailed,
7575                    &format!("task identity generation failed: {error}"),
7576                )
7577            })?;
7578            let mut nonce = [0_u8; 12];
7579            nonce.copy_from_slice(&bytes[..12]);
7580            let task_id = TaskId::from_nonce(nonce);
7581            let records = self
7582                .session_records
7583                .lock()
7584                .unwrap_or_else(|poisoned| poisoned.into_inner());
7585            if !records.records.values().any(|record| {
7586                record.task_binding.as_ref().and_then(|binding| binding.task_id.as_ref())
7587                    == Some(&task_id)
7588            }) {
7589                return Ok(task_id);
7590            }
7591        }
7592        Err(failure(
7593            NodeFailureCode::BackendBusy,
7594            "could not allocate a unique task ID",
7595        ))
7596    }
7597
7598    fn default_record_name(&self, provider: &AgentId) -> String {
7599        let records = self
7600            .session_records
7601            .lock()
7602            .unwrap_or_else(|poisoned| poisoned.into_inner());
7603        let ordinal = records
7604            .records
7605            .values()
7606            .filter(|record| &record.provider == provider)
7607            .count()
7608            .saturating_add(1);
7609        format!("{} #{ordinal}", provider.as_str())
7610    }
7611
7612    fn new_record(
7613        &self,
7614        address: &SessionAddress,
7615        provider: AgentId,
7616        mode: SessionMode,
7617        state: ManagedSessionState,
7618        provider_session: Option<gate4agent_types::ProviderSessionIdentity>,
7619        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7620        bundle: Option<ResolvedBundleReceipt>,
7621        context: Option<ResolvedContextPackReceipt>,
7622    ) -> Result<ManagedSessionRecord, NodeFailure> {
7623        let canonical_root = self.workspace_root(&address.workspace_id)?;
7624        let now = unix_time_ms();
7625        Ok(ManagedSessionRecord {
7626            record_id: self.allocate_record_id()?,
7627            display_name: self.default_record_name(&provider),
7628            provider,
7629            mode,
7630            state,
7631            workspace_id: address.workspace_id.clone(),
7632            canonical_root: opaque_windows_path(canonical_root),
7633            provider_session,
7634            active_session: Some(address.clone()),
7635            environment_profile,
7636            bundle,
7637            context_id: context.as_ref().map(|receipt| receipt.id.clone()),
7638            context,
7639            exported_context: None,
7640            task_binding: None,
7641            created_at_unix_ms: now,
7642            updated_at_unix_ms: now,
7643            last_error: None,
7644        })
7645    }
7646
7647    fn insert_record(&self, record: ManagedSessionRecord) -> Result<(), NodeFailure> {
7648        let mut records = self
7649            .session_records
7650            .lock()
7651            .unwrap_or_else(|poisoned| poisoned.into_inner());
7652        if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
7653            return Err(failure(
7654                NodeFailureCode::BackendBusy,
7655                "managed session record capacity is full; forget an inactive record first",
7656            ));
7657        }
7658        if let Some(identity) = record.provider_session.as_ref() {
7659            if records.records.values().any(|candidate| {
7660                candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
7661                    session_registry::same_provider_session(
7662                        &candidate.provider,
7663                        candidate_identity,
7664                        &record.provider,
7665                        identity,
7666                    )
7667                })
7668            }) {
7669                return Err(failure(
7670                    NodeFailureCode::SessionRecordConflict,
7671                    "provider session is already represented by another managed record",
7672                ));
7673            }
7674        }
7675        match records.records.entry(record.record_id.clone()) {
7676            std::collections::btree_map::Entry::Vacant(entry) => {
7677                entry.insert(record);
7678            }
7679            std::collections::btree_map::Entry::Occupied(_) => {
7680                return Err(failure(
7681                    NodeFailureCode::SessionRecordConflict,
7682                    "session record ID collision",
7683                ));
7684            }
7685        }
7686        Ok(())
7687    }
7688
7689    fn record(&self, record_id: &SessionRecordId) -> Result<ManagedSessionRecord, NodeFailure> {
7690        self.session_records
7691            .lock()
7692            .unwrap_or_else(|poisoned| poisoned.into_inner())
7693            .records
7694            .get(record_id)
7695            .cloned()
7696            .ok_or_else(|| {
7697                failure(
7698                    NodeFailureCode::UnknownSessionRecord,
7699                    "managed session record does not exist",
7700                )
7701            })
7702    }
7703
7704    fn revalidate_session_record_preview(
7705        &self,
7706        expected: &ManagedSessionRecord,
7707        identity: &ProviderSessionIdentity,
7708    ) -> Result<(), NodeFailure> {
7709        let current = self.record(&expected.record_id)?;
7710        let current_root = self.workspace_root(&current.workspace_id)?;
7711        if current.provider != expected.provider
7712            || current.workspace_id != expected.workspace_id
7713            || current.canonical_root != expected.canonical_root
7714            || current.provider_session.as_ref() != Some(identity)
7715            || !platform::roots_equal(
7716                &current_root,
7717                windows_path_text(&current.canonical_root),
7718            )
7719        {
7720            return Err(failure(
7721                NodeFailureCode::SessionRecordConflict,
7722                "managed session changed while its preview was loading",
7723            ));
7724        }
7725        Ok(())
7726    }
7727
7728    fn revalidate_session_record_context_export(
7729        &self,
7730        expected: &ManagedSessionRecord,
7731        identity: Option<&ProviderSessionIdentity>,
7732        session: &SessionAddress,
7733        allow_clean_detachment: bool,
7734    ) -> Result<(), NodeFailure> {
7735        let current = self.record(&expected.record_id)?;
7736        let current_root = self.workspace_root(&current.workspace_id)?;
7737        let snapshot = self.internal_session_snapshot(session)?;
7738        let binding = self
7739            .session_bindings
7740            .lock()
7741            .unwrap_or_else(|poisoned| poisoned.into_inner())
7742            .get(&session.session.instance_id)
7743            .cloned()
7744            .ok_or_else(|| {
7745                failure(
7746                    NodeFailureCode::SessionRecordConflict,
7747                    "managed session runtime binding disappeared during context export",
7748                )
7749            })?;
7750        if !session_record_context_export_binding_is_exact(
7751            expected,
7752            &current,
7753            identity,
7754            session,
7755            &binding,
7756            &snapshot.agent_id,
7757            &current_root,
7758            allow_clean_detachment,
7759        ) || !session_record_context_export_source_is_usable(
7760            &current,
7761            &snapshot.status,
7762            allow_clean_detachment,
7763        ) {
7764            return Err(failure(
7765                NodeFailureCode::SessionRecordConflict,
7766                "managed session binding changed during context export",
7767            ));
7768        }
7769        Ok(())
7770    }
7771
7772    fn revalidate_loaded_history(
7773        &self,
7774        session: &SessionAddress,
7775        candidate_id: &str,
7776        loaded: &HistorySessionRecord,
7777    ) -> Result<(), NodeFailure> {
7778        let snapshot = self.internal_session_snapshot(session)?;
7779        if snapshot.history.pending.is_some()
7780            || snapshot.history.loaded_candidate_id.as_deref() != Some(candidate_id)
7781            || snapshot.history.loaded.as_ref() != Some(loaded)
7782        {
7783            return Err(failure(
7784                NodeFailureCode::SessionRecordConflict,
7785                "managed session history changed during context export",
7786            ));
7787        }
7788        Ok(())
7789    }
7790
7791    fn remove_record_memory(&self, record_id: &SessionRecordId) -> Option<ManagedSessionRecord> {
7792        self.session_records
7793            .lock()
7794            .unwrap_or_else(|poisoned| poisoned.into_inner())
7795            .records
7796            .remove(record_id)
7797    }
7798
7799    fn discard_record(&self, record_id: &SessionRecordId) -> Result<(), NodeFailure> {
7800        if self.destroy_record_materialization(record_id)? {
7801            self.publish(NodeEvent::SessionRecordRemoved {
7802                record_id: record_id.clone(),
7803            });
7804            return Ok(());
7805        }
7806        let transaction = self
7807            .state_transaction
7808            .lock()
7809            .unwrap_or_else(|poisoned| poisoned.into_inner());
7810        let Some(removed) = self.remove_record_memory(record_id) else {
7811            return Ok(());
7812        };
7813        if let Err(error) = self.persist_state_locked() {
7814            let _ = self.insert_record(removed);
7815            return Err(persistence_failure(error));
7816        }
7817        drop(transaction);
7818        self.publish(NodeEvent::SessionRecordRemoved {
7819            record_id: record_id.clone(),
7820        });
7821        Ok(())
7822    }
7823
7824    fn publish_record(&self, record: ManagedSessionRecord) {
7825        self.publish(NodeEvent::SessionRecordUpserted { record });
7826    }
7827
7828    fn mark_record_error(
7829        &self,
7830        record_id: &SessionRecordId,
7831        message: &str,
7832    ) -> Result<(), NodeFailure> {
7833        let transaction = self
7834            .state_transaction
7835            .lock()
7836            .unwrap_or_else(|poisoned| poisoned.into_inner());
7837        let previous = {
7838            let mut records = self
7839                .session_records
7840                .lock()
7841                .unwrap_or_else(|poisoned| poisoned.into_inner());
7842            let Some(record) = records.records.get_mut(record_id) else {
7843                return Ok(());
7844            };
7845            let previous = record.clone();
7846            record.state = ManagedSessionState::Unavailable;
7847            record.last_error = Some(session_registry::sanitized_record_error_summary(message));
7848            record.updated_at_unix_ms = unix_time_ms();
7849            previous
7850        };
7851        if let Err(error) = self.persist_state_locked() {
7852            self.session_records
7853                .lock()
7854                .unwrap_or_else(|poisoned| poisoned.into_inner())
7855                .records
7856                .insert(record_id.clone(), previous);
7857            return Err(persistence_failure(error));
7858        }
7859        let record = self.record(record_id)?;
7860        drop(transaction);
7861        self.publish_record(record);
7862        Ok(())
7863    }
7864
7865    fn rename_session_record(
7866        &self,
7867        record_id: &SessionRecordId,
7868        display_name: String,
7869    ) -> Result<ManagedSessionRecord, NodeFailure> {
7870        validate_display_name(&display_name)
7871            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
7872        let _transaction = self
7873            .state_transaction
7874            .lock()
7875            .unwrap_or_else(|poisoned| poisoned.into_inner());
7876        let previous = {
7877            let mut records = self
7878                .session_records
7879                .lock()
7880                .unwrap_or_else(|poisoned| poisoned.into_inner());
7881            let record = records.records.get_mut(record_id).ok_or_else(|| {
7882                failure(
7883                    NodeFailureCode::UnknownSessionRecord,
7884                    "managed session record does not exist",
7885                )
7886            })?;
7887            let previous = record.clone();
7888            record.display_name = display_name;
7889            record.updated_at_unix_ms = unix_time_ms();
7890            previous
7891        };
7892        if let Err(error) = self.persist_state_locked() {
7893            self.session_records
7894                .lock()
7895                .unwrap_or_else(|poisoned| poisoned.into_inner())
7896                .records
7897                .insert(record_id.clone(), previous);
7898            return Err(persistence_failure(error));
7899        }
7900        let updated = self.record(record_id)?;
7901        self.publish_record(updated.clone());
7902        Ok(updated)
7903    }
7904
7905    fn set_session_task(
7906        &self,
7907        record_id: &SessionRecordId,
7908        expected_revision: u64,
7909        target: SessionTaskTargetV1,
7910    ) -> Result<ManagedSessionRecord, NodeFailure> {
7911        let transaction = self
7912            .state_transaction
7913            .lock()
7914            .unwrap_or_else(|poisoned| poisoned.into_inner());
7915        let current = self.record(record_id)?;
7916        let current_task_id = current
7917            .task_binding
7918            .as_ref()
7919            .and_then(|binding| binding.task_id.as_ref());
7920        let current_revision = current
7921            .task_binding
7922            .as_ref()
7923            .map_or(0, |binding| binding.revision);
7924        if expected_revision != current_revision {
7925            return Err(failure(
7926                NodeFailureCode::SessionRecordConflict,
7927                "managed session task binding revision changed",
7928            ));
7929        }
7930        let exact = match &target {
7931            SessionTaskTargetV1::Existing { task_id } => current_task_id == Some(task_id),
7932            SessionTaskTargetV1::Clear => current_task_id.is_none(),
7933            SessionTaskTargetV1::New => false,
7934        };
7935        if exact {
7936            return Ok(current);
7937        }
7938        let revision = current_revision.checked_add(1).ok_or_else(|| {
7939            failure(
7940                NodeFailureCode::SessionRecordConflict,
7941                "managed session task binding revision is exhausted",
7942            )
7943        })?;
7944        let task_id = match target {
7945            SessionTaskTargetV1::New => Some(self.allocate_task_id()?),
7946            SessionTaskTargetV1::Existing { task_id } => Some(task_id),
7947            SessionTaskTargetV1::Clear => None,
7948        };
7949        let changed_at_unix_ms = unix_time_ms()
7950            .max(current.updated_at_unix_ms.checked_add(1).ok_or_else(|| {
7951                failure(
7952                    NodeFailureCode::SessionRecordConflict,
7953                    "managed session record timestamp is exhausted",
7954                )
7955            })?);
7956        let updated = {
7957            let mut records = self
7958                .session_records
7959                .lock()
7960                .unwrap_or_else(|poisoned| poisoned.into_inner());
7961            let record = records.records.get_mut(record_id).ok_or_else(|| {
7962                failure(
7963                    NodeFailureCode::UnknownSessionRecord,
7964                    "managed session record does not exist",
7965                )
7966            })?;
7967            record.task_binding = Some(SessionTaskBindingV1 {
7968                revision,
7969                task_id,
7970                changed_at_unix_ms,
7971            });
7972            record.updated_at_unix_ms = changed_at_unix_ms;
7973            record.clone()
7974        };
7975        if let Err(error) = self.persist_state_locked() {
7976            self.session_records
7977                .lock()
7978                .unwrap_or_else(|poisoned| poisoned.into_inner())
7979                .records
7980                .insert(record_id.clone(), current);
7981            return Err(persistence_failure(error));
7982        }
7983        drop(transaction);
7984        self.publish_record(updated.clone());
7985        Ok(updated)
7986    }
7987
7988    fn index_provider_session(
7989        &self,
7990        workspace_id: WorkspaceId,
7991        provider: AgentId,
7992        identity: ProviderSessionIdentity,
7993        display_name: String,
7994    ) -> Result<ManagedSessionRecord, NodeFailure> {
7995        self.index_provider_session_with_policy(
7996            workspace_id,
7997            provider,
7998            identity,
7999            display_name,
8000            false,
8001        )
8002    }
8003
8004    fn index_provider_session_with_policy(
8005        &self,
8006        workspace_id: WorkspaceId,
8007        provider: AgentId,
8008        identity: ProviderSessionIdentity,
8009        display_name: String,
8010        allow_validated_transcript_path: bool,
8011    ) -> Result<ManagedSessionRecord, NodeFailure> {
8012        validate_display_name(&display_name)
8013            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8014        identity
8015            .validate()
8016            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8017        if identity.transcript_path.is_some() && !allow_validated_transcript_path {
8018            return Err(failure(
8019                NodeFailureCode::InvalidRequest,
8020                "provider session reference index v1 does not accept transcript paths",
8021            ));
8022        }
8023        let canonical_root = self.workspace_root(&workspace_id)?;
8024        if !self.enabled_providers.contains(&provider) {
8025            return Err(failure(
8026                NodeFailureCode::InvalidRequest,
8027                "provider is not enabled on this node",
8028            ));
8029        }
8030
8031        let transaction = self
8032            .state_transaction
8033            .lock()
8034            .unwrap_or_else(|poisoned| poisoned.into_inner());
8035        {
8036            let records = self
8037                .session_records
8038                .lock()
8039                .unwrap_or_else(|poisoned| poisoned.into_inner());
8040            if let Some(existing) = records.records.values().find(|candidate| {
8041                candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8042                    candidate_identity.key == identity.key && candidate_identity.id == identity.id
8043                })
8044            }) {
8045                if existing.provider == provider && existing.workspace_id == workspace_id {
8046                    return Ok(existing.clone());
8047                }
8048                return Err(failure(
8049                    NodeFailureCode::SessionRecordConflict,
8050                    "provider session reference conflicts with another provider or workspace",
8051                ));
8052            }
8053            if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
8054                return Err(failure(
8055                    NodeFailureCode::BackendBusy,
8056                    "managed session record capacity is full; forget an inactive record first",
8057                ));
8058            }
8059        }
8060
8061        let now = unix_time_ms();
8062        let record = ManagedSessionRecord {
8063            record_id: self.allocate_record_id()?,
8064            display_name,
8065            provider,
8066            mode: SessionMode::Pty,
8067            state: ManagedSessionState::Dormant,
8068            workspace_id,
8069            canonical_root: opaque_windows_path(canonical_root),
8070            provider_session: Some(identity),
8071            active_session: None,
8072            environment_profile: None,
8073            bundle: None,
8074            context_id: None,
8075            context: None,
8076            exported_context: None,
8077            task_binding: None,
8078            created_at_unix_ms: now,
8079            updated_at_unix_ms: now,
8080            last_error: None,
8081        };
8082        self.insert_record(record.clone())?;
8083        if let Err(error) = self.persist_state_locked() {
8084            self.remove_record_memory(&record.record_id);
8085            return Err(persistence_failure(error));
8086        }
8087        drop(transaction);
8088        self.publish_record(record.clone());
8089        Ok(record)
8090    }
8091
8092    fn bound_address_for_record(&self, record_id: &SessionRecordId) -> Option<SessionAddress> {
8093        self.session_bindings
8094            .lock()
8095            .unwrap_or_else(|poisoned| poisoned.into_inner())
8096            .iter()
8097            .find_map(|(instance_id, binding)| {
8098                (binding.record_id.as_ref() == Some(record_id)).then(|| SessionAddress {
8099                    workspace_id: binding.workspace_id.clone(),
8100                    session: SessionKey {
8101                        instance_id: *instance_id,
8102                        generation: binding.generation,
8103                    },
8104                })
8105            })
8106    }
8107
8108    async fn forget_session_record(
8109        &self,
8110        record_id: &SessionRecordId,
8111    ) -> Result<(), NodeFailure> {
8112        let record = self.record(record_id)?;
8113        if record.active_session.is_some()
8114            || matches!(
8115                record.state,
8116                ManagedSessionState::Live | ManagedSessionState::IdentityPending
8117            )
8118        {
8119            return Err(failure(
8120                NodeFailureCode::SessionRecordBusy,
8121                "stop the managed session before forgetting it",
8122            ));
8123        }
8124        if let Some(address) = self.bound_address_for_record(record_id) {
8125            self.remove_session(&address).await?;
8126        }
8127        self.remove_managed_session_record(record_id, &record.workspace_id).await
8128    }
8129
8130    /// Removes one managed session record from durable state and notifies
8131    /// every listener -- the shared transaction body behind both
8132    /// `forget_session_record`'s operator-driven path (busy-checked above
8133    /// it, and clears any still-live session binding before calling in
8134    /// here) and the retention sweep (`retire_unavailable_session_records`,
8135    /// which only ever selects `ManagedSessionState::Unavailable` records
8136    /// that are already known never-busy and never bound, so it calls
8137    /// straight into this with neither check). Cleans a managed-worktree
8138    /// lease if the record held one, removes the record's environment
8139    /// materialization (or, absent one, removes it from memory directly),
8140    /// persists under `state_transaction` with rollback on persist failure,
8141    /// then publishes `NodeEvent::SessionRecordRemoved` so c2's own
8142    /// legacy-provider bookkeeping (`record_is_legacy`) stays in sync
8143    /// exactly as it does for an operator-driven forget.
8144    async fn remove_managed_session_record(
8145        &self,
8146        record_id: &SessionRecordId,
8147        workspace_id: &WorkspaceId,
8148    ) -> Result<(), NodeFailure> {
8149        let managed_lease_id = self.managed_worktrees.lock()
8150            .unwrap_or_else(|poisoned| poisoned.into_inner())
8151            .lease_for_workspace(workspace_id);
8152        if !self.destroy_record_materialization(record_id)? {
8153            let transaction = self
8154                .state_transaction
8155                .lock()
8156                .unwrap_or_else(|poisoned| poisoned.into_inner());
8157            let removed = self.remove_record_memory(record_id).ok_or_else(|| {
8158                failure(
8159                    NodeFailureCode::UnknownSessionRecord,
8160                    "managed session record does not exist",
8161                )
8162            })?;
8163            if let Err(error) = self.persist_state_locked() {
8164                let _ = self.insert_record(removed);
8165                return Err(persistence_failure(error));
8166            }
8167            drop(transaction);
8168        }
8169        self.publish(NodeEvent::SessionRecordRemoved {
8170            record_id: record_id.clone(),
8171        });
8172        if let Some(lease_id) = managed_lease_id {
8173            if let Some(snapshot) = self.managed_worktrees.lock()
8174                .unwrap_or_else(|poisoned| poisoned.into_inner())
8175                .get(&lease_id)
8176                .map(ManagedWorktreeLeaseRecord::snapshot)
8177            {
8178                self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
8179            }
8180            let _ = self.cleanup_managed_worktree(&lease_id, false).await;
8181        }
8182        Ok(())
8183    }
8184
8185    /// Retires `ManagedSessionState::Unavailable` records against the
8186    /// node's configured `session_record_retention` age/keep-N policy.
8187    /// Never touches `Live`, `IdentityPending`, or `Dormant` records --
8188    /// `Dormant` is resumable and folding it into this sweep would delete
8189    /// records a caller can still act on. Both policy fields default to
8190    /// `0` (disabled); when both are `0` this is a no-op. When both are
8191    /// set, a record is retired only if it fails BOTH tests (older than
8192    /// `age_ms` AND beyond the newest `keep_per_workspace` in its
8193    /// workspace); when only one is set, that one alone decides. Called
8194    /// once at startup (alongside `reconcile_managed_worktrees`) and then
8195    /// on a `SESSION_RECORD_RETENTION_SWEEP_PERIOD` cadence from the drive
8196    /// loop -- see both call sites.
8197    async fn retire_unavailable_session_records(&self) {
8198        let policy = self.session_record_retention;
8199        let age_enabled = policy.age_ms > 0;
8200        let keep_enabled = policy.keep_per_workspace > 0;
8201        if !age_enabled && !keep_enabled {
8202            return;
8203        }
8204        let now = unix_time_ms();
8205        let candidates: Vec<(SessionRecordId, WorkspaceId, AgentId, u64)> = {
8206            self.session_records
8207                .lock()
8208                .unwrap_or_else(|poisoned| poisoned.into_inner())
8209                .records
8210                .values()
8211                .filter(|record| record.state == ManagedSessionState::Unavailable)
8212                .map(|record| {
8213                    (
8214                        record.record_id.clone(),
8215                        record.workspace_id.clone(),
8216                        record.provider.clone(),
8217                        record.updated_at_unix_ms,
8218                    )
8219                })
8220                .collect()
8221        };
8222        if candidates.is_empty() {
8223            return;
8224        }
8225        let mut by_workspace: BTreeMap<WorkspaceId, Vec<(SessionRecordId, AgentId, u64)>> =
8226            BTreeMap::new();
8227        for (record_id, workspace_id, provider, updated_at_unix_ms) in candidates {
8228            by_workspace
8229                .entry(workspace_id)
8230                .or_default()
8231                .push((record_id, provider, updated_at_unix_ms));
8232        }
8233        let reason = if age_enabled && keep_enabled {
8234            "age+keep-n"
8235        } else if age_enabled {
8236            "age"
8237        } else {
8238            "keep-n"
8239        };
8240        let mut retired: u64 = 0;
8241        for (workspace_id, mut records) in by_workspace {
8242            // Newest `updated_at_unix_ms` first, so index `>=
8243            // keep_per_workspace` names exactly "beyond the newest N".
8244            records.sort_by(|left, right| right.2.cmp(&left.2));
8245            for (index, (record_id, provider, updated_at_unix_ms)) in
8246                records.into_iter().enumerate()
8247            {
8248                let record_age_ms = now.saturating_sub(updated_at_unix_ms);
8249                let age_hit = age_enabled && record_age_ms >= policy.age_ms;
8250                let keep_hit = keep_enabled && index >= policy.keep_per_workspace as usize;
8251                let should_retire = if age_enabled && keep_enabled {
8252                    age_hit && keep_hit
8253                } else {
8254                    age_hit || keep_hit
8255                };
8256                if !should_retire {
8257                    continue;
8258                }
8259                match self.remove_managed_session_record(&record_id, &workspace_id).await {
8260                    Ok(()) => {
8261                        self.retired_records_total.fetch_add(1, Ordering::Relaxed);
8262                        retired += 1;
8263                        tracing::info!(
8264                            node_id = %self.node_id,
8265                            record_id = %record_id,
8266                            workspace_id = %workspace_id,
8267                            provider = %provider,
8268                            age_ms = record_age_ms,
8269                            reason,
8270                            "managed session record retired"
8271                        );
8272                    }
8273                    Err(error) => tracing::warn!(
8274                        node_id = %self.node_id,
8275                        record_id = %record_id,
8276                        workspace_id = %workspace_id,
8277                        code = ?error.code,
8278                        cause = %error.message,
8279                        "session record retention sweep failed to retire a record"
8280                    ),
8281                }
8282            }
8283        }
8284        if retired > 0 {
8285            let remaining_unavailable = self
8286                .session_records
8287                .lock()
8288                .unwrap_or_else(|poisoned| poisoned.into_inner())
8289                .records
8290                .values()
8291                .filter(|record| record.state == ManagedSessionState::Unavailable)
8292                .count();
8293            tracing::info!(
8294                node_id = %self.node_id,
8295                retired,
8296                remaining_unavailable,
8297                "session record retention sweep completed"
8298            );
8299        }
8300    }
8301
8302    async fn resume_session_record(
8303        &self,
8304        record_id: &SessionRecordId,
8305        terminal_size: gate4agent_types::TerminalSize,
8306        initial_prompt: Option<String>,
8307    ) -> Result<(ManagedSessionRecord, SessionAddress), NodeFailure> {
8308        let record = self.record(record_id)?;
8309        if record.active_session.is_some()
8310            || matches!(
8311                record.state,
8312                ManagedSessionState::Live | ManagedSessionState::IdentityPending
8313            )
8314        {
8315            return Err(failure(
8316                NodeFailureCode::SessionRecordBusy,
8317                "managed session already has a live runtime binding",
8318            ));
8319        }
8320        let identity = record.provider_session.clone().ok_or_else(|| {
8321            failure(
8322                NodeFailureCode::SessionRecordNotResumable,
8323                "managed session has no verified provider session identity",
8324            )
8325        })?;
8326        let working_directory = self.workspace_root(&record.workspace_id)?;
8327        if !platform::roots_equal(
8328            &working_directory,
8329            windows_path_text(&record.canonical_root),
8330        ) {
8331            return Err(failure(
8332                NodeFailureCode::SessionRecordConflict,
8333                "managed session workspace root changed; refusing to resume in another directory",
8334            ));
8335        }
8336        {
8337            let records = self
8338                .session_records
8339                .lock()
8340                .unwrap_or_else(|poisoned| poisoned.into_inner());
8341            if records.records.values().any(|candidate| {
8342                candidate.record_id != *record_id
8343                    && candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8344                        session_registry::same_provider_session(
8345                            &candidate.provider,
8346                            candidate_identity,
8347                            &record.provider,
8348                            &identity,
8349                        )
8350                    })
8351                    && (candidate.active_session.is_some()
8352                        || matches!(
8353                            candidate.state,
8354                            ManagedSessionState::Live | ManagedSessionState::IdentityPending
8355                        ))
8356            }) {
8357                return Err(failure(
8358                    NodeFailureCode::SessionRecordConflict,
8359                    "provider session is already active through another managed record",
8360                ));
8361            }
8362        }
8363        let runtime_requirement = if initial_prompt.is_some() {
8364            ProviderRuntimeRequirement::ResumeWithPrompt
8365        } else {
8366            ProviderRuntimeRequirement::Resume
8367        };
8368        let runtime_policy = self.admit_provider_runtime(
8369            &record.provider,
8370            runtime_requirement,
8371        )
8372        .await?;
8373        let request = ResumeLaunchRequest {
8374            working_directory,
8375            terminal_size,
8376            initial_prompt,
8377        };
8378        request
8379            .validate()
8380            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8381        self.ensure_binding_capacity()?;
8382        let resolved_materialization = match self.resolve_record_materialization(
8383            record_id,
8384            &record.provider,
8385            record.mode,
8386            record.environment_profile.as_ref(),
8387            record.bundle.as_ref(),
8388            record.context.as_ref(),
8389        ) {
8390            Ok(materialization) => materialization,
8391            Err(error) => {
8392                let label = match error.code {
8393                    NodeFailureCode::UnknownBundle => "bundle-unavailable",
8394                    NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
8395                    NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
8396                    _ => "environment-profile-unavailable",
8397                };
8398                self.mark_record_error(record_id, label)?;
8399                return Err(error);
8400            }
8401        };
8402        let (materialization_id, environment_overlay) = match resolved_materialization {
8403            Some((id, overlay)) => (Some(id), overlay),
8404            None => (None, None),
8405        };
8406        let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
8407        let mut environment_selection = match SpawnPreparer::select_environment(self, 
8408            instance_id,
8409            &record.provider,
8410            record.mode,
8411            record.environment_profile.as_ref(),
8412        ) {
8413            Ok(selection) => selection,
8414            Err(error) => {
8415                self.mark_record_error(record_id, "environment-profile-unavailable")?;
8416                return Err(error);
8417            }
8418        };
8419        let mut instance_overlay = environment_overlay
8420            .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
8421            .transpose()?
8422            .flatten();
8423        if let Some(stale_address) = self.bound_address_for_record(record_id) {
8424            self.remove_session(&stale_address).await?;
8425        }
8426        let address = SessionAddress {
8427            workspace_id: record.workspace_id.clone(),
8428            session: SessionKey {
8429                instance_id,
8430                generation: SessionGeneration(1),
8431            },
8432        };
8433        let transaction = self
8434            .state_transaction
8435            .lock()
8436            .unwrap_or_else(|poisoned| poisoned.into_inner());
8437        let current = self.record(record_id)?;
8438        if current.active_session.is_some()
8439            || matches!(
8440                current.state,
8441                ManagedSessionState::Live | ManagedSessionState::IdentityPending
8442            )
8443        {
8444            return Err(failure(
8445                NodeFailureCode::SessionRecordBusy,
8446                "managed session became active while resume was being prepared",
8447            ));
8448        }
8449        self.bind_managed_session_with_materialization(
8450            &address,
8451            record_id.clone(),
8452            runtime_policy,
8453            record.environment_profile.clone(),
8454            record.bundle.clone(),
8455            record.context.clone(),
8456            materialization_id,
8457        );
8458        if let Some(selection) = environment_selection.take() {
8459            selection.retain();
8460        }
8461        if let Some(overlay) = instance_overlay.take() {
8462            overlay.retain();
8463        }
8464        drop(transaction);
8465
8466        let transport = match record.mode {
8467            SessionMode::Pty => TransportKind::Pty,
8468            SessionMode::Inline => TransportKind::Pipe,
8469            SessionMode::Acp => TransportKind::Acp,
8470        };
8471        let agent_id = record.provider.clone();
8472        let dispatch_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
8473        if let Err(error) = self
8474            .dispatch_bounded(
8475                ControlCommand::Register {
8476                    instance_id,
8477                    agent_id,
8478                    transport,
8479                },
8480                dispatch_timeout,
8481            )
8482            .await
8483        {
8484            if let Some(binding) = self.remove_binding(&address) {
8485                SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
8486            }
8487            return Err(error);
8488        }
8489        let command = self.prepare_command(ControlCommand::Resume {
8490            instance_id,
8491            target: ResumeTarget::ProviderSession { identity },
8492            runtime_policy,
8493            request,
8494        });
8495        let started_after = self.current_sequence();
8496        if let Err(error) = self.arm_resume(&address, command.id, runtime_policy) {
8497            self.rollback_spawn(&address, dispatch_timeout).await?;
8498            return Err(error);
8499        }
8500        if let Err(error) = self.dispatch_envelope(command) {
8501            self.clear_armed_resume(&address);
8502            self.rollback_spawn(&address, dispatch_timeout).await?;
8503            return Err(error);
8504        }
8505        match self
8506            .wait_for_record_resume(&address, started_after)
8507            .await
8508        {
8509            Ok(settled_address) => {
8510                let managed_lease_id = {
8511                    self.managed_worktrees.lock()
8512                        .unwrap_or_else(|poisoned| poisoned.into_inner())
8513                        .lease_for_workspace(&record.workspace_id)
8514                };
8515                if let Some(lease_id) = managed_lease_id {
8516                    {
8517                        let mut bindings = self.session_bindings.lock()
8518                            .unwrap_or_else(|poisoned| poisoned.into_inner());
8519                        let binding = bindings.get_mut(&settled_address.session.instance_id)
8520                            .ok_or_else(|| failure(
8521                                NodeFailureCode::ManagedWorktreeRecoveryRequired,
8522                                "resumed managed session lost its exact binding",
8523                            ))?;
8524                        if binding.generation != settled_address.session.generation {
8525                            return Err(failure(
8526                                NodeFailureCode::ManagedWorktreeRecoveryRequired,
8527                                "resumed managed session generation diverged",
8528                            ));
8529                        }
8530                        binding.managed_worktree_lease_id = Some(lease_id.clone());
8531                    }
8532                    let snapshot = self.managed_worktrees.lock()
8533                        .unwrap_or_else(|poisoned| poisoned.into_inner())
8534                        .bind_session(
8535                            &lease_id,
8536                            ManagedWorktreeSessionHolder {
8537                                incarnation_id: self.incarnation_id,
8538                                instance_id: settled_address.session.instance_id,
8539                                generation: settled_address.session.generation,
8540                            },
8541                            Some(record_id.clone()),
8542                            unix_time_ms(),
8543                        ).map_err(|message| failure(
8544                            NodeFailureCode::ManagedWorktreeRecoveryRequired,
8545                            &message,
8546                        ))?;
8547                    if let Err(error) = self.persist_state() {
8548                        let _ = self.remove_session(&settled_address).await;
8549                        return Err(persistence_failure(error));
8550                    }
8551                    self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
8552                }
8553                Ok((self.record(record_id)?, settled_address))
8554            }
8555            Err(error) => {
8556                if matches!(
8557                    error.code,
8558                    NodeFailureCode::SessionRecordNotResumable
8559                        | NodeFailureCode::BackendOperationFailed
8560                ) {
8561                    if let Some(bound) = self.bound_address_for_record(record_id) {
8562                        let _ = self.remove_session(&bound).await;
8563                    }
8564                }
8565                Err(error)
8566            }
8567        }
8568    }
8569
8570    async fn wait_for_record_resume(
8571        &self,
8572        initial_address: &SessionAddress,
8573        after_sequence: u64,
8574    ) -> Result<SessionAddress, NodeFailure> {
8575        let deadline = Instant::now()
8576            + Duration::from_millis(MANAGED_RESUME_SETTLE_TIMEOUT_MS);
8577        let mut scan_after = after_sequence;
8578        loop {
8579            let events = self
8580                .history
8581                .lock()
8582                .unwrap_or_else(|poisoned| poisoned.into_inner())
8583                .events
8584                .iter()
8585                .filter(|event| event.sequence > scan_after)
8586                .cloned()
8587                .collect::<Vec<_>>();
8588            for envelope in events {
8589                scan_after = scan_after.max(envelope.sequence);
8590                let NodeEvent::Control { address, event } = envelope.event else {
8591                    continue;
8592                };
8593                if address.session.instance_id != initial_address.session.instance_id {
8594                    continue;
8595                }
8596                match event.event {
8597                    ControlEventKind::Resumed { .. } => return Ok(address),
8598                    ControlEventKind::ResumeDenied { reason } => {
8599                        return Err(failure(
8600                            NodeFailureCode::SessionRecordNotResumable,
8601                            &reason,
8602                        ));
8603                    }
8604                    ControlEventKind::ResumeFailed { message }
8605                    | ControlEventKind::CommandRejected { message } => {
8606                        return Err(failure(
8607                            NodeFailureCode::BackendOperationFailed,
8608                            &message,
8609                        ));
8610                    }
8611                    _ => {}
8612                }
8613            }
8614            if Instant::now() >= deadline {
8615                return Err(failure(
8616                    NodeFailureCode::BackendBusy,
8617                    "managed resume did not settle before the bounded deadline",
8618                ));
8619            }
8620            sleep(Duration::from_millis(5)).await;
8621        }
8622    }
8623
8624    fn workspace_root(&self, workspace_id: &WorkspaceId) -> Result<String, NodeFailure> {
8625        self.workspaces
8626            .read()
8627            .unwrap_or_else(|poisoned| poisoned.into_inner())
8628            .get(workspace_id)
8629            .cloned()
8630            .ok_or_else(|| failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"))
8631    }
8632
8633    fn workspace_roots(&self) -> Vec<PathBuf> {
8634        self.workspaces
8635            .read()
8636            .unwrap_or_else(|poisoned| poisoned.into_inner())
8637            .values()
8638            .map(PathBuf::from)
8639            .collect()
8640    }
8641
8642    async fn inspect_workspace(
8643        &self,
8644        workspace_id: WorkspaceId,
8645    ) -> Result<WorkspaceInspection, NodeFailure> {
8646        let _permit = self
8647            .inspection_slots
8648            .clone()
8649            .try_acquire_owned()
8650            .map_err(|_| {
8651                tracing::warn!(
8652                    workspace_id = %workspace_id,
8653                    "workspace inspection rejected: inspection capacity is busy",
8654                );
8655                failure(
8656                    NodeFailureCode::BackendBusy,
8657                    "workspace inspection capacity is busy",
8658                )
8659            })?;
8660        let canonical_root = self.workspace_root(&workspace_id).map_err(|error| {
8661            tracing::warn!(
8662                workspace_id = %workspace_id,
8663                code = ?error.code,
8664                "workspace inspection rejected: workspace is not registered",
8665            );
8666            error
8667        })?;
8668        let tree_root = canonical_root.clone();
8669        let started_at = Instant::now();
8670        let time_budget_ms = workspace_inspection_time_budget_ms();
8671        let entry_cap = workspace_inspection_entry_cap();
8672        let deadline = started_at + Duration::from_millis(time_budget_ms);
8673        let (entries, tree_truncated, walk_budget) = tokio::task::spawn_blocking(move || {
8674            collect_workspace_entries(Path::new(&tree_root), deadline, entry_cap)
8675        })
8676        .await
8677        .map_err(|error| {
8678            tracing::warn!(
8679                workspace_id = %workspace_id,
8680                error = %error,
8681                "workspace inspection rejected: walk task failed",
8682            );
8683            failure(
8684                NodeFailureCode::BackendOperationFailed,
8685                &format!("workspace inspection task failed: {error}"),
8686            )
8687        })?;
8688        let (mut git, git_time_budget_exceeded) =
8689            inspect_git_workspace(&canonical_root, deadline).await;
8690        if !git.worktrees.is_empty() {
8691            let registered = self
8692                .workspaces
8693                .read()
8694                .unwrap_or_else(|poisoned| poisoned.into_inner())
8695                .iter()
8696                .map(|(workspace_id, root)| (workspace_id.clone(), root.clone()))
8697                .collect::<Vec<_>>();
8698            for worktree in &mut git.worktrees {
8699                worktree.workspace_id = registered
8700                    .iter()
8701                    .find(|(_, root)| {
8702                        worktree_paths_equal(root, windows_path_text(&worktree.path))
8703                    })
8704                    .map(|(workspace_id, _)| workspace_id.clone());
8705            }
8706        }
8707        if git.is_repository {
8708            git.managed_worktree = self
8709                .managed_worktrees
8710                .lock()
8711                .unwrap_or_else(|poisoned| poisoned.into_inner())
8712                .git_scope(&workspace_id, git.branch.as_deref());
8713        }
8714        let truncation = workspace_inspection_truncation(
8715            &walk_budget,
8716            git_time_budget_exceeded,
8717            started_at.elapsed(),
8718        );
8719        if let Some(truncation) = &truncation {
8720            tracing::info!(
8721                workspace_id = %workspace_id,
8722                walk_time_budget_exceeded = truncation.walk_time_budget_exceeded,
8723                walk_entry_cap_exceeded = truncation.walk_entry_cap_exceeded,
8724                git_time_budget_exceeded = truncation.git_time_budget_exceeded,
8725                entries_visited = truncation.entries_visited,
8726                elapsed_ms = truncation.elapsed_ms,
8727                "workspace inspection truncated by its inner budget",
8728            );
8729        }
8730        Ok(WorkspaceInspection {
8731            workspace_id,
8732            entries,
8733            tree_truncated,
8734            git,
8735            truncation,
8736        })
8737    }
8738
8739    async fn browse_host_directories(
8740        &self,
8741        directory: Option<OpaqueHostPath>,
8742        after: Option<OpaqueHostPath>,
8743    ) -> Result<HostDirectoryListing, NodeFailure> {
8744        let permit = self
8745            .inspection_slots
8746            .clone()
8747            .try_acquire_owned()
8748            .map_err(|_| {
8749                failure(
8750                    NodeFailureCode::BackendBusy,
8751                    "host directory browse capacity is busy",
8752                )
8753            })?;
8754        let task = tokio::task::spawn_blocking(move || {
8755            let _permit = permit;
8756            browse_host_directories(directory, after)
8757        });
8758        timeout(Duration::from_millis(HOST_DIRECTORY_BROWSE_TIMEOUT_MS), task)
8759            .await
8760            .map_err(|_| {
8761                failure(
8762                    NodeFailureCode::HostDirectoryReadTimedOut,
8763                    "host directory browse exceeded its bounded deadline",
8764                )
8765            })?
8766            .map_err(|_| {
8767                failure(
8768                    NodeFailureCode::HostDirectoryReadFailed,
8769                    "host directory browse task failed",
8770                )
8771            })?
8772            .map_err(host_directory_failure)
8773    }
8774
8775    async fn read_workspace_file(
8776        &self,
8777        workspace_id: WorkspaceId,
8778        path: RepositoryPath,
8779    ) -> Result<WorkspaceFileRead, NodeFailure> {
8780        let canonical_root = self.workspace_root(&workspace_id)?;
8781        let permit = self
8782            .inspection_slots
8783            .clone()
8784            .try_acquire_owned()
8785            .map_err(|_| {
8786                failure(
8787                    NodeFailureCode::BackendBusy,
8788                    "workspace file read capacity is busy",
8789                )
8790            })?;
8791        let path_to_read = path.clone();
8792        let task = tokio::task::spawn_blocking(move || {
8793            let _permit = permit;
8794            read_workspace_file_from_disk(Path::new(&canonical_root), &path_to_read)
8795        });
8796        let content = timeout(
8797            Duration::from_millis(WORKSPACE_FILE_READ_TIMEOUT_MS),
8798            task,
8799        )
8800        .await
8801        .map_err(|_| {
8802            failure(
8803                NodeFailureCode::RepositoryFileReadTimedOut,
8804                "repository file read exceeded its bounded deadline",
8805            )
8806        })?
8807        .map_err(|_| {
8808            failure(
8809                NodeFailureCode::RepositoryFileReadFailed,
8810                "repository file read task failed",
8811            )
8812        })?
8813        .map_err(workspace_file_failure)?;
8814        let content = match content {
8815            WorkspaceFileBytes::Utf8(text) => {
8816                let revision = Some(workspace_file_revision(text.as_bytes()));
8817                return Ok(WorkspaceFileRead {
8818                    workspace_id,
8819                    path,
8820                    content: WorkspaceFileContent::Utf8 {
8821                        byte_len: u32::try_from(text.len())
8822                            .expect("bounded workspace text length must fit u32"),
8823                        text,
8824                    },
8825                    revision,
8826                });
8827            }
8828            WorkspaceFileBytes::NonUtf8 { byte_length } => {
8829                WorkspaceFileContent::NonUtf8 {
8830                    byte_len: u32::try_from(byte_length)
8831                        .expect("bounded workspace file length must fit u32"),
8832                }
8833            }
8834            WorkspaceFileBytes::TooLarge => WorkspaceFileContent::TooLarge {
8835                limit_bytes: MAX_WORKSPACE_FILE_BYTES as u32,
8836            },
8837        };
8838        Ok(WorkspaceFileRead {
8839            workspace_id,
8840            path,
8841            content,
8842            revision: None,
8843        })
8844    }
8845
8846    async fn write_workspace_file(
8847        &self,
8848        workspace_id: WorkspaceId,
8849        path: RepositoryPath,
8850        expected_revision: WorkspaceFileRevision,
8851        text: String,
8852    ) -> Result<WorkspaceFileRead, NodeFailure> {
8853        let canonical_root = self.workspace_root(&workspace_id)?;
8854        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8855            failure(NodeFailureCode::BackendBusy, "workspace file write capacity is busy")
8856        })?;
8857        let path_to_write = path.clone();
8858        let expected = expected_revision.as_str().to_owned();
8859        let text_to_write = text.clone();
8860        let task = tokio::task::spawn_blocking(move || {
8861            let _permit = permit;
8862            write_workspace_file_to_disk(
8863                Path::new(&canonical_root),
8864                &path_to_write,
8865                &expected,
8866                &text_to_write,
8867            )
8868        });
8869        let revision = timeout(Duration::from_millis(WORKSPACE_FILE_WRITE_TIMEOUT_MS), task)
8870            .await
8871            .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteTimedOut, "repository file write exceeded its bounded deadline"))?
8872            .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteFailed, "repository file write task failed"))?
8873            .map_err(workspace_file_write_failure)?;
8874        Ok(WorkspaceFileRead {
8875            workspace_id,
8876            path,
8877            content: WorkspaceFileContent::Utf8 {
8878                byte_len: u32::try_from(text.len()).expect("bounded workspace text length must fit u32"),
8879                text,
8880            },
8881            revision: Some(WorkspaceFileRevision::new(revision)
8882                .expect("workspace writer returns a valid SHA-256 revision")),
8883        })
8884    }
8885
8886    async fn create_workspace_file(
8887        &self,
8888        workspace_id: WorkspaceId,
8889        path: RepositoryPath,
8890    ) -> Result<WorkspaceFileRead, NodeFailure> {
8891        let canonical_root = self.workspace_root(&workspace_id)?;
8892        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8893            failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
8894        })?;
8895        let path_to_create = path.clone();
8896        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
8897        let task_commit_state = Arc::clone(&commit_state);
8898        let task = tokio::task::spawn_blocking(move || {
8899            let _permit = permit;
8900            create_workspace_file_on_disk(
8901                Path::new(&canonical_root),
8902                &path_to_create,
8903                &task_commit_state,
8904            )
8905        });
8906        let revision = settle_workspace_entry_create(
8907            task,
8908            commit_state,
8909            Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
8910            "repository file create exceeded its bounded deadline",
8911            "repository file create task failed",
8912        )
8913        .await?;
8914        Ok(WorkspaceFileRead {
8915            workspace_id,
8916            path,
8917            content: WorkspaceFileContent::Utf8 {
8918                text: String::new(),
8919                byte_len: 0,
8920            },
8921            revision: Some(WorkspaceFileRevision::new(revision)
8922                .expect("workspace creator returns a valid SHA-256 revision")),
8923        })
8924    }
8925
8926    async fn create_workspace_directory(
8927        &self,
8928        workspace_id: WorkspaceId,
8929        path: RepositoryPath,
8930    ) -> Result<WorkspaceEntry, NodeFailure> {
8931        let canonical_root = self.workspace_root(&workspace_id)?;
8932        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8933            failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
8934        })?;
8935        let path_to_create = path.clone();
8936        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
8937        let task_commit_state = Arc::clone(&commit_state);
8938        let task = tokio::task::spawn_blocking(move || {
8939            let _permit = permit;
8940            create_workspace_directory_on_disk(
8941                Path::new(&canonical_root),
8942                &path_to_create,
8943                &task_commit_state,
8944            )
8945        });
8946        settle_workspace_entry_create(
8947            task,
8948            commit_state,
8949            Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
8950            "repository directory create exceeded its bounded deadline",
8951            "repository directory create task failed",
8952        )
8953        .await?;
8954        Ok(WorkspaceEntry {
8955            relative_path: path,
8956            kind: WorkspaceEntryKind::Directory,
8957        })
8958    }
8959
8960    async fn read_git_history(
8961        &self,
8962        workspace_id: WorkspaceId,
8963        path: Option<RepositoryPath>,
8964        before: Option<GitObjectId>,
8965        limit: u16,
8966    ) -> Result<GitHistoryPage, NodeFailure> {
8967        let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8968            failure(NodeFailureCode::BackendBusy, "git history capacity is busy")
8969        })?;
8970        let root = self.workspace_root(&workspace_id)?;
8971        read_git_history_bounded(&root, path.as_ref(), before.as_ref(), limit).await
8972    }
8973
8974    async fn read_git_diff(
8975        &self,
8976        workspace_id: WorkspaceId,
8977        request: GitDiffRequest,
8978    ) -> Result<GitDiff, NodeFailure> {
8979        let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8980            failure(NodeFailureCode::BackendBusy, "git diff capacity is busy")
8981        })?;
8982        let root = self.workspace_root(&workspace_id)?;
8983        read_git_diff_bounded(&root, request).await
8984    }
8985
8986    fn native_session_route_root(
8987        &self,
8988        route: &NativeSessionCatalogRoute,
8989    ) -> Result<Option<String>, NodeFailure> {
8990        route
8991            .validate()
8992            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
8993        match route.scope {
8994            gate4agent_types::NativeSessionCatalogScope::Workspace => route
8995                .workspace_id
8996                .as_ref()
8997                .map(|workspace_id| self.workspace_root(workspace_id))
8998                .transpose(),
8999            gate4agent_types::NativeSessionCatalogScope::Unregistered => Ok(None),
9000        }
9001    }
9002
9003    fn project_native_session_entries(
9004        &self,
9005        route: &NativeSessionCatalogRoute,
9006        entries: Vec<ScopedNativeSessionCatalogEntry>,
9007    ) -> Vec<crate::protocol::NativeSessionCatalogEntry> {
9008        let current_root = route
9009            .workspace_id
9010            .as_ref()
9011            .and_then(|workspace_id| self.workspace_root(workspace_id).ok());
9012        let records = self
9013            .session_records
9014            .lock()
9015            .unwrap_or_else(|poisoned| poisoned.into_inner());
9016        let mut seen_records = std::collections::HashSet::new();
9017        entries
9018            .into_iter()
9019            .map(|entry| {
9020                let record_id = route.workspace_id.as_ref().and_then(|workspace_id| {
9021                    let matches = records
9022                        .records
9023                        .values()
9024                        .filter(|record| {
9025                            record.provider == route.provider
9026                                && record.workspace_id == *workspace_id
9027                                && current_root.as_deref().is_some_and(|root| {
9028                                    platform::roots_equal(
9029                                        root,
9030                                        windows_path_text(&record.canonical_root),
9031                                    )
9032                                })
9033                                && record.provider_session.as_ref().is_some_and(|identity| {
9034                                    session_registry::same_provider_session(
9035                                        &record.provider,
9036                                        identity,
9037                                        &route.provider,
9038                                        &entry.provider_session,
9039                                    )
9040                                })
9041                        })
9042                        .map(|record| record.record_id.clone())
9043                        .collect::<Vec<_>>();
9044                    (matches.len() == 1
9045                        && seen_records.insert(matches[0].clone()))
9046                        .then(|| matches[0].clone())
9047                });
9048                let metadata = entry.metadata;
9049                crate::protocol::NativeSessionCatalogEntry {
9050                    selection_id: metadata.selection_id,
9051                    title: metadata.title,
9052                    modified_at_unix_ms: metadata.modified_at_unix_ms,
9053                    model: metadata.model,
9054                    message_count: metadata.message_count,
9055                    completed_turn_count: metadata.completed_turn_count,
9056                    external_group: entry.external_group,
9057                    record_id,
9058                }
9059            })
9060            .collect()
9061    }
9062
9063    async fn catalog_native_sessions(
9064        &self,
9065        route: NativeSessionCatalogRoute,
9066        limit: u16,
9067    ) -> Result<(
9068        Vec<crate::protocol::NativeSessionCatalogEntry>,
9069        crate::protocol::NativeSessionCatalogSummary,
9070    ), NodeFailure> {
9071        let canonical_root = self.native_session_route_root(&route)?;
9072        let registered_workspace_roots = self.workspace_roots();
9073        let provider = route.provider.clone();
9074        let scope = route.scope;
9075        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9076            failure(
9077                NodeFailureCode::InvalidRequest,
9078                "native session catalog is not configured",
9079            )
9080        })?;
9081        let _permit = Arc::clone(&self.inspection_slots)
9082            .try_acquire_owned()
9083            .map_err(|_| {
9084                failure(
9085                    NodeFailureCode::BackendBusy,
9086                    "native session catalog capacity is busy",
9087                )
9088            })?;
9089        let page = run_native_session_catalog_operation(
9090            catalog,
9091            "native session catalog",
9092            move |catalog| {
9093                catalog.catalog_initial_for_scope(
9094                    &provider,
9095                    scope,
9096                    canonical_root.as_deref().map(Path::new),
9097                    &registered_workspace_roots,
9098                    limit,
9099                )
9100            },
9101        )
9102        .await?;
9103        let summary = crate::protocol::NativeSessionCatalogSummary {
9104            catalog_revision: page.revision,
9105            recent_cutoff_unix_ms: page.cutoff_unix_ms,
9106            recent_total_count: page.recent_total_count,
9107            older_total_count: page.older_total_count,
9108            recent_next_after_selection_id: page.next_after_selection_id,
9109            recent_has_more: page.remaining_count > 0,
9110        };
9111        Ok((self.project_native_session_entries(&route, page.entries), summary))
9112    }
9113
9114    async fn page_native_sessions(
9115        &self,
9116        route: NativeSessionCatalogRoute,
9117        window: crate::protocol::NativeSessionCatalogWindow,
9118        catalog_revision: u64,
9119        recent_cutoff_unix_ms: u64,
9120        after_selection_id: Option<String>,
9121        limit: u16,
9122    ) -> Result<crate::protocol::NativeSessionCatalogPage, NodeFailure> {
9123        let canonical_root = self.native_session_route_root(&route)?;
9124        let registered_workspace_roots = self.workspace_roots();
9125        let provider = route.provider.clone();
9126        let scope = route.scope;
9127        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9128            failure(
9129                NodeFailureCode::InvalidRequest,
9130                "native session catalog is not configured",
9131            )
9132        })?;
9133        let _permit = Arc::clone(&self.inspection_slots)
9134            .try_acquire_owned()
9135            .map_err(|_| {
9136                failure(
9137                    NodeFailureCode::BackendBusy,
9138                    "native session catalog capacity is busy",
9139                )
9140            })?;
9141        let page = run_native_session_catalog_operation(
9142            catalog,
9143            "native session catalog page",
9144            move |catalog| {
9145                Ok::<_, std::convert::Infallible>(catalog.catalog_page_for_scope(
9146                    &provider,
9147                    scope,
9148                    canonical_root.as_deref().map(Path::new),
9149                    &registered_workspace_roots,
9150                    window,
9151                    catalog_revision,
9152                    recent_cutoff_unix_ms,
9153                    after_selection_id.as_deref(),
9154                    limit,
9155                ))
9156            },
9157        )
9158        .await?;
9159        let page = page.map_err(|error| match error {
9160            NativeSessionCatalogError::StaleCatalog => failure(
9161                NodeFailureCode::StaleNativeSessionCatalog,
9162                "native session catalog revision or cursor is stale",
9163            ),
9164            _ => failure(
9165                NodeFailureCode::BackendOperationFailed,
9166                "native session catalog page failed",
9167            ),
9168        })?;
9169        let entries = self.project_native_session_entries(&route, page.entries);
9170        Ok(crate::protocol::NativeSessionCatalogPage {
9171            window: page.window,
9172            revision: page.revision,
9173            entries,
9174            next_after_selection_id: page.next_after_selection_id,
9175            remaining_count: page.remaining_count,
9176            has_more: page.remaining_count > 0,
9177        })
9178    }
9179
9180    async fn preview_native_session(
9181        &self,
9182        selection: NativeSessionSelection,
9183        message_limit: u16,
9184    ) -> Result<crate::protocol::NativeSessionPreview, NodeFailure> {
9185        selection
9186            .validate()
9187            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9188        let canonical_root = self.native_session_route_root(&selection.route)?;
9189        let registered_workspace_roots = self.workspace_roots();
9190        let provider = selection.route.provider.clone();
9191        let scope = selection.route.scope;
9192        let catalog_revision = selection.catalog_revision;
9193        let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9194        let selection_id = selection.selection_id.clone();
9195        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9196            failure(
9197                NodeFailureCode::InvalidRequest,
9198                "native session preview is not configured",
9199            )
9200        })?;
9201        let _permit = Arc::clone(&self.inspection_slots)
9202            .try_acquire_owned()
9203            .map_err(|_| {
9204                failure(
9205                    NodeFailureCode::BackendBusy,
9206                    "native session preview capacity is busy",
9207                )
9208            })?;
9209        let preview = run_native_session_catalog_operation(
9210            catalog,
9211            "native session preview",
9212            move |catalog| {
9213                Ok::<_, std::convert::Infallible>(catalog.preview_for_scope(
9214                    &provider,
9215                    scope,
9216                    canonical_root.as_deref().map(Path::new),
9217                    &registered_workspace_roots,
9218                    catalog_revision,
9219                    recent_cutoff_unix_ms,
9220                    &selection_id,
9221                    message_limit,
9222                ))
9223            },
9224        )
9225        .await?;
9226        preview
9227            .map(Into::into)
9228            .map_err(native_session_preview_failure)
9229    }
9230
9231    async fn index_native_session(
9232        &self,
9233        selection: NativeSessionSelection,
9234        display_name: String,
9235    ) -> Result<ManagedSessionRecord, NodeFailure> {
9236        selection
9237            .validate()
9238            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9239        if selection.route.scope
9240            != gate4agent_types::NativeSessionCatalogScope::Workspace
9241        {
9242            return Err(failure(
9243                NodeFailureCode::WorkspaceRegistrationRequired,
9244                "register the external project as a workspace before indexing or resuming it",
9245            ));
9246        }
9247        let workspace_id = selection
9248            .route
9249            .workspace_id
9250            .clone()
9251            .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9252        let canonical_root = self
9253            .native_session_route_root(&selection.route)?
9254            .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9255        let registered_workspace_roots = self.workspace_roots();
9256        let provider = selection.route.provider.clone();
9257        let catalog_revision = selection.catalog_revision;
9258        let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9259        let selection_id = selection.selection_id.clone();
9260        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9261            failure(
9262                NodeFailureCode::InvalidRequest,
9263                "native session index is not configured",
9264            )
9265        })?;
9266        let _permit = Arc::clone(&self.inspection_slots)
9267            .try_acquire_owned()
9268            .map_err(|_| {
9269                failure(
9270                    NodeFailureCode::BackendBusy,
9271                    "native session index capacity is busy",
9272                )
9273            })?;
9274        let resolved = run_native_session_catalog_operation(
9275            catalog,
9276            "native session index resolution",
9277            move |catalog| {
9278                Ok::<_, std::convert::Infallible>(catalog.resolve_selection_for_scope(
9279                    &provider,
9280                    gate4agent_types::NativeSessionCatalogScope::Workspace,
9281                    Some(Path::new(&canonical_root)),
9282                    &registered_workspace_roots,
9283                    catalog_revision,
9284                    recent_cutoff_unix_ms,
9285                    &selection_id,
9286                ))
9287            },
9288        )
9289        .await?
9290        .map_err(native_session_preview_failure)?;
9291        self.index_provider_session_with_policy(
9292            workspace_id,
9293            selection.route.provider,
9294            resolved.identity,
9295            display_name,
9296            true,
9297        )
9298    }
9299
9300    async fn preview_native_session_by_session_id(
9301        &self,
9302        workspace_id: WorkspaceId,
9303        provider: AgentId,
9304        session_id: String,
9305        message_limit: u16,
9306    ) -> Result<gate4agent_types::NativeSessionPreview, NodeFailure> {
9307        let canonical_root = self.workspace_root(&workspace_id)?;
9308        let registered_workspace_roots = self.workspace_roots();
9309        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9310            failure(
9311                NodeFailureCode::InvalidRequest,
9312                "native session preview is not configured",
9313            )
9314        })?;
9315        let _permit = Arc::clone(&self.inspection_slots)
9316            .try_acquire_owned()
9317            .map_err(|_| {
9318                failure(
9319                    NodeFailureCode::BackendBusy,
9320                    "native session preview capacity is busy",
9321                )
9322            })?;
9323        run_native_session_catalog_operation(
9324            catalog,
9325            "native session preview",
9326            move |catalog| {
9327                catalog.preview_session_id_for_workspace(
9328                    &provider,
9329                    Path::new(&canonical_root),
9330                    &registered_workspace_roots,
9331                    &session_id,
9332                    message_limit,
9333                )
9334            },
9335        )
9336        .await
9337    }
9338
9339    async fn register_workspace(
9340        &self,
9341        workspace_id: WorkspaceId,
9342        root: String,
9343    ) -> Result<WorkspaceSnapshot, NodeFailure> {
9344        validate_workspace_request_root(&root)?;
9345        let canonicalize_workspace_id = workspace_id.clone();
9346        let workspace = tokio::task::spawn_blocking(move || {
9347            WorkspaceConfig::new(canonicalize_workspace_id, root)
9348        })
9349        .await
9350        .map_err(|error| {
9351            failure(
9352                NodeFailureCode::BackendOperationFailed,
9353                &format!("workspace canonicalization task failed: {error}"),
9354            )
9355        })?
9356        .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
9357        let transaction = self
9358            .state_transaction
9359            .lock()
9360            .unwrap_or_else(|poisoned| poisoned.into_inner());
9361        let mut workspaces = self
9362            .workspaces
9363            .write()
9364            .unwrap_or_else(|poisoned| poisoned.into_inner());
9365        if workspaces.contains_key(&workspace_id) {
9366            return Err(failure(
9367                NodeFailureCode::DuplicateWorkspaceId,
9368                "workspace ID is already registered",
9369            ));
9370        }
9371        if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
9372            platform::roots_equal(existing_root, workspace.canonical_root())
9373        }) {
9374            return Err(failure(
9375                NodeFailureCode::DuplicateWorkspaceRoot,
9376                &format!("workspace root is already registered as '{existing_id}'"),
9377            ));
9378        }
9379        {
9380            let records = self
9381                .session_records
9382                .lock()
9383                .unwrap_or_else(|poisoned| poisoned.into_inner());
9384            if let Some(record) = records.records.values().find(|record| {
9385                record.workspace_id == workspace_id
9386                    && !record
9387                        .canonical_root
9388                        .as_utf8()
9389                        .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9390            }) {
9391                return Err(failure(
9392                    NodeFailureCode::SessionRecordConflict,
9393                    &format!(
9394                        "workspace ID is retained by session record '{}' at another root",
9395                        record.record_id,
9396                    ),
9397                ));
9398            }
9399        }
9400        let snapshot = WorkspaceSnapshot {
9401            workspace_id: workspace_id.clone(),
9402            canonical_root: opaque_windows_path(workspace.canonical_root().to_owned()),
9403            sessions: Vec::new(),
9404            worktree_service_mode: Some(WorktreeServiceMode::Manual),
9405            managed_worktree_profiles: Some(WorktreeProfileInventory {
9406                profiles: Vec::new(),
9407            }),
9408        };
9409        workspaces.insert(workspace_id.clone(), workspace.canonical_root().to_owned());
9410        drop(workspaces);
9411        let previous_records = {
9412            let mut records = self
9413                .session_records
9414                .lock()
9415                .unwrap_or_else(|poisoned| poisoned.into_inner());
9416            let previous = records.records.clone();
9417            for record in records.records.values_mut().filter(|record| {
9418                record.workspace_id == workspace_id
9419                    && record
9420                        .canonical_root
9421                        .as_utf8()
9422                        .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9423            }) {
9424                if record.active_session.is_none() {
9425                    record.state = detached_record_state(record);
9426                }
9427                if record.last_error.as_deref() == Some(WORKSPACE_UNAVAILABLE_ERROR) {
9428                    record.last_error = None;
9429                }
9430                record.updated_at_unix_ms = unix_time_ms();
9431            }
9432            previous
9433        };
9434        if let Err(error) = self.persist_state_locked() {
9435            self.workspaces
9436                .write()
9437                .unwrap_or_else(|poisoned| poisoned.into_inner())
9438                .remove(&workspace_id);
9439            self.session_records
9440                .lock()
9441                .unwrap_or_else(|poisoned| poisoned.into_inner())
9442                .records = previous_records;
9443            return Err(persistence_failure(error));
9444        }
9445        let restored = self
9446            .session_records
9447            .lock()
9448            .unwrap_or_else(|poisoned| poisoned.into_inner())
9449            .records
9450            .values()
9451            .filter(|record| record.workspace_id == workspace_id)
9452            .cloned()
9453            .collect::<Vec<_>>();
9454        drop(transaction);
9455        self.publish(NodeEvent::WorkspaceAdded {
9456            workspace: snapshot.clone(),
9457        });
9458        for record in restored {
9459            self.publish_record(record);
9460        }
9461        Ok(snapshot)
9462    }
9463
9464    async fn create_standalone_workspace(
9465        &self,
9466        workspace_id: WorkspaceId,
9467        root: String,
9468        initial_branch: Option<String>,
9469    ) -> Result<WorkspaceSnapshot, NodeFailure> {
9470        validate_workspace_request_root(&root)?;
9471        validate_git_revision("initial branch", initial_branch.as_deref())?;
9472        let preflight_workspace_id = workspace_id.clone();
9473        let preflight_root = root.clone();
9474        let canonical_candidate = tokio::task::spawn_blocking(move || {
9475            canonical_standalone_workspace_candidate(&preflight_workspace_id, &preflight_root)
9476        })
9477        .await
9478        .map_err(|_| failure(
9479            NodeFailureCode::BackendOperationFailed,
9480            "standalone workspace root preflight task failed",
9481        ))?
9482        .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
9483        {
9484            let workspaces = self
9485                .workspaces
9486                .read()
9487                .unwrap_or_else(|poisoned| poisoned.into_inner());
9488            if workspaces.contains_key(&workspace_id) {
9489                return Err(failure(
9490                    NodeFailureCode::DuplicateWorkspaceId,
9491                    "workspace ID is already registered",
9492                ));
9493            }
9494            if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
9495                platform::roots_equal(existing_root, &canonical_candidate)
9496            }) {
9497                return Err(failure(
9498                    NodeFailureCode::DuplicateWorkspaceRoot,
9499                    &format!("workspace root is already registered as '{existing_id}'"),
9500                ));
9501            }
9502        }
9503        let prepared = prepare_standalone_workspace(root, initial_branch.as_deref())
9504            .await
9505            .map_err(standalone_workspace_failure)?;
9506        prepared
9507            .verify_for_registration()
9508            .await
9509            .map_err(standalone_workspace_failure)?;
9510        match self
9511            .register_workspace(workspace_id, prepared.root().to_owned())
9512            .await
9513        {
9514            Ok(workspace) => Ok(workspace),
9515            Err(registration_error) => {
9516                if prepared.compensate_registration_failure().await {
9517                    Err(registration_error)
9518                } else {
9519                    Err(failure(
9520                        NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
9521                        "standalone workspace initialization succeeded but registration failed; recovery is required",
9522                    ))
9523                }
9524            }
9525        }
9526    }
9527
9528    fn unregister_workspace(&self, workspace_id: &WorkspaceId) -> Result<(), NodeFailure> {
9529        let bindings = self
9530            .session_bindings
9531            .lock()
9532            .unwrap_or_else(|poisoned| poisoned.into_inner());
9533        if bindings.values().any(|binding| &binding.workspace_id == workspace_id) {
9534            return Err(failure(
9535                NodeFailureCode::WorkspaceBusy,
9536                "workspace retains a bound session; remove every session before unregistering it",
9537            ));
9538        }
9539        drop(bindings);
9540        let transaction = self
9541            .state_transaction
9542            .lock()
9543            .unwrap_or_else(|poisoned| poisoned.into_inner());
9544        let mut workspaces = self
9545            .workspaces
9546            .write()
9547            .unwrap_or_else(|poisoned| poisoned.into_inner());
9548        if !workspaces.contains_key(workspace_id) {
9549            return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
9550        }
9551        if workspaces.len() == 1 {
9552            return Err(failure(
9553                NodeFailureCode::LastWorkspace,
9554                "node must retain at least one workspace",
9555            ));
9556        }
9557        let removed_root = workspaces
9558            .remove(workspace_id)
9559            .expect("workspace presence was validated");
9560        drop(workspaces);
9561        let previous_records = {
9562            let mut records = self
9563                .session_records
9564                .lock()
9565                .unwrap_or_else(|poisoned| poisoned.into_inner());
9566            let previous = records.records.clone();
9567            for record in records
9568                .records
9569                .values_mut()
9570                .filter(|record| &record.workspace_id == workspace_id)
9571            {
9572                record.active_session = None;
9573                record.state = ManagedSessionState::Unavailable;
9574                record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
9575                record.updated_at_unix_ms = unix_time_ms();
9576            }
9577            previous
9578        };
9579        if let Err(error) = self.persist_state_locked() {
9580            self.workspaces
9581                .write()
9582                .unwrap_or_else(|poisoned| poisoned.into_inner())
9583                .insert(workspace_id.clone(), removed_root);
9584            self.session_records
9585                .lock()
9586                .unwrap_or_else(|poisoned| poisoned.into_inner())
9587                .records = previous_records;
9588            return Err(persistence_failure(error));
9589        }
9590        let unavailable = self
9591            .session_records
9592            .lock()
9593            .unwrap_or_else(|poisoned| poisoned.into_inner())
9594            .records
9595            .values()
9596            .filter(|record| &record.workspace_id == workspace_id)
9597            .cloned()
9598            .collect::<Vec<_>>();
9599        drop(transaction);
9600        self.publish(NodeEvent::WorkspaceRemoved {
9601            workspace_id: workspace_id.clone(),
9602        });
9603        for record in unavailable {
9604            self.publish_record(record);
9605        }
9606        Ok(())
9607    }
9608
9609    async fn create_worktree(
9610        &self,
9611        source_workspace_id: WorkspaceId,
9612        workspace_id: WorkspaceId,
9613        target_root: String,
9614        branch: String,
9615        base: Option<String>,
9616    ) -> Result<(GitWorktreeSnapshot, WorkspaceSnapshot), NodeFailure> {
9617        self.require_worktree_service(&source_workspace_id)?;
9618        validate_workspace_request_root(&target_root)?;
9619        validate_git_revision("worktree base", base.as_deref())?;
9620        let source_root = self.workspace_root(&source_workspace_id)?;
9621        {
9622            let workspaces = self
9623                .workspaces
9624                .read()
9625                .unwrap_or_else(|poisoned| poisoned.into_inner());
9626            if workspaces.contains_key(&workspace_id) {
9627                return Err(failure(
9628                    NodeFailureCode::DuplicateWorkspaceId,
9629                    "workspace ID is already registered",
9630                ));
9631            }
9632            if let Some((existing_id, _)) = workspaces
9633                .iter()
9634                .find(|(_, root)| worktree_paths_equal(root, &target_root))
9635            {
9636                return Err(failure(
9637                    NodeFailureCode::DuplicateWorkspaceRoot,
9638                    &format!("workspace root is already registered as '{existing_id}'"),
9639                ));
9640            }
9641        }
9642        let mut worktree = create_git_worktree(
9643            &source_root,
9644            &target_root,
9645            &branch,
9646            base.as_deref(),
9647        )
9648        .await
9649        .map_err(git_worktree_failure)?;
9650        let workspace = match self
9651            .register_workspace(workspace_id, worktree.path.clone())
9652            .await
9653        {
9654            Ok(workspace) => workspace,
9655            Err(registration_error) => {
9656                if remove_git_worktree(&source_root, &worktree.path).await.is_err() {
9657                    return Err(failure(
9658                        NodeFailureCode::ManagedWorktreeRecoveryRequired,
9659                        "worktree registration failed and compensation requires recovery",
9660                    ));
9661                }
9662                return Err(failure(
9663                    registration_error.code,
9664                    &registration_error.message,
9665                ));
9666            }
9667        };
9668        worktree.workspace_id = Some(workspace.workspace_id.clone());
9669        Ok((protocol_worktree(worktree), workspace))
9670    }
9671
9672    async fn remove_worktree(
9673        &self,
9674        source_workspace_id: WorkspaceId,
9675        target_root: String,
9676    ) -> Result<Option<WorkspaceId>, NodeFailure> {
9677        self.require_worktree_service(&source_workspace_id)?;
9678        validate_workspace_request_root(&target_root)?;
9679        let source_root = self.workspace_root(&source_workspace_id)?;
9680        let target_root = normalize_worktree_removal_target(&target_root)
9681            .map_err(git_worktree_failure)?;
9682        let target = list_git_worktrees(&source_root)
9683            .await
9684            .map_err(git_worktree_failure)?
9685            .into_iter()
9686            .find(|worktree| worktree_paths_equal(&worktree.path, &target_root))
9687            .ok_or_else(|| {
9688                failure(
9689                    NodeFailureCode::WorktreeProtected,
9690                    "refusing to remove a path that is not in Git's worktree listing",
9691                )
9692            })?;
9693        let registered_workspace_id = {
9694            let workspaces = self
9695                .workspaces
9696                .read()
9697                .unwrap_or_else(|poisoned| poisoned.into_inner());
9698            let matched = workspaces
9699                .iter()
9700                .find(|(_, root)| worktree_paths_equal(root, &target.path))
9701                .map(|(workspace_id, _)| workspace_id.clone());
9702            if matched.is_some() && workspaces.len() == 1 {
9703                return Err(failure(
9704                    NodeFailureCode::LastWorkspace,
9705                    "node must retain at least one workspace",
9706                ));
9707            }
9708            matched
9709        };
9710        if let Some(workspace_id) = registered_workspace_id.as_ref() {
9711            let bindings = self
9712                .session_bindings
9713                .lock()
9714                .unwrap_or_else(|poisoned| poisoned.into_inner());
9715            if bindings
9716                .values()
9717                .any(|binding| &binding.workspace_id == workspace_id)
9718            {
9719                return Err(failure(
9720                    NodeFailureCode::WorkspaceBusy,
9721                    "worktree workspace retains a bound session; remove every session first",
9722                ));
9723            }
9724        }
9725        remove_git_worktree(&source_root, &target.path)
9726            .await
9727            .map_err(git_worktree_failure)?;
9728        if let Some(workspace_id) = registered_workspace_id.as_ref() {
9729            self.unregister_workspace(workspace_id)
9730                .map_err(|error| failure(error.code, &error.message))?;
9731        }
9732        Ok(registered_workspace_id)
9733    }
9734
9735    async fn cleanup_managed_worktree(
9736        &self,
9737        lease_id: &ManagedWorktreeLeaseId,
9738        explicit: bool,
9739    ) -> Result<ManagedWorktreeLeaseSnapshot, NodeFailure> {
9740        let lease = self.managed_worktrees.lock()
9741            .unwrap_or_else(|poisoned| poisoned.into_inner())
9742            .get(lease_id)
9743            .cloned()
9744            .ok_or_else(|| failure(
9745                NodeFailureCode::UnknownManagedWorktreeLease,
9746                "managed worktree lease does not exist",
9747            ))?;
9748        if lease.state == ManagedWorktreeLeaseState::Removed {
9749            return Ok(lease.snapshot());
9750        }
9751        if lease.has_holders() {
9752            return Err(failure(
9753                NodeFailureCode::ManagedWorktreeBusy,
9754                "managed worktree lease still has a session or durable-record holder",
9755            ));
9756        }
9757        if self
9758            .materializations
9759            .lock()
9760            .unwrap_or_else(|poisoned| poisoned.into_inner())
9761            .values()
9762            .any(|record| record.managed_lease_id() == Some(lease_id))
9763        {
9764            return Err(failure(
9765                NodeFailureCode::ManagedWorktreeRecoveryRequired,
9766                "managed worktree cleanup is blocked by session environment ownership",
9767            ));
9768        }
9769        if !explicit && lease.retention == ManagedWorktreeRetention::Retain {
9770            let snapshot = {
9771                let mut registry = self.managed_worktrees.lock()
9772                    .unwrap_or_else(|poisoned| poisoned.into_inner());
9773                let current = registry.get_mut(lease_id)
9774                    .expect("managed worktree lease remains present");
9775                current.state = ManagedWorktreeLeaseState::Retained;
9776                current.cleanup_failure = None;
9777                current.updated_at_unix_ms = unix_time_ms();
9778                current.snapshot()
9779            };
9780            if self.persist_state().is_err() {
9781                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
9782            }
9783            self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
9784            return Ok(snapshot);
9785        }
9786        let profile = self.managed_profile(&lease.source_workspace_id, &lease.profile_id)?;
9787        if profile.revision() != &lease.profile_revision {
9788            self.mark_managed_recovery_required(
9789                lease_id,
9790                ManagedWorktreeCleanupFailure::OwnershipConflict,
9791            );
9792            return Err(failure(
9793                NodeFailureCode::ManagedWorktreeRecoveryRequired,
9794                "managed worktree profile revision changed",
9795            ));
9796        }
9797        profile.validate_target_authority(&lease.lease_id, &lease.target_root).map_err(|message| {
9798            self.mark_managed_recovery_required(
9799                lease_id,
9800                ManagedWorktreeCleanupFailure::OwnershipConflict,
9801            );
9802            failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message)
9803        })?;
9804        let source_root = self.workspace_root(&lease.source_workspace_id)?;
9805        let listed = list_git_worktrees(&source_root).await.map_err(|error| {
9806            self.mark_managed_cleanup_blocked(lease_id, ManagedWorktreeCleanupFailure::Backend);
9807            managed_git_worktree_failure(error)
9808        })?;
9809        let target = listed.iter()
9810            .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
9811        if let Some(target) = target {
9812            if target.is_main || target.is_bare || !exact_owned_worktree(
9813                &lease,
9814                &target.path,
9815                target.branch.as_deref(),
9816                &target.head,
9817            ) {
9818                self.mark_managed_recovery_required(
9819                    lease_id,
9820                    ManagedWorktreeCleanupFailure::OwnershipConflict,
9821                );
9822                return Err(failure(
9823                    NodeFailureCode::ManagedWorktreeOwnershipConflict,
9824                    "Git worktree identity no longer matches the managed lease",
9825                ));
9826            }
9827            if target.prunable {
9828                self.mark_managed_cleanup_blocked(
9829                    lease_id,
9830                    ManagedWorktreeCleanupFailure::Prunable,
9831                );
9832                return Err(failure(
9833                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
9834                    "managed worktree is prunable and requires recovery",
9835                ));
9836            }
9837            if let Err(error) = remove_git_worktree(&source_root, &target.path).await {
9838                let failure_kind = match error.kind {
9839                    GitWorktreeErrorKind::Dirty => ManagedWorktreeCleanupFailure::Dirty,
9840                    GitWorktreeErrorKind::Locked => ManagedWorktreeCleanupFailure::Locked,
9841                    GitWorktreeErrorKind::Protected => ManagedWorktreeCleanupFailure::OwnershipConflict,
9842                    GitWorktreeErrorKind::Conflict => ManagedWorktreeCleanupFailure::Busy,
9843                    GitWorktreeErrorKind::Invalid | GitWorktreeErrorKind::NotRepository
9844                    | GitWorktreeErrorKind::Failed => ManagedWorktreeCleanupFailure::Backend,
9845                };
9846                if failure_kind == ManagedWorktreeCleanupFailure::OwnershipConflict {
9847                    self.mark_managed_recovery_required(lease_id, failure_kind);
9848                } else {
9849                    self.mark_managed_cleanup_blocked(lease_id, failure_kind);
9850                }
9851                return Err(managed_git_worktree_failure(error));
9852            }
9853        } else {
9854            match std::fs::symlink_metadata(&lease.target_root) {
9855                Err(error) if error.kind() == io::ErrorKind::NotFound => {}
9856                Ok(_) => {
9857                    self.mark_managed_recovery_required(
9858                        lease_id,
9859                        ManagedWorktreeCleanupFailure::OwnershipConflict,
9860                    );
9861                    return Err(failure(
9862                        NodeFailureCode::ManagedWorktreeOwnershipConflict,
9863                        "managed target exists but is not owned by Git",
9864                    ));
9865                }
9866                Err(_) => {
9867                    self.mark_managed_cleanup_blocked(
9868                        lease_id,
9869                        ManagedWorktreeCleanupFailure::Backend,
9870                    );
9871                    return Err(failure(
9872                        NodeFailureCode::BackendOperationFailed,
9873                        "managed target identity could not be inspected",
9874                    ));
9875                }
9876            }
9877        }
9878        let registered = self.workspaces.read()
9879            .unwrap_or_else(|poisoned| poisoned.into_inner())
9880            .contains_key(&lease.workspace_id);
9881        if registered {
9882            self.unregister_workspace(&lease.workspace_id)?;
9883        }
9884        let removed = {
9885            self.managed_worktrees.lock()
9886                .unwrap_or_else(|poisoned| poisoned.into_inner())
9887                .tombstone(lease_id, unix_time_ms())
9888                .expect("managed worktree lease remains present during cleanup")
9889        };
9890        self.persist_state().map_err(persistence_failure)?;
9891        self.publish(NodeEvent::ManagedWorktreeRemoved { lease_id: lease_id.clone() });
9892        Ok(removed.snapshot())
9893    }
9894
9895    fn mark_managed_cleanup_blocked(
9896        &self,
9897        lease_id: &ManagedWorktreeLeaseId,
9898        failure_kind: ManagedWorktreeCleanupFailure,
9899    ) {
9900        let snapshot = {
9901            let mut registry = self.managed_worktrees.lock()
9902                .unwrap_or_else(|poisoned| poisoned.into_inner());
9903            let Some(lease) = registry.get_mut(lease_id) else { return };
9904            lease.state = ManagedWorktreeLeaseState::CleanupBlocked;
9905            lease.cleanup_failure = Some(failure_kind);
9906            lease.updated_at_unix_ms = unix_time_ms();
9907            lease.snapshot()
9908        };
9909        let _ = self.persist_state();
9910        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
9911    }
9912
9913    fn require_worktree_service(
9914        &self,
9915        source_workspace_id: &WorkspaceId,
9916    ) -> Result<(), NodeFailure> {
9917        if self.managed_worktrees.lock()
9918            .unwrap_or_else(|poisoned| poisoned.into_inner())
9919            .lease_for_workspace(source_workspace_id)
9920            .is_some()
9921        {
9922            return Err(failure(
9923                NodeFailureCode::ManagedWorktreeOwnershipConflict,
9924                "managed worktree targets are internal-only workspaces",
9925            ));
9926        }
9927        match self
9928            .worktree_service_modes
9929            .get(source_workspace_id)
9930            .copied()
9931            .unwrap_or(WorktreeServiceMode::Manual)
9932        {
9933            WorktreeServiceMode::Manual => Ok(()),
9934            WorktreeServiceMode::Managed | WorktreeServiceMode::Off => Err(failure(
9935                NodeFailureCode::UnsupportedSpawnCapability,
9936                "Git worktree service mode is unavailable in this node build",
9937            )),
9938        }
9939    }
9940
9941    fn managed_reservation_conflict(
9942        &self,
9943        workspace_id: Option<&WorkspaceId>,
9944        root: Option<&str>,
9945    ) -> bool {
9946        self.managed_worktrees.lock()
9947            .unwrap_or_else(|poisoned| poisoned.into_inner())
9948            .active_records()
9949            .any(|lease| {
9950                workspace_id.is_some_and(|workspace_id| {
9951                    workspace_id == &lease.source_workspace_id
9952                        || workspace_id == &lease.workspace_id
9953                }) || root.is_some_and(|root| worktree_paths_equal(root, &lease.target_root))
9954            })
9955    }
9956
9957    fn reject_managed_reservation(
9958        &self,
9959        workspace_id: Option<&WorkspaceId>,
9960        root: Option<&str>,
9961    ) -> Result<(), NodeFailure> {
9962        if self.managed_reservation_conflict(workspace_id, root) {
9963            Err(failure(
9964                NodeFailureCode::ManagedWorktreeOwnershipConflict,
9965                "managed worktree ownership may only be mutated through its lease cleanup",
9966            ))
9967        } else {
9968            Ok(())
9969        }
9970    }
9971
9972    fn bound_workspace_root(&self, address: &SessionAddress) -> Result<String, NodeFailure> {
9973        self.validate_address(address)?;
9974        self.workspace_root(&address.workspace_id)
9975    }
9976
9977    /// `Resume`/`ResumeWithPrompt` are always a raw-PTY relaunch (see
9978    /// `require_policy`'s own doc on those two requirements), so they keep
9979    /// gating on `binding.runtime_policy`'s PTY-terminal-text-inference
9980    /// flags exactly as before; `pipe_transport`/`acp_transport` stay
9981    /// `false` on the `require_policy` call below for them because a PTY
9982    /// resume never needs those facts, not because this method cannot look
9983    /// them up.
9984    ///
9985    /// `SemanticPrompt` -- the requirement `Prompt`/`Paste` share -- reads
9986    /// the SAME `binding.runtime_policy` every other requirement reads here
9987    /// (one computed policy, read by every gate). That policy is no longer
9988    /// PTY-terminal-verification-shaped for an ACP session --
9989    /// `spawn_session_with_deadline` computes it BY TRANSPORT now, through
9990    /// `provider_runtime::policy_for_transport` -- so consulting it for
9991    /// `Prompt`/`Paste` on ACP is sound where it used to gate the wrong fact
9992    /// (this is what made an ACP `Prompt` come back `UnsupportedCapability`
9993    /// even though a prompt is an ACP session's only interface). Pipe keeps
9994    /// reading the same PTY-probe-derived value it always has --
9995    /// `policy_for_transport` passes Pipe through unchanged this pass (see
9996    /// its own doc for why).
9997    ///
9998    /// What this method still adds on top, for ACP/Pipe only, is a
9999    /// turn-in-flight PRECONDITION ahead of that policy check: a session
10000    /// takes one turn at a time, so a `Prompt`/`Paste` is refused by name
10001    /// (`NodeFailureCode::TurnInFlight`) while a turn is already running,
10002    /// read off the same live `SessionSnapshot` state `require_acp_session`
10003    /// already reads for the ACP control verbs -- `TurnStarted` observed
10004    /// with no matching `TurnCompleted` yet is exactly `ProviderSnapshot::
10005    /// lead_activity != Idle`. Sending a second prompt mid-turn would put
10006    /// its fate in vendor-specific behaviour this node cannot observe (one
10007    /// agent may queue it, another drop it, another interleave it), so this
10008    /// refuses it outright rather than gambling on that -- a rule with no
10009    /// PTY analogue, which is why PTY does not take this extra precondition.
10010    fn require_session_runtime_policy(
10011        &self,
10012        address: &SessionAddress,
10013        requirement: ProviderRuntimeRequirement,
10014    ) -> Result<ProviderRuntimePolicy, NodeFailure> {
10015        let runtime_policy = {
10016            let bindings = self
10017                .session_bindings
10018                .lock()
10019                .unwrap_or_else(|poisoned| poisoned.into_inner());
10020            let binding = bindings
10021                .get(&address.session.instance_id)
10022                .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10023            if binding.workspace_id != address.workspace_id {
10024                return Err(failure(
10025                    NodeFailureCode::SessionWorkspaceMismatch,
10026                    "session belongs to another workspace",
10027                ));
10028            }
10029            if binding.generation != address.session.generation {
10030                return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10031            }
10032            binding.runtime_policy
10033        };
10034        if requirement == ProviderRuntimeRequirement::SemanticPrompt {
10035            let snapshot = self.internal_session_snapshot(address)?;
10036            if matches!(snapshot.transport, TransportKind::Acp | TransportKind::Pipe)
10037                && snapshot.provider.lead_activity != ProviderActivity::Idle
10038            {
10039                return Err(failure(
10040                    NodeFailureCode::TurnInFlight,
10041                    "session already has a turn in flight; wait for it to complete before sending another prompt",
10042                ));
10043            }
10044        }
10045        require_policy(runtime_policy, false, false, requirement)
10046            .map(|()| runtime_policy)
10047            .map_err(|_| failure(
10048                NodeFailureCode::UnsupportedCapability,
10049                "session runtime policy does not admit this semantic operation",
10050            ))
10051    }
10052
10053    fn remove_binding(&self, address: &SessionAddress) -> Option<SessionBinding> {
10054        let mut bindings = self
10055            .session_bindings
10056            .lock()
10057            .unwrap_or_else(|poisoned| poisoned.into_inner());
10058        if bindings.get(&address.session.instance_id).is_some_and(|binding| {
10059            binding.workspace_id == address.workspace_id
10060                && binding.generation == address.session.generation
10061        }) {
10062            let removed = bindings.remove(&address.session.instance_id);
10063            self.clear_terminal_frame_watermark(address);
10064            SessionLifecycleHook::on_binding_removed(self, address, removed.as_ref());
10065            return removed;
10066        }
10067        None
10068    }
10069
10070    fn cleanup_session_owned_materialization(
10071        &self,
10072        address: &SessionAddress,
10073        binding: &SessionBinding,
10074    ) -> Result<(), NodeFailure> {
10075        let Some(id) = binding.materialization_id.as_ref() else {
10076            return Ok(());
10077        };
10078        let owner = self
10079            .materializations
10080            .lock()
10081            .unwrap_or_else(|poisoned| poisoned.into_inner())
10082            .get(id)
10083            .map(|record| record.owner().clone());
10084        match owner {
10085            None | Some(MaterializationOwner::Record { .. }) => Ok(()),
10086            Some(MaterializationOwner::Session {
10087                incarnation_id,
10088                instance_id,
10089                generation,
10090            }) if incarnation_id == self.incarnation_id
10091                && instance_id == address.session.instance_id
10092                && generation == address.session.generation =>
10093            {
10094                self.cleanup_materialization(id)
10095            }
10096            Some(MaterializationOwner::Session { .. }) => Err(failure(
10097                NodeFailureCode::BackendOperationFailed,
10098                "session environment ownership requires recovery",
10099            )),
10100        }
10101    }
10102
10103    fn clear_terminal_frame_watermark(&self, address: &SessionAddress) {
10104        let mut watermarks = self
10105            .terminal_frame_watermarks
10106            .lock()
10107            .unwrap_or_else(|poisoned| poisoned.into_inner());
10108        if watermarks
10109            .get(&address.session.instance_id)
10110            .is_some_and(|(current, _)| current == address)
10111        {
10112            watermarks.remove(&address.session.instance_id);
10113        }
10114    }
10115
10116    #[cfg(test)]
10117    fn address_for(
10118        &self,
10119        instance_id: AgentInstanceId,
10120        generation: SessionGeneration,
10121    ) -> Option<SessionAddress> {
10122        let binding = self.session_bindings
10123            .lock()
10124            .unwrap_or_else(|poisoned| poisoned.into_inner())
10125            .get(&instance_id)
10126            .cloned()?;
10127        (binding.generation == generation).then_some(SessionAddress {
10128            workspace_id: binding.workspace_id,
10129            session: SessionKey { instance_id, generation },
10130        })
10131    }
10132
10133    fn arm_resume(
10134        &self,
10135        address: &SessionAddress,
10136        command_id: CommandId,
10137        runtime_policy: ProviderRuntimePolicy,
10138    ) -> Result<(), NodeFailure> {
10139        let mut bindings = self
10140            .session_bindings
10141            .lock()
10142            .unwrap_or_else(|poisoned| poisoned.into_inner());
10143        let binding = bindings
10144            .get_mut(&address.session.instance_id)
10145            .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10146        if binding.workspace_id != address.workspace_id {
10147            return Err(failure(
10148                NodeFailureCode::SessionWorkspaceMismatch,
10149                "session belongs to another workspace",
10150            ));
10151        }
10152        if binding.generation != address.session.generation {
10153            return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10154        }
10155        binding.pending_resume = Some((binding.generation, command_id, runtime_policy));
10156        Ok(())
10157    }
10158
10159    fn clear_armed_resume(&self, address: &SessionAddress) {
10160        let mut bindings = self
10161            .session_bindings
10162            .lock()
10163            .unwrap_or_else(|poisoned| poisoned.into_inner());
10164        if let Some(binding) = bindings.get_mut(&address.session.instance_id) {
10165            if binding.workspace_id == address.workspace_id
10166                && binding.generation == address.session.generation
10167            {
10168                binding.pending_resume = None;
10169            }
10170        }
10171    }
10172
10173    fn publish_control(&self, event: ControlEvent) {
10174        let revokes_harness_mcp = matches!(
10175            &event.event,
10176            ControlEventKind::Exited { .. }
10177                | ControlEventKind::Failed { .. }
10178                | ControlEventKind::Removed
10179        );
10180        let (address, previous_address, record_id, identity_admitted, resume_command_rejected) = {
10181            let mut bindings = self
10182                .session_bindings
10183                .lock()
10184                .unwrap_or_else(|poisoned| poisoned.into_inner());
10185            let Some(binding) = bindings.get_mut(&event.instance_id) else {
10186                return;
10187            };
10188            let previous_address = SessionAddress {
10189                workspace_id: binding.workspace_id.clone(),
10190                session: SessionKey {
10191                    instance_id: event.instance_id,
10192                    generation: binding.generation,
10193                },
10194            };
10195            let mut resume_command_rejected = false;
10196            if binding.generation == event.generation {
10197                if let Some((pending_generation, pending_command_id, _)) = binding.pending_resume {
10198                    let rejected_resume = matches!(
10199                        &event.event,
10200                        ControlEventKind::CommandRejected { .. }
10201                    ) && event.command_id == Some(pending_command_id);
10202                    let resume_failed = matches!(
10203                        &event.event,
10204                        ControlEventKind::ResumeDenied { .. }
10205                            | ControlEventKind::ResumeFailed { .. }
10206                    );
10207                    if pending_generation == binding.generation
10208                        && (rejected_resume || resume_failed)
10209                    {
10210                        resume_command_rejected = rejected_resume;
10211                        binding.pending_resume = None;
10212                    }
10213                }
10214            } else {
10215                let expected = binding.generation.0.checked_add(1);
10216                let authorized = matches!(&event.event, ControlEventKind::ResumeAuthorized { .. });
10217                if !binding.pending_resume.is_some_and(|(generation, _, _)| {
10218                    generation == binding.generation
10219                })
10220                    || expected != Some(event.generation.0)
10221                    || !authorized
10222                {
10223                    return;
10224                }
10225                let (_, _, runtime_policy) = binding
10226                    .pending_resume
10227                    .expect("authorized resume retains its pending runtime policy");
10228                binding.generation = event.generation;
10229                binding.runtime_policy = runtime_policy;
10230                binding.pending_resume = None;
10231            }
10232            (
10233                SessionAddress {
10234                    workspace_id: binding.workspace_id.clone(),
10235                    session: SessionKey {
10236                        instance_id: event.instance_id,
10237                        generation: event.generation,
10238                    },
10239                },
10240                (previous_address.session.generation != event.generation)
10241                    .then_some(previous_address),
10242                binding.record_id.clone(),
10243                binding.runtime_policy.provider_session_identity,
10244                resume_command_rejected,
10245            )
10246        };
10247        if let Some(previous_address) = previous_address.as_ref() {
10248            self.clear_terminal_frame_watermark(previous_address);
10249        }
10250        if let Some(record_id) = record_id {
10251            self.reconcile_managed_record(
10252                &record_id,
10253                &address,
10254                &event,
10255                identity_admitted,
10256                resume_command_rejected,
10257            );
10258        }
10259        // Every state change a session's process lifecycle can reach gets a
10260        // line here: this is the single chokepoint every `ControlEventKind`
10261        // passes through regardless of transport (PTY/pipe/ACP) or whether it
10262        // came from a fresh spawn or a resume. Per-message provider chatter
10263        // (`ProviderEvent`/`ProviderGap`/terminal frames) is deliberately not
10264        // matched here -- that is exactly the per-frame noise this crate
10265        // family's level discipline forbids at INFO.
10266        match &event.event {
10267            ControlEventKind::Running { process_id } => {
10268                tracing::info!(
10269                    node_id = %self.node_id,
10270                    workspace_id = %address.workspace_id,
10271                    session = ?address.session,
10272                    pid = ?process_id,
10273                    "session process is running",
10274                );
10275            }
10276            ControlEventKind::Exited { exit_code, forced } => {
10277                tracing::info!(
10278                    node_id = %self.node_id,
10279                    workspace_id = %address.workspace_id,
10280                    session = ?address.session,
10281                    exit_code = ?exit_code,
10282                    forced = forced,
10283                    "session process exited",
10284                );
10285            }
10286            ControlEventKind::Failed { message } => {
10287                tracing::warn!(
10288                    node_id = %self.node_id,
10289                    workspace_id = %address.workspace_id,
10290                    session = ?address.session,
10291                    cause = %message,
10292                    "session failed",
10293                );
10294            }
10295            ControlEventKind::CommandRejected { message } => {
10296                tracing::warn!(
10297                    node_id = %self.node_id,
10298                    workspace_id = %address.workspace_id,
10299                    session = ?address.session,
10300                    cause = %message,
10301                    "session command rejected",
10302                );
10303            }
10304            ControlEventKind::Removed => {
10305                tracing::info!(
10306                    node_id = %self.node_id,
10307                    workspace_id = %address.workspace_id,
10308                    session = ?address.session,
10309                    "session removed",
10310                );
10311            }
10312            ControlEventKind::ResumeAuthorized { session: provider_session } => {
10313                tracing::info!(
10314                    node_id = %self.node_id,
10315                    workspace_id = %address.workspace_id,
10316                    session = ?address.session,
10317                    provider_session = ?provider_session,
10318                    "session resume authorized",
10319                );
10320            }
10321            ControlEventKind::Resumed { session: provider_session, process_id } => {
10322                tracing::info!(
10323                    node_id = %self.node_id,
10324                    workspace_id = %address.workspace_id,
10325                    session = ?address.session,
10326                    provider_session = ?provider_session,
10327                    pid = ?process_id,
10328                    "session resumed",
10329                );
10330            }
10331            ControlEventKind::ResumeDenied { reason } => {
10332                tracing::warn!(
10333                    node_id = %self.node_id,
10334                    workspace_id = %address.workspace_id,
10335                    session = ?address.session,
10336                    cause = %reason,
10337                    "session resume denied",
10338                );
10339            }
10340            ControlEventKind::ResumeFailed { message } => {
10341                tracing::warn!(
10342                    node_id = %self.node_id,
10343                    workspace_id = %address.workspace_id,
10344                    session = ?address.session,
10345                    cause = %message,
10346                    "session resume failed",
10347                );
10348            }
10349            _ => {}
10350        }
10351        let stream_chunk = agent_stream_chunk(&event);
10352        self.publish(NodeEvent::Control {
10353            address: address.clone(),
10354            event,
10355        });
10356        if let Some(chunk) = stream_chunk {
10357            self.publish_agent_stream_chunk(address.clone(), chunk);
10358        }
10359        if revokes_harness_mcp {
10360            SessionLifecycleHook::on_session_ended(self, &address);
10361        }
10362    }
10363
10364    fn reconcile_managed_record(
10365        &self,
10366        record_id: &SessionRecordId,
10367        address: &SessionAddress,
10368        event: &ControlEvent,
10369        identity_admitted: bool,
10370        resume_command_rejected: bool,
10371    ) {
10372        let _transaction = self
10373            .state_transaction
10374            .lock()
10375            .unwrap_or_else(|poisoned| poisoned.into_inner());
10376        let observed_identity = match &event.event {
10377            ControlEventKind::ProviderEvent {
10378                event: gate4agent_types::ProviderEvent::SessionIdentityObserved { identity },
10379                ..
10380            } if identity_admitted => Some(identity.clone()),
10381            _ => None,
10382        };
10383        let identity_was_observed = observed_identity.is_some();
10384        let replacement_id = observed_identity
10385            .as_ref()
10386            .and_then(|_| self.allocate_record_id().ok());
10387        let mut upserts = Vec::new();
10388        let mut removals = Vec::new();
10389        let mut rebind = None;
10390        let original_records;
10391        let original_materializations = self
10392            .materializations
10393            .lock()
10394            .unwrap_or_else(|poisoned| poisoned.into_inner())
10395            .clone();
10396        {
10397            let mut records = self
10398                .session_records
10399                .lock()
10400                .unwrap_or_else(|poisoned| poisoned.into_inner());
10401            original_records = records.records.clone();
10402            let Some(current_snapshot) = records.records.get(record_id).cloned() else {
10403                return;
10404            };
10405            if let Some(identity) = observed_identity {
10406                let existing_id = records
10407                    .records
10408                    .values()
10409                    .find(|record| {
10410                        record.record_id != *record_id
10411                            && record.provider_session.as_ref().is_some_and(|record_identity| {
10412                                session_registry::same_provider_session(
10413                                    &record.provider,
10414                                    record_identity,
10415                                    &current_snapshot.provider,
10416                                    &identity,
10417                                )
10418                            })
10419                    })
10420                    .map(|record| record.record_id.clone());
10421                if let Some(existing_id) = existing_id {
10422                    let scope_matches = records.records.get(&existing_id).is_some_and(|record| {
10423                        record.mode == current_snapshot.mode
10424                            && record.workspace_id == current_snapshot.workspace_id
10425                            && record.environment_profile == current_snapshot.environment_profile
10426                            && record.bundle == current_snapshot.bundle
10427                            && record.context_id == current_snapshot.context_id
10428                            && record.context == current_snapshot.context
10429                            && record
10430                                .canonical_root
10431                                .as_utf8()
10432                                .zip(current_snapshot.canonical_root.as_utf8())
10433                                .is_some_and(|(left, right)| platform::roots_equal(left, right))
10434                    });
10435                    if !scope_matches {
10436                        if let Some(current) = records.records.get_mut(record_id) {
10437                            current.state = ManagedSessionState::Unavailable;
10438                            current.last_error =
10439                                Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR.to_owned());
10440                            current.updated_at_unix_ms = unix_time_ms();
10441                            upserts.push(current.clone());
10442                        }
10443                    } else {
10444                        let existing_is_active = records
10445                            .records
10446                            .get(&existing_id)
10447                            .is_some_and(|record| record.active_session.is_some());
10448                        if existing_is_active {
10449                            if let Some(current) = records.records.get_mut(record_id) {
10450                                current.state = ManagedSessionState::Unavailable;
10451                                current.last_error =
10452                                    Some(PROVIDER_SESSION_LIVE_CONFLICT_ERROR.to_owned());
10453                                current.updated_at_unix_ms = unix_time_ms();
10454                                upserts.push(current.clone());
10455                            }
10456                        } else {
10457                            if current_snapshot.provider_session.is_some() {
10458                                if let Some(current) = records.records.get_mut(record_id) {
10459                                    current.active_session = None;
10460                                    current.state = ManagedSessionState::Dormant;
10461                                    current.updated_at_unix_ms = unix_time_ms();
10462                                    upserts.push(current.clone());
10463                                }
10464                            } else {
10465                                records.records.remove(record_id);
10466                                removals.push(record_id.clone());
10467                            }
10468                            if let Some(existing) = records.records.get_mut(&existing_id) {
10469                                existing.provider_session = Some(identity.clone());
10470                                existing.active_session = Some(address.clone());
10471                                existing.state = ManagedSessionState::Live;
10472                                existing.updated_at_unix_ms = unix_time_ms();
10473                                existing.last_error = None;
10474                                upserts.push(existing.clone());
10475                            }
10476                            rebind = Some(existing_id);
10477                        }
10478                    }
10479                } else if current_snapshot
10480                    .provider_session
10481                    .as_ref()
10482                    .is_some_and(|current| {
10483                        !session_registry::same_provider_session(
10484                            &current_snapshot.provider,
10485                            current,
10486                            &current_snapshot.provider,
10487                            &identity,
10488                        )
10489                    })
10490                {
10491                    if let Some(current) = records.records.get_mut(record_id) {
10492                        current.active_session = None;
10493                        if current.bundle.is_some() || current.context.is_some() {
10494                            let had_bundle = current.bundle.is_some();
10495                            current.provider_session = None;
10496                            current.bundle = None;
10497                            current.context_id = None;
10498                            current.context = None;
10499                            current.state = ManagedSessionState::Unavailable;
10500                            current.last_error = Some(if had_bundle {
10501                                "bundle-unavailable".to_owned()
10502                            } else {
10503                                "context-unavailable".to_owned()
10504                            });
10505                        } else {
10506                            current.state = ManagedSessionState::Dormant;
10507                        }
10508                        current.updated_at_unix_ms = unix_time_ms();
10509                        upserts.push(current.clone());
10510                    }
10511                    if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
10512                        if let Some(current) = records.records.get_mut(record_id) {
10513                            current.state = ManagedSessionState::Unavailable;
10514                            current.last_error =
10515                                Some(MANAGED_SESSION_CAPACITY_ERROR.to_owned());
10516                            current.updated_at_unix_ms = unix_time_ms();
10517                            upserts.push(current.clone());
10518                        }
10519                    } else if let Some(new_record_id) = replacement_id {
10520                        let now = unix_time_ms();
10521                        let replacement = ManagedSessionRecord {
10522                            record_id: new_record_id.clone(),
10523                            display_name: format!(
10524                                "{} #{}",
10525                                current_snapshot.provider.as_str(),
10526                                records
10527                                    .records
10528                                    .values()
10529                                    .filter(|record| {
10530                                        record.provider == current_snapshot.provider
10531                                    })
10532                                    .count()
10533                                    .saturating_add(1),
10534                            ),
10535                            provider: current_snapshot.provider.clone(),
10536                            mode: current_snapshot.mode,
10537                            state: ManagedSessionState::Live,
10538                            workspace_id: current_snapshot.workspace_id,
10539                            canonical_root: current_snapshot.canonical_root,
10540                            provider_session: Some(identity),
10541                            active_session: Some(address.clone()),
10542                            environment_profile: current_snapshot.environment_profile,
10543                            bundle: current_snapshot.bundle,
10544                            context_id: current_snapshot.context_id,
10545                            context: current_snapshot.context,
10546                            // This is a fresh record_id representing the session under its
10547                            // newly-observed identity; it has not yet produced any clean
10548                            // exit of its own, so it starts with no exported pack (the old
10549                            // record, still addressable under its own record_id, keeps
10550                            // whatever exported_context it already had).
10551                            exported_context: None,
10552                            task_binding: current_snapshot.task_binding,
10553                            created_at_unix_ms: now,
10554                            updated_at_unix_ms: now,
10555                            last_error: None,
10556                        };
10557                        records
10558                            .records
10559                            .insert(new_record_id.clone(), replacement.clone());
10560                        upserts.push(replacement);
10561                        rebind = Some(new_record_id);
10562                    } else if let Some(current) = records.records.get_mut(record_id) {
10563                        current.state = ManagedSessionState::Unavailable;
10564                        current.last_error =
10565                            Some(PROVIDER_IDENTITY_ALLOCATION_ERROR.to_owned());
10566                        upserts.push(current.clone());
10567                    }
10568                } else if let Some(current) = records.records.get_mut(record_id) {
10569                    current.provider_session = Some(identity);
10570                    current.active_session = Some(address.clone());
10571                    current.state = ManagedSessionState::Live;
10572                    current.updated_at_unix_ms = unix_time_ms();
10573                    current.last_error = None;
10574                    upserts.push(current.clone());
10575                }
10576            } else if let Some(current) = records.records.get_mut(record_id) {
10577                if resume_command_rejected {
10578                    current.active_session = None;
10579                    current.state = detached_record_state(current);
10580                    current.updated_at_unix_ms = unix_time_ms();
10581                    current.last_error =
10582                        Some(PROVIDER_RESUME_REJECTED_ERROR.to_owned());
10583                    upserts.push(current.clone());
10584                } else {
10585                    match &event.event {
10586                    ControlEventKind::Running { .. }
10587                    | ControlEventKind::ResumeAuthorized { .. }
10588                    | ControlEventKind::Resumed { .. } => {
10589                        current.active_session = Some(address.clone());
10590                        current.state = active_record_state(
10591                            identity_admitted,
10592                            current.provider_session.is_some(),
10593                        );
10594                        current.updated_at_unix_ms = unix_time_ms();
10595                        current.last_error = None;
10596                        upserts.push(current.clone());
10597                    }
10598                    ControlEventKind::Exited { .. } | ControlEventKind::Removed => {
10599                        current.active_session = None;
10600                        current.state = detached_record_state(current);
10601                        current.updated_at_unix_ms = unix_time_ms();
10602                        upserts.push(current.clone());
10603                    }
10604                    ControlEventKind::Failed { message }
10605                    | ControlEventKind::ResumeFailed { message } => {
10606                        current.active_session = None;
10607                        current.state = detached_record_state(current);
10608                        current.updated_at_unix_ms = unix_time_ms();
10609                        current.last_error = Some(
10610                            session_registry::sanitized_record_error_summary(message),
10611                        );
10612                        upserts.push(current.clone());
10613                    }
10614                    ControlEventKind::ResumeDenied { reason } => {
10615                        current.active_session = None;
10616                        current.state = detached_record_state(current);
10617                        current.updated_at_unix_ms = unix_time_ms();
10618                        current.last_error = Some(
10619                            session_registry::sanitized_record_error_summary(reason),
10620                        );
10621                        upserts.push(current.clone());
10622                    }
10623                        _ => {}
10624                    }
10625                }
10626            }
10627        }
10628        let materialization_owner_record = identity_was_observed.then(|| {
10629            rebind.clone().unwrap_or_else(|| record_id.clone())
10630        }).and_then(|candidate| {
10631            self.session_records
10632                .lock()
10633                .unwrap_or_else(|poisoned| poisoned.into_inner())
10634                .records
10635                .get(&candidate)
10636                .filter(|record| {
10637                    record.provider_session.is_some()
10638                        && record.active_session.as_ref() == Some(address)
10639                })
10640                .map(|_| candidate)
10641        });
10642        if let Some(owner_record_id) = materialization_owner_record {
10643            let materialization_id = self
10644                .session_bindings
10645                .lock()
10646                .unwrap_or_else(|poisoned| poisoned.into_inner())
10647                .get(&address.session.instance_id)
10648                .and_then(|binding| binding.materialization_id.clone());
10649            if let Some(materialization_id) = materialization_id {
10650                let mut transfer_failed = false;
10651                {
10652                    let mut materializations = self
10653                        .materializations
10654                        .lock()
10655                        .unwrap_or_else(|poisoned| poisoned.into_inner());
10656                    if let Some(ownership) = materializations.get_mut(&materialization_id) {
10657                        match ownership.owner() {
10658                            MaterializationOwner::Session {
10659                                incarnation_id,
10660                                instance_id,
10661                                generation,
10662                            } if *incarnation_id == self.incarnation_id
10663                                && *instance_id == address.session.instance_id
10664                                && *generation == address.session.generation =>
10665                            {
10666                                transfer_failed = ownership
10667                                    .transfer_to_record(owner_record_id.clone(), unix_time_ms())
10668                                    .is_err();
10669                            }
10670                            MaterializationOwner::Record { record_id }
10671                                if record_id == &owner_record_id => {}
10672                            MaterializationOwner::Record { record_id } => {
10673                                let expected = record_id.clone();
10674                                transfer_failed = ownership
10675                                    .transfer_record_owner(
10676                                        &expected,
10677                                        owner_record_id.clone(),
10678                                        unix_time_ms(),
10679                                    )
10680                                    .is_err();
10681                            }
10682                            _ => transfer_failed = true,
10683                        }
10684                        if transfer_failed {
10685                            let _ = ownership.mark_recovery_required(unix_time_ms());
10686                        }
10687                    } else {
10688                        transfer_failed = true;
10689                    }
10690                }
10691                if transfer_failed {
10692                    if let Some(record) = self
10693                        .session_records
10694                        .lock()
10695                        .unwrap_or_else(|poisoned| poisoned.into_inner())
10696                        .records
10697                        .get_mut(&owner_record_id)
10698                    {
10699                        record.state = ManagedSessionState::Unavailable;
10700                        record.last_error = Some("environment-profile-unavailable".to_owned());
10701                        record.updated_at_unix_ms = unix_time_ms();
10702                        upserts.push(record.clone());
10703                    }
10704                }
10705            }
10706        }
10707        if let Some(new_record_id) = rebind.clone() {
10708            if let Some(binding) = self
10709                .session_bindings
10710                .lock()
10711                .unwrap_or_else(|poisoned| poisoned.into_inner())
10712                .get_mut(&address.session.instance_id)
10713            {
10714                binding.record_id = Some(new_record_id);
10715            }
10716        }
10717        if upserts.is_empty() && removals.is_empty() {
10718            return;
10719        }
10720        if self.persist_state_locked().is_err() {
10721            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
10722            let mut records = self
10723                .session_records
10724                .lock()
10725                .unwrap_or_else(|poisoned| poisoned.into_inner());
10726            records.records = original_records;
10727            *self
10728                .materializations
10729                .lock()
10730                .unwrap_or_else(|poisoned| poisoned.into_inner()) = original_materializations;
10731            if let Some(current) = records.records.get_mut(record_id) {
10732                current.state = ManagedSessionState::Unavailable;
10733                current.last_error = Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned());
10734                current.updated_at_unix_ms = unix_time_ms();
10735                upserts = vec![current.clone()];
10736            } else {
10737                upserts.clear();
10738            }
10739            removals.clear();
10740            drop(records);
10741            if let Some(binding) = self
10742                .session_bindings
10743                .lock()
10744                .unwrap_or_else(|poisoned| poisoned.into_inner())
10745                .get_mut(&address.session.instance_id)
10746            {
10747                binding.record_id = Some(record_id.clone());
10748            }
10749            eprintln!("gate4agent-node: {DURABLE_STATE_COMMIT_FAILED_ERROR}");
10750        }
10751        for record in upserts {
10752            self.publish_record(record);
10753        }
10754        for record_id in removals {
10755            self.publish(NodeEvent::SessionRecordRemoved { record_id });
10756        }
10757    }
10758
10759    fn snapshot(&self) -> NodeSnapshot {
10760        let control = self.handle.snapshot();
10761        let bindings = self
10762            .session_bindings
10763            .lock()
10764            .unwrap_or_else(|poisoned| poisoned.into_inner())
10765            .clone();
10766        let workspace_roots = self
10767            .workspaces
10768            .read()
10769            .unwrap_or_else(|poisoned| poisoned.into_inner())
10770            .clone();
10771        let environment_profiles = self
10772            .environment_profiles
10773            .read()
10774            .unwrap_or_else(|poisoned| poisoned.into_inner());
10775        let mut workspaces = workspace_roots
10776            .iter()
10777            .map(|(workspace_id, canonical_root)| {
10778                (
10779                    workspace_id.clone(),
10780                    WorkspaceSnapshot {
10781                        workspace_id: workspace_id.clone(),
10782                        canonical_root: opaque_windows_path(canonical_root.clone()),
10783                        sessions: Vec::new(),
10784                        worktree_service_mode: Some(
10785                            self.worktree_service_modes
10786                                .get(workspace_id)
10787                                .copied()
10788                                .unwrap_or(WorktreeServiceMode::Manual),
10789                        ),
10790                        managed_worktree_profiles: Some(WorktreeProfileInventory {
10791                            profiles: self
10792                                .managed_worktree_profiles
10793                                .get(workspace_id)
10794                                .into_iter()
10795                                .flat_map(BTreeMap::values)
10796                                .map(|profile| ManagedWorktreeProfileSummary {
10797                                    id: profile.profile_id().clone(),
10798                                    revision: profile.revision().clone(),
10799                                    retention: profile.retention(),
10800                                })
10801                                .collect(),
10802                        }),
10803                    },
10804                )
10805            })
10806            .collect::<BTreeMap<_, _>>();
10807        let mut agent_progress = Vec::new();
10808        for session in &control.sessions {
10809            let Some(binding) = bindings.get(&session.instance_id) else {
10810                continue;
10811            };
10812            if binding.generation != session.generation {
10813                continue;
10814            }
10815            if let Some(workspace) = workspaces.get_mut(&binding.workspace_id) {
10816                let address = SessionAddress {
10817                    workspace_id: binding.workspace_id.clone(),
10818                    session: SessionKey {
10819                        instance_id: session.instance_id,
10820                        generation: session.generation,
10821                    },
10822                };
10823                if let Some(progress) =
10824                    agent_progress_from_provider_snapshot(address, &session.provider)
10825                {
10826                    agent_progress.push(progress);
10827                }
10828                workspace.sessions.push(session.clone());
10829            }
10830        }
10831        NodeSnapshot {
10832            node_id: self.node_id.clone(),
10833            enabled_providers: self.enabled_providers.clone(),
10834            provider_runtime_statuses: {
10835                let updates = self
10836                    .provider_runtime_status_updates
10837                    .read()
10838                    .unwrap_or_else(|poisoned| poisoned.into_inner());
10839                ProviderRuntimeStatuses::new(
10840                    self.provider_runtime_statuses
10841                        .iter()
10842                        .filter(|status| !updates.contains_key(status.provider()))
10843                        .cloned()
10844                        .chain(updates.values().cloned()),
10845                )
10846                .expect("refreshed provider runtime inventory remains bounded and unique")
10847            },
10848            workspaces: workspaces.into_values().collect(),
10849            session_records: self
10850                .session_records
10851                .lock()
10852                .unwrap_or_else(|poisoned| poisoned.into_inner())
10853                .records
10854                .values()
10855                .cloned()
10856                .collect(),
10857            managed_worktrees: self.managed_worktrees
10858                .lock()
10859                .unwrap_or_else(|poisoned| poisoned.into_inner())
10860                .snapshots(),
10861            launch_inventory: Some(LaunchInventory {
10862                spawn_profiles: Some(
10863                    self.spawn_profiles
10864                        .iter()
10865                        .filter_map(|profile| {
10866                            resolved_spawn_profile_summary(profile, &environment_profiles)
10867                        })
10868                        .collect(),
10869                ),
10870                bundles: Some(
10871                    self.bundle_catalog
10872                        .read()
10873                        .unwrap_or_else(|poisoned| poisoned.into_inner())
10874                        .iter()
10875                        .map(NodeBundle::receipt)
10876                        .collect(),
10877                ),
10878                // Opaque station allowlist ids from empty-default catalog /
10879                // optional startup loader. Never secrets. Dig2 probe stays stub.
10880                network_allowlists: {
10881                    let catalog = self
10882                        .network_allowlist_catalog
10883                        .read()
10884                        .unwrap_or_else(|poisoned| poisoned.into_inner());
10885                    if catalog.is_empty() {
10886                        None
10887                    } else {
10888                        Some(catalog.ids().cloned().collect())
10889                    }
10890                },
10891            }),
10892            agent_progress,
10893        }
10894    }
10895
10896    fn publish(&self, event: NodeEvent) -> NodeEventEnvelope {
10897        assert!(
10898            !matches!(
10899                &event,
10900                NodeEvent::TerminalFrame { .. } | NodeEvent::AgentStream { .. }
10901            ),
10902            "terminal frame and agent stream events must use their own dedicated live channel",
10903        );
10904        let mut history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
10905        let sequence = history
10906            .last_sequence
10907            .checked_add(1)
10908            .expect("node event sequence exhausted");
10909        if history.events.len() == NODE_EVENT_HISTORY_MAX {
10910            if let Some(evicted) = history.events.pop_front() {
10911                history.replay_floor_sequence = evicted
10912                    .sequence
10913                    .checked_add(1)
10914                    .expect("node replay floor sequence exhausted");
10915                history.removed_record_providers.remove(&evicted.sequence);
10916            }
10917        }
10918        match &event {
10919            NodeEvent::HarnessMcpReadCall { .. } => {
10920                panic!("transient harness MCP calls must not enter durable event history")
10921            }
10922            NodeEvent::SessionRecordUpserted { record } => {
10923                history
10924                    .record_providers
10925                    .insert(record.record_id.clone(), record.provider.clone());
10926            }
10927            NodeEvent::SessionRecordRemoved { record_id } => {
10928                if let Some(provider) = history.record_providers.remove(record_id) {
10929                    history.removed_record_providers.insert(sequence, provider);
10930                }
10931            }
10932            NodeEvent::ManagedWorktreeUpserted { .. }
10933            | NodeEvent::ManagedWorktreeRemoved { .. }
10934            | NodeEvent::Control { .. }
10935            | NodeEvent::ControllerChanged { .. }
10936            | NodeEvent::SessionRecordHistorySummarized { .. }
10937            | NodeEvent::WorkspaceAdded { .. }
10938            | NodeEvent::WorkspaceRemoved { .. }
10939            | NodeEvent::ResyncRequired { .. }
10940            | NodeEvent::AgentStream { .. }
10941            | NodeEvent::TerminalFrame { .. } => {}
10942        }
10943        let envelope = NodeEventEnvelope { sequence, event };
10944        history.events.push_back(envelope.clone());
10945        history.last_sequence = sequence;
10946        let _ = self.event_tx.send(envelope.clone());
10947        drop(history);
10948        envelope
10949    }
10950
10951    /// Publishes a transient (never durable) `NodeEvent` -- today only
10952    /// `NodeEvent::HarnessMcpReadCall` -- onto the dedicated
10953    /// `harness_mcp_proxy_event_tx` broadcast (see that field's own doc for
10954    /// why this must never ride the shared `event_tx` bus). Returns whether
10955    /// the send reached at least one subscriber: `broadcast::Sender::send`
10956    /// fails in exactly one way, no active receiver at that instant, and
10957    /// the caller (`HarnessMcpProxyRegistry::begin_call`) uses a `false`
10958    /// return to fail the pending call immediately rather than leave it to
10959    /// expire silently at `LOCAL_READ_TIMEOUT`/
10960    /// `MAX_HARNESS_MCP_CALL_DEADLINE_MS`.
10961    fn publish_transient(&self, event: NodeEvent) -> bool {
10962        assert!(event.requires_harness_mcp_proxy_capability());
10963        assert!(event.harness_mcp_contract_is_valid_at(unix_time_ms()));
10964        let envelope = NodeEventEnvelope { sequence: 0, event };
10965        match self.harness_mcp_proxy_event_tx.send(Arc::new(vec![envelope])) {
10966            Ok(_) => true,
10967            Err(broadcast::error::SendError(undelivered)) => {
10968                if let Some(NodeEventEnvelope {
10969                    event: NodeEvent::HarnessMcpReadCall { call_id, request, .. },
10970                    ..
10971                }) = undelivered.first() {
10972                    // `request.content_type` only, never a per-tool label --
10973                    // this crate does not decode `request.body` and does not
10974                    // hold `hatchery-harness-api` (Nested Control Plane
10975                    // doctrine, Law 3; see this crate's dependency on
10976                    // `gate4agent-node-protocol`'s own `Forbidden:` line).
10977                    tracing::warn!(
10978                        call_id = call_id.as_str(),
10979                        content_type = ?request.content_type,
10980                        cause = "no-subscribers",
10981                        "harness MCP proxy event could not be published",
10982                    );
10983                }
10984                false
10985            }
10986        }
10987    }
10988
10989    fn publish_terminal_frames(&self) {
10990        let snapshot = self.handle.snapshot();
10991        let bindings = self
10992            .session_bindings
10993            .lock()
10994            .unwrap_or_else(|poisoned| poisoned.into_inner());
10995        let candidates = snapshot
10996            .sessions
10997            .iter()
10998            .filter_map(|session| {
10999                let binding = bindings.get(&session.instance_id)?;
11000                if binding.generation != session.generation {
11001                    return None;
11002                }
11003                let frame = session.terminal_frame.clone()?;
11004                Some((
11005                    SessionAddress {
11006                        workspace_id: binding.workspace_id.clone(),
11007                        session: SessionKey {
11008                            instance_id: session.instance_id,
11009                            generation: session.generation,
11010                        },
11011                    },
11012                    frame,
11013                ))
11014            })
11015            .collect();
11016        drop(bindings);
11017        self.publish_terminal_frame_candidates(candidates);
11018    }
11019
11020    fn publish_terminal_frame_candidates(
11021        &self,
11022        candidates: Vec<(SessionAddress, TerminalFrame)>,
11023    ) {
11024        let mut watermarks = self
11025            .terminal_frame_watermarks
11026            .lock()
11027            .unwrap_or_else(|poisoned| poisoned.into_inner());
11028        let mut events = Vec::with_capacity(candidates.len());
11029        for (address, frame) in candidates {
11030            let is_first_frame = !watermarks.contains_key(&address.session.instance_id);
11031            let advanced = watermarks
11032                .get(&address.session.instance_id)
11033                .map_or(true, |(current_address, sequence)| {
11034                    current_address != &address || frame.sequence > *sequence
11035                });
11036            if !advanced {
11037                continue;
11038            }
11039            if is_first_frame {
11040                // Do NOT sessionize on first PTY output: a session is a
11041                // provider conversation id, not bytes on the wire. Drafts
11042                // leave the provisional map only when HQ attaches
11043                // (Resize / TerminalBytes). The attach-never-arrived timer
11044                // (PROVISIONAL_SPAWN_GRACE_MS) covers deploy lag; HQ's
11045                // disconnect grace starts only on a real client WS close.
11046                // This is the first PTY output this node has observed for
11047                // this session -- a one-time event per spawn, not per-frame
11048                // chatter (the watermark makes sure of that). A session that
11049                // reaches this line with no controller attached anywhere on
11050                // the node is running unobserved: exactly how processes from
11051                // repeated spawn attempts went orphaned for hours without a
11052                // single line about it.
11053                if self.controller_state().is_none() {
11054                    tracing::warn!(
11055                        node_id = %self.node_id,
11056                        workspace_id = %address.workspace_id,
11057                        session = ?address.session,
11058                        terminal_size = ?frame.size,
11059                        "session producing output with no controller attached",
11060                    );
11061                } else {
11062                    tracing::info!(
11063                        node_id = %self.node_id,
11064                        workspace_id = %address.workspace_id,
11065                        session = ?address.session,
11066                        terminal_size = ?frame.size,
11067                        "session produced its first output",
11068                    );
11069                }
11070            }
11071            watermarks.insert(
11072                address.session.instance_id,
11073                (address.clone(), frame.sequence),
11074            );
11075            if let Some((at, reported)) = self
11076                .pty_input_marks
11077                .lock()
11078                .unwrap_or_else(|poisoned| poisoned.into_inner())
11079                .get_mut(&address.session.instance_id)
11080            {
11081                // First frame after an input: PTY write -> output published.
11082                if !*reported && at.elapsed() < Duration::from_secs(5) {
11083                    *reported = true;
11084                    tracing::debug!(target: "pty_lat", instance_id = address.session.instance_id.0, node_us = at.elapsed().as_micros() as u64, "pty input to frame published");
11085                }
11086            }
11087            events.push(NodeEvent::TerminalFrame { address, frame });
11088        }
11089        drop(watermarks);
11090        if events.is_empty() {
11091            return;
11092        }
11093        let mut history = self
11094            .history
11095            .lock()
11096            .unwrap_or_else(|poisoned| poisoned.into_inner());
11097        let mut envelopes = Vec::with_capacity(events.len());
11098        for event in events {
11099            let sequence = history
11100                .last_sequence
11101                .checked_add(1)
11102                .expect("node event sequence exhausted");
11103            history.last_sequence = sequence;
11104            envelopes.push(NodeEventEnvelope { sequence, event });
11105        }
11106        let _ = self.terminal_event_tx.send(Arc::new(envelopes));
11107        drop(history);
11108    }
11109
11110    /// Publishes one `AgentStreamChunkV1` onto the dedicated agent-stream
11111    /// channel, mirroring `publish_terminal_frame_candidates` above: the
11112    /// envelope's `sequence` is drawn from the same `history.last_sequence`
11113    /// counter every other event kind shares, never a counter of its own --
11114    /// `queue_connection_event`/`queue_connection_event_batch` compare
11115    /// `envelope.sequence` against one `discard_events_through` watermark
11116    /// regardless of which broadcast channel it arrived on, so a
11117    /// per-channel counter would desynchronize that comparison.
11118    fn publish_agent_stream_chunk(&self, address: SessionAddress, chunk: AgentStreamChunkV1) {
11119        let mut history = self
11120            .history
11121            .lock()
11122            .unwrap_or_else(|poisoned| poisoned.into_inner());
11123        let sequence = history
11124            .last_sequence
11125            .checked_add(1)
11126            .expect("node event sequence exhausted");
11127        history.last_sequence = sequence;
11128        drop(history);
11129        // Debug, not info/warn -- this fires on every single chunk, but it
11130        // is exactly the counter the next drop-measurement pass needs: what
11131        // the node actually emitted (`source_sequence`, `kind`) versus what
11132        // a harness subscriber's own forwarder later logs having received
11133        // (`hatchery-harness-service::runtime`'s "forwarding agent stream
11134        // event"), so a gap between the two logs names itself instead of
11135        // needing a new counter added ad hoc.
11136        tracing::debug!(
11137            source_sequence = chunk.source_sequence,
11138            kind = agent_stream_chunk_kind_label(&chunk.kind),
11139            envelope_sequence = sequence,
11140            "publishing agent stream chunk",
11141        );
11142        let envelope = NodeEventEnvelope {
11143            sequence,
11144            event: NodeEvent::AgentStream { address, chunk },
11145        };
11146        let _ = self.agent_stream_event_tx.send(Arc::new(vec![envelope]));
11147    }
11148
11149    fn current_sequence(&self) -> u64 {
11150        self.history
11151            .lock()
11152            .unwrap_or_else(|poisoned| poisoned.into_inner())
11153            .last_sequence
11154    }
11155
11156    fn controller_state(&self) -> Option<ControllerState> {
11157        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11158        let lease = controller.as_ref().copied()?;
11159        let remaining = lease.expires_at.saturating_duration_since(Instant::now());
11160        if remaining.is_zero() {
11161            *controller = None;
11162            return None;
11163        }
11164        Some(ControllerState {
11165            connection_id: lease.connection_id,
11166            lease_remaining_ms: remaining.as_millis().min(u64::MAX as u128) as u64,
11167        })
11168    }
11169
11170    fn acquire_controller(&self, connection_id: u64, role: ClientRole, lease_ms: u64) -> Result<ControllerState, NodeFailure> {
11171        if role != ClientRole::Operator {
11172            tracing::warn!(
11173                node_id = %self.node_id,
11174                connection_id,
11175                "controller lease rejected: observer connections cannot acquire control",
11176            );
11177            return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections cannot acquire control"));
11178        }
11179        let lease_ms = if lease_ms == 0 {
11180            DEFAULT_CONTROLLER_LEASE_MS
11181        } else {
11182            lease_ms.clamp(MIN_CONTROLLER_LEASE_MS, MAX_CONTROLLER_LEASE_MS)
11183        };
11184        let now = Instant::now();
11185        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11186        if let Some(active) = controller.as_ref() {
11187            if active.connection_id != connection_id && active.expires_at > now {
11188                tracing::warn!(
11189                    node_id = %self.node_id,
11190                    connection_id,
11191                    holder_connection_id = active.connection_id,
11192                    "controller lease rejected: another operator holds it",
11193                );
11194                return Err(failure(NodeFailureCode::ControllerBusy, "another operator holds the controller lease"));
11195            }
11196        }
11197        // A reconnect (this connection already holds the lease, renewing
11198        // before expiry) happens on a periodic cadence; logging it at INFO
11199        // every renewal would be exactly the heartbeat chatter this crate
11200        // family's level discipline forbids. A genuine attach -- the
11201        // controller identity actually changing -- is a state change and
11202        // gets INFO; a same-connection renewal gets DEBUG.
11203        let is_new_attach = match controller.as_ref() {
11204            Some(active) => active.connection_id != connection_id,
11205            None => true,
11206        };
11207        *controller = Some(ControllerLease {
11208            connection_id,
11209            expires_at: now + Duration::from_millis(lease_ms),
11210        });
11211        drop(controller);
11212        let state = ControllerState { connection_id, lease_remaining_ms: lease_ms };
11213        if is_new_attach {
11214            tracing::info!(
11215                node_id = %self.node_id,
11216                connection_id,
11217                lease_ms,
11218                "controller lease acquired",
11219            );
11220        } else {
11221            tracing::debug!(
11222                node_id = %self.node_id,
11223                connection_id,
11224                lease_ms,
11225                "controller lease renewed",
11226            );
11227        }
11228        self.publish(NodeEvent::ControllerChanged { controller: Some(state.clone()) });
11229        Ok(state)
11230    }
11231
11232    fn release_controller(&self, connection_id: u64) -> bool {
11233        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11234        let released = controller.as_ref().is_some_and(|active| active.connection_id == connection_id);
11235        if released {
11236            *controller = None;
11237        }
11238        drop(controller);
11239        if released {
11240            tracing::info!(
11241                node_id = %self.node_id,
11242                connection_id,
11243                "controller lease released",
11244            );
11245            self.publish(NodeEvent::ControllerChanged { controller: None });
11246        }
11247        released
11248    }
11249
11250    /// Mark a fresh Spawn as provisional and arm the attach-never-arrived
11251    /// reaper (PROVISIONAL_SPAWN_GRACE_MS from spawn). Removed from this map
11252    /// only when HQ attaches (Resize / TerminalBytes) — that is *not* a
11253    /// provider session; it only cancels the orphan timer. Disconnect/reload
11254    /// draft kills are owned by HQ and arm only on a real client WS close.
11255    fn mark_spawn_provisional(&self, address: SessionAddress) {
11256        let id = address.session.instance_id;
11257        self.provisional_spawns
11258            .lock()
11259            .unwrap_or_else(|poisoned| poisoned.into_inner())
11260            .insert(id, (address, Instant::now()));
11261        tracing::info!(
11262            node_id = %self.node_id,
11263            instance_id = id.0,
11264            grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
11265            "draft spawn; waiting for HQ view",
11266        );
11267    }
11268
11269    fn sessionize_spawn(&self, instance_id: AgentInstanceId) {
11270        let removed = self
11271            .provisional_spawns
11272            .lock()
11273            .unwrap_or_else(|poisoned| poisoned.into_inner())
11274            .remove(&instance_id);
11275        if removed.is_some() {
11276            tracing::info!(
11277                node_id = %self.node_id,
11278                instance_id = instance_id.0,
11279                "draft spawn claimed by HQ view",
11280            );
11281        }
11282    }
11283
11284    fn take_due_provisional_spawns(&self) -> Vec<SessionAddress> {
11285        let grace = Duration::from_millis(PROVISIONAL_SPAWN_GRACE_MS);
11286        let mut map = self
11287            .provisional_spawns
11288            .lock()
11289            .unwrap_or_else(|poisoned| poisoned.into_inner());
11290        let due: Vec<AgentInstanceId> = map
11291            .iter()
11292            .filter(|(_, (_, at))| at.elapsed() >= grace)
11293            .map(|(id, _)| *id)
11294            .collect();
11295        due.into_iter()
11296            .filter_map(|id| map.remove(&id).map(|(addr, _)| addr))
11297            .collect()
11298    }
11299
11300    async fn reap_provisional_spawn(&self, address: SessionAddress) {
11301        let instance_id = address.session.instance_id;
11302        tracing::warn!(
11303            node_id = %self.node_id,
11304            instance_id = instance_id.0,
11305            workspace = %address.workspace_id,
11306            reason = "attach_never_arrived",
11307            grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
11308            "draft spawn never viewed; stopping PTY",
11309        );
11310        let _ = self.dispatch(ControlCommand::Stop {
11311            instance_id,
11312            force: true,
11313        });
11314        let _ = self.remove_session(&address).await;
11315    }
11316
11317    fn require_controller(&self, connection_id: u64, role: ClientRole) -> Result<(), NodeFailure> {
11318        if role != ClientRole::Operator {
11319            return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections are read-only"));
11320        }
11321        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11322        let now = Instant::now();
11323        match controller.as_ref().copied() {
11324            Some(active) if active.connection_id == connection_id && active.expires_at > now => Ok(()),
11325            Some(active) if active.expires_at <= now => {
11326                *controller = None;
11327                Err(failure(NodeFailureCode::ControllerRequired, "controller lease expired"))
11328            }
11329            _ => Err(failure(NodeFailureCode::ControllerRequired, "controller lease is required")),
11330        }
11331    }
11332
11333    fn validate_address(&self, address: &SessionAddress) -> Result<AgentId, NodeFailure> {
11334        if !self
11335            .workspaces
11336            .read()
11337            .unwrap_or_else(|poisoned| poisoned.into_inner())
11338            .contains_key(&address.workspace_id)
11339        {
11340            return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
11341        }
11342        let binding = self
11343            .session_bindings
11344            .lock()
11345            .unwrap_or_else(|poisoned| poisoned.into_inner())
11346            .get(&address.session.instance_id)
11347            .cloned()
11348            .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
11349        if binding.workspace_id != address.workspace_id {
11350            return Err(failure(
11351                NodeFailureCode::SessionWorkspaceMismatch,
11352                "session belongs to another workspace",
11353            ));
11354        }
11355        if binding.generation != address.session.generation {
11356            return Err(failure(
11357                NodeFailureCode::StaleGeneration,
11358                &format!(
11359                    "stale session generation: expected {}, received {}",
11360                    binding.generation.0,
11361                    address.session.generation.0,
11362                ),
11363            ));
11364        }
11365        let snapshot = self.handle.snapshot();
11366        let Some(current) = snapshot
11367            .sessions
11368            .iter()
11369            .find(|item| item.instance_id == address.session.instance_id)
11370        else {
11371            return Err(failure(NodeFailureCode::UnknownSession, "session instance does not exist"));
11372        };
11373        if current.generation != address.session.generation {
11374            return Err(failure(
11375                NodeFailureCode::StaleGeneration,
11376                &format!(
11377                    "stale session generation: expected {}, received {}",
11378                    current.generation.0,
11379                    address.session.generation.0,
11380                ),
11381            ));
11382        }
11383        Ok(current.agent_id.clone())
11384    }
11385
11386    fn dispatch(&self, command: ControlCommand) -> Result<CommandId, NodeFailure> {
11387        self.dispatch_envelope(self.prepare_command(command))
11388    }
11389
11390    fn prepare_command(&self, command: ControlCommand) -> CommandEnvelope {
11391        let id = self.next_command_id.fetch_add(1, Ordering::AcqRel);
11392        CommandEnvelope {
11393            id: CommandId(id),
11394            command,
11395        }
11396    }
11397
11398    fn dispatch_envelope(&self, envelope: CommandEnvelope) -> Result<CommandId, NodeFailure> {
11399        let command_id = envelope.id;
11400        self.handle
11401            .dispatch(envelope)
11402            .map_err(|error| match error {
11403                PortDispatchError::Full => failure(NodeFailureCode::BackendBusy, "backend command ingress is full"),
11404                PortDispatchError::Disconnected => failure(NodeFailureCode::BackendDisconnected, "backend command ingress is disconnected"),
11405            })?;
11406        Ok(command_id)
11407    }
11408
11409    /// Records the kernel's own reason for asynchronously refusing
11410    /// `command_id` -- see `rejected_command_reasons`'s doc for why a
11411    /// spawn-dispatch waiter otherwise has no way to learn it. Bounded by
11412    /// `REJECTED_COMMAND_REASON_MAX_ENTRIES`: expired entries are swept
11413    /// first, and if the map is still at capacity after that, the single
11414    /// oldest surviving entry is evicted -- `CommandId` is monotonically
11415    /// assigned (`next_command_id.fetch_add`), so the `BTreeMap`'s first key
11416    /// is always the oldest.
11417    fn record_rejected_command(
11418        &self,
11419        command_id: CommandId,
11420        message: String,
11421        unsupported_transport: bool,
11422    ) {
11423        let now = Instant::now();
11424        let mut reasons = self.rejected_command_reasons.lock()
11425            .unwrap_or_else(|poisoned| poisoned.into_inner());
11426        reasons.retain(|_, reason| reason.expires_at > now);
11427        if reasons.len() >= REJECTED_COMMAND_REASON_MAX_ENTRIES {
11428            if let Some(&oldest) = reasons.keys().next() {
11429                reasons.remove(&oldest);
11430            }
11431        }
11432        reasons.insert(
11433            command_id,
11434            RejectedCommandReason {
11435                message,
11436                unsupported_transport,
11437                expires_at: now + Duration::from_millis(REJECTED_COMMAND_REASON_TTL_MS),
11438            },
11439        );
11440    }
11441
11442    /// Removes and returns the kernel's reason for refusing `command_id`,
11443    /// if the drive loop has recorded one and it has not expired -- see
11444    /// `rejected_command_reasons`'s doc. Consumed at most once: a spawn
11445    /// wait loop calls this instead of blindly polling out its whole
11446    /// deadline when the command it is waiting on turns out to have been
11447    /// refused rather than merely slow.
11448    fn take_rejected_command_reason(&self, command_id: CommandId) -> Option<RejectedCommandReason> {
11449        let now = Instant::now();
11450        let mut reasons = self.rejected_command_reasons.lock()
11451            .unwrap_or_else(|poisoned| poisoned.into_inner());
11452        match reasons.remove(&command_id) {
11453            Some(reason) if reason.expires_at > now => Some(reason),
11454            _ => None,
11455        }
11456    }
11457
11458    async fn dispatch_bounded(
11459        &self,
11460        command: ControlCommand,
11461        timeout_duration: Duration,
11462    ) -> Result<CommandId, NodeFailure> {
11463        let deadline = Instant::now() + timeout_duration;
11464        loop {
11465            match self.dispatch(command.clone()) {
11466                Ok(command_id) => return Ok(command_id),
11467                Err(error) if error.code == NodeFailureCode::BackendBusy && Instant::now() < deadline => {
11468                    sleep(Duration::from_millis(2)).await;
11469                }
11470                Err(error) => return Err(error),
11471            }
11472        }
11473    }
11474
11475    fn internal_session_snapshot(
11476        &self,
11477        address: &SessionAddress,
11478    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11479        self.validate_address(address)?;
11480        self.handle
11481            .snapshot()
11482            .sessions
11483            .clone()
11484            .into_iter()
11485            .find(|session| {
11486                session.instance_id == address.session.instance_id
11487                    && session.generation == address.session.generation
11488            })
11489            .ok_or_else(|| {
11490                failure(
11491                    NodeFailureCode::UnknownSession,
11492                    "session snapshot is unavailable",
11493                )
11494            })
11495    }
11496
11497    /// Confirms `address` names a live session spawned over the ACP
11498    /// transport and returns its current snapshot -- the gate every
11499    /// `acp-control-v1` verb shares. `require_session_runtime_policy`
11500    /// cannot answer this by itself: `ProviderRuntimePolicy` carries no
11501    /// transport bit for `Acp`/`Inline` (see that method's own doc), only
11502    /// the session's own recorded `TransportKind` does, and that lives on
11503    /// the live snapshot, not the binding.
11504    fn require_acp_session(
11505        &self,
11506        address: &SessionAddress,
11507    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11508        let snapshot = self.internal_session_snapshot(address)?;
11509        if snapshot.transport != TransportKind::Acp {
11510            return Err(failure(
11511                NodeFailureCode::UnsupportedTransport,
11512                "this verb requires a session spawned over the ACP transport",
11513            ));
11514        }
11515        Ok(snapshot)
11516    }
11517
11518    async fn dispatch_history_bounded(
11519        &self,
11520        address: &SessionAddress,
11521        command: ControlCommand,
11522        operation: HistoryOperation,
11523    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11524        self.validate_address(address)?;
11525        let started_after = self.current_sequence();
11526        let command_id = self
11527            .dispatch_bounded(
11528                command,
11529                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
11530            )
11531            .await?;
11532        let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
11533        let mut scan_after = started_after;
11534        let mut accepted_after = None;
11535        loop {
11536            let events = self
11537                .history
11538                .lock()
11539                .unwrap_or_else(|poisoned| poisoned.into_inner())
11540                .events
11541                .iter()
11542                .filter(|envelope| envelope.sequence > scan_after)
11543                .cloned()
11544                .collect::<Vec<_>>();
11545            for envelope in events {
11546                scan_after = scan_after.max(envelope.sequence);
11547                let NodeEvent::Control {
11548                    address: event_address,
11549                    event,
11550                } = envelope.event
11551                else {
11552                    continue;
11553                };
11554                if event_address != *address {
11555                    continue;
11556                }
11557                if event.command_id == Some(command_id) {
11558                    match &event.event {
11559                        ControlEventKind::CommandRejected { .. } => {
11560                            return Err(failure(
11561                                NodeFailureCode::InvalidRequest,
11562                                "history request was rejected",
11563                            ));
11564                        }
11565                        ControlEventKind::HistoryRequested {
11566                            operation: requested,
11567                            ..
11568                        } if requested == &operation => {
11569                            accepted_after = Some(envelope.sequence);
11570                            continue;
11571                        }
11572                        _ => {}
11573                    }
11574                }
11575                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
11576                    match (&operation, &event.event) {
11577                        (
11578                            HistoryOperation::Discover { .. },
11579                            ControlEventKind::HistoryDiscovered { .. },
11580                        )
11581                        | (
11582                            HistoryOperation::Load { .. },
11583                            ControlEventKind::HistoryLoaded { .. },
11584                        ) => return self.internal_session_snapshot(address),
11585                        (_, ControlEventKind::HistoryFailed { .. }) => {
11586                            return Err(failure(
11587                                NodeFailureCode::BackendOperationFailed,
11588                                "history operation failed",
11589                            ));
11590                        }
11591                        _ => {}
11592                    }
11593                }
11594            }
11595            if Instant::now() >= deadline {
11596                return Err(failure(
11597                    NodeFailureCode::BackendBusy,
11598                    if accepted_after.is_some() {
11599                        "history operation did not settle before the bounded deadline"
11600                    } else {
11601                        "history operation was not accepted before the bounded deadline"
11602                    },
11603                ));
11604            }
11605            sleep(Duration::from_millis(2)).await;
11606        }
11607    }
11608
11609    async fn discover_history(
11610        &self,
11611        address: &SessionAddress,
11612        limit: u16,
11613    ) -> Result<Vec<HistoryCandidateSummary>, NodeFailure> {
11614        let query = HistoryQuery {
11615            working_directory: Some(self.workspace_root(&address.workspace_id)?),
11616            limit,
11617        };
11618        query
11619            .validate()
11620            .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history query"))?;
11621        let snapshot = self
11622            .dispatch_history_bounded(
11623                address,
11624                ControlCommand::DiscoverHistory {
11625                    instance_id: address.session.instance_id,
11626                    query: query.clone(),
11627                },
11628                HistoryOperation::Discover { query },
11629            )
11630            .await?;
11631        if snapshot.history.pending.is_some() || snapshot.history.loaded.is_some() {
11632            return Err(failure(
11633                NodeFailureCode::BackendOperationFailed,
11634                "history discovery settled into an invalid state",
11635            ));
11636        }
11637        Ok(snapshot.history.candidates)
11638    }
11639
11640    async fn load_history(
11641        &self,
11642        address: &SessionAddress,
11643        candidate_id: String,
11644    ) -> Result<HistorySessionRecord, NodeFailure> {
11645        validate_candidate_id(&candidate_id)
11646            .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history candidate"))?;
11647        let snapshot = self
11648            .dispatch_history_bounded(
11649                address,
11650                ControlCommand::LoadHistory {
11651                    instance_id: address.session.instance_id,
11652                    candidate_id: candidate_id.clone(),
11653                },
11654                HistoryOperation::Load {
11655                    candidate_id: candidate_id.clone(),
11656                },
11657            )
11658            .await?;
11659        if snapshot.history.pending.is_some()
11660            || snapshot.history.loaded_candidate_id.as_ref() != Some(&candidate_id)
11661        {
11662            return Err(failure(
11663                NodeFailureCode::BackendOperationFailed,
11664                "history load settled into an invalid state",
11665            ));
11666        }
11667        snapshot.history.loaded.ok_or_else(|| {
11668            failure(
11669                NodeFailureCode::BackendOperationFailed,
11670                "history load produced no bounded session",
11671            )
11672        })
11673    }
11674
11675    /// `allow_clean_detachment`: `false` for the on-demand
11676    /// `NodeRequest::ExportContextPackForSessionRecord` API path (the
11677    /// managed record must stay untouched, still live, for the whole call);
11678    /// `true` only for the reactive auto-export-at-exit path (`§2.1`), which
11679    /// reacts to the very clean-exit event that detaches the record out from
11680    /// under this same call — see `session_record_export_target_matches`.
11681    async fn export_context_pack_for_session_record(
11682        &self,
11683        record_id: &SessionRecordId,
11684        session: &SessionAddress,
11685        allow_clean_detachment: bool,
11686    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11687        // C2 replaces a relayed failure's prose with a canned per-code
11688        // string (`C2NodeFailure`'s own `From`), so this path's dozen
11689        // distinct refusals are indistinguishable to anyone downstream --
11690        // and a refusal here expires a continuation, which keeps no field
11691        // for why. The prose stays on the node, where it was written.
11692        let outcome = self
11693            .export_context_pack_for_session_record_inner(record_id, session, allow_clean_detachment)
11694            .await;
11695        if let Err(failure) = &outcome {
11696            tracing::warn!(
11697                record = %record_id,
11698                code = ?failure.code,
11699                reason = %failure.message,
11700                "session-record context-pack export refused",
11701            );
11702        }
11703        outcome
11704    }
11705
11706    async fn export_context_pack_for_session_record_inner(
11707        &self,
11708        record_id: &SessionRecordId,
11709        session: &SessionAddress,
11710        allow_clean_detachment: bool,
11711    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11712        // Several steps here refuse with the same BackendOperationFailed
11713        // code and no prose of their own, so which step failed is
11714        // otherwise unrecoverable -- and a refusal here expires a
11715        // continuation.
11716        let step = |name: &'static str| move |failure: NodeFailure| {
11717            tracing::warn!(step = name, code = ?failure.code, "context-pack export step refused");
11718            failure
11719        };
11720        let record = self.record(record_id).map_err(step("record-lookup"))?;
11721        // `provider_session` is `Some` only for a provider whose resolved
11722        // runtime policy admits live identity verification (raw PTY plus a
11723        // semantic PTY adapter -- `provider_runtime::policy_from_capability_flags`).
11724        // The PTY-sidecar-observation shape (no fleet provider today, but
11725        // capability-keyed rather than vendor-locked) hardcodes
11726        // `provider_session_identity: false` in `provider_runtime::evaluate`,
11727        // so its records go straight to `Live` with `provider_session: None`
11728        // and stay that way for the record's entire life -- that is this
11729        // shape's permanent resting state, not a race with observation, and
11730        // `discover_history`/`load_history` below do not need an identity to
11731        // do their job for it. `IdentityPending` is the actual race (an
11732        // identity-admitted provider whose observation has not landed yet):
11733        // it keeps refusing here rather than falling back, because that
11734        // provider's own history the fallback would otherwise trust is not
11735        // guaranteed to be exact without the identity it is still waiting on.
11736        let identity = match record.provider_session.clone() {
11737            Some(identity) => {
11738                if identity.key != ProviderSessionKey::SessionId {
11739                    return Err(step("provider-identity-key")(failure(
11740                        NodeFailureCode::BackendOperationFailed,
11741                        "managed session provider identity cannot bind history exactly",
11742                    )));
11743                }
11744                Some(identity)
11745            }
11746            None if record.state == ManagedSessionState::IdentityPending => {
11747                return Err(step("provider-session-identity")(failure(
11748                    NodeFailureCode::BackendOperationFailed,
11749                    "managed session has not yet observed its provider session identity",
11750                )));
11751            }
11752            None => None,
11753        };
11754        self.revalidate_session_record_context_export(
11755            &record,
11756            identity.as_ref(),
11757            session,
11758            allow_clean_detachment,
11759        ).map_err(step("revalidate-before-discovery"))?;
11760        let candidates = self
11761            .discover_history(session, HISTORY_DISCOVERY_LIMIT_MAX)
11762            .await
11763            .map_err(step("discover-history"))?;
11764        // With no verified identity to filter by, the discovered set must
11765        // still resolve to exactly one candidate: the same ambiguity refusal
11766        // an identity filter would otherwise produce is the only safety
11767        // property left available for this shape.
11768        let candidate_id = select_history_candidate(
11769            &candidates,
11770            identity.as_ref().map(|identity| identity.id.as_str()),
11771        ).map_err(step("unique-candidate"))?;
11772        let loaded = self.load_history(session, candidate_id.clone()).await
11773            .map_err(step("load-history"))?;
11774        if let Some(identity) = identity.as_ref() {
11775            if loaded.session_id != identity.id {
11776                return Err(failure(
11777                    NodeFailureCode::SessionRecordConflict,
11778                    "loaded history identity does not match the managed session record",
11779                ));
11780            }
11781        }
11782        self.revalidate_session_record_context_export(
11783            &record,
11784            identity.as_ref(),
11785            session,
11786            allow_clean_detachment,
11787        )?;
11788        self.revalidate_loaded_history(session, &candidate_id, &loaded)
11789            .map_err(step("revalidate-loaded-history"))?;
11790        let pack = self.materialize_context_pack(session).await
11791            .map_err(step("materialize-context-pack"))?;
11792        self.commit_context_pack_for_session_record(
11793            &record,
11794            identity.as_ref(),
11795            session,
11796            &candidate_id,
11797            &loaded,
11798            pack,
11799            allow_clean_detachment,
11800        )
11801    }
11802
11803    async fn export_context_pack(
11804        &self,
11805        address: &SessionAddress,
11806    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11807        let pack = self.materialize_context_pack(address).await?;
11808        self.commit_context_pack(pack)
11809    }
11810
11811    async fn materialize_context_pack(
11812        &self,
11813        address: &SessionAddress,
11814    ) -> Result<NodeContextPack, NodeFailure> {
11815        let snapshot = self.internal_session_snapshot(address)?;
11816        if !context_pack_source_status_is_usable(&snapshot.status) {
11817            return Err(failure(
11818                NodeFailureCode::InvalidRequest,
11819                "context export requires a running or successfully exited source session",
11820            ));
11821        }
11822        if snapshot.history.pending.is_some()
11823            || snapshot.history.loaded_candidate_id.is_none()
11824        {
11825            return Err(failure(
11826                NodeFailureCode::InvalidRequest,
11827                "context export requires settled loaded history",
11828            ));
11829        }
11830        let loaded = snapshot.history.loaded.clone().ok_or_else(|| {
11831            failure(
11832                NodeFailureCode::InvalidRequest,
11833                "context export requires settled loaded history",
11834            )
11835        })?;
11836        let source_provider = snapshot.agent_id.clone();
11837        let loaded_candidate_id = snapshot.history.loaded_candidate_id.clone();
11838        let repository = self
11839            .context_pack_repository(&address.workspace_id)
11840            .await?;
11841        let current = self.internal_session_snapshot(address)?;
11842        if !context_pack_source_status_is_usable(&current.status)
11843            || current.agent_id != source_provider
11844            || current.history.pending.is_some()
11845            || current.history.loaded_candidate_id != loaded_candidate_id
11846            || current.history.loaded.as_ref() != Some(&loaded)
11847        {
11848            return Err(failure(
11849                NodeFailureCode::ContextPackMaterializationFailed,
11850                "context source changed during bounded repository capture",
11851            ));
11852        }
11853        NodeContextPack::export_with_repository(
11854            ContextPackLineageReceipt {
11855                source_node_id: self.node_id.clone(),
11856                source_session: address.clone(),
11857                source_provider,
11858            },
11859            &loaded,
11860            Some(repository),
11861        )
11862        .map_err(|_| {
11863            failure(
11864                NodeFailureCode::ContextPackMaterializationFailed,
11865                "bounded context pack export failed",
11866            )
11867        })
11868    }
11869
11870    /// Write-through to the durable [`ContextPackStore`], if one is
11871    /// configured (`state_path` is `Some`). A node with no durable state
11872    /// path — fixtures, tests, ephemeral runs — has no `ContextPackStore`
11873    /// open at all and this is a no-op, matching `persist_state`'s own
11874    /// "durability optional, degrade gracefully when unconfigured" idiom.
11875    fn commit_context_pack_durably(&self, pack: &NodeContextPack) -> Result<(), NodeFailure> {
11876        let mut store = self
11877            .context_pack_store
11878            .lock()
11879            .unwrap_or_else(|poisoned| poisoned.into_inner());
11880        let Some(store) = store.as_mut() else {
11881            return Ok(());
11882        };
11883        store.commit(pack).map_err(|_| {
11884            failure(
11885                NodeFailureCode::ContextPackMaterializationFailed,
11886                "durable context pack commit failed",
11887            )
11888        })
11889    }
11890
11891    fn commit_context_pack(
11892        &self,
11893        pack: NodeContextPack,
11894    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11895        self.commit_context_pack_durably(&pack)?;
11896        self.context_catalog
11897            .write()
11898            .unwrap_or_else(|poisoned| poisoned.into_inner())
11899            .insert(pack)
11900            .map_err(|_| {
11901                failure(
11902                    NodeFailureCode::ContextPackMaterializationFailed,
11903                    "context pack catalog is full",
11904                )
11905            })
11906    }
11907
11908    fn commit_context_pack_for_session_record(
11909        &self,
11910        expected: &ManagedSessionRecord,
11911        identity: Option<&ProviderSessionIdentity>,
11912        session: &SessionAddress,
11913        candidate_id: &str,
11914        loaded: &HistorySessionRecord,
11915        pack: NodeContextPack,
11916        allow_clean_detachment: bool,
11917    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11918        let current_root = self.workspace_root(&expected.workspace_id)?;
11919        let _transaction = self
11920            .state_transaction
11921            .lock()
11922            .unwrap_or_else(|poisoned| poisoned.into_inner());
11923        let records = self
11924            .session_records
11925            .lock()
11926            .unwrap_or_else(|poisoned| poisoned.into_inner());
11927        let current = records.records.get(&expected.record_id).ok_or_else(|| {
11928            failure(
11929                NodeFailureCode::SessionRecordConflict,
11930                "managed session record disappeared during context export",
11931            )
11932        })?;
11933        if !session_record_export_target_matches(expected, current, session, allow_clean_detachment) {
11934            return Err(failure(
11935                NodeFailureCode::SessionRecordConflict,
11936                "managed session record changed during context export",
11937            ));
11938        }
11939        let bindings = self
11940            .session_bindings
11941            .lock()
11942            .unwrap_or_else(|poisoned| poisoned.into_inner());
11943        let binding = bindings.get(&session.session.instance_id).ok_or_else(|| {
11944            failure(
11945                NodeFailureCode::SessionRecordConflict,
11946                "managed session runtime binding disappeared during context export",
11947            )
11948        })?;
11949        let runtime = self
11950            .handle
11951            .snapshot()
11952            .sessions
11953            .iter()
11954            .find(|snapshot| {
11955                snapshot.instance_id == session.session.instance_id
11956                    && snapshot.generation == session.session.generation
11957            })
11958            .cloned()
11959            .ok_or_else(|| {
11960                failure(
11961                    NodeFailureCode::SessionRecordConflict,
11962                    "managed session runtime disappeared during context export",
11963                )
11964            })?;
11965        let receipt = pack.receipt();
11966        if !session_record_context_export_binding_is_exact(
11967            expected,
11968            current,
11969            identity,
11970            session,
11971            binding,
11972            &runtime.agent_id,
11973            &current_root,
11974            allow_clean_detachment,
11975        ) || !session_record_context_export_source_is_usable(
11976            current,
11977            &runtime.status,
11978            allow_clean_detachment,
11979        ) || runtime.history.pending.is_some()
11980            || runtime.history.loaded_candidate_id.as_deref() != Some(candidate_id)
11981            || runtime.history.loaded.as_ref() != Some(loaded)
11982            || receipt.lineage.source_node_id != self.node_id
11983            || receipt.lineage.source_session != *session
11984            || receipt.lineage.source_provider != current.provider
11985            || receipt.source_message_count != loaded.message_count
11986        {
11987            return Err(failure(
11988                NodeFailureCode::SessionRecordConflict,
11989                "managed session source changed before context catalog commit",
11990            ));
11991        }
11992        self.commit_context_pack_durably(&pack)?;
11993        self.context_catalog
11994            .write()
11995            .unwrap_or_else(|poisoned| poisoned.into_inner())
11996            .insert(pack)
11997            .map_err(|_| {
11998                failure(
11999                    NodeFailureCode::ContextPackMaterializationFailed,
12000                    "context pack catalog is full",
12001                )
12002            })
12003    }
12004
12005    async fn context_pack_repository(
12006        &self,
12007        workspace_id: &WorkspaceId,
12008    ) -> Result<ContextPackRepository, NodeFailure> {
12009        let first = self
12010            .context_pack_repository_observation(workspace_id)
12011            .await?;
12012        let second = self
12013            .context_pack_repository_observation(workspace_id)
12014            .await?;
12015        stable_context_pack_repository(first, second)
12016    }
12017
12018    async fn context_pack_repository_observation(
12019        &self,
12020        workspace_id: &WorkspaceId,
12021    ) -> Result<ContextPackRepositoryObservation, NodeFailure> {
12022        let git = self.inspect_workspace(workspace_id.clone()).await?.git;
12023        let head = if git.is_repository {
12024            let root = self.workspace_root(workspace_id)?;
12025            match resolve_base_commit_with_timeout(
12026                &root,
12027                "HEAD",
12028                GIT_COMMAND_TIMEOUT_MS,
12029            )
12030            .await
12031            {
12032                Ok(commit) => ContextPackRepositoryHead::Commit(commit),
12033                Err(error) if error.kind == GitWorktreeErrorKind::Conflict => {
12034                    ContextPackRepositoryHead::Unborn
12035                }
12036                Err(_) => {
12037                    return Err(failure(
12038                        NodeFailureCode::ContextPackMaterializationFailed,
12039                        "context pack HEAD observation failed",
12040                    ));
12041                }
12042            }
12043        } else {
12044            ContextPackRepositoryHead::NotRepository
12045        };
12046        let mut files = Vec::with_capacity(CONTEXT_PACK_SELECTED_FILES.len());
12047        for selected_path in CONTEXT_PACK_SELECTED_FILES {
12048            let path = RepositoryPath::utf8(selected_path.to_owned()).map_err(|_| {
12049                failure(
12050                    NodeFailureCode::InvalidRepositoryPath,
12051                    "context pack selected file path is invalid",
12052                )
12053            })?;
12054            match self.read_workspace_file(workspace_id.clone(), path).await {
12055                Ok(WorkspaceFileRead {
12056                    content: WorkspaceFileContent::Utf8 { text, byte_len },
12057                    ..
12058                }) => files.push(ContextPackRepositoryFileSource::utf8(
12059                    selected_path,
12060                    text,
12061                    byte_len,
12062                )),
12063                Ok(WorkspaceFileRead {
12064                    content: WorkspaceFileContent::NonUtf8 { byte_len },
12065                    ..
12066                }) => files.push(ContextPackRepositoryFileSource::skipped(
12067                    selected_path,
12068                    ContextPackSelectedFileSkipReason::NonUtf8,
12069                    Some(byte_len),
12070                )),
12071                Ok(WorkspaceFileRead {
12072                    content: WorkspaceFileContent::TooLarge { .. },
12073                    ..
12074                }) => files.push(ContextPackRepositoryFileSource::skipped(
12075                    selected_path,
12076                    ContextPackSelectedFileSkipReason::TooLarge,
12077                    None,
12078                )),
12079                Err(error) => {
12080                    let reason = if matches!(
12081                        error.code,
12082                        NodeFailureCode::InvalidRepositoryPath
12083                            | NodeFailureCode::RepositoryFileNotRegular
12084                            | NodeFailureCode::RepositoryPathUnsafe
12085                    ) {
12086                        ContextPackSelectedFileSkipReason::Unsafe
12087                    } else {
12088                        ContextPackSelectedFileSkipReason::Unavailable
12089                    };
12090                    files.push(ContextPackRepositoryFileSource::skipped(
12091                        selected_path,
12092                        reason,
12093                        None,
12094                    ));
12095                }
12096            }
12097        }
12098        Ok(ContextPackRepositoryObservation { head, git, files })
12099    }
12100
12101    fn forget_context_pack(&self, context_id: &SpawnContextId) -> Result<(), NodeFailure> {
12102        let referenced_by_binding = self
12103            .session_bindings
12104            .lock()
12105            .unwrap_or_else(|poisoned| poisoned.into_inner())
12106            .values()
12107            .any(|binding| {
12108                binding.context.as_ref().map(|receipt| &receipt.id) == Some(context_id)
12109            });
12110        let referenced_by_record = self
12111            .session_records
12112            .lock()
12113            .unwrap_or_else(|poisoned| poisoned.into_inner())
12114            .records
12115            .values()
12116            .any(|record| record.context_id.as_ref() == Some(context_id));
12117        let referenced_by_materialization = self
12118            .materializations
12119            .lock()
12120            .unwrap_or_else(|poisoned| poisoned.into_inner())
12121            .values()
12122            .any(|ownership| {
12123                ownership.context().map(|receipt| &receipt.id) == Some(context_id)
12124            });
12125        if referenced_by_binding
12126            || referenced_by_record
12127            || referenced_by_materialization
12128        {
12129            return Err(failure(
12130                NodeFailureCode::ContextPackBusy,
12131                "context pack is still referenced",
12132            ));
12133        }
12134        let mut catalog = self.context_catalog
12135            .write()
12136            .unwrap_or_else(|poisoned| poisoned.into_inner());
12137        if catalog.get(context_id).is_none() {
12138            return Err(failure(
12139                NodeFailureCode::UnknownContextPack,
12140                "context pack is unavailable",
12141            ));
12142        }
12143        self.spawn_idempotency
12144            .lock()
12145            .unwrap_or_else(|poisoned| poisoned.into_inner())
12146            .entries
12147            .retain(|_, entry| !entry.value.references_context(context_id));
12148        let removed = catalog.remove(context_id);
12149        debug_assert!(removed.is_some(), "validated context pack remains present");
12150        Ok(())
12151    }
12152
12153    async fn dispatch_input_bounded(
12154        &self,
12155        address: &SessionAddress,
12156        action: InputAction,
12157    ) -> Result<(), NodeFailure> {
12158        let started_after = self.current_sequence();
12159        self.pty_input_marks
12160            .lock()
12161            .unwrap_or_else(|poisoned| poisoned.into_inner())
12162            .insert(address.session.instance_id, (Instant::now(), false));
12163        self.pty_input_wake.notify_one();
12164        let command_id = self
12165            .dispatch_bounded(
12166                ControlCommand::SendInput {
12167                    instance_id: address.session.instance_id,
12168                    action,
12169                },
12170                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12171            )
12172            .await?;
12173        // A semantic prompt may spend the provider's full readiness budget before
12174        // the runtime emits InputCompleted. Keep the node request deadline bounded,
12175        // but strictly beyond every readiness policy in this node's catalog.
12176        let deadline = Instant::now() + Duration::from_millis(self.input_settle_timeout_ms);
12177        let mut scan_after = started_after;
12178        let mut accepted_after = None;
12179        loop {
12180            let events = self
12181                .history
12182                .lock()
12183                .unwrap_or_else(|poisoned| poisoned.into_inner())
12184                .events
12185                .iter()
12186                .filter(|envelope| envelope.sequence > scan_after)
12187                .cloned()
12188                .collect::<Vec<_>>();
12189            for envelope in events {
12190                scan_after = scan_after.max(envelope.sequence);
12191                let NodeEvent::Control {
12192                    address: event_address,
12193                    event,
12194                } = envelope.event
12195                else {
12196                    continue;
12197                };
12198                if event_address != *address {
12199                    continue;
12200                }
12201                if event.command_id == Some(command_id) {
12202                    match &event.event {
12203                        ControlEventKind::CommandRejected { message } => {
12204                            return Err(failure(NodeFailureCode::InvalidRequest, message));
12205                        }
12206                        ControlEventKind::InputRequested { .. } => {
12207                            accepted_after = Some(envelope.sequence);
12208                            continue;
12209                        }
12210                        _ => {}
12211                    }
12212                }
12213                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12214                    match &event.event {
12215                        ControlEventKind::InputCompleted { .. } => return Ok(()),
12216                        ControlEventKind::InputFailed { message, .. } => {
12217                            return Err(failure(
12218                                NodeFailureCode::BackendOperationFailed,
12219                                message,
12220                            ));
12221                        }
12222                        _ => {}
12223                    }
12224                }
12225            }
12226            if Instant::now() >= deadline {
12227                return Err(failure(
12228                    NodeFailureCode::BackendBusy,
12229                    if accepted_after.is_some() {
12230                        "input was accepted but did not settle before the bounded deadline"
12231                    } else {
12232                        "input was not accepted before the bounded deadline"
12233                    },
12234                ));
12235            }
12236            sleep(Duration::from_millis(2)).await;
12237        }
12238    }
12239
12240    async fn dispatch_resize_bounded(
12241        &self,
12242        address: &SessionAddress,
12243        size: gate4agent_types::TerminalSize,
12244    ) -> Result<(), NodeFailure> {
12245        let started_after = self.current_sequence();
12246        let command_id = self
12247            .dispatch_bounded(
12248                ControlCommand::Resize {
12249                    instance_id: address.session.instance_id,
12250                    size,
12251                },
12252                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12253            )
12254            .await?;
12255        let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
12256        let mut scan_after = started_after;
12257        let mut accepted_after = None;
12258        loop {
12259            let events = self
12260                .history
12261                .lock()
12262                .unwrap_or_else(|poisoned| poisoned.into_inner())
12263                .events
12264                .iter()
12265                .filter(|envelope| envelope.sequence > scan_after)
12266                .cloned()
12267                .collect::<Vec<_>>();
12268            for envelope in events {
12269                scan_after = scan_after.max(envelope.sequence);
12270                let NodeEvent::Control {
12271                    address: event_address,
12272                    event,
12273                } = envelope.event
12274                else {
12275                    continue;
12276                };
12277                if event_address != *address {
12278                    continue;
12279                }
12280                if event.command_id == Some(command_id) {
12281                    match &event.event {
12282                        ControlEventKind::CommandRejected { message } => {
12283                            return Err(failure(NodeFailureCode::InvalidRequest, message));
12284                        }
12285                        ControlEventKind::ResizeRequested {
12286                            size: requested_size,
12287                            ..
12288                        } if *requested_size == size => {
12289                            accepted_after = Some(envelope.sequence);
12290                            continue;
12291                        }
12292                        _ => {}
12293                    }
12294                }
12295                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12296                    match &event.event {
12297                        ControlEventKind::Resized { size: settled_size }
12298                            if *settled_size == size =>
12299                        {
12300                            return Ok(());
12301                        }
12302                        ControlEventKind::ResizeFailed { message } => {
12303                            return Err(failure(
12304                                NodeFailureCode::BackendOperationFailed,
12305                                message,
12306                            ));
12307                        }
12308                        _ => {}
12309                    }
12310                }
12311            }
12312            if Instant::now() >= deadline {
12313                return Err(failure(
12314                    NodeFailureCode::BackendBusy,
12315                    if accepted_after.is_some() {
12316                        "resize was accepted but did not settle before the bounded deadline"
12317                    } else {
12318                        "resize was not accepted before the bounded deadline"
12319                    },
12320                ));
12321            }
12322            sleep(Duration::from_millis(2)).await;
12323        }
12324    }
12325
12326    async fn spawn_session(
12327        &self,
12328        workspace_id: WorkspaceId,
12329        provider: AgentId,
12330        mode: SessionMode,
12331        terminal_size: gate4agent_types::TerminalSize,
12332        initial_prompt: Option<String>,
12333    ) -> Result<SessionAddress, NodeFailure> {
12334        if self.managed_worktrees.lock()
12335            .unwrap_or_else(|poisoned| poisoned.into_inner())
12336            .lease_for_workspace(&workspace_id)
12337            .is_some()
12338        {
12339            return Err(failure(
12340                NodeFailureCode::ManagedWorktreeOwnershipConflict,
12341                "managed worktree targets may only be spawned through their lease request",
12342            ));
12343        }
12344        self
12345            .spawn_session_with_deadline(
12346                workspace_id,
12347                provider,
12348                mode,
12349                terminal_size,
12350                initial_prompt,
12351                None,
12352                None,
12353                None,
12354                None,
12355                None,
12356                SpawnRecordPolicy::ProviderIdentityOnly,
12357                None,
12358                &[],
12359                None,
12360                // `NodeRequest::Spawn` (this method's only caller) carries no
12361                // approval-level selection of its own -- see
12362                // `HarnessOperatorRequestV1::SpawnSession`'s own field for
12363                // the one operator-wire path that can choose something other
12364                // than the axis default.
12365                ApprovalLevel::default(),
12366                None,
12367                None,
12368            )
12369            .await
12370            .map(|(session, _runtime_policy)| session)
12371    }
12372
12373    async fn spawn_session_with_deadline(
12374        &self,
12375        workspace_id: WorkspaceId,
12376        provider: AgentId,
12377        mode: SessionMode,
12378        terminal_size: gate4agent_types::TerminalSize,
12379        initial_prompt: Option<String>,
12380        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
12381        bundle: Option<ResolvedBundleReceipt>,
12382        context: Option<ResolvedContextPackReceipt>,
12383        managed_authority: Option<ManagedWorktreeLeaseId>,
12384        admitted_runtime_policy: Option<ProviderRuntimePolicy>,
12385        record_policy: SpawnRecordPolicy,
12386        deadline: Option<Instant>,
12387        required_capabilities: &[ProviderRuntimeCapability],
12388        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
12389        approval_level: ApprovalLevel,
12390        network_allowlist: Option<crate::protocol::SpawnNetworkAllowlistId>,
12391        // Opaque dig2browser station profile id (ids only). Feature
12392        // `dig2-station-probe`: after probe at resolve, spawn takes an
12393        // exclusive node-local lease; cleared on session end. Never
12394        // ImportSession / cookies on C2. Feature off: ignored (stub echo
12395        // already happened on the receipt path).
12396        browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
12397    ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
12398        let reserved_lease = self.managed_worktrees.lock()
12399            .unwrap_or_else(|poisoned| poisoned.into_inner())
12400            .lease_for_workspace(&workspace_id);
12401        match (reserved_lease.as_ref(), managed_authority.as_ref()) {
12402            (Some(expected), Some(provided)) if expected == provided => {}
12403            (None, None) => {}
12404            _ => {
12405                return Err(failure(
12406                    NodeFailureCode::ManagedWorktreeOwnershipConflict,
12407                    "spawn lacks exact managed worktree lease authority",
12408                ));
12409            }
12410        }
12411        // Refuse concurrent distinct environment profiles (incl. ProviderHome
12412        // bindings) on the same workspace cwd. Parallel path: managed worktree
12413        // spawn so each profile gets its own workspace_id.
12414        self.ensure_workspace_environment_profile_compatible(
12415            &workspace_id,
12416            environment_profile.as_ref(),
12417        )?;
12418        let runtime_requirement = match (mode, initial_prompt.is_some()) {
12419            (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
12420            (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
12421            (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
12422            (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
12423        };
12424        // This session's own transport -- computed once, here, from `mode`,
12425        // and reused both to admit spawn (below) and, further down, to
12426        // compute the final `runtime_policy` this session actually operates
12427        // under (`provider_runtime::policy_for_transport`). Registration
12428        // reuses this exact value rather than recomputing it a second time.
12429        let transport = match mode {
12430            SessionMode::Pty => TransportKind::Pty,
12431            SessionMode::Inline => TransportKind::Pipe,
12432            SessionMode::Acp => TransportKind::Acp,
12433        };
12434        let pipe_transport = self.provider_supports_pipe_transport(&provider);
12435        let acp_transport = self.provider_supports_acp_transport(&provider);
12436        let runtime_policy = if let Some(runtime_policy) = admitted_runtime_policy {
12437            require_policy(runtime_policy, pipe_transport, acp_transport, runtime_requirement).map_err(|_| failure(
12438                NodeFailureCode::UnsupportedSpawnCapability,
12439                "pre-admitted provider runtime no longer satisfies spawn requirements",
12440            ))?;
12441            runtime_policy
12442        } else if let Some(deadline) = deadline {
12443            let remaining = spawn_deadline_remaining(deadline)?;
12444            timeout(
12445                remaining,
12446                self.admit_provider_runtime(&provider, runtime_requirement),
12447            )
12448            .await
12449            .map_err(|_| {
12450                failure(
12451                    NodeFailureCode::SpawnDeadlineExceeded,
12452                    "provider admission exceeded the spawn deadline",
12453                )
12454            })??
12455        } else {
12456            self.admit_provider_runtime(&provider, runtime_requirement).await?
12457        };
12458        let runtime_policy = self.effective_spawn_runtime_policy(runtime_policy);
12459        // The single point where this session's `runtime_policy` becomes
12460        // transport-correct: `admit_provider_runtime` (and the pre-admitted
12461        // branch above) only ever compute a PTY-terminal-verification-shaped
12462        // value, sound outright only for `TransportKind::Pty` (Pipe still
12463        // relies on that same value downstream -- see `policy_for_transport`'s
12464        // own doc for why). `policy_for_transport` is the explicit,
12465        // exhaustive match Item 1 asks for -- this is the ONE place its
12466        // output becomes the session's `runtime_policy`, so every later
12467        // reader (`ControlCommand::Start`, `session_bindings`, and every
12468        // engine gate that reads `state.runtime_policy`) sees the same,
12469        // correctly-computed value.
12470        let runtime_policy =
12471            crate::provider_runtime::policy_for_transport(transport, runtime_policy);
12472        if required_capabilities
12473            .iter()
12474            .any(|capability| !runtime_policy.admits(*capability))
12475        {
12476            return Err(failure(
12477                NodeFailureCode::UnsupportedSpawnCapability,
12478                "provider runtime does not admit a required spawn capability",
12479            ));
12480        }
12481        if let Some(deadline) = deadline {
12482            spawn_deadline_remaining(deadline)?;
12483        }
12484        self.ensure_binding_capacity()?;
12485        let working_directory = self.workspace_root(&workspace_id)?;
12486        let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
12487        let session = SessionKey {
12488            instance_id,
12489            generation: SessionGeneration(1),
12490        };
12491        let address = SessionAddress {
12492            workspace_id: workspace_id.clone(),
12493            session,
12494        };
12495        // This is the request-accepted checkpoint: admission (managed
12496        // worktree authority, runtime capability, dispatch deadline) already
12497        // passed and a concrete session identity is minted. It is a distinct
12498        // state from the process actually running (`ControlEventKind::Running`,
12499        // logged in `publish_control`) or the terminal outcome (`Exited`/
12500        // `Failed`, logged there too) -- this line exists so that gap is no
12501        // longer silent.
12502        tracing::info!(
12503            node_id = %self.node_id,
12504            workspace_id = %workspace_id,
12505            provider = %provider,
12506            environment_profile = ?environment_profile.as_ref().map(|receipt| &receipt.profile_id),
12507            session = ?address.session,
12508            "session spawn request accepted",
12509        );
12510        let mut prepared_spawn = SpawnPreparer::prepare(
12511            self,
12512            SpawnPrepareRequest {
12513                instance_id,
12514                address: &address,
12515                provider: &provider,
12516                mode,
12517                environment_profile: environment_profile.as_ref(),
12518                bundle: bundle.as_ref(),
12519                context: context.as_ref(),
12520                managed_authority: managed_authority.as_ref(),
12521                harness_mcp,
12522                approval_level,
12523                network_allowlist: network_allowlist.as_ref(),
12524                browser_profile_id,
12525            },
12526        )?;
12527        let materialization_id = prepared_spawn.materialization_id.take();
12528        let dispatch_timeout = spawn_dispatch_timeout(deadline)?;
12529        let record_id = self.bind_spawn_session_with_materialization(
12530            &address,
12531            provider.clone(),
12532            mode,
12533            runtime_policy,
12534            record_policy,
12535            environment_profile,
12536            bundle,
12537            context,
12538            materialization_id,
12539        )?;
12540        prepared_spawn.retain_session_guards();
12541        // `transport` was already computed above, alongside this session's
12542        // `runtime_policy`; reused here for `Register` rather than derived a
12543        // second time.
12544        let agent_id = provider;
12545        let register_command_id = match self.dispatch_bounded(
12546            ControlCommand::Register {
12547                instance_id,
12548                agent_id,
12549                transport,
12550            },
12551            dispatch_timeout,
12552        )
12553        .await {
12554            Ok(command_id) => command_id,
12555            Err(error) => {
12556                if let Some(binding) = self.remove_binding(&address) {
12557                    SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
12558                }
12559                if let Some(record_id) = record_id.as_ref() {
12560                    self.discard_record(record_id)?;
12561                }
12562                return Err(spawn_dispatch_error(error, deadline));
12563            }
12564        };
12565        let start_timeout = match spawn_dispatch_timeout(deadline) {
12566            Ok(timeout) => timeout,
12567            Err(error) => {
12568                let recovery = self
12569                    .rollback_spawn(
12570                        &address,
12571                        Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12572                    )
12573                    .await;
12574                return match recovery {
12575                    Ok(()) => {
12576                        if let Some(record_id) = record_id.as_ref() {
12577                            self.discard_record(record_id)?;
12578                        }
12579                        Err(error)
12580                    }
12581                    Err(recovery_error) => {
12582                        if let Some(record_id) = record_id.as_ref() {
12583                            self.mark_record_error(
12584                                record_id,
12585                                "spawn deadline elapsed and registration recovery failed",
12586                            )?;
12587                        }
12588                        Err(recovery_error)
12589                    }
12590                };
12591            }
12592        };
12593        if let Err(start_error) = self.verify_harness_mcp_before_start(harness_mcp) {
12594            if let Err(recovery_error) = self
12595                .rollback_spawn(&address, Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS))
12596                .await
12597            {
12598                return Err(recovery_error);
12599            }
12600            if let Some(record_id) = record_id.as_ref() {
12601                self.discard_record(record_id)?;
12602            }
12603            return Err(start_error);
12604        }
12605        let start_result = self
12606            .dispatch_bounded(
12607                ControlCommand::Start {
12608                    instance_id,
12609                    runtime_policy,
12610                    request: StartRequest {
12611                        working_directory,
12612                        terminal_size,
12613                        initial_prompt,
12614                        session_options: None,
12615                        approval_level,
12616                    },
12617                },
12618                start_timeout,
12619            )
12620            .await;
12621        let start_command_id = match start_result {
12622            Ok(command_id) => command_id,
12623            Err(start_error) => {
12624                let start_error = spawn_dispatch_error(start_error, deadline);
12625                let recovery = self
12626                    .rollback_spawn(
12627                        &address,
12628                        Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12629                    )
12630                    .await;
12631                return match recovery {
12632                    Ok(()) => {
12633                        if let Some(record_id) = record_id.as_ref() {
12634                            self.discard_record(record_id)?;
12635                        }
12636                        Err(start_error)
12637                    }
12638                    Err(recovery_error) => {
12639                        let diagnostic = format!(
12640                            "start failed and registration recovery failed: {}",
12641                            recovery_error.message,
12642                        );
12643                        if let Some(record_id) = record_id.as_ref() {
12644                            self.mark_record_error(record_id, &diagnostic)?;
12645                        }
12646                        Err(failure(recovery_error.code, &diagnostic))
12647                    }
12648                };
12649            }
12650        };
12651
12652        let commit_deadline = deadline.unwrap_or_else(|| Instant::now() + dispatch_timeout);
12653        loop {
12654            // `Register`/`Start` only fail synchronously for a port-level
12655            // ingress problem (see `dispatch_bounded`); the kernel refusing
12656            // either command's content -- an unsupported transport, an
12657            // unknown agent -- lands one drive-loop tick later, and no
12658            // session ever appears in `self.handle.snapshot()` for it to
12659            // notice below. Check for that named reason before falling
12660            // through to the blind presence poll, so a rejected spawn
12661            // reports why instead of just timing out.
12662            if let Some(reason) = self.take_rejected_command_reason(register_command_id)
12663                .or_else(|| self.take_rejected_command_reason(start_command_id))
12664            {
12665                self.rollback_spawn(
12666                    &address,
12667                    Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12668                ).await.map_err(|error| {
12669                        failure(
12670                            error.code,
12671                            &format!(
12672                                "spawn was rejected ({}) and registration recovery failed: {}",
12673                                reason.message, error.message
12674                            ),
12675                        )
12676                    })?;
12677                if let Some(record_id) = record_id.as_ref() {
12678                    self.discard_record(record_id)?;
12679                }
12680                let code = if reason.unsupported_transport {
12681                    NodeFailureCode::UnsupportedTransport
12682                } else {
12683                    NodeFailureCode::BackendOperationFailed
12684                };
12685                return Err(failure(code, &reason.message));
12686            }
12687            if let Some(current) = self
12688                .handle
12689                .snapshot()
12690                .sessions
12691                .iter()
12692                .find(|current| current.instance_id == instance_id)
12693            {
12694                if current.generation != session.generation {
12695                    let divergence = failure(
12696                        NodeFailureCode::StaleGeneration,
12697                        "spawn generation diverged",
12698                    );
12699                    return match self
12700                        .rollback_spawn(
12701                            &address,
12702                            Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12703                        )
12704                        .await
12705                    {
12706                        Ok(()) => {
12707                            if let Some(record_id) = record_id.as_ref() {
12708                                self.discard_record(record_id)?;
12709                            }
12710                            Err(divergence)
12711                        }
12712                        Err(recovery_error) => {
12713                            if let Some(record_id) = record_id.as_ref() {
12714                                self.mark_record_error(
12715                                    record_id,
12716                                    "spawn generation diverged and recovery failed",
12717                                )?;
12718                            }
12719                            Err(recovery_error)
12720                        }
12721                    };
12722                }
12723                if !matches!(current.status, gate4agent_types::SessionStatus::Registered) {
12724                    prepared_spawn.retain_launch_guards();
12725                    return Ok((address, runtime_policy));
12726                }
12727            }
12728            if Instant::now() >= commit_deadline {
12729                self.rollback_spawn(
12730                    &address,
12731                    Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12732                ).await.map_err(|error| {
12733                        failure(
12734                            error.code,
12735                            &format!(
12736                                "start did not commit and registration recovery failed: {}",
12737                                error.message
12738                            ),
12739                        )
12740                    })?;
12741                if let Some(record_id) = record_id.as_ref() {
12742                    self.discard_record(record_id)?;
12743                }
12744                return Err(failure(
12745                    if deadline.is_some() {
12746                        NodeFailureCode::SpawnDeadlineExceeded
12747                    } else {
12748                        NodeFailureCode::BackendBusy
12749                    },
12750                    "start did not commit before the bounded deadline; registration was removed",
12751                ));
12752            }
12753            sleep(Duration::from_millis(2)).await;
12754        }
12755    }
12756
12757    fn effective_spawn_runtime_policy(
12758        &self,
12759        runtime_policy: ProviderRuntimePolicy,
12760    ) -> ProviderRuntimePolicy {
12761        #[cfg(feature = "fixture")]
12762        let runtime_policy = if self.fixture_semantic_hook_policy {
12763            ProviderRuntimePolicy::new(true, true, true, true, false, true)
12764                .expect("monitoring Hook fixture policy is internally valid")
12765        } else {
12766            runtime_policy
12767        };
12768        runtime_policy
12769    }
12770
12771    fn verify_harness_mcp_before_start(
12772        &self,
12773        prepared: Option<&PreparedHarnessMcpSpawn>,
12774    ) -> Result<(), NodeFailure> {
12775        prepared
12776            .map(PreparedHarnessMcpSpawn::verify_helper_program)
12777            .transpose()
12778            .map(|_| ())
12779            .map_err(harness_mcp_failure)
12780    }
12781
12782    async fn rollback_spawn(
12783        &self,
12784        address: &SessionAddress,
12785        commit_timeout: Duration,
12786    ) -> Result<(), NodeFailure> {
12787        self.dispatch_bounded(
12788            ControlCommand::Remove {
12789                instance_id: address.session.instance_id,
12790            },
12791            commit_timeout,
12792        )
12793        .await?;
12794        self.wait_until_removed(address, commit_timeout).await?;
12795        if let Some(binding) = self.remove_binding(address) {
12796            SessionLifecycleHook::cleanup_session_materialization(self, address, &binding)?;
12797        }
12798        Ok(())
12799    }
12800
12801    async fn remove_session(&self, address: &SessionAddress) -> Result<(), NodeFailure> {
12802        let commit_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
12803        self.dispatch_bounded(
12804            ControlCommand::Remove {
12805                instance_id: address.session.instance_id,
12806            },
12807            commit_timeout,
12808        )
12809            .await?;
12810        self.wait_until_removed(address, commit_timeout).await?;
12811        let removed = self.remove_binding(address);
12812        if let Some(binding) = removed.as_ref() {
12813            SessionLifecycleHook::cleanup_session_materialization(self, address, binding)?;
12814        }
12815        if let Some(lease_id) = removed.and_then(|binding| binding.managed_worktree_lease_id) {
12816            let snapshot = self.managed_worktrees.lock()
12817                .unwrap_or_else(|poisoned| poisoned.into_inner())
12818                .release_session(
12819                    &lease_id,
12820                    self.incarnation_id,
12821                    address.session.instance_id,
12822                    address.session.generation,
12823                    unix_time_ms(),
12824                );
12825            if self.persist_state().is_err() {
12826                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
12827            }
12828            if let Some(snapshot) = snapshot {
12829                self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
12830            }
12831            let _ = self.cleanup_managed_worktree(&lease_id, false).await;
12832        }
12833        Ok(())
12834    }
12835
12836    /// Polls until `address`'s session leaves the engine's own session map,
12837    /// or the bounded deadline expires.
12838    ///
12839    /// A `Remove` sent while the session is not yet in a terminal status
12840    /// (e.g. still `Stopping`, mid-effect right after a `Stop`) is rejected
12841    /// once by the engine (`SessionStatus::allows_remove`) and
12842    /// then discarded -- nothing re-issues it on its own, so the removal
12843    /// that would satisfy this wait never arrives and the caller was
12844    /// guaranteed to time out even though the session settles microseconds
12845    /// later. So this re-dispatches on every poll tick: a session that
12846    /// settles mid-wait -- the common case right after a `Stop` -- still
12847    /// gets removed inside the same bounded deadline.
12848    ///
12849    /// It re-dispatches only once the status will ACCEPT the command. A
12850    /// rejected re-dispatch is not the harmless no-op this loop first
12851    /// assumed: every rejection publishes a `ControlEventKind::Command
12852    /// Rejected` to every subscriber and writes a WARN line. Ungated, one
12853    /// ordinary teardown produced 241 of them in 1.6 seconds -- the whole
12854    /// `Stopping` window at one known-doomed dispatch per 2ms tick, fanned
12855    /// out to the harness and every attached client. Gated, the same
12856    /// teardown sends exactly one `Remove`, at the first tick the engine
12857    /// can act on it.
12858    ///
12859    /// The status is read from the snapshot this loop already takes, so
12860    /// the gate costs nothing extra. It races -- the status can move
12861    /// between the read and the dispatch -- and that is fine: a rare lost
12862    /// race is one rejection, and the next tick retries.
12863    ///
12864    /// Re-dispatch is separately rate-limited to
12865    /// [`REMOVE_REDISPATCH_INTERVAL_MS`], because the status gate alone
12866    /// leaves a second burst behind it. Once the session settles, every
12867    /// 2ms tick sent another `Remove` while the accepted one was still in
12868    /// flight, and each of those landed on an instance the engine had
12869    /// already dropped -- `agent instance … is not registered`, observed
12870    /// live as a tail of rejections immediately after the `session
12871    /// removed` line. The presence poll stays at 2ms so the wait still
12872    /// exits promptly; only the sending slows down.
12873    ///
12874    /// Rate-limited rather than once-only on purpose: the status gate
12875    /// covers the status precondition, but `Gate4AgentEngine::remove` has
12876    /// others (a pending operation, an in-flight capability probe or
12877    /// history request) that clear on their own. A single shot would turn
12878    /// those into a guaranteed `BackendBusy` timeout; at 10Hz they still
12879    /// get their retries, just twenty of them across the deadline instead
12880    /// of a thousand.
12881    async fn wait_until_removed(
12882        &self,
12883        address: &SessionAddress,
12884        commit_timeout: Duration,
12885    ) -> Result<(), NodeFailure> {
12886        let deadline = Instant::now() + commit_timeout;
12887        let mut next_dispatch = Instant::now();
12888        loop {
12889            let snapshot = self.handle.snapshot();
12890            let status = snapshot
12891                .sessions
12892                .iter()
12893                .find(|session| session.instance_id == address.session.instance_id)
12894                .map(|session| session.status.clone());
12895            let Some(status) = status else {
12896                return Ok(());
12897            };
12898            let now = Instant::now();
12899            if now >= deadline {
12900                return Err(failure(
12901                    NodeFailureCode::BackendBusy,
12902                    "remove did not commit before the bounded deadline; session binding was retained",
12903                ));
12904            }
12905            if status.allows_remove() && now >= next_dispatch {
12906                let _ = self.dispatch(ControlCommand::Remove {
12907                    instance_id: address.session.instance_id,
12908                });
12909                next_dispatch = now + Duration::from_millis(REMOVE_REDISPATCH_INTERVAL_MS);
12910            }
12911            sleep(Duration::from_millis(2)).await;
12912        }
12913    }
12914
12915    fn resync(&self, after_sequence: u64) -> NodeResponse {
12916        let history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
12917        let event_sequence = history.last_sequence;
12918        let oldest_available_sequence = history.replay_floor_sequence;
12919        let events = history
12920            .events
12921            .iter()
12922            .filter(|event| event.sequence > after_sequence)
12923            .cloned()
12924            .collect();
12925        drop(history);
12926        NodeResponse::Resync {
12927            event_sequence,
12928            oldest_available_sequence,
12929            snapshot: self.snapshot(),
12930            events,
12931        }
12932    }
12933}
12934
12935/// Coarse label for one `AgentStreamChunkKindV1`, used only by
12936/// `NodeShared::publish_agent_stream_chunk`'s own debug log -- mirrors
12937/// `hatchery-harness-service`'s `agent_stream_event_kind_label` (same
12938/// "name what crossed this boundary" reasoning, one layer upstream of it).
12939fn agent_stream_chunk_kind_label(kind: &AgentStreamChunkKindV1) -> &'static str {
12940    match kind {
12941        AgentStreamChunkKindV1::Text { .. } => "text",
12942        AgentStreamChunkKindV1::Thinking { .. } => "thinking",
12943        AgentStreamChunkKindV1::InteractionPrompt { .. } => "interaction-prompt",
12944        AgentStreamChunkKindV1::ModeCatalog { .. } => "mode-catalog",
12945        AgentStreamChunkKindV1::ConfigOptions { .. } => "config-options",
12946        AgentStreamChunkKindV1::ModelCatalog { .. } => "model-catalog",
12947        AgentStreamChunkKindV1::Blocked { .. } => "blocked",
12948    }
12949}
12950
12951/// Serves this wire on the local endpoint, and -- when `call_home` names a
12952/// relay -- over an outbound connection to it as well.
12953///
12954/// The two live in one future rather than a fourth arm of `run`'s own
12955/// `select!` because they are one concern: this is where the node's wire
12956/// is served, and the direction a given connection was opened in does not
12957/// change what happens on it. It also means the existing graceful drain
12958/// below covers both without being duplicated.
12959///
12960/// Either side finishing ends the pair, which is correct: the listener
12961/// only returns on shutdown or a bind failure, and `call_home_loop` only
12962/// returns on shutdown -- a failed relay connection is retried inside it,
12963/// never propagated out.
12964async fn accept_connections(
12965    endpoint: &str,
12966    call_home: Option<std::net::SocketAddr>,
12967    identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
12968    shared: Arc<NodeShared>,
12969) -> Result<(), NodeServerError> {
12970    let mut connections = JoinSet::new();
12971    let result = match call_home {
12972        None => accept_connections_inner(endpoint, shared, &mut connections).await,
12973        Some(relay) => {
12974            let listener =
12975                accept_connections_inner(endpoint, Arc::clone(&shared), &mut connections);
12976            let dialler = call_home_loop(relay, identity, Arc::clone(&shared));
12977            tokio::pin!(listener);
12978            tokio::pin!(dialler);
12979            tokio::select! {
12980                result = &mut listener => result,
12981                result = &mut dialler => result,
12982            }
12983        }
12984    };
12985    let graceful_deadline = Instant::now() + Duration::from_millis(CONNECTION_SHUTDOWN_GRACE_MS);
12986    while !connections.is_empty() {
12987        let remaining = graceful_deadline.saturating_duration_since(Instant::now());
12988        if remaining.is_zero()
12989            || timeout(remaining, connections.join_next()).await.is_err()
12990        {
12991            break;
12992        }
12993    }
12994    connections.abort_all();
12995    while connections.join_next().await.is_some() {}
12996    result
12997}
12998
12999/// Backoff ladder for a relay that will not answer, capped rather than
13000/// unbounded: a node whose relay is down is not broken and should keep
13001/// trying forever, but it should not spend a core doing it.
13002const CALL_HOME_BACKOFFS_MS: [u64; 5] = [500, 1_000, 2_000, 4_000, 8_000];
13003
13004/// Dials the relay, serves this wire over the connection, and does it
13005/// again when the connection ends.
13006///
13007/// The node is the SERVER on the socket it just opened. That inversion is
13008/// the whole point: `serve_connection` is the same function the local
13009/// listener feeds, and it neither knows nor cares which end placed the
13010/// call. The one thing this direction adds is the preface -- the relay
13011/// accepted a socket and cannot know whose it is, so the node says its
13012/// name before the handshake it would otherwise begin with.
13013///
13014/// Never returns an error. A relay that is down, refusing, or speaking a
13015/// different protocol is a condition to retry, not a reason to take the
13016/// node's local endpoint down with it; the only way out is shutdown.
13017async fn call_home_loop(
13018    relay: std::net::SocketAddr,
13019    identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13020    shared: Arc<NodeShared>,
13021) -> Result<(), NodeServerError> {
13022    let mut failures = 0_usize;
13023    loop {
13024        if shared.shutdown.load(Ordering::Acquire) {
13025            return Ok(());
13026        }
13027        let permit = tokio::select! {
13028            permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13029                permit.map_err(|_| NodeServerError::PreauthClosed)?
13030            }
13031            _ = shared.shutdown_notify.notified() => return Ok(()),
13032        };
13033        match dial_relay_and_serve(relay, identity.as_deref(), Arc::clone(&shared), permit).await {
13034            Ok(()) => {
13035                // A clean end is the relay hanging up, which it does on
13036                // its own restart. Reconnect immediately rather than
13037                // serving the backoff a peer that is plainly there.
13038                failures = 0;
13039                tracing::info!(node_id = %shared.node_id, %relay, "call-home connection closed");
13040            }
13041            Err(error) => {
13042                failures = failures.saturating_add(1);
13043                tracing::warn!(
13044                    node_id = %shared.node_id,
13045                    %relay,
13046                    cause = %error,
13047                    attempt = failures,
13048                    "call-home connection failed",
13049                );
13050                let backoff = CALL_HOME_BACKOFFS_MS
13051                    [failures.saturating_sub(1).min(CALL_HOME_BACKOFFS_MS.len() - 1)];
13052                tokio::select! {
13053                    _ = sleep(Duration::from_millis(backoff)) => {}
13054                    _ = shared.shutdown_notify.notified() => return Ok(()),
13055                }
13056            }
13057        }
13058    }
13059}
13060
13061/// One call-home attempt: connect, name this node, then serve.
13062async fn dial_relay_and_serve(
13063    relay: std::net::SocketAddr,
13064    identity: Option<&hatchery_enroll::NodeIdentity>,
13065    shared: Arc<NodeShared>,
13066    preauth_permit: OwnedSemaphorePermit,
13067) -> Result<(), NodeServerError> {
13068    let mut stream = tokio::net::TcpStream::connect(relay)
13069        .await
13070        .map_err(NodeServerError::CallHomeTransport)?;
13071    // Nagle off for the same reason the rest of this wire wants it off:
13072    // the traffic is small request/response frames, and coalescing them
13073    // adds latency to every one.
13074    let _ = stream.set_nodelay(true);
13075    gate4agent_node_wire::write_call_home_announce(&mut stream, &shared.node_id)
13076        .await
13077        .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13078    tracing::info!(node_id = %shared.node_id, %relay, "announced this node to the relay");
13079    let mut session_token = None;
13080    if let Some(identity) = identity {
13081        // An enrolled relay answers the announce with a signed-call-home challenge; a
13082        // token-mode relay sends its wire hello instead. Peek to tell them apart
13083        // without consuming either.
13084        let mut magic = [0_u8; 4];
13085        let peeked = timeout(Duration::from_secs(10), stream.peek(&mut magic))
13086            .await
13087            .map_err(|_| NodeServerError::Handshake("relay sent nothing after the announce".to_owned()))?
13088            .map_err(NodeServerError::CallHomeTransport)?;
13089        if peeked == 4 && &magic == hatchery_enroll::CHALLENGE_MAGIC {
13090            let mut frame = [0_u8; hatchery_enroll::CHALLENGE_LEN];
13091            timeout(Duration::from_secs(5), tokio::io::AsyncReadExt::read_exact(&mut stream, &mut frame))
13092                .await
13093                .map_err(|_| NodeServerError::Handshake("relay challenge timed out".to_owned()))?
13094                .map_err(NodeServerError::CallHomeTransport)?;
13095            let (answer, token) = identity
13096                .answer_challenge(shared.node_id.as_str(), &frame)
13097                .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13098            tokio::io::AsyncWriteExt::write_all(&mut stream, &answer).await.map_err(NodeServerError::CallHomeTransport)?;
13099            tracing::info!(node_id = %shared.node_id, "answered the relay's signed call-home challenge");
13100            session_token = Some(token);
13101        }
13102    }
13103    serve_connection(stream, shared, session_token, preauth_permit).await
13104}
13105
13106async fn accept_connections_inner(
13107    endpoint: &str,
13108    shared: Arc<NodeShared>,
13109    connections: &mut JoinSet<Result<(), NodeServerError>>,
13110) -> Result<(), NodeServerError> {
13111    let mut listener = OwnerOnlyLocalListener::bind(endpoint).await?;
13112    loop {
13113        while connections.try_join_next().is_some() {}
13114        if shared.shutdown.load(Ordering::Acquire) {
13115            return Ok(());
13116        }
13117        let preauth_permit = tokio::select! {
13118            permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13119                permit.map_err(|_| NodeServerError::PreauthClosed)?
13120            }
13121            _ = shared.shutdown_notify.notified() => return Ok(()),
13122        };
13123        let server = tokio::select! {
13124            result = listener.accept() => result?,
13125            _ = shared.shutdown_notify.notified() => return Ok(()),
13126        };
13127        let connection_shared = Arc::clone(&shared);
13128        connections.spawn(async move {
13129            serve_connection(server, connection_shared, None, preauth_permit).await
13130        });
13131        if shared.shutdown.load(Ordering::Acquire) {
13132            return Ok(());
13133        }
13134    }
13135}
13136
13137/// Serves one connection, whichever end opened it.
13138///
13139/// Generic over the stream rather than typed to `LocalServerStream`
13140/// because a call-home connection arrives on a `TcpStream` the node itself
13141/// dialled. Nothing in the body ever depended on the concrete type -- it
13142/// reads and writes framed JSON and then `tokio::io::split`s -- so this is
13143/// the whole of what welding "who dials" to "who serves" was costing.
13144async fn serve_connection<S>(
13145    mut pipe: S,
13146    shared: Arc<NodeShared>,
13147    session_token: Option<String>,
13148    preauth_permit: OwnedSemaphorePermit,
13149) -> Result<(), NodeServerError>
13150where
13151    S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
13152{
13153    // A signed call-home connection authenticates with its own per-connection token.
13154    let auth_token: &[u8] = session_token.as_deref().map_or(shared.access_token.as_bytes(), str::as_bytes);
13155    let hello_frame = timeout(
13156        Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13157        read_json_frame_limited_body_timeout(
13158            &mut pipe,
13159            MAX_NODE_HELLO_FRAME_BYTES,
13160            Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13161        ),
13162    )
13163    .await
13164    .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13165    let ClientFrame::Hello(hello) = hello_frame else {
13166        return Err(NodeServerError::Handshake("first frame must be hello".to_owned()));
13167    };
13168    if hello.build_stamp != BUILD_STAMP {
13169        return Err(NodeServerError::Handshake(format!(
13170            "build stamp mismatch: local={BUILD_STAMP} remote={}",
13171            hello.build_stamp,
13172        )));
13173    }
13174    let compatibility = match hello.compatibility.as_ref() {
13175        Some(offer) => {
13176            let mut selected = node_compatibility_support(&shared)?
13177                .negotiate(offer)
13178                .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13179            project_negotiated_provider_ids(&mut selected);
13180            Some(selected)
13181        }
13182        None => None,
13183    };
13184    let server_nonce = random_nonce().map_err(NodeServerError::Authentication)?;
13185    let server_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13186        (Some(offer), Some(selected)) => negotiated_auth_proof(
13187            auth_token,
13188            AuthDirection::Server,
13189            hello.role,
13190            &hello.client_nonce,
13191            &server_nonce,
13192            offer,
13193            selected,
13194        ),
13195        (None, None) => auth_proof(
13196            auth_token,
13197            AuthDirection::Server,
13198            hello.role,
13199            &hello.client_nonce,
13200            &server_nonce,
13201        ),
13202        _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13203    }
13204    .map_err(NodeServerError::Authentication)?;
13205    write_json_frame_limited(
13206        &mut pipe,
13207        &ServerFrame::Challenge(ServerChallenge {
13208            build_stamp: BUILD_STAMP.to_owned(),
13209            server_nonce,
13210            server_proof,
13211            compatibility: compatibility.clone(),
13212        }),
13213        MAX_NODE_HELLO_FRAME_BYTES,
13214    )
13215    .await?;
13216    let authentication = timeout(
13217        Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13218        read_json_frame_limited_body_timeout(
13219            &mut pipe,
13220            MAX_NODE_HELLO_FRAME_BYTES,
13221            Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13222        ),
13223    )
13224    .await
13225    .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13226    let ClientFrame::Authenticate(authentication) = authentication else {
13227        return Err(NodeServerError::Handshake("second frame must authenticate the challenge".to_owned()));
13228    };
13229    let expected_client_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13230        (Some(offer), Some(selected)) => negotiated_auth_proof(
13231            auth_token,
13232            AuthDirection::Client,
13233            hello.role,
13234            &hello.client_nonce,
13235            &server_nonce,
13236            offer,
13237            selected,
13238        ),
13239        (None, None) => auth_proof(
13240            auth_token,
13241            AuthDirection::Client,
13242            hello.role,
13243            &hello.client_nonce,
13244            &server_nonce,
13245        ),
13246        _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13247    }
13248    .map_err(NodeServerError::Authentication)?;
13249    if !proofs_match(&authentication.client_proof, &expected_client_proof) {
13250        return Err(NodeServerError::Handshake("access denied".to_owned()));
13251    }
13252    let selected_capabilities = compatibility
13253        .as_ref()
13254        .map(|selected| selected.capabilities.clone())
13255        .unwrap_or_default();
13256    let include_provider_runtime_status = selected_capabilities.iter().any(|capability| {
13257        capability.as_str() == NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY
13258    });
13259    let include_open_provider_ids = selected_capabilities.iter().any(|capability| {
13260        capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
13261    });
13262    let include_terminal_frame_events = selected_capabilities.iter().any(|capability| {
13263        capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
13264    });
13265    let include_agent_stream_events = selected_capabilities.iter().any(|capability| {
13266        capability.as_str() == NODE_AGENT_STREAM_EVENTS_CAPABILITY
13267    });
13268    let include_spawn_profiles = selected_capabilities.iter().any(|capability| {
13269        capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
13270    });
13271    let include_managed_worktrees = selected_capabilities.iter().any(|capability| {
13272        capability.as_str() == NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY
13273    }) && selected_capabilities.iter().any(|capability| {
13274        capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
13275    });
13276    let include_child_environment_profiles = selected_capabilities.iter().any(|capability| {
13277        capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
13278    });
13279    let include_session_bundles = selected_capabilities.iter().any(|capability| {
13280        capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
13281    });
13282    let include_history_context_packs = selected_capabilities.iter().any(|capability| {
13283        capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
13284    });
13285    let include_agent_progress = selected_capabilities.iter().any(|capability| {
13286        capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
13287    });
13288    let include_session_task_correlation = selected_capabilities.iter().any(|capability| {
13289        capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
13290    });
13291    let include_harness_mcp_proxy = selected_capabilities.iter().any(|capability| {
13292        capability.as_str() == NODE_HARNESS_MCP_READ_PROXY_CAPABILITY
13293    });
13294    let authenticated_permit = Arc::clone(&shared.authenticated_slots)
13295        .try_acquire_owned()
13296        .map_err(|_| NodeServerError::AuthenticatedConnectionLimit)?;
13297    let connection_id = shared.next_connection_id.fetch_add(1, Ordering::AcqRel);
13298    let _release_guard = ControllerReleaseGuard {
13299        shared: Arc::clone(&shared),
13300        connection_id,
13301    };
13302    drop(preauth_permit);
13303    let _authenticated_permit = authenticated_permit;
13304    let mut event_rx = shared.event_tx.subscribe();
13305    let mut terminal_event_rx = terminal_event_subscription(
13306        &shared,
13307        include_terminal_frame_events,
13308    );
13309    let mut agent_stream_event_rx = agent_stream_event_subscription(
13310        &shared,
13311        include_agent_stream_events,
13312    );
13313    let mut harness_mcp_proxy_event_rx = harness_mcp_proxy_event_subscription(
13314        &shared,
13315        include_harness_mcp_proxy,
13316    );
13317    write_json_frame(
13318        &mut pipe,
13319        &ServerFrame::Hello(NodeHello {
13320            build_stamp: BUILD_STAMP.to_owned(),
13321            incarnation_id: shared.incarnation_id,
13322            connection_id,
13323            role: hello.role,
13324            event_sequence: shared.current_sequence(),
13325            controller: shared.controller_state(),
13326            snapshot: snapshot_for_wire(
13327                &shared,
13328                include_provider_runtime_status,
13329                include_open_provider_ids,
13330                include_spawn_profiles,
13331                include_managed_worktrees,
13332                include_child_environment_profiles,
13333                include_session_bundles,
13334                include_history_context_packs,
13335                include_agent_progress,
13336                include_session_task_correlation,
13337            ),
13338            compatibility,
13339        }),
13340    )
13341    .await?;
13342    let (mut reader, mut writer) = tokio::io::split(pipe);
13343    let (frame_tx, mut frame_rx) = mpsc::channel(16);
13344    let reader_task = tokio::spawn(async move {
13345        loop {
13346            let frame = read_json_frame_limited_body_timeout::<_, ClientFrame>(
13347                &mut reader,
13348                MAX_NODE_CLIENT_FRAME_BYTES,
13349                Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13350            )
13351            .await;
13352            let terminal = frame.is_err();
13353            if frame_tx.send(frame).await.is_err() || terminal {
13354                break;
13355            }
13356        }
13357    });
13358    let _reader_abort = AbortTaskOnDrop(reader_task.abort_handle());
13359    let mut pending_events = Vec::new();
13360    let mut resync_required = false;
13361    let mut discard_events_through = 0_u64;
13362    loop {
13363        // One tick of the serve loop, whether or not it had anything to do.
13364        // Against `connection_events_sent` this separates "busy" from
13365        // "spinning": a loop turning over thousands of times a second while
13366        // sending nothing is not serving, it is burning a core.
13367        shared.connection_loop_iterations.fetch_add(1, Ordering::Relaxed);
13368        let durable_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13369            .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13370        for _ in 0..durable_drain_budget {
13371            match event_rx.try_recv() {
13372                Ok(event) => queue_connection_event(
13373                    &mut pending_events,
13374                    event,
13375                    discard_events_through,
13376                ),
13377                Err(broadcast::error::TryRecvError::Empty) => break,
13378                Err(broadcast::error::TryRecvError::Lagged(_)) => {
13379                    resync_required = true;
13380                    break;
13381                }
13382                Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13383            }
13384        }
13385        if let Some(receiver) = terminal_event_rx.as_mut() {
13386            match receiver.try_recv() {
13387                Ok(events) => queue_connection_event_batch(
13388                    &mut pending_events,
13389                    &events,
13390                    discard_events_through,
13391                ),
13392                Err(broadcast::error::TryRecvError::Empty) => {}
13393                Err(broadcast::error::TryRecvError::Lagged(_)) => {
13394                    resync_required = true;
13395                }
13396                Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13397            }
13398        }
13399        if let Some(receiver) = agent_stream_event_rx.as_mut() {
13400            // Loops up to the same remaining-room budget the durable
13401            // `event_rx` drain above uses, rather than one `try_recv` per
13402            // tick: each successful receive here is itself only ONE
13403            // `NodeEventEnvelope` batch (`publish_agent_stream_chunk` always
13404            // sends `vec![envelope]`, never more), so a single-shot drain
13405            // caps this connection's agent-stream throughput at one chunk
13406            // per outer-loop tick regardless of how many are already queued
13407            // -- exactly the kind of self-inflicted stall this crate's own
13408            // durable-event budget exists to avoid.
13409            let agent_stream_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13410                .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13411            for _ in 0..agent_stream_drain_budget {
13412                match receiver.try_recv() {
13413                    Ok(events) => queue_connection_event_batch(
13414                        &mut pending_events,
13415                        &events,
13416                        discard_events_through,
13417                    ),
13418                    Err(broadcast::error::TryRecvError::Empty) => break,
13419                    Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
13420                        tracing::warn!(
13421                            connection_id,
13422                            dropped,
13423                            "agent stream event subscription lagged; forcing a resync",
13424                        );
13425                        resync_required = true;
13426                        break;
13427                    }
13428                    Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13429                }
13430            }
13431        }
13432        if let Some(receiver) = harness_mcp_proxy_event_rx.as_mut() {
13433            // Mirrors the agent-stream drain immediately above: each
13434            // successful receive is one single-envelope batch
13435            // (`publish_transient` always sends `vec![envelope]`), so this
13436            // loops up to the same remaining-room budget rather than
13437            // stopping after one `try_recv`.
13438            let harness_mcp_proxy_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13439                .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13440            for _ in 0..harness_mcp_proxy_drain_budget {
13441                match receiver.try_recv() {
13442                    Ok(events) => queue_connection_event_batch(
13443                        &mut pending_events,
13444                        &events,
13445                        discard_events_through,
13446                    ),
13447                    Err(broadcast::error::TryRecvError::Empty) => break,
13448                    Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
13449                        tracing::warn!(
13450                            connection_id,
13451                            dropped,
13452                            "harness MCP proxy event subscription lagged; forcing a resync",
13453                        );
13454                        resync_required = true;
13455                        break;
13456                    }
13457                    Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13458                }
13459            }
13460        }
13461        if resync_required {
13462            let event = resync_required_event(&shared);
13463            apply_connection_resync_watermark(
13464                &mut pending_events,
13465                &mut discard_events_through,
13466                event.sequence,
13467            );
13468            write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
13469            resync_required = false;
13470            continue;
13471        }
13472        if !pending_events.is_empty() {
13473            pending_events.sort_unstable_by_key(|event| event.sequence);
13474            let burst_len = connection_event_burst_len(pending_events.len());
13475            shared
13476                .connection_events_sent
13477                .fetch_add(burst_len as u64, Ordering::Relaxed);
13478            for event in pending_events.drain(..burst_len) {
13479                let event = if include_managed_worktrees {
13480                    Some(event)
13481                } else {
13482                    project_event_without_managed_worktrees(event)
13483                };
13484                let event = event.and_then(|event| if include_open_provider_ids {
13485                    Some(event)
13486                } else {
13487                    project_event_legacy_provider_ids(&shared, event)
13488                });
13489                let event = event.map(|event| if include_child_environment_profiles {
13490                    event
13491                } else {
13492                    project_event_without_child_environment_profile(event)
13493                });
13494                let event = event.map(|event| if include_session_bundles {
13495                    event
13496                } else {
13497                    project_event_without_session_bundle(event)
13498                });
13499                let event = event.map(|event| {
13500                    project_event_history_for_wire(event, include_history_context_packs)
13501                });
13502                let event = event.map(|event| if include_history_context_packs {
13503                    event
13504                } else {
13505                    project_event_without_context_pack(event)
13506                });
13507                let event = event.map(|event| if include_session_task_correlation {
13508                    event
13509                } else {
13510                    project_event_without_session_task_binding(event)
13511                });
13512                let exact_harness_controller = hello.role == ClientRole::Operator
13513                    && shared.controller_state().is_some_and(|controller| {
13514                        controller.connection_id == connection_id
13515                    });
13516                let event = event.and_then(|event| {
13517                    if include_harness_mcp_proxy && exact_harness_controller {
13518                        Some(event)
13519                    } else {
13520                        project_event_without_harness_mcp_proxy(event)
13521                    }
13522                });
13523                if let Some(event) = event {
13524                    write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
13525                }
13526            }
13527        }
13528        tokio::select! {
13529            biased;
13530            frame = frame_rx.recv() => {
13531                let frame = match frame {
13532                    Some(frame) => frame,
13533                    None => break,
13534                };
13535                let frame = match frame {
13536                    Ok(frame) => frame,
13537                    Err(FrameError::Io(error)) if matches!(error.kind(), io::ErrorKind::BrokenPipe | io::ErrorKind::UnexpectedEof) => break,
13538                    Err(error) => return Err(error.into()),
13539                };
13540                let ClientFrame::Request(request) = frame else {
13541                    return Err(NodeServerError::Handshake("hello may only be sent once".to_owned()));
13542                };
13543                #[cfg(feature = "fixture")]
13544                let probe_spawn_managed_worktree_v2 = matches!(
13545                    &request.request,
13546                    NodeRequest::SpawnManagedWorktreeV2 { .. }
13547                );
13548                let requires_open_provider_ids =
13549                    request_requires_open_provider_ids(&shared, &request.request);
13550                let requires_child_environment_profile =
13551                    request_requires_child_environment_profile(&shared, &request.request);
13552                let requires_session_bundle =
13553                    request_requires_session_bundle(&shared, &request.request);
13554                let requires_history_context_pack =
13555                    request_requires_history_context_pack(&shared, &request.request);
13556                let mut reply = if requires_open_provider_ids && !include_open_provider_ids {
13557                    ResponseEnvelope {
13558                        request_id: request.request_id,
13559                        result: Err(failure(
13560                            NodeFailureCode::UnsupportedCapability,
13561                            "open provider IDs were not negotiated",
13562                        )),
13563                    }
13564                } else if requires_child_environment_profile
13565                    && !include_child_environment_profiles
13566                {
13567                    ResponseEnvelope {
13568                        request_id: request.request_id,
13569                        result: Err(failure(
13570                            NodeFailureCode::UnsupportedCapability,
13571                            "child environment profiles were not negotiated",
13572                        )),
13573                    }
13574                } else if requires_session_bundle && !include_session_bundles {
13575                    ResponseEnvelope {
13576                        request_id: request.request_id,
13577                        result: Err(failure(
13578                            NodeFailureCode::UnsupportedCapability,
13579                            "session bundles were not negotiated",
13580                        )),
13581                    }
13582                } else if requires_history_context_pack && !include_history_context_packs {
13583                    ResponseEnvelope {
13584                        request_id: request.request_id,
13585                        result: Err(failure(
13586                            NodeFailureCode::UnsupportedCapability,
13587                            "history context packs were not negotiated",
13588                        )),
13589                    }
13590                } else if request_uses_unnegotiated_capability(
13591                    &request.request,
13592                    &selected_capabilities,
13593                )
13594                {
13595                    ResponseEnvelope {
13596                        request_id: request.request_id,
13597                        result: Err(failure(
13598                            NodeFailureCode::UnsupportedCapability,
13599                            "request capability was not negotiated",
13600                        )),
13601                    }
13602                } else {
13603                    process_request(&shared, connection_id, hello.role, request).await
13604                };
13605                #[cfg(feature = "fixture")]
13606                if probe_spawn_managed_worktree_v2 {
13607                    shared
13608                        .fixture_spawn_managed_worktree_v2_failure_probe
13609                        .record(reply.result.as_ref().err().map(|failure| failure.code));
13610                }
13611                if !include_provider_runtime_status {
13612                    clear_response_provider_runtime_status(&mut reply);
13613                }
13614                if !include_managed_worktrees {
13615                    project_response_without_managed_worktrees(&mut reply);
13616                }
13617                if !include_open_provider_ids {
13618                    project_response_legacy_provider_ids(&shared, &mut reply);
13619                }
13620                if !include_child_environment_profiles {
13621                    project_response_without_child_environment_profile(&mut reply);
13622                }
13623                if !include_session_bundles {
13624                    project_response_without_session_bundle(&mut reply);
13625                }
13626                project_response_history_for_wire(
13627                    &mut reply,
13628                    include_history_context_packs,
13629                );
13630                if !include_history_context_packs {
13631                    project_response_without_context_pack(&mut reply);
13632                }
13633                if !include_agent_progress {
13634                    project_response_without_agent_progress(&mut reply);
13635                }
13636                if !include_session_task_correlation {
13637                    project_response_without_session_task_binding(&mut reply);
13638                }
13639                write_json_frame(&mut writer, &ServerFrame::Reply(reply)).await?;
13640            }
13641            _ = tokio::task::yield_now(), if !pending_events.is_empty() => {}
13642            event = event_rx.recv() => {
13643                match event {
13644                    Ok(event) => queue_connection_event(
13645                        &mut pending_events,
13646                        event,
13647                        discard_events_through,
13648                    ),
13649                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13650                    Err(broadcast::error::RecvError::Closed) => break,
13651                }
13652            }
13653            events = receive_terminal_event_batch(&mut terminal_event_rx) => {
13654                match events {
13655                    Ok(events) => queue_connection_event_batch(
13656                        &mut pending_events,
13657                        &events,
13658                        discard_events_through,
13659                    ),
13660                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13661                    Err(broadcast::error::RecvError::Closed) => break,
13662                }
13663            }
13664            events = receive_terminal_event_batch(&mut agent_stream_event_rx) => {
13665                match events {
13666                    Ok(events) => queue_connection_event_batch(
13667                        &mut pending_events,
13668                        &events,
13669                        discard_events_through,
13670                    ),
13671                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13672                    Err(broadcast::error::RecvError::Closed) => break,
13673                }
13674            }
13675            events = receive_terminal_event_batch(&mut harness_mcp_proxy_event_rx) => {
13676                match events {
13677                    Ok(events) => queue_connection_event_batch(
13678                        &mut pending_events,
13679                        &events,
13680                        discard_events_through,
13681                    ),
13682                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13683                    Err(broadcast::error::RecvError::Closed) => break,
13684                }
13685            }
13686            _ = shared.shutdown_notify.notified() => break,
13687        }
13688    }
13689    Ok(())
13690}
13691
13692async fn receive_terminal_event_batch(
13693    receiver: &mut Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>>,
13694) -> Result<Arc<Vec<NodeEventEnvelope>>, broadcast::error::RecvError> {
13695    match receiver {
13696        Some(receiver) => receiver.recv().await,
13697        None => std::future::pending().await,
13698    }
13699}
13700
13701fn terminal_event_subscription(
13702    shared: &NodeShared,
13703    enabled: bool,
13704) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13705    enabled.then(|| shared.terminal_event_tx.subscribe())
13706}
13707
13708/// Mirrors `terminal_event_subscription` exactly, over the agent stream's
13709/// own channel -- see `NodeShared::agent_stream_event_tx`'s own doc for why
13710/// this needs a dedicated channel rather than a flag on the terminal one.
13711fn agent_stream_event_subscription(
13712    shared: &NodeShared,
13713    enabled: bool,
13714) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13715    enabled.then(|| shared.agent_stream_event_tx.subscribe())
13716}
13717
13718/// Mirrors `agent_stream_event_subscription` exactly, over the harness MCP
13719/// proxy's own channel -- see `NodeShared::harness_mcp_proxy_event_tx`'s
13720/// own doc for why this needs a dedicated channel rather than a ride on
13721/// the durable `event_tx`.
13722fn harness_mcp_proxy_event_subscription(
13723    shared: &NodeShared,
13724    enabled: bool,
13725) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13726    enabled.then(|| shared.harness_mcp_proxy_event_tx.subscribe())
13727}
13728
13729fn queue_connection_event(
13730    pending: &mut Vec<NodeEventEnvelope>,
13731    event: NodeEventEnvelope,
13732    discard_through: u64,
13733) {
13734    if event.sequence <= discard_through
13735        && !event.event.requires_harness_mcp_proxy_capability()
13736    {
13737        return;
13738    }
13739    if let NodeEvent::TerminalFrame { address, .. } = &event.event {
13740        if let Some(index) = pending.iter().position(|current| {
13741            matches!(
13742                &current.event,
13743                NodeEvent::TerminalFrame {
13744                    address: current_address,
13745                    ..
13746                } if current_address == address
13747            )
13748        }) {
13749            pending.remove(index);
13750        }
13751    }
13752    pending.push(event);
13753}
13754
13755fn project_event_without_harness_mcp_proxy(
13756    envelope: NodeEventEnvelope,
13757) -> Option<NodeEventEnvelope> {
13758    (!envelope.event.requires_harness_mcp_proxy_capability()).then_some(envelope)
13759}
13760
13761fn queue_connection_event_batch(
13762    pending: &mut Vec<NodeEventEnvelope>,
13763    events: &[NodeEventEnvelope],
13764    discard_through: u64,
13765) {
13766    for event in events {
13767        queue_connection_event(pending, event.clone(), discard_through);
13768    }
13769}
13770
13771fn apply_connection_resync_watermark(
13772    pending: &mut Vec<NodeEventEnvelope>,
13773    discard_through: &mut u64,
13774    marker_sequence: u64,
13775) {
13776    *discard_through = (*discard_through).max(marker_sequence);
13777    pending.retain(|event| event.sequence > *discard_through);
13778}
13779
13780fn connection_event_burst_len(pending_len: usize) -> usize {
13781    pending_len.min(NODE_CONNECTION_EVENT_BURST_MAX)
13782}
13783
13784fn resync_required_event(shared: &NodeShared) -> NodeEventEnvelope {
13785    let history = shared
13786        .history
13787        .lock()
13788        .unwrap_or_else(|poisoned| poisoned.into_inner());
13789    let sequence = history.last_sequence;
13790    let oldest_available_sequence = history.replay_floor_sequence;
13791    NodeEventEnvelope {
13792        sequence,
13793        event: NodeEvent::ResyncRequired {
13794            oldest_available_sequence,
13795        },
13796    }
13797}
13798
13799fn node_compatibility_support(
13800    shared: &NodeShared,
13801) -> Result<NodeCompatibilitySupport, NodeServerError> {
13802    let mut support = node_compatibility_support_for_manifest(
13803        &shared.provider_contracts,
13804        &shared.provider_adapter_contracts,
13805    )?;
13806    if shared
13807        .delivery_store
13808        .lock()
13809        .unwrap_or_else(|poisoned| poisoned.into_inner())
13810        .is_some()
13811    {
13812        support.capabilities.push(
13813            CapabilityId::new(NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY)
13814                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13815        );
13816    }
13817    if shared.harness_mcp_registry.is_some() {
13818        support.capabilities.push(
13819            CapabilityId::new(NODE_HARNESS_MCP_READ_PROXY_CAPABILITY)
13820                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13821        );
13822    }
13823    Ok(support)
13824}
13825
13826fn node_compatibility_support_for_manifest(
13827    provider_contracts: &[ProviderContractSupport],
13828    provider_adapter_contracts: &[ProviderAdapterContractSupport],
13829) -> Result<NodeCompatibilitySupport, NodeServerError> {
13830    Ok(NodeCompatibilitySupport {
13831        build_stamp: BUILD_STAMP.to_owned(),
13832        capabilities: baseline_capabilities()?,
13833        host: platform::host_descriptor().map_err(NodeServerError::Handshake)?,
13834        path_semantics: platform::path_semantics(),
13835        local_transport: platform::local_transport(),
13836        state_schema: StateSchemaSupport {
13837            versions: ProtocolRange::new(NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10)
13838                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13839        },
13840        provider_contracts: provider_contracts.to_vec(),
13841        provider_adapter_contracts: provider_adapter_contracts.to_vec(),
13842    })
13843}
13844
13845fn baseline_capabilities() -> Result<Vec<CapabilityId>, NodeServerError> {
13846    let capabilities = [
13847        NODE_COMPATIBILITY_METADATA_CAPABILITY,
13848        CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
13849        NODE_REPOSITORY_PATH_CAPABILITY,
13850        NODE_WORKSPACE_FILE_READ_CAPABILITY,
13851        NODE_WORKSPACE_FILE_WRITE_CAPABILITY,
13852        NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
13853        NODE_GIT_READ_CAPABILITY,
13854        NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY,
13855        NODE_PROVIDER_ID_OPEN_CAPABILITY,
13856        NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
13857        NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY,
13858        NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
13859        NODE_AGENT_STREAM_EVENTS_CAPABILITY,
13860        NODE_ACP_CONTROL_CAPABILITY,
13861        NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
13862        NODE_SPAWN_PROFILE_REVISION_CAPABILITY,
13863        NODE_WORKTREE_SELECTION_CAPABILITY,
13864        NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
13865        NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
13866        NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
13867        NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
13868        NODE_HISTORY_CONTEXT_PACK_CAPABILITY,
13869        NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
13870        NODE_NATIVE_SESSION_CATALOG_CAPABILITY,
13871        NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
13872        NODE_NATIVE_SESSION_INDEX_CAPABILITY,
13873        NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
13874        NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
13875        NODE_SESSION_TASK_CORRELATION_CAPABILITY,
13876    ]
13877        .into_iter()
13878        .map(|capability| {
13879            CapabilityId::new(capability)
13880                .map_err(|error| NodeServerError::Handshake(error.to_string()))
13881        })
13882        .collect::<Result<Vec<_>, _>>()?;
13883    #[cfg(windows)]
13884    let capabilities = {
13885        let mut capabilities = capabilities;
13886        capabilities.push(
13887            CapabilityId::new(NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY)
13888                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13889        );
13890        capabilities
13891    };
13892    Ok(capabilities)
13893}
13894
13895fn request_uses_unnegotiated_capability(
13896    request: &NodeRequest,
13897    selected_capabilities: &[CapabilityId],
13898) -> bool {
13899    let selected = |required: &str| {
13900        selected_capabilities
13901            .iter()
13902            .any(|capability| capability.as_str() == required)
13903    };
13904    request
13905        .required_capability()
13906        .is_some_and(|required| !selected(required))
13907        || (request.requires_worktree_selection_capability()
13908            && !selected(NODE_WORKTREE_SELECTION_CAPABILITY))
13909        || (request.requires_spawn_spec_defaults_overrides_capability()
13910            && !selected(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY))
13911        || (request.requires_spawn_profile_revision_capability()
13912            && !selected(NODE_SPAWN_PROFILE_REVISION_CAPABILITY))
13913        || (request.requires_child_environment_profile_capability()
13914            && !selected(NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY))
13915        || (request.requires_session_bundle_materialization_capability()
13916            && !selected(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY))
13917        || (request.requires_history_context_pack_capability()
13918            && !selected(NODE_HISTORY_CONTEXT_PACK_CAPABILITY))
13919}
13920
13921fn snapshot_for_wire(
13922    shared: &NodeShared,
13923    include_provider_runtime_status: bool,
13924    include_open_provider_ids: bool,
13925    include_spawn_profiles: bool,
13926    include_managed_worktrees: bool,
13927    include_child_environment_profiles: bool,
13928    include_session_bundles: bool,
13929    include_history_context_packs: bool,
13930    include_agent_progress: bool,
13931    include_session_task_correlation: bool,
13932) -> NodeSnapshot {
13933    let mut snapshot = shared.snapshot();
13934    if !include_provider_runtime_status {
13935        snapshot.provider_runtime_statuses.clear();
13936    }
13937    if !include_open_provider_ids {
13938        project_snapshot_legacy_provider_ids(&mut snapshot);
13939    }
13940    if !include_managed_worktrees {
13941        snapshot.managed_worktrees.clear();
13942        for workspace in &mut snapshot.workspaces {
13943            workspace.managed_worktree_profiles = None;
13944        }
13945    }
13946    if let Some(inventory) = snapshot.launch_inventory.as_mut() {
13947        if !include_spawn_profiles {
13948            inventory.spawn_profiles = None;
13949        }
13950        if !include_session_bundles {
13951            inventory.bundles = None;
13952        }
13953        if inventory.spawn_profiles.is_none()
13954            && inventory.bundles.is_none()
13955            && inventory.network_allowlists.is_none()
13956        {
13957            snapshot.launch_inventory = None;
13958        }
13959    }
13960    if !include_child_environment_profiles {
13961        clear_snapshot_child_environment_profiles(&mut snapshot);
13962    }
13963    if !include_session_bundles {
13964        clear_snapshot_session_bundles(&mut snapshot);
13965    }
13966    project_snapshot_history_for_wire(&mut snapshot, include_history_context_packs);
13967    if !include_history_context_packs {
13968        clear_snapshot_context_packs(&mut snapshot);
13969    }
13970    if !include_agent_progress {
13971        snapshot.agent_progress.clear();
13972    }
13973    if !include_session_task_correlation {
13974        clear_snapshot_session_task_bindings(&mut snapshot);
13975    }
13976    snapshot
13977}
13978
13979fn clear_snapshot_session_task_bindings(snapshot: &mut NodeSnapshot) {
13980    for record in &mut snapshot.session_records {
13981        record.task_binding = None;
13982    }
13983}
13984
13985fn project_event_without_session_task_binding(
13986    mut envelope: NodeEventEnvelope,
13987) -> NodeEventEnvelope {
13988    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
13989        record.task_binding = None;
13990    }
13991    envelope
13992}
13993
13994fn project_response_without_session_task_binding(reply: &mut ResponseEnvelope) {
13995    let Ok(response) = reply.result.as_mut() else { return };
13996    match response {
13997        NodeResponse::Snapshot { snapshot, .. } => {
13998            clear_snapshot_session_task_bindings(snapshot);
13999        }
14000        NodeResponse::Resync { snapshot, events, .. } => {
14001            clear_snapshot_session_task_bindings(snapshot);
14002            for event in events {
14003                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14004                    record.task_binding = None;
14005                }
14006            }
14007        }
14008        NodeResponse::SessionRecordUpdated { record }
14009        | NodeResponse::ProviderSessionIndexed { record }
14010        | NodeResponse::NativeSessionIndexed { record, .. }
14011        | NodeResponse::SessionRecordResumed { record, .. } => {
14012            record.task_binding = None;
14013        }
14014        _ => {}
14015    }
14016}
14017
14018fn project_response_without_agent_progress(reply: &mut ResponseEnvelope) {
14019    let Ok(response) = reply.result.as_mut() else { return };
14020    match response {
14021        NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
14022            snapshot.agent_progress.clear();
14023        }
14024        _ => {}
14025    }
14026}
14027
14028fn project_event_without_managed_worktrees(
14029    mut envelope: NodeEventEnvelope,
14030) -> Option<NodeEventEnvelope> {
14031    if matches!(
14032        envelope.event,
14033        NodeEvent::ManagedWorktreeUpserted { .. }
14034            | NodeEvent::ManagedWorktreeRemoved { .. }
14035    ) {
14036        return None;
14037    }
14038    if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
14039        workspace.managed_worktree_profiles = None;
14040    }
14041    Some(envelope)
14042}
14043
14044fn project_response_without_managed_worktrees(reply: &mut ResponseEnvelope) {
14045    if matches!(
14046        reply.result,
14047        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { .. })
14048            | Ok(NodeResponse::ManagedWorktreeCleanup { .. })
14049    ) {
14050        reply.result = Err(failure(
14051            NodeFailureCode::UnsupportedCapability,
14052            "managed worktree capability was not negotiated",
14053        ));
14054        return;
14055    }
14056    let Ok(response) = reply.result.as_mut() else { return };
14057    match response {
14058        NodeResponse::Snapshot { snapshot, .. } => {
14059            snapshot.managed_worktrees.clear();
14060            for workspace in &mut snapshot.workspaces {
14061                workspace.managed_worktree_profiles = None;
14062            }
14063        }
14064        NodeResponse::Resync { snapshot, events, .. } => {
14065            snapshot.managed_worktrees.clear();
14066            for workspace in &mut snapshot.workspaces {
14067                workspace.managed_worktree_profiles = None;
14068            }
14069            *events = std::mem::take(events)
14070                .into_iter()
14071                .filter_map(project_event_without_managed_worktrees)
14072                .collect();
14073        }
14074        NodeResponse::WorkspaceRegistered { workspace }
14075        | NodeResponse::StandaloneWorkspaceCreated { workspace }
14076        | NodeResponse::WorktreeCreated { workspace, .. } => {
14077            workspace.managed_worktree_profiles = None;
14078        }
14079        NodeResponse::WorkspaceInspected { inspection } => {
14080            inspection.git.managed_worktree = None;
14081        }
14082        _ => {}
14083    }
14084}
14085
14086fn project_negotiated_provider_ids(compatibility: &mut NegotiatedNodeCompatibility) {
14087    let includes_open_provider_ids = compatibility.capabilities.iter().any(|capability| {
14088        capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
14089    });
14090    if includes_open_provider_ids {
14091        return;
14092    }
14093    compatibility
14094        .provider_contracts
14095        .retain(|contract| provider_id_is_legacy(&contract.provider));
14096    compatibility
14097        .provider_adapter_contracts
14098        .retain(|contract| provider_id_is_legacy(&contract.provider));
14099}
14100
14101fn request_requires_open_provider_ids(shared: &NodeShared, request: &NodeRequest) -> bool {
14102    if let Some(spec) = match request {
14103        NodeRequest::SpawnSpec { spec }
14104        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14105        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14106        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14107        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14108        _ => None,
14109    } {
14110        return shared
14111            .resolve_spawn_spec(spec)
14112            .is_ok_and(|resolved| !provider_id_is_legacy(&resolved.provider));
14113    }
14114    request_requires_open_provider_ids_with(
14115        request,
14116        |session| shared.validate_address(session).ok(),
14117        |record_id| shared.record(record_id).ok().map(|record| record.provider),
14118    )
14119}
14120
14121fn request_requires_child_environment_profile(
14122    shared: &NodeShared,
14123    request: &NodeRequest,
14124) -> bool {
14125    if let Some(spec) = match request {
14126        NodeRequest::SpawnSpec { spec }
14127        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14128        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14129        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14130        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14131        _ => None,
14132    } {
14133        return shared
14134            .resolve_spawn_spec(spec)
14135            .is_ok_and(|resolved| resolved.environment_profile_id.is_some());
14136    }
14137    match request {
14138        NodeRequest::Resume { session, .. }
14139        | NodeRequest::Prompt { session, .. }
14140        | NodeRequest::Paste { session, .. }
14141        | NodeRequest::Input { session, .. }
14142        | NodeRequest::TerminalBytes { session, .. }
14143        | NodeRequest::TerminalControl { session, .. }
14144        | NodeRequest::Resize { session, .. }
14145        | NodeRequest::Interrupt { session }
14146        | NodeRequest::Stop { session, .. }
14147        | NodeRequest::Remove { session }
14148        | NodeRequest::ResolveInteraction { session, .. }
14149        | NodeRequest::SetSessionMode { session, .. }
14150        | NodeRequest::SetSessionConfigOption { session, .. }
14151        | NodeRequest::SetSessionModel { session, .. }
14152        | NodeRequest::DiscoverHistory { session, .. }
14153        | NodeRequest::LoadHistory { session, .. }
14154        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14155        | NodeRequest::ExportContextPack { session } => shared
14156            .session_bindings
14157            .lock()
14158            .unwrap_or_else(|poisoned| poisoned.into_inner())
14159            .get(&session.session.instance_id)
14160            .filter(|binding| {
14161                binding.workspace_id == session.workspace_id
14162                    && binding.generation == session.session.generation
14163            })
14164            .map_or(true, |binding| binding.environment_profile.is_some()),
14165        NodeRequest::RenameSessionRecord { record_id, .. }
14166        | NodeRequest::SetSessionTask { record_id, .. }
14167        | NodeRequest::ResumeSessionRecord { record_id, .. }
14168        | NodeRequest::ForgetSessionRecord { record_id } => shared
14169            .record(record_id)
14170            .map_or(true, |record| record.environment_profile.is_some()),
14171        NodeRequest::ArmHarnessMcpReservation { .. }
14172        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14173        | NodeRequest::ActivateHarnessMcpReservation { .. }
14174        | NodeRequest::AbortHarnessMcpReservation { .. }
14175        | NodeRequest::PutHarnessMcpReplyChunk { .. }
14176        | NodeRequest::RejectHarnessMcpCall { .. }
14177        | NodeRequest::Snapshot
14178        | NodeRequest::Resync { .. }
14179        | NodeRequest::BeginDeliveryStage { .. }
14180        | NodeRequest::PutDeliveryBlobChunk { .. }
14181        | NodeRequest::CommitDeliveryStage { .. }
14182        | NodeRequest::AbortDeliveryStage { .. }
14183        | NodeRequest::BrowseHostDirectories { .. }
14184        | NodeRequest::InspectWorkspace { .. }
14185        | NodeRequest::ReadWorkspaceFile { .. }
14186        | NodeRequest::WriteWorkspaceFile { .. }
14187        | NodeRequest::CreateWorkspaceFile { .. }
14188        | NodeRequest::CreateWorkspaceDirectory { .. }
14189        | NodeRequest::ReadGitHistory { .. }
14190        | NodeRequest::ReadGitDiff { .. }
14191        | NodeRequest::AcquireController { .. }
14192        | NodeRequest::ReleaseController
14193        | NodeRequest::Spawn { .. }
14194        | NodeRequest::SpawnSpec { .. }
14195        | NodeRequest::RegisterWorkspace { .. }
14196        | NodeRequest::CreateStandaloneWorkspace { .. }
14197        | NodeRequest::UnregisterWorkspace { .. }
14198        | NodeRequest::CreateWorktree { .. }
14199        | NodeRequest::RemoveWorktree { .. }
14200        | NodeRequest::SpawnManagedWorktree { .. }
14201        | NodeRequest::SpawnManagedWorktreeV2 { .. }
14202        | NodeRequest::CleanupManagedWorktree { .. }
14203        | NodeRequest::IndexProviderSession { .. }
14204        | NodeRequest::IndexNativeSession { .. }
14205        | NodeRequest::CatalogNativeSessions { .. }
14206        | NodeRequest::PageNativeSessions { .. }
14207        | NodeRequest::PreviewNativeSession { .. }
14208        | NodeRequest::PreviewSessionRecord { .. }
14209        | NodeRequest::ForgetContextPack { .. }
14210        | NodeRequest::ResolveDurableContextPack { .. }
14211        | NodeRequest::ReadContextPack { .. }
14212        | NodeRequest::Shutdown => false,
14213    }
14214}
14215
14216fn request_requires_session_bundle(shared: &NodeShared, request: &NodeRequest) -> bool {
14217    if let Some(spec) = match request {
14218        NodeRequest::SpawnSpec { spec }
14219        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14220        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14221        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14222        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14223        _ => None,
14224    } {
14225        return shared
14226            .resolve_spawn_spec(spec)
14227            .is_ok_and(|resolved| resolved.bundle_id.is_some());
14228    }
14229    match request {
14230        NodeRequest::Resume { session, .. }
14231        | NodeRequest::Prompt { session, .. }
14232        | NodeRequest::Paste { session, .. }
14233        | NodeRequest::Input { session, .. }
14234        | NodeRequest::TerminalBytes { session, .. }
14235        | NodeRequest::TerminalControl { session, .. }
14236        | NodeRequest::Resize { session, .. }
14237        | NodeRequest::Interrupt { session }
14238        | NodeRequest::Stop { session, .. }
14239        | NodeRequest::Remove { session }
14240        | NodeRequest::ResolveInteraction { session, .. }
14241        | NodeRequest::SetSessionMode { session, .. }
14242        | NodeRequest::SetSessionConfigOption { session, .. }
14243        | NodeRequest::SetSessionModel { session, .. }
14244        | NodeRequest::DiscoverHistory { session, .. }
14245        | NodeRequest::LoadHistory { session, .. }
14246        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14247        | NodeRequest::ExportContextPack { session } => shared
14248            .session_bindings
14249            .lock()
14250            .unwrap_or_else(|poisoned| poisoned.into_inner())
14251            .get(&session.session.instance_id)
14252            .filter(|binding| {
14253                binding.workspace_id == session.workspace_id
14254                    && binding.generation == session.session.generation
14255            })
14256            .map_or(true, |binding| binding.bundle.is_some()),
14257        NodeRequest::RenameSessionRecord { record_id, .. }
14258        | NodeRequest::SetSessionTask { record_id, .. }
14259        | NodeRequest::ResumeSessionRecord { record_id, .. }
14260        | NodeRequest::ForgetSessionRecord { record_id } => shared
14261            .record(record_id)
14262            .map_or(true, |record| record.bundle.is_some()),
14263        NodeRequest::ArmHarnessMcpReservation { .. }
14264        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14265        | NodeRequest::ActivateHarnessMcpReservation { .. }
14266        | NodeRequest::AbortHarnessMcpReservation { .. }
14267        | NodeRequest::PutHarnessMcpReplyChunk { .. }
14268        | NodeRequest::RejectHarnessMcpCall { .. }
14269        | NodeRequest::Snapshot
14270        | NodeRequest::Resync { .. }
14271        | NodeRequest::BeginDeliveryStage { .. }
14272        | NodeRequest::PutDeliveryBlobChunk { .. }
14273        | NodeRequest::CommitDeliveryStage { .. }
14274        | NodeRequest::AbortDeliveryStage { .. }
14275        | NodeRequest::BrowseHostDirectories { .. }
14276        | NodeRequest::InspectWorkspace { .. }
14277        | NodeRequest::ReadWorkspaceFile { .. }
14278        | NodeRequest::WriteWorkspaceFile { .. }
14279        | NodeRequest::CreateWorkspaceFile { .. }
14280        | NodeRequest::CreateWorkspaceDirectory { .. }
14281        | NodeRequest::ReadGitHistory { .. }
14282        | NodeRequest::ReadGitDiff { .. }
14283        | NodeRequest::AcquireController { .. }
14284        | NodeRequest::ReleaseController
14285        | NodeRequest::Spawn { .. }
14286        | NodeRequest::SpawnSpec { .. }
14287        | NodeRequest::RegisterWorkspace { .. }
14288        | NodeRequest::CreateStandaloneWorkspace { .. }
14289        | NodeRequest::UnregisterWorkspace { .. }
14290        | NodeRequest::CreateWorktree { .. }
14291        | NodeRequest::RemoveWorktree { .. }
14292        | NodeRequest::SpawnManagedWorktree { .. }
14293        | NodeRequest::SpawnManagedWorktreeV2 { .. }
14294        | NodeRequest::CleanupManagedWorktree { .. }
14295        | NodeRequest::IndexProviderSession { .. }
14296        | NodeRequest::IndexNativeSession { .. }
14297        | NodeRequest::CatalogNativeSessions { .. }
14298        | NodeRequest::PageNativeSessions { .. }
14299        | NodeRequest::PreviewNativeSession { .. }
14300        | NodeRequest::PreviewSessionRecord { .. }
14301        | NodeRequest::ForgetContextPack { .. }
14302        | NodeRequest::ResolveDurableContextPack { .. }
14303        | NodeRequest::ReadContextPack { .. }
14304        | NodeRequest::Shutdown => false,
14305    }
14306}
14307
14308fn request_requires_history_context_pack(shared: &NodeShared, request: &NodeRequest) -> bool {
14309    if request.requires_history_context_pack_capability() {
14310        return true;
14311    }
14312    if let Some(spec) = match request {
14313        NodeRequest::SpawnSpec { spec }
14314        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14315        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14316        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14317        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14318        _ => None,
14319    } {
14320        return shared
14321            .resolve_spawn_spec(spec)
14322            .map_or(true, |resolved| resolved.context_id.is_some());
14323    }
14324    match request {
14325        NodeRequest::Resume { session, .. }
14326        | NodeRequest::Prompt { session, .. }
14327        | NodeRequest::Paste { session, .. }
14328        | NodeRequest::Input { session, .. }
14329        | NodeRequest::TerminalBytes { session, .. }
14330        | NodeRequest::TerminalControl { session, .. }
14331        | NodeRequest::Resize { session, .. }
14332        | NodeRequest::Interrupt { session }
14333        | NodeRequest::Stop { session, .. }
14334        | NodeRequest::Remove { session }
14335        | NodeRequest::ResolveInteraction { session, .. }
14336        | NodeRequest::SetSessionMode { session, .. }
14337        | NodeRequest::SetSessionConfigOption { session, .. }
14338        | NodeRequest::SetSessionModel { session, .. } => shared
14339            .session_bindings
14340            .lock()
14341            .unwrap_or_else(|poisoned| poisoned.into_inner())
14342            .get(&session.session.instance_id)
14343            .filter(|binding| {
14344                binding.workspace_id == session.workspace_id
14345                    && binding.generation == session.session.generation
14346            })
14347            .map_or(true, |binding| binding.context.is_some()),
14348        NodeRequest::RenameSessionRecord { record_id, .. }
14349        | NodeRequest::SetSessionTask { record_id, .. }
14350        | NodeRequest::ResumeSessionRecord { record_id, .. }
14351        | NodeRequest::ForgetSessionRecord { record_id } => shared
14352            .record(record_id)
14353            .map_or(true, |record| record.context.is_some()),
14354        NodeRequest::ArmHarnessMcpReservation { .. }
14355        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14356        | NodeRequest::ActivateHarnessMcpReservation { .. }
14357        | NodeRequest::AbortHarnessMcpReservation { .. }
14358        | NodeRequest::PutHarnessMcpReplyChunk { .. }
14359        | NodeRequest::RejectHarnessMcpCall { .. }
14360        | NodeRequest::Snapshot
14361        | NodeRequest::Resync { .. }
14362        | NodeRequest::BeginDeliveryStage { .. }
14363        | NodeRequest::PutDeliveryBlobChunk { .. }
14364        | NodeRequest::CommitDeliveryStage { .. }
14365        | NodeRequest::AbortDeliveryStage { .. }
14366        | NodeRequest::BrowseHostDirectories { .. }
14367        | NodeRequest::InspectWorkspace { .. }
14368        | NodeRequest::ReadWorkspaceFile { .. }
14369        | NodeRequest::WriteWorkspaceFile { .. }
14370        | NodeRequest::CreateWorkspaceFile { .. }
14371        | NodeRequest::CreateWorkspaceDirectory { .. }
14372        | NodeRequest::ReadGitHistory { .. }
14373        | NodeRequest::ReadGitDiff { .. }
14374        | NodeRequest::AcquireController { .. }
14375        | NodeRequest::ReleaseController
14376        | NodeRequest::Spawn { .. }
14377        | NodeRequest::SpawnSpec { .. }
14378        | NodeRequest::RegisterWorkspace { .. }
14379        | NodeRequest::CreateStandaloneWorkspace { .. }
14380        | NodeRequest::UnregisterWorkspace { .. }
14381        | NodeRequest::CreateWorktree { .. }
14382        | NodeRequest::RemoveWorktree { .. }
14383        | NodeRequest::SpawnManagedWorktree { .. }
14384        | NodeRequest::SpawnManagedWorktreeV2 { .. }
14385        | NodeRequest::CleanupManagedWorktree { .. }
14386        | NodeRequest::IndexProviderSession { .. }
14387        | NodeRequest::IndexNativeSession { .. }
14388        | NodeRequest::CatalogNativeSessions { .. }
14389        | NodeRequest::PageNativeSessions { .. }
14390        | NodeRequest::PreviewNativeSession { .. }
14391        | NodeRequest::PreviewSessionRecord { .. }
14392        | NodeRequest::DiscoverHistory { .. }
14393        | NodeRequest::LoadHistory { .. }
14394        | NodeRequest::ExportContextPackForSessionRecord { .. }
14395        | NodeRequest::ExportContextPack { .. }
14396        | NodeRequest::ForgetContextPack { .. }
14397        | NodeRequest::ResolveDurableContextPack { .. }
14398        | NodeRequest::ReadContextPack { .. }
14399        | NodeRequest::Shutdown => false,
14400    }
14401}
14402
14403fn request_requires_open_provider_ids_with(
14404    request: &NodeRequest,
14405    provider_for_session: impl Fn(&SessionAddress) -> Option<AgentId>,
14406    provider_for_record: impl Fn(&SessionRecordId) -> Option<AgentId>,
14407) -> bool {
14408    match request {
14409        NodeRequest::Spawn { provider, .. } => !provider_id_is_legacy(provider),
14410        NodeRequest::IndexProviderSession { provider, .. } => !provider_id_is_legacy(provider),
14411        NodeRequest::CatalogNativeSessions { route, .. }
14412        | NodeRequest::PageNativeSessions { route, .. } => {
14413            !provider_id_is_legacy(&route.provider)
14414        }
14415        NodeRequest::PreviewNativeSession { selection, .. }
14416        | NodeRequest::IndexNativeSession { selection, .. } => {
14417            !provider_id_is_legacy(&selection.route.provider)
14418        }
14419        NodeRequest::SpawnSpec { .. }
14420        | NodeRequest::SpawnSpecWithHarnessMcp { .. } => false,
14421        NodeRequest::SpawnManagedWorktree { .. }
14422        | NodeRequest::SpawnManagedWorktreeV2 { .. } => false,
14423        NodeRequest::Resume { session, .. }
14424        | NodeRequest::Prompt { session, .. }
14425        | NodeRequest::Paste { session, .. }
14426        | NodeRequest::Input { session, .. }
14427        | NodeRequest::TerminalBytes { session, .. }
14428        | NodeRequest::TerminalControl { session, .. }
14429        | NodeRequest::Resize { session, .. }
14430        | NodeRequest::Interrupt { session }
14431        | NodeRequest::Stop { session, .. }
14432        | NodeRequest::Remove { session }
14433        | NodeRequest::ResolveInteraction { session, .. }
14434        | NodeRequest::SetSessionMode { session, .. }
14435        | NodeRequest::SetSessionConfigOption { session, .. }
14436        | NodeRequest::SetSessionModel { session, .. }
14437        | NodeRequest::DiscoverHistory { session, .. }
14438        | NodeRequest::LoadHistory { session, .. }
14439        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14440        | NodeRequest::ExportContextPack { session } => provider_for_session(session)
14441            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14442        NodeRequest::RenameSessionRecord { record_id, .. }
14443        | NodeRequest::SetSessionTask { record_id, .. }
14444        | NodeRequest::ResumeSessionRecord { record_id, .. }
14445        | NodeRequest::ForgetSessionRecord { record_id } => provider_for_record(record_id)
14446            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14447        NodeRequest::PreviewSessionRecord { record_id, .. } => provider_for_record(record_id)
14448            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14449        NodeRequest::ArmHarnessMcpReservation { .. }
14450        | NodeRequest::ActivateHarnessMcpReservation { .. }
14451        | NodeRequest::AbortHarnessMcpReservation { .. }
14452        | NodeRequest::PutHarnessMcpReplyChunk { .. }
14453        | NodeRequest::RejectHarnessMcpCall { .. }
14454        | NodeRequest::Snapshot
14455        | NodeRequest::Resync { .. }
14456        | NodeRequest::BeginDeliveryStage { .. }
14457        | NodeRequest::PutDeliveryBlobChunk { .. }
14458        | NodeRequest::CommitDeliveryStage { .. }
14459        | NodeRequest::AbortDeliveryStage { .. }
14460        | NodeRequest::BrowseHostDirectories { .. }
14461        | NodeRequest::InspectWorkspace { .. }
14462        | NodeRequest::ReadWorkspaceFile { .. }
14463        | NodeRequest::WriteWorkspaceFile { .. }
14464        | NodeRequest::CreateWorkspaceFile { .. }
14465        | NodeRequest::CreateWorkspaceDirectory { .. }
14466        | NodeRequest::ReadGitHistory { .. }
14467        | NodeRequest::ReadGitDiff { .. }
14468        | NodeRequest::AcquireController { .. }
14469        | NodeRequest::ReleaseController
14470        | NodeRequest::RegisterWorkspace { .. }
14471        | NodeRequest::CreateStandaloneWorkspace { .. }
14472        | NodeRequest::UnregisterWorkspace { .. }
14473        | NodeRequest::CreateWorktree { .. }
14474        | NodeRequest::RemoveWorktree { .. }
14475        | NodeRequest::CleanupManagedWorktree { .. }
14476        | NodeRequest::Shutdown => false,
14477        NodeRequest::ForgetContextPack { .. } => true,
14478        // No session/record to cheaply resolve a provider from here either;
14479        // the resolved receipt can carry an open-id provider, so require the
14480        // same capability as ForgetContextPack unconditionally. `ReadContextPack`
14481        // is the same digest-keyed lookup one step further into the same
14482        // catalog, so it inherits the same conservative answer.
14483        NodeRequest::ResolveDurableContextPack { .. }
14484        | NodeRequest::ReadContextPack { .. } => true,
14485    }
14486}
14487
14488fn project_snapshot_legacy_provider_ids(snapshot: &mut NodeSnapshot) {
14489    snapshot.enabled_providers.retain(provider_id_is_legacy);
14490    snapshot.provider_runtime_statuses = ProviderRuntimeStatuses::new(
14491        snapshot
14492            .provider_runtime_statuses
14493            .as_slice()
14494            .iter()
14495            .filter(|status| provider_id_is_legacy(status.provider()))
14496            .cloned(),
14497    )
14498    .expect("a filtered provider runtime projection remains valid");
14499    snapshot
14500        .session_records
14501        .retain(|record| provider_id_is_legacy(&record.provider));
14502    for workspace in &mut snapshot.workspaces {
14503        workspace
14504            .sessions
14505            .retain(|session| provider_id_is_legacy(&session.agent_id));
14506    }
14507}
14508
14509fn clear_snapshot_child_environment_profiles(snapshot: &mut NodeSnapshot) {
14510    for record in &mut snapshot.session_records {
14511        record.environment_profile = None;
14512    }
14513    if let Some(profiles) = snapshot
14514        .launch_inventory
14515        .as_mut()
14516        .and_then(|inventory| inventory.spawn_profiles.as_mut())
14517    {
14518        for profile in profiles {
14519            profile.environment_profile = None;
14520        }
14521    }
14522}
14523
14524fn project_event_without_child_environment_profile(
14525    mut envelope: NodeEventEnvelope,
14526) -> NodeEventEnvelope {
14527    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14528        record.environment_profile = None;
14529    }
14530    envelope
14531}
14532
14533fn project_response_without_child_environment_profile(reply: &mut ResponseEnvelope) {
14534    let contains_environment_profile = match reply.result.as_ref() {
14535        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
14536            receipt.environment_profile.is_some()
14537        }
14538        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14539            receipt.spawn.environment_profile.is_some()
14540        }
14541        _ => false,
14542    };
14543    if contains_environment_profile {
14544        reply.result = Err(failure(
14545            NodeFailureCode::UnsupportedCapability,
14546            "child environment profiles were not negotiated",
14547        ));
14548        return;
14549    }
14550    let Ok(response) = reply.result.as_mut() else {
14551        return;
14552    };
14553    match response {
14554        NodeResponse::Snapshot { snapshot, .. } => {
14555            clear_snapshot_child_environment_profiles(snapshot);
14556        }
14557        NodeResponse::Resync { snapshot, events, .. } => {
14558            clear_snapshot_child_environment_profiles(snapshot);
14559            for event in events {
14560                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14561                    record.environment_profile = None;
14562                }
14563            }
14564        }
14565        NodeResponse::SessionRecordUpdated { record }
14566        | NodeResponse::ProviderSessionIndexed { record }
14567        | NodeResponse::NativeSessionIndexed { record, .. }
14568        | NodeResponse::SessionRecordResumed { record, .. } => {
14569            record.environment_profile = None;
14570        }
14571        NodeResponse::Armed { .. }
14572        | NodeResponse::Spawned { .. }
14573        | NodeResponse::Activated { .. }
14574        | NodeResponse::Aborted { .. }
14575        | NodeResponse::ReplyChunkAccepted { .. }
14576        | NodeResponse::CallRejected { .. }
14577        | NodeResponse::DeliveryStageBegun { .. }
14578        | NodeResponse::DeliveryBlobChunkAccepted { .. }
14579        | NodeResponse::DeliveryCommitted { .. }
14580        | NodeResponse::DeliveryStageAborted { .. }
14581        | NodeResponse::WorkspaceInspected { .. }
14582        | NodeResponse::HostDirectoriesBrowsed { .. }
14583        | NodeResponse::WorkspaceFileRead { .. }
14584        | NodeResponse::WorkspaceFileWritten { .. }
14585        | NodeResponse::WorkspaceFileCreated { .. }
14586        | NodeResponse::WorkspaceDirectoryCreated { .. }
14587        | NodeResponse::GitHistoryRead { .. }
14588        | NodeResponse::GitDiffRead { .. }
14589        | NodeResponse::Controller { .. }
14590        | NodeResponse::SpawnAccepted { .. }
14591        | NodeResponse::SpawnSpecAccepted { .. }
14592        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14593        | NodeResponse::ManagedWorktreeCleanup { .. }
14594        | NodeResponse::NativeSessionsCataloged { .. }
14595        | NodeResponse::NativeSessionsPaged { .. }
14596        | NodeResponse::NativeSessionPreviewed { .. }
14597        | NodeResponse::SessionRecordPreviewed { .. }
14598        | NodeResponse::HistoryDiscovered { .. }
14599        | NodeResponse::HistoryLoaded { .. }
14600        | NodeResponse::ContextPackForSessionRecordExported { .. }
14601        | NodeResponse::ContextPackExported { .. }
14602        | NodeResponse::ContextPackForgotten { .. }
14603        | NodeResponse::DurableContextPackResolved { .. }
14604        | NodeResponse::ContextPackBytesRead { .. }
14605        | NodeResponse::SessionRecordForgotten { .. }
14606        | NodeResponse::WorkspaceRegistered { .. }
14607        | NodeResponse::StandaloneWorkspaceCreated { .. }
14608        | NodeResponse::WorkspaceUnregistered { .. }
14609        | NodeResponse::WorktreeCreated { .. }
14610        | NodeResponse::WorktreeRemoved { .. }
14611        | NodeResponse::Accepted
14612        | NodeResponse::ShuttingDown => {}
14613    }
14614}
14615
14616fn clear_snapshot_session_bundles(snapshot: &mut NodeSnapshot) {
14617    for record in &mut snapshot.session_records {
14618        record.bundle = None;
14619    }
14620}
14621
14622fn project_event_without_session_bundle(
14623    mut envelope: NodeEventEnvelope,
14624) -> NodeEventEnvelope {
14625    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14626        record.bundle = None;
14627    }
14628    envelope
14629}
14630
14631fn project_response_without_session_bundle(reply: &mut ResponseEnvelope) {
14632    let contains_bundle = match reply.result.as_ref() {
14633        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.bundle.is_some(),
14634        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14635            receipt.spawn.bundle.is_some()
14636        }
14637        _ => false,
14638    };
14639    if contains_bundle {
14640        reply.result = Err(failure(
14641            NodeFailureCode::UnsupportedCapability,
14642            "session bundles were not negotiated",
14643        ));
14644        return;
14645    }
14646    let Ok(response) = reply.result.as_mut() else {
14647        return;
14648    };
14649    match response {
14650        NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_session_bundles(snapshot),
14651        NodeResponse::Resync { snapshot, events, .. } => {
14652            clear_snapshot_session_bundles(snapshot);
14653            for event in events {
14654                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14655                    record.bundle = None;
14656                }
14657            }
14658        }
14659        NodeResponse::SessionRecordUpdated { record }
14660        | NodeResponse::ProviderSessionIndexed { record }
14661        | NodeResponse::NativeSessionIndexed { record, .. }
14662        | NodeResponse::SessionRecordResumed { record, .. } => record.bundle = None,
14663        NodeResponse::Armed { .. }
14664        | NodeResponse::Spawned { .. }
14665        | NodeResponse::Activated { .. }
14666        | NodeResponse::Aborted { .. }
14667        | NodeResponse::ReplyChunkAccepted { .. }
14668        | NodeResponse::CallRejected { .. }
14669        | NodeResponse::DeliveryStageBegun { .. }
14670        | NodeResponse::DeliveryBlobChunkAccepted { .. }
14671        | NodeResponse::DeliveryCommitted { .. }
14672        | NodeResponse::DeliveryStageAborted { .. }
14673        | NodeResponse::WorkspaceInspected { .. }
14674        | NodeResponse::HostDirectoriesBrowsed { .. }
14675        | NodeResponse::WorkspaceFileRead { .. }
14676        | NodeResponse::WorkspaceFileWritten { .. }
14677        | NodeResponse::WorkspaceFileCreated { .. }
14678        | NodeResponse::WorkspaceDirectoryCreated { .. }
14679        | NodeResponse::GitHistoryRead { .. }
14680        | NodeResponse::GitDiffRead { .. }
14681        | NodeResponse::Controller { .. }
14682        | NodeResponse::SpawnAccepted { .. }
14683        | NodeResponse::SpawnSpecAccepted { .. }
14684        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14685        | NodeResponse::ManagedWorktreeCleanup { .. }
14686        | NodeResponse::NativeSessionsCataloged { .. }
14687        | NodeResponse::NativeSessionsPaged { .. }
14688        | NodeResponse::NativeSessionPreviewed { .. }
14689        | NodeResponse::SessionRecordPreviewed { .. }
14690        | NodeResponse::HistoryDiscovered { .. }
14691        | NodeResponse::HistoryLoaded { .. }
14692        | NodeResponse::ContextPackForSessionRecordExported { .. }
14693        | NodeResponse::ContextPackExported { .. }
14694        | NodeResponse::ContextPackForgotten { .. }
14695        | NodeResponse::DurableContextPackResolved { .. }
14696        | NodeResponse::ContextPackBytesRead { .. }
14697        | NodeResponse::SessionRecordForgotten { .. }
14698        | NodeResponse::WorkspaceRegistered { .. }
14699        | NodeResponse::StandaloneWorkspaceCreated { .. }
14700        | NodeResponse::WorkspaceUnregistered { .. }
14701        | NodeResponse::WorktreeCreated { .. }
14702        | NodeResponse::WorktreeRemoved { .. }
14703        | NodeResponse::Accepted
14704        | NodeResponse::ShuttingDown => {}
14705    }
14706}
14707
14708fn project_snapshot_history_for_wire(
14709    snapshot: &mut NodeSnapshot,
14710    include_history_metadata: bool,
14711) {
14712    for workspace in &mut snapshot.workspaces {
14713        for session in &mut workspace.sessions {
14714            if include_history_metadata {
14715                session.history.loaded = None;
14716            } else {
14717                session.history = Default::default();
14718            }
14719        }
14720    }
14721}
14722
14723fn project_event_history_for_wire(
14724    mut envelope: NodeEventEnvelope,
14725    include_history_metadata: bool,
14726) -> NodeEventEnvelope {
14727    if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
14728        for session in &mut workspace.sessions {
14729            if include_history_metadata {
14730                session.history.loaded = None;
14731            } else {
14732                session.history = Default::default();
14733            }
14734        }
14735    }
14736    envelope
14737}
14738
14739fn project_response_history_for_wire(
14740    reply: &mut ResponseEnvelope,
14741    include_history_metadata: bool,
14742) {
14743    let Ok(response) = reply.result.as_mut() else {
14744        return;
14745    };
14746    match response {
14747        NodeResponse::Snapshot { snapshot, .. } => {
14748            project_snapshot_history_for_wire(snapshot, include_history_metadata);
14749        }
14750        NodeResponse::Resync {
14751            snapshot, events, ..
14752        } => {
14753            project_snapshot_history_for_wire(snapshot, include_history_metadata);
14754            for event in events {
14755                *event = project_event_history_for_wire(
14756                    event.clone(),
14757                    include_history_metadata,
14758                );
14759            }
14760        }
14761        NodeResponse::WorkspaceRegistered { workspace }
14762        | NodeResponse::StandaloneWorkspaceCreated { workspace }
14763        | NodeResponse::WorktreeCreated { workspace, .. } => {
14764            for session in &mut workspace.sessions {
14765                if include_history_metadata {
14766                    session.history.loaded = None;
14767                } else {
14768                    session.history = Default::default();
14769                }
14770            }
14771        }
14772        _ => {}
14773    }
14774}
14775
14776fn clear_snapshot_context_packs(snapshot: &mut NodeSnapshot) {
14777    for record in &mut snapshot.session_records {
14778        record.context_id = None;
14779        record.context = None;
14780        record.exported_context = None;
14781    }
14782}
14783
14784fn project_event_without_context_pack(
14785    mut envelope: NodeEventEnvelope,
14786) -> NodeEventEnvelope {
14787    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14788        record.context_id = None;
14789        record.context = None;
14790        record.exported_context = None;
14791    }
14792    envelope
14793}
14794
14795fn project_response_without_context_pack(reply: &mut ResponseEnvelope) {
14796    let contains_context = match reply.result.as_ref() {
14797        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.context.is_some(),
14798        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14799            receipt.spawn.context.is_some()
14800        }
14801        Ok(NodeResponse::HistoryDiscovered { .. })
14802        | Ok(NodeResponse::HistoryLoaded { .. })
14803        | Ok(NodeResponse::ContextPackForSessionRecordExported { .. })
14804        | Ok(NodeResponse::ContextPackExported { .. })
14805        | Ok(NodeResponse::ContextPackForgotten { .. })
14806        | Ok(NodeResponse::DurableContextPackResolved { .. }) => true,
14807        _ => false,
14808    };
14809    if contains_context {
14810        reply.result = Err(failure(
14811            NodeFailureCode::UnsupportedCapability,
14812            "history context packs were not negotiated",
14813        ));
14814        return;
14815    }
14816    let Ok(response) = reply.result.as_mut() else {
14817        return;
14818    };
14819    match response {
14820        NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_context_packs(snapshot),
14821        NodeResponse::Resync {
14822            snapshot, events, ..
14823        } => {
14824            clear_snapshot_context_packs(snapshot);
14825            for event in events {
14826                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14827                    record.context_id = None;
14828                    record.context = None;
14829                    record.exported_context = None;
14830                }
14831            }
14832        }
14833        NodeResponse::SessionRecordUpdated { record }
14834        | NodeResponse::ProviderSessionIndexed { record }
14835        | NodeResponse::NativeSessionIndexed { record, .. }
14836        | NodeResponse::SessionRecordResumed { record, .. } => {
14837            record.context_id = None;
14838            record.context = None;
14839            record.exported_context = None;
14840        }
14841        _ => {}
14842    }
14843}
14844
14845fn project_response_legacy_provider_ids(shared: &NodeShared, reply: &mut ResponseEnvelope) {
14846    let contains_open_record = match reply.result.as_ref() {
14847        Ok(NodeResponse::SessionRecordUpdated { record })
14848        | Ok(NodeResponse::ProviderSessionIndexed { record })
14849        | Ok(NodeResponse::SessionRecordResumed { record, .. }) => {
14850            !provider_id_is_legacy(&record.provider)
14851        }
14852        Ok(NodeResponse::NativeSessionIndexed { selection, record }) => {
14853            !provider_id_is_legacy(&selection.route.provider)
14854                || !provider_id_is_legacy(&record.provider)
14855        }
14856        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
14857            !provider_id_is_legacy(&receipt.provider)
14858        }
14859        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14860            !provider_id_is_legacy(&receipt.spawn.provider)
14861        }
14862        Ok(NodeResponse::NativeSessionsCataloged { route, .. })
14863        | Ok(NodeResponse::NativeSessionsPaged { route, .. }) => {
14864            !provider_id_is_legacy(&route.provider)
14865        }
14866        Ok(NodeResponse::NativeSessionPreviewed { selection, .. }) => {
14867            !provider_id_is_legacy(&selection.route.provider)
14868        }
14869        _ => false,
14870    };
14871    if contains_open_record {
14872        reply.result = Err(failure(
14873            NodeFailureCode::UnsupportedCapability,
14874            "open provider IDs were not negotiated",
14875        ));
14876        return;
14877    }
14878    let Ok(response) = reply.result.as_mut() else {
14879        return;
14880    };
14881    match response {
14882        NodeResponse::Snapshot { snapshot, .. } => {
14883            project_snapshot_legacy_provider_ids(snapshot);
14884        }
14885        NodeResponse::Resync { snapshot, events, .. } => {
14886            project_snapshot_legacy_provider_ids(snapshot);
14887            *events = std::mem::take(events)
14888                .into_iter()
14889                .filter_map(|event| project_event_legacy_provider_ids(shared, event))
14890                .collect();
14891        }
14892        NodeResponse::WorkspaceRegistered { workspace }
14893        | NodeResponse::StandaloneWorkspaceCreated { workspace }
14894        | NodeResponse::WorktreeCreated { workspace, .. } => {
14895            workspace
14896                .sessions
14897                .retain(|session| provider_id_is_legacy(&session.agent_id));
14898        }
14899        NodeResponse::Armed { .. }
14900        | NodeResponse::Spawned { .. }
14901        | NodeResponse::Activated { .. }
14902        | NodeResponse::Aborted { .. }
14903        | NodeResponse::ReplyChunkAccepted { .. }
14904        | NodeResponse::CallRejected { .. }
14905        | NodeResponse::DeliveryStageBegun { .. }
14906        | NodeResponse::DeliveryBlobChunkAccepted { .. }
14907        | NodeResponse::DeliveryCommitted { .. }
14908        | NodeResponse::DeliveryStageAborted { .. }
14909        | NodeResponse::WorkspaceInspected { .. }
14910        | NodeResponse::HostDirectoriesBrowsed { .. }
14911        | NodeResponse::WorkspaceFileRead { .. }
14912        | NodeResponse::WorkspaceFileWritten { .. }
14913        | NodeResponse::WorkspaceFileCreated { .. }
14914        | NodeResponse::WorkspaceDirectoryCreated { .. }
14915        | NodeResponse::GitHistoryRead { .. }
14916        | NodeResponse::GitDiffRead { .. }
14917        | NodeResponse::Controller { .. }
14918        | NodeResponse::SpawnAccepted { .. }
14919        | NodeResponse::SpawnSpecAccepted { .. }
14920        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14921        | NodeResponse::ManagedWorktreeCleanup { .. }
14922        | NodeResponse::NativeSessionsCataloged { .. }
14923        | NodeResponse::NativeSessionsPaged { .. }
14924        | NodeResponse::NativeSessionPreviewed { .. }
14925        | NodeResponse::SessionRecordPreviewed { .. }
14926        | NodeResponse::HistoryDiscovered { .. }
14927        | NodeResponse::HistoryLoaded { .. }
14928        | NodeResponse::ContextPackForSessionRecordExported { .. }
14929        | NodeResponse::ContextPackExported { .. }
14930        | NodeResponse::ContextPackForgotten { .. }
14931        | NodeResponse::DurableContextPackResolved { .. }
14932        | NodeResponse::ContextPackBytesRead { .. }
14933        | NodeResponse::SessionRecordUpdated { .. }
14934        | NodeResponse::ProviderSessionIndexed { .. }
14935        | NodeResponse::NativeSessionIndexed { .. }
14936        | NodeResponse::SessionRecordResumed { .. }
14937        | NodeResponse::SessionRecordForgotten { .. }
14938        | NodeResponse::WorkspaceUnregistered { .. }
14939        | NodeResponse::WorktreeRemoved { .. }
14940        | NodeResponse::Accepted
14941        | NodeResponse::ShuttingDown => {}
14942    }
14943}
14944
14945fn project_event_legacy_provider_ids(
14946    shared: &NodeShared,
14947    mut envelope: NodeEventEnvelope,
14948) -> Option<NodeEventEnvelope> {
14949    let include = match &mut envelope.event {
14950        NodeEvent::HarnessMcpReadCall { .. } => true,
14951        NodeEvent::Control { address, .. }
14952        | NodeEvent::AgentStream { address, .. }
14953        | NodeEvent::TerminalFrame { address, .. } => shared
14954            .validate_address(address)
14955            .is_ok_and(|provider| provider_id_is_legacy(&provider)),
14956        NodeEvent::WorkspaceAdded { workspace } => {
14957            workspace
14958                .sessions
14959                .retain(|session| provider_id_is_legacy(&session.agent_id));
14960            true
14961        }
14962        NodeEvent::SessionRecordUpserted { record } => {
14963            provider_id_is_legacy(&record.provider)
14964        }
14965        NodeEvent::SessionRecordHistorySummarized { .. } => false,
14966        NodeEvent::SessionRecordRemoved { .. } => shared
14967            .history
14968            .lock()
14969            .unwrap_or_else(|poisoned| poisoned.into_inner())
14970            .removed_record_providers
14971            .get(&envelope.sequence)
14972            .is_some_and(provider_id_is_legacy),
14973        NodeEvent::ControllerChanged { .. }
14974        | NodeEvent::ManagedWorktreeUpserted { .. }
14975        | NodeEvent::ManagedWorktreeRemoved { .. }
14976        | NodeEvent::WorkspaceRemoved { .. }
14977        | NodeEvent::ResyncRequired { .. } => true,
14978    };
14979    include.then_some(envelope)
14980}
14981
14982fn clear_response_provider_runtime_status(reply: &mut ResponseEnvelope) {
14983    let Ok(response) = reply.result.as_mut() else {
14984        return;
14985    };
14986    match response {
14987        NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
14988            snapshot.provider_runtime_statuses.clear();
14989        }
14990        NodeResponse::Armed { .. }
14991        | NodeResponse::Spawned { .. }
14992        | NodeResponse::Activated { .. }
14993        | NodeResponse::Aborted { .. }
14994        | NodeResponse::ReplyChunkAccepted { .. }
14995        | NodeResponse::CallRejected { .. }
14996        | NodeResponse::DeliveryStageBegun { .. }
14997        | NodeResponse::DeliveryBlobChunkAccepted { .. }
14998        | NodeResponse::DeliveryCommitted { .. }
14999        | NodeResponse::DeliveryStageAborted { .. }
15000        | NodeResponse::WorkspaceInspected { .. }
15001        | NodeResponse::HostDirectoriesBrowsed { .. }
15002        | NodeResponse::WorkspaceFileRead { .. }
15003        | NodeResponse::WorkspaceFileWritten { .. }
15004        | NodeResponse::WorkspaceFileCreated { .. }
15005        | NodeResponse::WorkspaceDirectoryCreated { .. }
15006        | NodeResponse::GitHistoryRead { .. }
15007        | NodeResponse::GitDiffRead { .. }
15008        | NodeResponse::Controller { .. }
15009        | NodeResponse::SpawnAccepted { .. }
15010        | NodeResponse::SpawnSpecAccepted { .. }
15011        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15012        | NodeResponse::ManagedWorktreeCleanup { .. }
15013        | NodeResponse::NativeSessionsCataloged { .. }
15014        | NodeResponse::NativeSessionsPaged { .. }
15015        | NodeResponse::NativeSessionPreviewed { .. }
15016        | NodeResponse::SessionRecordPreviewed { .. }
15017        | NodeResponse::HistoryDiscovered { .. }
15018        | NodeResponse::HistoryLoaded { .. }
15019        | NodeResponse::ContextPackForSessionRecordExported { .. }
15020        | NodeResponse::ContextPackExported { .. }
15021        | NodeResponse::ContextPackForgotten { .. }
15022        | NodeResponse::DurableContextPackResolved { .. }
15023        | NodeResponse::ContextPackBytesRead { .. }
15024        | NodeResponse::SessionRecordUpdated { .. }
15025        | NodeResponse::ProviderSessionIndexed { .. }
15026        | NodeResponse::NativeSessionIndexed { .. }
15027        | NodeResponse::SessionRecordResumed { .. }
15028        | NodeResponse::SessionRecordForgotten { .. }
15029        | NodeResponse::WorkspaceRegistered { .. }
15030        | NodeResponse::StandaloneWorkspaceCreated { .. }
15031        | NodeResponse::WorkspaceUnregistered { .. }
15032        | NodeResponse::WorktreeCreated { .. }
15033        | NodeResponse::WorktreeRemoved { .. }
15034        | NodeResponse::Accepted
15035        | NodeResponse::ShuttingDown => {}
15036    }
15037}
15038
15039struct ControllerReleaseGuard {
15040    shared: Arc<NodeShared>,
15041    connection_id: u64,
15042}
15043
15044impl Drop for ControllerReleaseGuard {
15045    fn drop(&mut self) {
15046        self.shared.release_controller(self.connection_id);
15047    }
15048}
15049
15050struct AbortTaskOnDrop(AbortHandle);
15051
15052impl Drop for AbortTaskOnDrop {
15053    fn drop(&mut self) {
15054        self.0.abort();
15055    }
15056}
15057
15058/// Reads an unsigned env-config override, following the pattern of every
15059/// other bounded knob in this module: missing/unparsable/zero falls back to
15060/// `default`, and the resolved value is always clamped to `max` so a bad
15061/// override cannot reopen a bound this budget exists to enforce.
15062fn env_config_bounded(name: &str, default: u64, max: u64) -> u64 {
15063    std::env::var(name)
15064        .ok()
15065        .and_then(|value| value.parse::<u64>().ok())
15066        .filter(|value| *value > 0)
15067        .unwrap_or(default)
15068        .min(max)
15069}
15070
15071fn workspace_inspection_time_budget_ms() -> u64 {
15072    env_config_bounded(
15073        "GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS",
15074        WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT,
15075        WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX,
15076    )
15077}
15078
15079fn workspace_inspection_entry_cap() -> usize {
15080    env_config_bounded(
15081        "GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP",
15082        WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT as u64,
15083        WORKSPACE_INSPECTION_ENTRY_CAP_MAX as u64,
15084    ) as usize
15085}
15086
15087/// Tracks the shared walk-side budget across the whole recursive walk: a
15088/// wall-clock deadline (shared with the git phase that runs after the walk)
15089/// and a cap on directory entries visited, whether or not they were pushed
15090/// to the response. Cheap to check per entry — one `Instant::now()` and one
15091/// integer compare.
15092struct WorkspaceWalkBudget {
15093    deadline: Instant,
15094    entry_cap: usize,
15095    entries_visited: u64,
15096    time_budget_exceeded: bool,
15097    entry_cap_exceeded: bool,
15098}
15099
15100impl WorkspaceWalkBudget {
15101    fn new(deadline: Instant, entry_cap: usize) -> Self {
15102        Self {
15103            deadline,
15104            entry_cap,
15105            entries_visited: 0,
15106            time_budget_exceeded: false,
15107            entry_cap_exceeded: false,
15108        }
15109    }
15110
15111    fn is_exhausted(&self) -> bool {
15112        self.time_budget_exceeded || self.entry_cap_exceeded
15113    }
15114
15115    /// Records one visited directory entry (pushed to the response or not)
15116    /// and reports whether the walk must stop now.
15117    fn record_visit(&mut self) -> bool {
15118        self.entries_visited += 1;
15119        if self.entries_visited as usize >= self.entry_cap {
15120            self.entry_cap_exceeded = true;
15121        }
15122        if Instant::now() >= self.deadline {
15123            self.time_budget_exceeded = true;
15124        }
15125        self.is_exhausted()
15126    }
15127}
15128
15129fn collect_workspace_entries(
15130    root: &Path,
15131    deadline: Instant,
15132    entry_cap: usize,
15133) -> (Vec<WorkspaceEntry>, bool, WorkspaceWalkBudget) {
15134    let mut entries = Vec::new();
15135    let mut truncated = false;
15136    let mut budget = WorkspaceWalkBudget::new(deadline, entry_cap);
15137    walk_workspace_directory(root, None, 0, &mut entries, &mut truncated, &mut budget);
15138    // DFS pre-order emits `x/child` between prefix-named siblings `x` and
15139    // `x-y` ('-' sorts below '/'), while the bounded operator projections
15140    // require strict global byte order over relative paths.
15141    entries.sort_by(|left, right| left.relative_path.as_utf8().cmp(&right.relative_path.as_utf8()));
15142    (entries, truncated, budget)
15143}
15144
15145/// Builds the additive `WorkspaceInspectionTruncationV1` for an
15146/// `inspect_workspace` response — `None` once the walk and git phase both
15147/// completed inside their shared budget.
15148fn workspace_inspection_truncation(
15149    walk_budget: &WorkspaceWalkBudget,
15150    git_time_budget_exceeded: bool,
15151    elapsed: Duration,
15152) -> Option<WorkspaceInspectionTruncationV1> {
15153    if !walk_budget.time_budget_exceeded
15154        && !walk_budget.entry_cap_exceeded
15155        && !git_time_budget_exceeded
15156    {
15157        return None;
15158    }
15159    Some(WorkspaceInspectionTruncationV1 {
15160        walk_time_budget_exceeded: walk_budget.time_budget_exceeded,
15161        walk_entry_cap_exceeded: walk_budget.entry_cap_exceeded,
15162        git_time_budget_exceeded,
15163        entries_visited: walk_budget.entries_visited,
15164        elapsed_ms: u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX),
15165    })
15166}
15167
15168fn walk_workspace_directory(
15169    directory: &Path,
15170    relative_directory: Option<&RepositoryPath>,
15171    depth: usize,
15172    entries: &mut Vec<WorkspaceEntry>,
15173    truncated: &mut bool,
15174    budget: &mut WorkspaceWalkBudget,
15175) {
15176    if budget.is_exhausted() {
15177        *truncated = true;
15178        return;
15179    }
15180    let read_dir = match std::fs::read_dir(directory) {
15181        Ok(read_dir) => read_dir,
15182        Err(_) => {
15183            *truncated = true;
15184            return;
15185        }
15186    };
15187    let mut children = Vec::new();
15188    for child in read_dir {
15189        match child {
15190            Ok(child) => children.push(child),
15191            Err(_) => *truncated = true,
15192        }
15193    }
15194    children.sort_by_key(|child| child.file_name());
15195    for child in children {
15196        if budget.record_visit() {
15197            *truncated = true;
15198            return;
15199        }
15200        if entries.len() >= WORKSPACE_TREE_MAX_ENTRIES {
15201            *truncated = true;
15202            return;
15203        }
15204        let file_type = match child.file_type() {
15205            Ok(file_type) => file_type,
15206            Err(_) => {
15207                *truncated = true;
15208                continue;
15209            }
15210        };
15211        if file_type.is_symlink() {
15212            continue;
15213        }
15214        let name = match child.file_name().into_string() {
15215            Ok(name) => name,
15216            Err(_) => {
15217                *truncated = true;
15218                continue;
15219            }
15220        };
15221        if file_type.is_dir() && is_skipped_workspace_directory(&name, &child.path()) {
15222            continue;
15223        }
15224        let relative_path = match relative_directory {
15225            None => windows_repository_path(name),
15226            Some(parent) => parent
15227                .as_utf8()
15228                .and_then(|parent| windows_repository_path(format!("{parent}/{name}"))),
15229        };
15230        let Some(relative_path) = relative_path else {
15231            *truncated = true;
15232            continue;
15233        };
15234        let kind = if file_type.is_dir() {
15235            WorkspaceEntryKind::Directory
15236        } else if file_type.is_file() {
15237            WorkspaceEntryKind::File
15238        } else {
15239            continue;
15240        };
15241        entries.push(WorkspaceEntry {
15242            relative_path: relative_path.clone(),
15243            kind,
15244        });
15245        if file_type.is_dir() {
15246            if depth + 1 < WORKSPACE_TREE_MAX_DEPTH {
15247                walk_workspace_directory(
15248                    &child.path(),
15249                    Some(&relative_path),
15250                    depth + 1,
15251                    entries,
15252                    truncated,
15253                    budget,
15254                );
15255            } else {
15256                *truncated = true;
15257            }
15258        }
15259    }
15260}
15261
15262fn windows_repository_path(value: String) -> Option<RepositoryPath> {
15263    if value.contains('\\') {
15264        return None;
15265    }
15266    RepositoryPath::utf8(value).ok()
15267}
15268
15269fn windows_repository_path_from_bytes(value: &[u8]) -> Option<RepositoryPath> {
15270    windows_repository_path(std::str::from_utf8(value).ok()?.to_owned())
15271}
15272
15273/// Excludes a directory from the workspace tree walk. The three literal
15274/// names are the common convention-based cases; the `CACHEDIR.TAG` check
15275/// beneath them catches every build-output directory regardless of name
15276/// (cargo writes `CACHEDIR.TAG` into every `--target-dir` it creates, so
15277/// this covers `target-a2`, `target-a2-tui`, and any future renamed build
15278/// directory without maintaining a name heuristic list). The name check
15279/// runs first and returns early so a directory rejected by name never pays
15280/// for the extra `CACHEDIR.TAG` stat.
15281fn is_skipped_workspace_directory(name: &str, path: &Path) -> bool {
15282    if name.eq_ignore_ascii_case(".git")
15283        || name.eq_ignore_ascii_case("target")
15284        || name.eq_ignore_ascii_case("node_modules")
15285    {
15286        return true;
15287    }
15288    path.join("CACHEDIR.TAG").is_file()
15289}
15290
15291fn context_pack_source_status_is_usable(status: &SessionStatus) -> bool {
15292    matches!(
15293        status,
15294        SessionStatus::Running | SessionStatus::Exited { exit_code: Some(0) }
15295    )
15296}
15297
15298#[derive(Clone, Debug, Eq, PartialEq)]
15299enum ContextPackRepositoryHead {
15300    NotRepository,
15301    Unborn,
15302    Commit(String),
15303}
15304
15305#[derive(Clone, Debug, Eq, PartialEq)]
15306struct ContextPackRepositoryObservation {
15307    head: ContextPackRepositoryHead,
15308    git: GitSnapshot,
15309    files: Vec<ContextPackRepositoryFileSource>,
15310}
15311
15312fn stable_context_pack_repository(
15313    first: ContextPackRepositoryObservation,
15314    second: ContextPackRepositoryObservation,
15315) -> Result<ContextPackRepository, NodeFailure> {
15316    if first != second {
15317        return Err(failure(
15318            NodeFailureCode::ContextPackMaterializationFailed,
15319            "context repository changed during bounded capture",
15320        ));
15321    }
15322    Ok(ContextPackRepository::from_sources(
15323        &second.git,
15324        second.files,
15325    ))
15326}
15327
15328/// Appends a "skipped: budget exhausted" diagnostic and returns `true` once
15329/// (and only once) the shared walk+git `deadline` has already passed. Every
15330/// remaining phase in `inspect_git_workspace` checks this immediately
15331/// before issuing its own git call, so a budget spent by the walk (or by an
15332/// earlier git call) skips the rest of the chain instead of paying for it
15333/// anyway.
15334fn git_budget_exhausted(snapshot: &mut GitSnapshot, deadline: Instant, stage: &str) -> bool {
15335    if Instant::now() < deadline {
15336        return false;
15337    }
15338    append_git_diagnostic(
15339        snapshot,
15340        &format!("{stage} skipped: workspace inspection time budget exhausted"),
15341    );
15342    true
15343}
15344
15345/// Deadline-aware sibling of `run_git_bounded`, used only by
15346/// `inspect_git_workspace`'s six-call chain: caps the per-call timeout to
15347/// whatever remains of the shared `deadline` (never more than
15348/// `GIT_COMMAND_TIMEOUT_MS`), so a slow walk phase that already spent most
15349/// of the budget cannot still hand a full per-call timeout to every git
15350/// probe behind it. `inspect_git_workspace` already guards each call site
15351/// with `git_budget_exhausted`, so `deadline` is guaranteed not to have
15352/// passed yet here.
15353async fn run_git_bounded_with_deadline(
15354    root: &str,
15355    arguments: &[&str],
15356    output_limit: usize,
15357    deadline: Instant,
15358) -> io::Result<GitCommandOutput> {
15359    let remaining = deadline.checked_duration_since(Instant::now());
15360    let timeout_ms = remaining
15361        .and_then(|remaining| u64::try_from(remaining.as_millis()).ok())
15362        .unwrap_or(0)
15363        .clamp(1, GIT_COMMAND_TIMEOUT_MS);
15364    run_git_read_bounded(root, arguments, output_limit, timeout_ms).await
15365}
15366
15367/// Runs the bounded six-call git probe chain used by `inspect_workspace`.
15368/// `deadline` is the same absolute instant the preceding directory walk was
15369/// bounded by, so time already spent walking counts against what remains
15370/// for git enrichment. Returns the resulting `GitSnapshot` plus whether the
15371/// shared budget (not an individual per-call timeout) is what cut the git
15372/// phase short.
15373async fn inspect_git_workspace(root: &str, deadline: Instant) -> (GitSnapshot, bool) {
15374    let mut snapshot = GitSnapshot {
15375        is_repository: false,
15376        branch: None,
15377        status: Vec::new(),
15378        recent_commits: Vec::new(),
15379        worktrees: Vec::new(),
15380        managed_worktree: None,
15381        truncated: false,
15382        diagnostic: None,
15383    };
15384    if git_budget_exhausted(&mut snapshot, deadline, "git repository probe") {
15385        return (snapshot, true);
15386    }
15387    let repository = match run_git_bounded_with_deadline(
15388        root,
15389        &["rev-parse", "--is-inside-work-tree"],
15390        4 * 1_024,
15391        deadline,
15392    )
15393    .await
15394    {
15395        Ok(output) => output,
15396        Err(error) => {
15397            append_git_diagnostic(
15398                &mut snapshot,
15399                &format!("git inspection unavailable: {error}"),
15400            );
15401            return (snapshot, false);
15402        }
15403    };
15404    snapshot.truncated |= repository.truncated;
15405    if repository.timed_out {
15406        append_git_diagnostic(&mut snapshot, "git repository probe timed out");
15407        return (snapshot, false);
15408    }
15409    if !repository.success
15410        || String::from_utf8_lossy(&repository.stdout).trim() != "true"
15411    {
15412        let stderr = String::from_utf8_lossy(&repository.stderr);
15413        if !stderr.to_ascii_lowercase().contains("not a git repository") {
15414            append_git_diagnostic(&mut snapshot, stderr.trim());
15415        }
15416        return (snapshot, false);
15417    }
15418    snapshot.is_repository = true;
15419
15420    if git_budget_exhausted(&mut snapshot, deadline, "git branch query") {
15421        return (snapshot, true);
15422    }
15423    match run_git_bounded_with_deadline(root, &["branch", "--show-current"], 4 * 1_024, deadline).await {
15424        Ok(output) => {
15425            snapshot.truncated |= output.truncated;
15426            if output.timed_out {
15427                append_git_diagnostic(&mut snapshot, "git branch query timed out");
15428            } else if output.success {
15429                let branch = String::from_utf8_lossy(&output.stdout).trim().to_owned();
15430                if !branch.is_empty() {
15431                    snapshot.branch = Some(truncate_git_field(
15432                        &branch,
15433                        MAX_REPOSITORY_PATH_BYTES,
15434                        &mut snapshot.truncated,
15435                    ));
15436                }
15437            }
15438        }
15439        Err(error) => append_git_diagnostic(
15440            &mut snapshot,
15441            &format!("git branch query failed: {error}"),
15442        ),
15443    }
15444    if snapshot.branch.is_none() && !git_budget_exhausted(&mut snapshot, deadline, "git detached-HEAD probe") {
15445        if let Ok(output) = run_git_bounded_with_deadline(root, &["rev-parse", "--short", "HEAD"], 4 * 1_024, deadline).await {
15446            snapshot.truncated |= output.truncated;
15447            if output.success && !output.timed_out {
15448                let head = String::from_utf8_lossy(&output.stdout).trim().to_owned();
15449                if !head.is_empty() {
15450                    snapshot.branch = Some(format!("detached:{head}"));
15451                }
15452            }
15453        }
15454    }
15455
15456    if git_budget_exhausted(&mut snapshot, deadline, "git status") {
15457        return (snapshot, true);
15458    }
15459    match run_git_bounded_with_deadline(
15460        root,
15461        &[
15462            "status",
15463            "--porcelain=v1",
15464            "-z",
15465            "--renames",
15466            "--untracked-files=normal",
15467            "--",
15468            ".",
15469        ],
15470        GIT_OUTPUT_MAX_BYTES,
15471        deadline,
15472    )
15473    .await
15474    {
15475        Ok(output) => {
15476            snapshot.truncated |= output.truncated;
15477            if output.timed_out {
15478                append_git_diagnostic(&mut snapshot, "git status timed out");
15479            } else if output.success {
15480                parse_git_status(&output.stdout, &mut snapshot);
15481            } else {
15482                append_git_diagnostic(
15483                    &mut snapshot,
15484                    String::from_utf8_lossy(&output.stderr).trim(),
15485                );
15486            }
15487        }
15488        Err(error) => append_git_diagnostic(
15489            &mut snapshot,
15490            &format!("git status failed: {error}"),
15491        ),
15492    }
15493
15494    if git_budget_exhausted(&mut snapshot, deadline, "git log") {
15495        return (snapshot, true);
15496    }
15497    let log_limit = (GIT_COMMIT_MAX_ENTRIES + 1).to_string();
15498    match run_git_bounded_with_deadline(
15499        root,
15500        &["log", "-n", &log_limit, "--pretty=format:%H%x1f%s", "--", "."],
15501        16 * 1_024,
15502        deadline,
15503    )
15504    .await
15505    {
15506        Ok(output) => {
15507            snapshot.truncated |= output.truncated;
15508            if output.timed_out {
15509                append_git_diagnostic(&mut snapshot, "git log timed out");
15510            } else if output.success {
15511                parse_git_commits(&output.stdout, &mut snapshot);
15512            } else {
15513                let stderr = String::from_utf8_lossy(&output.stderr);
15514                if !stderr.to_ascii_lowercase().contains("does not have any commits") {
15515                    append_git_diagnostic(&mut snapshot, stderr.trim());
15516                }
15517            }
15518        }
15519        Err(error) => append_git_diagnostic(
15520            &mut snapshot,
15521            &format!("git log failed: {error}"),
15522        ),
15523    }
15524
15525    if git_budget_exhausted(&mut snapshot, deadline, "git worktree list") {
15526        return (snapshot, true);
15527    }
15528    match list_git_worktrees_with_deadline(root, deadline).await {
15529        Ok(worktrees) => {
15530            snapshot.worktrees = worktrees.into_iter().map(protocol_worktree).collect()
15531        }
15532        Err(error) => append_git_diagnostic(
15533            &mut snapshot,
15534            &format!("git worktree list failed: {}", error.message),
15535        ),
15536    }
15537    (snapshot, false)
15538}
15539
15540fn parse_git_status(output: &[u8], snapshot: &mut GitSnapshot) {
15541    let mut remaining = output;
15542    while !remaining.is_empty() {
15543        if snapshot.status.len() >= GIT_STATUS_MAX_ENTRIES {
15544            snapshot.truncated = true;
15545            break;
15546        }
15547        let Some(record_end) = remaining.iter().position(|byte| *byte == 0) else {
15548            snapshot.truncated = true;
15549            break;
15550        };
15551        let record = &remaining[..record_end];
15552        remaining = &remaining[record_end + 1..];
15553        let has_previous_path = record
15554            .get(..2)
15555            .is_some_and(|status| status.iter().any(|byte| matches!(*byte, b'R' | b'C')));
15556        let previous_path_bytes = if has_previous_path {
15557            let Some(previous_end) = remaining.iter().position(|byte| *byte == 0) else {
15558                snapshot.truncated = true;
15559                break;
15560            };
15561            let previous = &remaining[..previous_end];
15562            remaining = &remaining[previous_end + 1..];
15563            Some(previous)
15564        } else {
15565            None
15566        };
15567        if record.len() < 4
15568            || record[2] != b' '
15569            || !is_git_status_code(record[0])
15570            || !is_git_status_code(record[1])
15571        {
15572            snapshot.truncated = true;
15573            continue;
15574        }
15575        let Some(path) = windows_repository_path_from_bytes(&record[3..]) else {
15576            snapshot.truncated = true;
15577            continue;
15578        };
15579        let previous_path = match previous_path_bytes {
15580            Some(previous) => {
15581                let Some(previous) = windows_repository_path_from_bytes(previous) else {
15582                    snapshot.truncated = true;
15583                    continue;
15584                };
15585                Some(previous)
15586            }
15587            None => None,
15588        };
15589        snapshot.status.push(GitStatusEntry {
15590            index_status: (record[0] as char).to_string(),
15591            worktree_status: (record[1] as char).to_string(),
15592            path,
15593            previous_path,
15594        });
15595    }
15596}
15597
15598fn is_git_status_code(byte: u8) -> bool {
15599    matches!(byte, b' ' | b'M' | b'T' | b'A' | b'D' | b'R' | b'C' | b'U' | b'?' | b'!')
15600}
15601
15602fn parse_git_commits(output: &[u8], snapshot: &mut GitSnapshot) {
15603    for line in String::from_utf8_lossy(output).lines() {
15604        let Some((id, summary)) = line.split_once('\u{1f}') else {
15605            continue;
15606        };
15607        if snapshot.recent_commits.len() >= GIT_COMMIT_MAX_ENTRIES {
15608            snapshot.truncated = true;
15609            break;
15610        }
15611        let id = truncate_git_field(id, 64, &mut snapshot.truncated);
15612        let summary = truncate_git_field(summary, 512, &mut snapshot.truncated);
15613        snapshot.recent_commits.push(GitCommitSummary { id, summary });
15614    }
15615}
15616
15617fn truncate_git_field(value: &str, max_bytes: usize, truncated: &mut bool) -> String {
15618    if value.len() <= max_bytes {
15619        return value.to_owned();
15620    }
15621    *truncated = true;
15622    let mut boundary = max_bytes;
15623    while boundary > 0 && !value.is_char_boundary(boundary) {
15624        boundary -= 1;
15625    }
15626    value[..boundary].to_owned()
15627}
15628
15629fn append_git_diagnostic(snapshot: &mut GitSnapshot, message: &str) {
15630    if message.is_empty() {
15631        return;
15632    }
15633    let normalized = message
15634        .chars()
15635        .map(|character| if character.is_control() { ' ' } else { character })
15636        .collect::<String>();
15637    let mut diagnostic = snapshot.diagnostic.take().unwrap_or_default();
15638    if !diagnostic.is_empty() {
15639        diagnostic.push_str("; ");
15640    }
15641    diagnostic.push_str(normalized.trim());
15642    snapshot.diagnostic = Some(truncate_git_field(
15643        &diagnostic,
15644        GIT_DIAGNOSTIC_MAX_BYTES,
15645        &mut snapshot.truncated,
15646    ));
15647}
15648
15649async fn run_git_bounded(
15650    root: &str,
15651    arguments: &[&str],
15652    output_limit: usize,
15653) -> io::Result<GitCommandOutput> {
15654    run_git_read_bounded(root, arguments, output_limit, GIT_COMMAND_TIMEOUT_MS).await
15655}
15656
15657async fn read_git_history_bounded(
15658    root: &str,
15659    path: Option<&RepositoryPath>,
15660    before: Option<&GitObjectId>,
15661    limit: u16,
15662) -> Result<GitHistoryPage, NodeFailure> {
15663    let path = path
15664        .map(|path| {
15665            path.as_utf8().ok_or_else(|| {
15666                failure(
15667                    NodeFailureCode::InvalidRepositoryPath,
15668                    "git history path must use UTF-8",
15669                )
15670            })
15671        })
15672        .transpose()?
15673        .unwrap_or(".");
15674    let count = (usize::from(limit) + 1).to_string();
15675    let start = before
15676        .map(|revision| format!("{}^", revision.as_str()))
15677        .unwrap_or_else(|| "HEAD".to_owned());
15678    let arguments = [
15679        "--no-pager",
15680        "log",
15681        "--no-decorate",
15682        "--date=iso-strict",
15683        "--pretty=format:%H%x1f%P%x1f%s%x1f%an%x1f%ae%x1f%aI%x1f%cn%x1f%ce%x1f%cI%x1e",
15684        "-n",
15685        count.as_str(),
15686        start.as_str(),
15687        "--",
15688        path,
15689    ];
15690    let output = run_git_bounded(root, &arguments, 256 * 1_024)
15691        .await
15692        .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git history command failed"))?;
15693    if output.timed_out {
15694        return Err(failure(NodeFailureCode::GitReadTimedOut, "git history exceeded its bounded deadline"));
15695    }
15696    if !output.success {
15697        return Err(failure(NodeFailureCode::GitReadFailed, "git history could not be read"));
15698    }
15699    let mut commits = parse_git_history(&output.stdout);
15700    let has_more = commits.len() > usize::from(limit) || output.truncated;
15701    commits.truncate(usize::from(limit));
15702    let next_before = has_more.then(|| commits.last().map(|commit| commit.id.clone())).flatten();
15703    Ok(GitHistoryPage { commits, next_before, truncated: output.truncated })
15704}
15705
15706fn parse_git_history(output: &[u8]) -> Vec<GitCommitDetails> {
15707    String::from_utf8_lossy(output)
15708        .split('\u{1e}')
15709        .filter_map(|record| {
15710            let fields = record.trim_matches(['\r', '\n']).split('\u{1f}').collect::<Vec<_>>();
15711            if fields.len() != 9 {
15712                return None;
15713            }
15714            let id = GitObjectId::new(fields[0].to_owned()).ok()?;
15715            let parents = if fields[1].is_empty() {
15716                Vec::new()
15717            } else {
15718                fields[1]
15719                    .split(' ')
15720                    .map(|value| GitObjectId::new(value.to_owned()))
15721                    .collect::<Result<Vec<_>, _>>()
15722                    .ok()?
15723            };
15724            Some(GitCommitDetails {
15725                id,
15726                parents,
15727                subject: bounded_git_text(fields[2], 512),
15728                author_name: bounded_git_text(fields[3], 256),
15729                author_email: bounded_git_text(fields[4], 320),
15730                authored_at: bounded_git_text(fields[5], 64),
15731                committer_name: bounded_git_text(fields[6], 256),
15732                committer_email: bounded_git_text(fields[7], 320),
15733                committed_at: bounded_git_text(fields[8], 64),
15734                signature_status: GitSignatureStatus::NoSignature,
15735                signer: None,
15736            })
15737        })
15738        .take(usize::from(MAX_GIT_HISTORY_COMMITS) + 1)
15739        .collect()
15740}
15741
15742fn bounded_git_text(value: &str, max_bytes: usize) -> String {
15743    let normalized = value
15744        .chars()
15745        .map(|character| if character.is_control() { ' ' } else { character })
15746        .collect::<String>();
15747    if normalized.len() <= max_bytes {
15748        return normalized;
15749    }
15750    let mut boundary = max_bytes;
15751    while boundary > 0 && !normalized.is_char_boundary(boundary) {
15752        boundary -= 1;
15753    }
15754    normalized[..boundary].to_owned()
15755}
15756
15757async fn read_git_diff_bounded(root: &str, request: GitDiffRequest) -> Result<GitDiff, NodeFailure> {
15758    let path = request.path.as_ref().map(|path| {
15759        path.as_utf8()
15760            .ok_or_else(|| failure(NodeFailureCode::InvalidRepositoryPath, "git diff path must use UTF-8"))
15761    }).transpose()?;
15762    let mut arguments = match &request.mode {
15763        GitDiffMode::Working => vec!["--no-pager", "-c", "diff.external=", "diff", "--no-ext-diff", "--no-textconv"],
15764        GitDiffMode::Staged => vec!["--no-pager", "-c", "diff.external=", "diff", "--cached", "--no-ext-diff", "--no-textconv"],
15765        GitDiffMode::Commit { .. } => vec!["--no-pager", "-c", "diff.external=", "show", "--format=fuller", "--no-ext-diff", "--no-textconv"],
15766    };
15767    if let GitDiffMode::Commit { revision } = &request.mode {
15768        arguments.push(revision.as_str());
15769    }
15770    arguments.push("--");
15771    if let Some(path) = path {
15772        arguments.push(path);
15773    }
15774    let output = run_git_bounded(root, &arguments, MAX_GIT_DIFF_BYTES)
15775        .await
15776        .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git diff command failed"))?;
15777    if output.timed_out {
15778        return Err(failure(NodeFailureCode::GitReadTimedOut, "git diff exceeded its bounded deadline"));
15779    }
15780    if !output.success {
15781        return Err(failure(NodeFailureCode::GitReadFailed, "git diff could not be read"));
15782    }
15783    Ok(GitDiff {
15784        mode: request.mode,
15785        path: request.path,
15786        text: String::from_utf8_lossy(&output.stdout).into_owned(),
15787        truncated: output.truncated,
15788    })
15789}
15790
15791async fn process_request(shared: &NodeShared, connection_id: u64, role: ClientRole, envelope: RequestEnvelope) -> ResponseEnvelope {
15792    let result = process_request_inner(shared, connection_id, role, envelope.request).await;
15793    ResponseEnvelope { request_id: envelope.request_id, result }
15794}
15795
15796async fn process_request_inner(shared: &NodeShared, connection_id: u64, role: ClientRole, request: NodeRequest) -> Result<NodeResponse, NodeFailure> {
15797    if !request.harness_mcp_contract_is_valid_at(unix_time_ms()) {
15798        return Err(failure(NodeFailureCode::InvalidRequest, "harness MCP request is invalid or stale"));
15799    }
15800    let read_only = matches!(
15801        &request,
15802        NodeRequest::Snapshot
15803            | NodeRequest::Resync { .. }
15804            | NodeRequest::BrowseHostDirectories { .. }
15805            | NodeRequest::InspectWorkspace { .. }
15806            | NodeRequest::ReadWorkspaceFile { .. }
15807            | NodeRequest::ReadGitHistory { .. }
15808            | NodeRequest::ReadGitDiff { .. }
15809            | NodeRequest::CatalogNativeSessions { .. }
15810            | NodeRequest::PageNativeSessions { .. }
15811            | NodeRequest::PreviewNativeSession { .. }
15812            | NodeRequest::PreviewSessionRecord { .. }
15813    );
15814    let _mutation_guard = if read_only {
15815        None
15816    } else {
15817        let guard = shared.mutation_gate.lock().await;
15818        if shared.shutdown.load(Ordering::Acquire) {
15819            return Err(failure(NodeFailureCode::ShuttingDown, "node shutdown has begun"));
15820        }
15821        Some(guard)
15822    };
15823    match request {
15824        NodeRequest::Snapshot => Ok(NodeResponse::Snapshot {
15825            event_sequence: shared.current_sequence(),
15826            controller: shared.controller_state(),
15827            snapshot: shared.snapshot(),
15828        }),
15829        NodeRequest::Resync { after_sequence } => Ok(shared.resync(after_sequence)),
15830        NodeRequest::ArmHarnessMcpReservation {
15831            reservation_id,
15832            activation_digest,
15833            spawn_spec,
15834            launch,
15835            expires_at_unix_ms,
15836        } => {
15837            shared.require_controller(connection_id, role)?;
15838            let registry = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15839                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15840            })?;
15841            let expires_at_unix_ms = registry
15842                .arm(reservation_id.clone(), activation_digest.clone(), spawn_spec, launch, expires_at_unix_ms)
15843                .await
15844                .map_err(harness_mcp_failure)?;
15845            Ok(NodeResponse::Armed { reservation_id, activation_digest, expires_at_unix_ms })
15846        }
15847        NodeRequest::SpawnSpecWithHarnessMcp {
15848            reservation_id,
15849            activation_digest,
15850            spec,
15851            deadline_unix_ms,
15852        } => {
15853            shared.require_controller(connection_id, role)?;
15854            let receipt = shared
15855                .spawn_from_spec_with_harness_mcp(
15856                    reservation_id.clone(), activation_digest.clone(), spec, deadline_unix_ms,
15857                )
15858                .await?;
15859            Ok(NodeResponse::Spawned { reservation_id, activation_digest, receipt })
15860        }
15861        NodeRequest::ActivateHarnessMcpReservation {
15862            reservation_id,
15863            activation_digest,
15864            record_id,
15865            session,
15866        } => {
15867            shared.require_controller(connection_id, role)?;
15868            let provider_root_pid = shared.handle.snapshot().sessions.iter().find(|current| {
15869                current.instance_id == session.session.instance_id
15870                    && current.generation == session.session.generation
15871            }).and_then(|current| current.process_id).ok_or_else(|| {
15872                failure(NodeFailureCode::BindingMismatch, "provider root process is unavailable")
15873            })?;
15874            shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15875                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15876            })?.activate(
15877                &reservation_id, &activation_digest, &record_id, &session, provider_root_pid,
15878            ).map_err(harness_mcp_failure)?;
15879            Ok(NodeResponse::Activated { reservation_id, activation_digest, record_id, session })
15880        }
15881        NodeRequest::AbortHarnessMcpReservation { reservation_id, activation_digest } => {
15882            shared.require_controller(connection_id, role)?;
15883            let instance_id = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15884                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15885            })?.abort(&reservation_id, &activation_digest).map_err(harness_mcp_failure)?;
15886            if let Some(instance_id) = instance_id {
15887                SessionLifecycleHook::on_harness_reservation_aborted(&*shared, instance_id);
15888            }
15889            Ok(NodeResponse::Aborted { reservation_id, activation_digest })
15890        }
15891        NodeRequest::PutHarnessMcpReplyChunk {
15892            reservation_id, activation_digest, record_id, session, call_id,
15893            offset, final_chunk, chunk_hex,
15894        } => {
15895            shared.require_controller(connection_id, role)?;
15896            let (next_offset, completed) = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15897                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15898            })?.put_reply_chunk(
15899                &reservation_id, &activation_digest, &record_id, &session, &call_id,
15900                offset, final_chunk, &chunk_hex,
15901            ).map_err(harness_mcp_failure)?;
15902            Ok(NodeResponse::ReplyChunkAccepted {
15903                reservation_id, activation_digest, record_id, session, call_id,
15904                next_offset, completed,
15905            })
15906        }
15907        NodeRequest::RejectHarnessMcpCall {
15908            reservation_id, activation_digest, record_id, session, call_id, reason,
15909        } => {
15910            shared.require_controller(connection_id, role)?;
15911            shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15912                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15913            })?.reject_call(
15914                &reservation_id, &activation_digest, &record_id, &session, &call_id, reason,
15915            ).map_err(harness_mcp_failure)?;
15916            Ok(NodeResponse::CallRejected {
15917                reservation_id, activation_digest, record_id, session, call_id,
15918            })
15919        }
15920        NodeRequest::BeginDeliveryStage { manifest } => {
15921            shared.require_controller(connection_id, role)?;
15922            shared.begin_delivery_stage(manifest)
15923        }
15924        NodeRequest::PutDeliveryBlobChunk {
15925            stage_id,
15926            blob_digest,
15927            offset,
15928            chunk_hex,
15929        } => {
15930            shared.require_controller(connection_id, role)?;
15931            shared.put_delivery_blob_chunk(stage_id, blob_digest, offset, chunk_hex)
15932        }
15933        NodeRequest::CommitDeliveryStage { stage_id } => {
15934            shared.require_controller(connection_id, role)?;
15935            let receipt = shared.commit_delivery_stage(stage_id)?;
15936            Ok(NodeResponse::DeliveryCommitted { receipt })
15937        }
15938        NodeRequest::AbortDeliveryStage { stage_id } => {
15939            shared.require_controller(connection_id, role)?;
15940            shared.abort_delivery_stage(&stage_id)?;
15941            Ok(NodeResponse::DeliveryStageAborted { stage_id })
15942        }
15943        NodeRequest::BrowseHostDirectories { directory, after } => {
15944            let listing = shared.browse_host_directories(directory, after).await?;
15945            Ok(NodeResponse::HostDirectoriesBrowsed { listing })
15946        }
15947        NodeRequest::InspectWorkspace { workspace_id } => {
15948            let inspection = shared.inspect_workspace(workspace_id).await?;
15949            Ok(NodeResponse::WorkspaceInspected { inspection })
15950        }
15951        NodeRequest::ReadWorkspaceFile { workspace_id, path } => {
15952            let file = shared.read_workspace_file(workspace_id, path).await?;
15953            Ok(NodeResponse::WorkspaceFileRead { file })
15954        }
15955        NodeRequest::WriteWorkspaceFile { workspace_id, path, expected_revision, text } => {
15956            shared.require_controller(connection_id, role)?;
15957            let file = shared
15958                .write_workspace_file(workspace_id, path, expected_revision, text)
15959                .await?;
15960            Ok(NodeResponse::WorkspaceFileWritten { file })
15961        }
15962        NodeRequest::CreateWorkspaceFile { workspace_id, path } => {
15963            shared.require_controller(connection_id, role)?;
15964            let file = shared.create_workspace_file(workspace_id, path).await?;
15965            Ok(NodeResponse::WorkspaceFileCreated { file })
15966        }
15967        NodeRequest::CreateWorkspaceDirectory { workspace_id, path } => {
15968            shared.require_controller(connection_id, role)?;
15969            let entry = shared
15970                .create_workspace_directory(workspace_id.clone(), path)
15971                .await?;
15972            Ok(NodeResponse::WorkspaceDirectoryCreated { workspace_id, entry })
15973        }
15974        NodeRequest::ReadGitHistory { workspace_id, path, before, limit } => {
15975            if role != ClientRole::Operator {
15976                return Err(failure(NodeFailureCode::ObserverReadOnly, "git history requires an operator connection"));
15977            }
15978            if !(1..=MAX_GIT_HISTORY_COMMITS).contains(&limit) {
15979                return Err(failure(NodeFailureCode::InvalidRequest, "git history limit is invalid"));
15980            }
15981            let page = shared
15982                .read_git_history(workspace_id.clone(), path, before, limit)
15983                .await?;
15984            Ok(NodeResponse::GitHistoryRead { workspace_id, page })
15985        }
15986        NodeRequest::ReadGitDiff { workspace_id, request } => {
15987            if role != ClientRole::Operator {
15988                return Err(failure(NodeFailureCode::ObserverReadOnly, "git diff requires an operator connection"));
15989            }
15990            let diff = shared.read_git_diff(workspace_id.clone(), request).await?;
15991            Ok(NodeResponse::GitDiffRead { workspace_id, diff })
15992        }
15993        NodeRequest::CatalogNativeSessions { route, limit } => {
15994            if role != ClientRole::Operator {
15995                return Err(failure(
15996                    NodeFailureCode::ObserverReadOnly,
15997                    "native session catalog requires an operator connection",
15998                ));
15999            }
16000            if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit) {
16001                return Err(failure(
16002                    NodeFailureCode::InvalidRequest,
16003                    "native session catalog limit is invalid",
16004                ));
16005            }
16006            let (entries, summary) = shared
16007                .catalog_native_sessions(route.clone(), limit)
16008                .await?;
16009            Ok(NodeResponse::NativeSessionsCataloged {
16010                route,
16011                entries,
16012                summary: Some(summary),
16013            })
16014        }
16015        NodeRequest::PageNativeSessions {
16016            route,
16017            window,
16018            catalog_revision,
16019            recent_cutoff_unix_ms,
16020            after_selection_id,
16021            limit,
16022        } => {
16023            if role != ClientRole::Operator {
16024                return Err(failure(
16025                    NodeFailureCode::ObserverReadOnly,
16026                    "native session catalog paging requires an operator connection",
16027                ));
16028            }
16029            if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit)
16030                || after_selection_id
16031                    .as_deref()
16032                    .is_some_and(|cursor| validate_candidate_id(cursor).is_err())
16033            {
16034                return Err(failure(
16035                    NodeFailureCode::InvalidRequest,
16036                    "native session catalog page request is invalid",
16037                ));
16038            }
16039            let page = shared
16040                .page_native_sessions(
16041                    route.clone(),
16042                    window,
16043                    catalog_revision,
16044                    recent_cutoff_unix_ms,
16045                    after_selection_id,
16046                    limit,
16047                )
16048                .await?;
16049            Ok(NodeResponse::NativeSessionsPaged {
16050                route,
16051                page,
16052            })
16053        }
16054        NodeRequest::PreviewNativeSession {
16055            selection,
16056            message_limit,
16057        } => {
16058            if role != ClientRole::Operator {
16059                return Err(failure(
16060                    NodeFailureCode::ObserverReadOnly,
16061                    "native session preview requires an operator connection",
16062                ));
16063            }
16064            if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16065                .contains(&message_limit)
16066                || selection.validate().is_err()
16067            {
16068                return Err(failure(
16069                    NodeFailureCode::InvalidRequest,
16070                    "native session preview request is invalid",
16071                ));
16072            }
16073            let preview = shared
16074                .preview_native_session(selection.clone(), message_limit)
16075                .await?;
16076            Ok(NodeResponse::NativeSessionPreviewed {
16077                selection,
16078                preview,
16079            })
16080        }
16081        NodeRequest::IndexNativeSession {
16082            selection,
16083            display_name,
16084        } => {
16085            shared.require_controller(connection_id, role)?;
16086            let record = shared
16087                .index_native_session(selection.clone(), display_name)
16088                .await?;
16089            Ok(NodeResponse::NativeSessionIndexed { selection, record })
16090        }
16091        NodeRequest::PreviewSessionRecord {
16092            record_id,
16093            message_limit,
16094        } => {
16095            if role != ClientRole::Operator {
16096                return Err(failure(
16097                    NodeFailureCode::ObserverReadOnly,
16098                    "native session preview requires an operator connection",
16099                ));
16100            }
16101            if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16102                .contains(&message_limit)
16103            {
16104                return Err(failure(
16105                    NodeFailureCode::InvalidRequest,
16106                    "native session preview request is invalid",
16107                ));
16108            }
16109            let record = shared.record(&record_id)?;
16110            let working_directory = shared.workspace_root(&record.workspace_id)?;
16111            if !platform::roots_equal(
16112                &working_directory,
16113                windows_path_text(&record.canonical_root),
16114            ) {
16115                return Err(failure(
16116                    NodeFailureCode::SessionRecordConflict,
16117                    "managed session workspace root changed; refusing to preview another directory",
16118                ));
16119            }
16120            let identity = record.provider_session.clone().ok_or_else(|| {
16121                failure(
16122                    NodeFailureCode::BackendOperationFailed,
16123                    "native session preview is unavailable for this session record",
16124                )
16125            })?;
16126            if identity.key != ProviderSessionKey::SessionId {
16127                return Err(failure(
16128                    NodeFailureCode::BackendOperationFailed,
16129                    "native session preview is unavailable for this session identity",
16130                ));
16131            }
16132            let preview = shared
16133                .preview_native_session_by_session_id(
16134                    record.workspace_id.clone(), record.provider.clone(), identity.id.clone(), message_limit,
16135                )
16136                .await?;
16137            if preview.session_id != identity.id {
16138                return Err(failure(
16139                    NodeFailureCode::SessionRecordConflict,
16140                    "native session preview identity changed while loading",
16141                ));
16142            }
16143            shared.revalidate_session_record_preview(&record, &identity)?;
16144            let preview: gate4agent_types::SessionRecordPreview = preview.into();
16145            shared.publish(NodeEvent::SessionRecordHistorySummarized {
16146                record_id: record_id.clone(),
16147                summary: SessionHistorySummaryV1::from(&preview),
16148            });
16149            Ok(NodeResponse::SessionRecordPreviewed {
16150                record_id,
16151                preview,
16152            })
16153        }
16154        NodeRequest::AcquireController { lease_ms } => {
16155            let controller = shared.acquire_controller(connection_id, role, lease_ms)?;
16156            Ok(NodeResponse::Controller { controller: Some(controller) })
16157        }
16158        NodeRequest::ReleaseController => {
16159            shared.release_controller(connection_id);
16160            Ok(NodeResponse::Controller { controller: shared.controller_state() })
16161        }
16162        NodeRequest::RegisterWorkspace { workspace_id, root } => {
16163            shared.require_controller(connection_id, role)?;
16164            let root = require_windows_path(root)?;
16165            shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16166            let workspace = shared.register_workspace(workspace_id, root).await?;
16167            Ok(NodeResponse::WorkspaceRegistered { workspace })
16168        }
16169        NodeRequest::CreateStandaloneWorkspace {
16170            workspace_id,
16171            root,
16172            initial_branch,
16173        } => {
16174            shared.require_controller(connection_id, role)?;
16175            let root = require_windows_path(root)?;
16176            shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16177            let workspace = shared
16178                .create_standalone_workspace(workspace_id, root, initial_branch)
16179                .await?;
16180            Ok(NodeResponse::StandaloneWorkspaceCreated { workspace })
16181        }
16182        NodeRequest::UnregisterWorkspace { workspace_id } => {
16183            shared.require_controller(connection_id, role)?;
16184            shared.reject_managed_reservation(Some(&workspace_id), None)?;
16185            shared.unregister_workspace(&workspace_id)?;
16186            Ok(NodeResponse::WorkspaceUnregistered { workspace_id })
16187        }
16188        NodeRequest::CreateWorktree {
16189            source_workspace_id,
16190            workspace_id,
16191            target_root,
16192            branch,
16193            base,
16194        } => {
16195            shared.require_controller(connection_id, role)?;
16196            let target_root = require_windows_path(target_root)?;
16197            shared.reject_managed_reservation(Some(&workspace_id), Some(&target_root))?;
16198            let (worktree, workspace) = shared
16199                .create_worktree(
16200                    source_workspace_id,
16201                    workspace_id,
16202                    target_root,
16203                    branch,
16204                    base,
16205                )
16206                .await?;
16207            Ok(NodeResponse::WorktreeCreated { worktree, workspace })
16208        }
16209        NodeRequest::RemoveWorktree {
16210            source_workspace_id,
16211            target_root,
16212        } => {
16213            shared.require_controller(connection_id, role)?;
16214            let native_target_root = require_windows_path(target_root.clone())?;
16215            shared.reject_managed_reservation(None, Some(&native_target_root))?;
16216            let workspace_id = shared
16217                .remove_worktree(source_workspace_id, native_target_root)
16218                .await?;
16219            Ok(NodeResponse::WorktreeRemoved {
16220                target_root,
16221                workspace_id,
16222            })
16223        }
16224        NodeRequest::Spawn { workspace_id, provider, mode, terminal_size, initial_prompt } => {
16225            shared.require_controller(connection_id, role)?;
16226            if !terminal_size.is_valid() {
16227                return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
16228            }
16229            if let Some(prompt) = initial_prompt.as_deref() {
16230                validate_node_text("spawn initial prompt", prompt)?;
16231            }
16232            match shared
16233                .spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt)
16234                .await
16235            {
16236                Ok(session) => {
16237                    shared.mark_spawn_provisional(session.clone());
16238                    Ok(NodeResponse::SpawnAccepted { session })
16239                }
16240                Err(err) => {
16241                    tracing::warn!(
16242                        %provider,
16243                        %workspace_id,
16244                        ?mode,
16245                        code = ?err.code,
16246                        message = %err.message,
16247                        "session spawn refused"
16248                    );
16249                    Err(err)
16250                }
16251            }
16252        }
16253        NodeRequest::SpawnSpec { spec } => {
16254            shared.require_controller(connection_id, role)?;
16255            let receipt = shared.spawn_from_spec(spec).await?;
16256            Ok(NodeResponse::SpawnSpecAccepted { receipt })
16257        }
16258        NodeRequest::SpawnManagedWorktree { request } => {
16259            shared.require_controller(connection_id, role)?;
16260            let receipt = shared.spawn_managed_worktree(request).await?;
16261            Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
16262        }
16263        NodeRequest::SpawnManagedWorktreeV2 { request } => {
16264            shared.require_controller(connection_id, role)?;
16265            let receipt = shared.spawn_managed_worktree_v2(request).await?;
16266            Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
16267        }
16268        NodeRequest::CleanupManagedWorktree { lease_id } => {
16269            shared.require_controller(connection_id, role)?;
16270            let lease = shared.cleanup_managed_worktree(&lease_id, true).await?;
16271            Ok(NodeResponse::ManagedWorktreeCleanup { lease })
16272        }
16273        NodeRequest::DiscoverHistory { session, limit } => {
16274            controlled_session(shared, connection_id, role, &session)?;
16275            let candidates = shared.discover_history(&session, limit).await?;
16276            Ok(NodeResponse::HistoryDiscovered {
16277                session,
16278                candidates,
16279            })
16280        }
16281        NodeRequest::LoadHistory {
16282            session,
16283            candidate_id,
16284        } => {
16285            controlled_session(shared, connection_id, role, &session)?;
16286            let loaded = shared
16287                .load_history(&session, candidate_id)
16288                .await?;
16289            Ok(NodeResponse::HistoryLoaded {
16290                session,
16291                session_id: loaded.session_id,
16292                message_count: loaded.message_count,
16293                completed_turn_count: loaded.completed_turn_count,
16294            })
16295        }
16296        NodeRequest::ExportContextPackForSessionRecord { record_id, session } => {
16297            shared.require_controller(connection_id, role)?;
16298            let context = shared
16299                .export_context_pack_for_session_record(&record_id, &session, false)
16300                .await?;
16301            Ok(NodeResponse::ContextPackForSessionRecordExported {
16302                record_id,
16303                session,
16304                context,
16305            })
16306        }
16307        NodeRequest::ExportContextPack { session } => {
16308            controlled_session(shared, connection_id, role, &session)?;
16309            let context = shared.export_context_pack(&session).await?;
16310            Ok(NodeResponse::ContextPackExported { context })
16311        }
16312        NodeRequest::ForgetContextPack { context_id } => {
16313            shared.require_controller(connection_id, role)?;
16314            shared.forget_context_pack(&context_id)?;
16315            Ok(NodeResponse::ContextPackForgotten { context_id })
16316        }
16317        NodeRequest::ResolveDurableContextPack { context_id } => {
16318            // Read-only, idempotent, no controller lease required: this lets
16319            // a caller preflight "is this durably exported pack still here"
16320            // without holding the Node's mutation controller.
16321            let context = shared
16322                .context_catalog
16323                .read()
16324                .unwrap_or_else(|poisoned| poisoned.into_inner())
16325                .get(&context_id)
16326                .map(|pack| pack.receipt().clone())
16327                .ok_or_else(|| {
16328                    failure(
16329                        NodeFailureCode::UnknownContextPack,
16330                        "context pack is not present",
16331                    )
16332                })?;
16333            Ok(NodeResponse::DurableContextPackResolved { context })
16334        }
16335        NodeRequest::ReadContextPack { digest } => {
16336            // Read-only fetch of the pack's own bytes, keyed by content digest
16337            // rather than the caller's `SpawnContextId`: same no-controller-lease
16338            // rationale as `ResolveDurableContextPack` above, one step further
16339            // down the same catalog.
16340            let (id, byte_len, bytes) = shared
16341                .context_catalog
16342                .read()
16343                .unwrap_or_else(|poisoned| poisoned.into_inner())
16344                .get_by_digest(&digest)
16345                .map(|pack| {
16346                    (pack.receipt().id.clone(), pack.receipt().byte_len, pack.bytes().to_vec())
16347                })
16348                .ok_or_else(|| {
16349                    failure(
16350                        NodeFailureCode::UnknownContextPack,
16351                        &format!("context pack {digest} not on this node"),
16352                    )
16353                })?;
16354            tracing::debug!(
16355                digest = digest.as_str(),
16356                byte_len,
16357                "read context pack bytes",
16358            );
16359            Ok(NodeResponse::ContextPackBytesRead {
16360                pack: ContextPackBytesRead {
16361                    digest,
16362                    id,
16363                    byte_len,
16364                    bytes,
16365                },
16366            })
16367        }
16368        NodeRequest::Resume { session, terminal_size, initial_prompt } => {
16369            let provider = controlled_session(shared, connection_id, role, &session)?;
16370            if let Some(prompt) = initial_prompt.as_deref() {
16371                validate_node_text("resume initial prompt", prompt)?;
16372            }
16373            let runtime_requirement = if initial_prompt.is_some() {
16374                ProviderRuntimeRequirement::ResumeWithPrompt
16375            } else {
16376                ProviderRuntimeRequirement::Resume
16377            };
16378            shared.require_session_runtime_policy(&session, runtime_requirement)?;
16379            let runtime_policy = shared
16380                .admit_provider_runtime(&provider, runtime_requirement)
16381                .await?;
16382            let working_directory = shared.bound_workspace_root(&session)?;
16383            let request = ResumeLaunchRequest {
16384                working_directory,
16385                terminal_size,
16386                initial_prompt,
16387            };
16388            request
16389                .validate()
16390                .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
16391            let command = shared.prepare_command(ControlCommand::Resume {
16392                instance_id: session.session.instance_id,
16393                target: ResumeTarget::CurrentProvider,
16394                runtime_policy,
16395                request,
16396            });
16397            shared.arm_resume(&session, command.id, runtime_policy)?;
16398            let dispatch = shared.dispatch_envelope(command);
16399            if let Err(error) = dispatch {
16400                shared.clear_armed_resume(&session);
16401                return Err(error);
16402            }
16403            Ok(NodeResponse::Accepted)
16404        }
16405        NodeRequest::RenameSessionRecord {
16406            record_id,
16407            display_name,
16408        } => {
16409            shared.require_controller(connection_id, role)?;
16410            let record = shared.rename_session_record(&record_id, display_name)?;
16411            Ok(NodeResponse::SessionRecordUpdated { record })
16412        }
16413        NodeRequest::SetSessionTask {
16414            record_id,
16415            expected_revision,
16416            target,
16417        } => {
16418            shared.require_controller(connection_id, role)?;
16419            let record = shared.set_session_task(&record_id, expected_revision, target)?;
16420            Ok(NodeResponse::SessionRecordUpdated { record })
16421        }
16422        NodeRequest::IndexProviderSession {
16423            workspace_id,
16424            provider,
16425            identity,
16426            display_name,
16427        } => {
16428            shared.require_controller(connection_id, role)?;
16429            let record = shared.index_provider_session(
16430                workspace_id,
16431                provider,
16432                identity,
16433                display_name,
16434            )?;
16435            Ok(NodeResponse::ProviderSessionIndexed { record })
16436        }
16437        NodeRequest::ResumeSessionRecord {
16438            record_id,
16439            terminal_size,
16440            initial_prompt,
16441        } => {
16442            shared.require_controller(connection_id, role)?;
16443            if let Some(prompt) = initial_prompt.as_deref() {
16444                validate_node_text("managed resume initial prompt", prompt)?;
16445            }
16446            let (record, session) = shared
16447                .resume_session_record(
16448                    &record_id,
16449                    terminal_size,
16450                    initial_prompt,
16451                )
16452                .await?;
16453            Ok(NodeResponse::SessionRecordResumed { record, session })
16454        }
16455        NodeRequest::ForgetSessionRecord { record_id } => {
16456            shared.require_controller(connection_id, role)?;
16457            shared.forget_session_record(&record_id).await?;
16458            Ok(NodeResponse::SessionRecordForgotten { record_id })
16459        }
16460        NodeRequest::Prompt { session, text } => {
16461            let agent_id = controlled_session(shared, connection_id, role, &session)?;
16462            validate_node_text("prompt", &text)?;
16463            shared.require_session_runtime_policy(
16464                &session,
16465                ProviderRuntimeRequirement::SemanticPrompt,
16466            )?;
16467            let framing = prompt_framing(&agent_id);
16468            shared
16469                .dispatch_input_bounded(
16470                    &session,
16471                    InputAction::SubmitPrompt(PromptPayload { text, framing }),
16472                )
16473                .await?;
16474            Ok(NodeResponse::Accepted)
16475        }
16476        NodeRequest::Paste { session, text } => {
16477            controlled_session(shared, connection_id, role, &session)?;
16478            validate_node_text("paste", &text)?;
16479            shared.require_session_runtime_policy(
16480                &session,
16481                ProviderRuntimeRequirement::SemanticPrompt,
16482            )?;
16483            shared
16484                .dispatch_input_bounded(
16485                    &session,
16486                    InputAction::InsertDraft(PromptPayload {
16487                        text,
16488                        framing: PromptFraming::BracketedPaste,
16489                    }),
16490                )
16491                .await?;
16492            Ok(NodeResponse::Accepted)
16493        }
16494        NodeRequest::Input { session, text } => {
16495            controlled_session(shared, connection_id, role, &session)?;
16496            validate_node_text("terminal input", &text)?;
16497            shared
16498                .dispatch_input_bounded(
16499                    &session,
16500                    InputAction::TerminalText(TerminalText { text }),
16501                )
16502                .await?;
16503            Ok(NodeResponse::Accepted)
16504        }
16505        NodeRequest::TerminalBytes { session, bytes } => {
16506            controlled_session(shared, connection_id, role, &session)?;
16507            shared.sessionize_spawn(session.session.instance_id);
16508            let action = terminal_bytes_action(bytes)?;
16509            shared
16510                .dispatch_input_bounded(&session, action)
16511                .await?;
16512            Ok(NodeResponse::Accepted)
16513        }
16514        NodeRequest::TerminalControl { session, control } => {
16515            controlled_session(shared, connection_id, role, &session)?;
16516            shared
16517                .dispatch_input_bounded(&session, InputAction::TerminalControl(control))
16518                .await?;
16519            Ok(NodeResponse::Accepted)
16520        }
16521        NodeRequest::Resize { session, size } => {
16522            controlled_session(shared, connection_id, role, &session)?;
16523            shared.sessionize_spawn(session.session.instance_id);
16524            if !size.is_valid() {
16525                return Err(failure(NodeFailureCode::InvalidRequest, "terminal size is invalid"));
16526            }
16527            shared.dispatch_resize_bounded(&session, size).await?;
16528            Ok(NodeResponse::Accepted)
16529        }
16530        NodeRequest::Interrupt { session } => {
16531            controlled_session(shared, connection_id, role, &session)?;
16532            shared
16533                .dispatch_input_bounded(
16534                    &session,
16535                    InputAction::TerminalControl(TerminalControl::Interrupt),
16536                )
16537                .await?;
16538            Ok(NodeResponse::Accepted)
16539        }
16540        NodeRequest::Stop { session, force } => {
16541            controlled_session(shared, connection_id, role, &session)?;
16542            shared.sessionize_spawn(session.session.instance_id);
16543            shared.dispatch(ControlCommand::Stop { instance_id: session.session.instance_id, force })?;
16544            Ok(NodeResponse::Accepted)
16545        }
16546        NodeRequest::Remove { session } => {
16547            controlled_session(shared, connection_id, role, &session)?;
16548            shared.remove_session(&session).await?;
16549            Ok(NodeResponse::Accepted)
16550        }
16551        NodeRequest::ResolveInteraction { session, correlation_id, response } => {
16552            shared.require_controller(connection_id, role)?;
16553            let snapshot = shared.require_acp_session(&session)?;
16554            let interaction = snapshot
16555                .provider
16556                .interactions
16557                .iter()
16558                .find(|interaction| {
16559                    interaction_correlation(
16560                        session.session.instance_id,
16561                        session.session.generation,
16562                        interaction.id.0,
16563                    ) == correlation_id
16564                })
16565                .ok_or_else(|| failure(
16566                    NodeFailureCode::InvalidRequest,
16567                    "unknown interaction correlation",
16568                ))?;
16569            if interaction.status != ProviderInteractionStatus::Pending {
16570                return Err(failure(
16571                    NodeFailureCode::BackendOperationFailed,
16572                    "interaction has already been resolved",
16573                ));
16574            }
16575            response
16576                .validate_for(interaction.interaction_kind)
16577                .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
16578            let interaction_id = interaction.id;
16579            let response_kind = response.kind();
16580            let response_outcome = response.outcome();
16581            shared.dispatch(ControlCommand::ResolveInteraction {
16582                instance_id: session.session.instance_id,
16583                generation: session.session.generation,
16584                interaction_id,
16585                response,
16586            })?;
16587            // The one line that lets an operator compare what THIS node
16588            // recorded against what the provider itself reports doing --
16589            // measured live on a kimi run where our side resolved `true`
16590            // while the agent reported "rejected" and neither layer had a
16591            // line to line up against the other.
16592            tracing::info!(
16593                node_id = %shared.node_id,
16594                session = ?session.session,
16595                correlation_id = %correlation_id,
16596                interaction_id = interaction_id.0,
16597                response_kind = ?response_kind,
16598                outcome = ?response_outcome,
16599                "interaction resolved",
16600            );
16601            Ok(NodeResponse::Accepted)
16602        }
16603        NodeRequest::SetSessionMode { session, mode_id } => {
16604            shared.require_controller(connection_id, role)?;
16605            shared.require_acp_session(&session)?;
16606            shared.dispatch(ControlCommand::SetSessionMode {
16607                instance_id: session.session.instance_id,
16608                mode_id,
16609            })?;
16610            Ok(NodeResponse::Accepted)
16611        }
16612        NodeRequest::SetSessionConfigOption {
16613            session,
16614            option_id,
16615            value_json,
16616        } => {
16617            shared.require_controller(connection_id, role)?;
16618            shared.require_acp_session(&session)?;
16619            shared.dispatch(ControlCommand::SetSessionConfigOption {
16620                instance_id: session.session.instance_id,
16621                option_id,
16622                value_json,
16623            })?;
16624            Ok(NodeResponse::Accepted)
16625        }
16626        NodeRequest::SetSessionModel { session, model_id } => {
16627            shared.require_controller(connection_id, role)?;
16628            shared.require_acp_session(&session)?;
16629            shared.dispatch(ControlCommand::SetSessionModel {
16630                instance_id: session.session.instance_id,
16631                model_id,
16632            })?;
16633            Ok(NodeResponse::Accepted)
16634        }
16635        NodeRequest::Shutdown => {
16636            shared.require_controller(connection_id, role)?;
16637            shared.begin_shutdown_locked().await?;
16638            Ok(NodeResponse::ShuttingDown)
16639        }
16640    }
16641}
16642
16643fn controlled_session(shared: &NodeShared, connection_id: u64, role: ClientRole, session: &SessionAddress) -> Result<AgentId, NodeFailure> {
16644    shared.require_controller(connection_id, role)?;
16645    shared.validate_address(session)
16646}
16647
16648/// Selects the sole history candidate matching `provider_session_id` when
16649/// the managed record carries a verified identity, or the sole candidate in
16650/// the discovered set at all when it does not (see
16651/// `export_context_pack_for_session_record_inner`'s identity match). Either
16652/// way, more than one match refuses instead of guessing which one is exact.
16653fn select_history_candidate(
16654    candidates: &[HistoryCandidateSummary],
16655    provider_session_id: Option<&str>,
16656) -> Result<String, NodeFailure> {
16657    let mut matches = candidates.iter().filter(|candidate| match provider_session_id {
16658        Some(id) => candidate.session_id_hint == id,
16659        None => true,
16660    });
16661    let candidate = matches.next().ok_or_else(|| {
16662        failure(
16663            NodeFailureCode::SessionRecordConflict,
16664            if provider_session_id.is_some() {
16665                "no history candidate matches the managed provider session"
16666            } else {
16667                "no history candidate exists for the managed session"
16668            },
16669        )
16670    })?;
16671    if matches.next().is_some() {
16672        return Err(failure(
16673            NodeFailureCode::SessionRecordConflict,
16674            if provider_session_id.is_some() {
16675                "history identity is ambiguous for the managed provider session"
16676            } else {
16677                "history is ambiguous without a verified provider session identity"
16678            },
16679        ));
16680    }
16681    Ok(candidate.id.clone())
16682}
16683
16684fn resolved_spawn_profile_summary(
16685    profile: &SpawnProfileDefaults,
16686    environment_profiles: &BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>,
16687) -> Option<SpawnProfileSummary> {
16688    let environment_profile = match profile.environment_profile_id.as_ref() {
16689        None => None,
16690        Some(profile_id) => {
16691            let binding = environment_profiles.get(profile_id)?;
16692            if binding.provider != profile.provider
16693                || binding.native_profile_id(profile.mode).is_none()
16694            {
16695                return None;
16696            }
16697            Some(ResolvedEnvironmentProfileReceipt {
16698                profile_id: binding.id.clone(),
16699                profile_revision: binding.revision.clone(),
16700                network_allowlist: None,
16701                browser_profile_id: None,
16702            })
16703        }
16704    };
16705    Some(SpawnProfileSummary {
16706        id: profile.profile_id.clone(),
16707        revision: profile.revision.clone(),
16708        environment_profile,
16709    })
16710}
16711
16712/// True if `current` is an acceptable version of `expected` at export
16713/// revalidation/commit time.
16714///
16715/// When `allow_clean_detachment` is `false` (the on-demand,
16716/// `NodeRequest::ExportContextPackForSessionRecord` API path), `current` must
16717/// be byte-identical to `expected` and still actively bound to `session` —
16718/// the original, unchanged invariant.
16719///
16720/// When `true` (the reactive auto-export-at-exit path, `§2.1`), `current` may
16721/// ALSO be the exact Live -> Dormant transition that the same clean exit this
16722/// export reacts to produces on its own: `state` Live -> Dormant,
16723/// `active_session` `Some(session)` -> `None`, `updated_at_unix_ms`
16724/// advancing. Every other field — including `provider_session`, `context`,
16725/// `bundle`, `task_binding` — must still match exactly. A record that changed
16726/// in any other way (rebind, a different active session, an identity
16727/// conflict, capacity eviction) is rejected either way; this widens nothing
16728/// about which sessions are eligible, only how the record's own expected
16729/// detachment is tolerated mid-export.
16730fn session_record_export_target_matches(
16731    expected: &ManagedSessionRecord,
16732    current: &ManagedSessionRecord,
16733    session: &SessionAddress,
16734    allow_clean_detachment: bool,
16735) -> bool {
16736    if current == expected && current.active_session.as_ref() == Some(session) {
16737        return current.state == ManagedSessionState::Live;
16738    }
16739    allow_clean_detachment
16740        && current.state == ManagedSessionState::Dormant
16741        && current.active_session.is_none()
16742        && current.record_id == expected.record_id
16743        && current.display_name == expected.display_name
16744        && current.provider == expected.provider
16745        && current.mode == expected.mode
16746        && current.workspace_id == expected.workspace_id
16747        && current.canonical_root == expected.canonical_root
16748        && current.provider_session == expected.provider_session
16749        && current.environment_profile == expected.environment_profile
16750        && current.bundle == expected.bundle
16751        && current.context_id == expected.context_id
16752        && current.context == expected.context
16753        && current.exported_context == expected.exported_context
16754        && current.task_binding == expected.task_binding
16755        && current.created_at_unix_ms == expected.created_at_unix_ms
16756        && current.last_error == expected.last_error
16757}
16758
16759fn session_record_context_export_binding_is_exact(
16760    expected: &ManagedSessionRecord,
16761    current: &ManagedSessionRecord,
16762    identity: Option<&ProviderSessionIdentity>,
16763    session: &SessionAddress,
16764    binding: &SessionBinding,
16765    provider: &AgentId,
16766    current_root: &str,
16767    allow_clean_detachment: bool,
16768) -> bool {
16769    session_record_export_target_matches(expected, current, session, allow_clean_detachment)
16770        && current.provider_session.as_ref() == identity
16771        && current.workspace_id == session.workspace_id
16772        && provider == &current.provider
16773        && binding.workspace_id == session.workspace_id
16774        && binding.generation == session.session.generation
16775        && binding.record_id.as_ref() == Some(&current.record_id)
16776        && platform::roots_equal(
16777            current_root,
16778            windows_path_text(&current.canonical_root),
16779        )
16780}
16781
16782fn session_record_context_export_source_is_usable(
16783    record: &ManagedSessionRecord,
16784    status: &SessionStatus,
16785    allow_clean_detachment: bool,
16786) -> bool {
16787    (record.state == ManagedSessionState::Live
16788        || (allow_clean_detachment && record.state == ManagedSessionState::Dormant))
16789        && context_pack_source_status_is_usable(status)
16790}
16791
16792fn prompt_framing(_agent_id: &AgentId) -> PromptFraming {
16793    PromptFraming::BracketedPaste
16794}
16795
16796fn validate_node_text(field: &str, text: &str) -> Result<(), NodeFailure> {
16797    if text.len() > MAX_NODE_TEXT_BYTES {
16798        return Err(failure(
16799            NodeFailureCode::InvalidRequest,
16800            &format!(
16801                "{field} length {} exceeds the {MAX_NODE_TEXT_BYTES}-byte node limit",
16802                text.len(),
16803            ),
16804        ));
16805    }
16806    Ok(())
16807}
16808
16809fn validate_terminal_bytes(bytes: &[u8]) -> Result<(), NodeFailure> {
16810    if bytes.is_empty() || bytes.len() > MAX_NODE_TERMINAL_BYTES {
16811        return Err(failure(
16812            NodeFailureCode::InvalidRequest,
16813            &format!(
16814                "terminal byte sequence must contain 1..={MAX_NODE_TERMINAL_BYTES} bytes; received {}",
16815                bytes.len(),
16816            ),
16817        ));
16818    }
16819    Ok(())
16820}
16821
16822fn terminal_bytes_action(bytes: Vec<u8>) -> Result<InputAction, NodeFailure> {
16823    validate_terminal_bytes(&bytes)?;
16824    Ok(InputAction::TerminalBytes(bytes))
16825}
16826
16827fn validate_workspace_request_root(root: &str) -> Result<(), NodeFailure> {
16828    if root.is_empty()
16829        || root.len() > MAX_WORKSPACE_ROOT_BYTES
16830        || root.chars().any(char::is_control)
16831    {
16832        return Err(failure(
16833            NodeFailureCode::InvalidWorkspaceRoot,
16834            &format!(
16835                "workspace root must contain 1..={MAX_WORKSPACE_ROOT_BYTES} bytes and no control characters",
16836            ),
16837        ));
16838    }
16839    Ok(())
16840}
16841
16842fn canonical_standalone_workspace_candidate(
16843    workspace_id: &WorkspaceId,
16844    root: &str,
16845) -> Result<String, NodeServerError> {
16846    let path = Path::new(root);
16847    match std::fs::symlink_metadata(path) {
16848        Ok(_) => WorkspaceConfig::new(workspace_id.clone(), path)
16849            .map(|workspace| workspace.canonical_root),
16850        Err(error) if error.kind() == io::ErrorKind::NotFound => {
16851            if !path.is_absolute() {
16852                return Err(NodeServerError::InvalidWorkspaceRoot {
16853                    workspace_id: workspace_id.clone(),
16854                    path: root.to_owned(),
16855                    message: "path is not absolute".to_owned(),
16856                });
16857            }
16858            let name = path.file_name().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
16859                workspace_id: workspace_id.clone(),
16860                path: root.to_owned(),
16861                message: "path has no standalone directory name".to_owned(),
16862            })?;
16863            let parent = path.parent().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
16864                workspace_id: workspace_id.clone(),
16865                path: root.to_owned(),
16866                message: "path has no parent directory".to_owned(),
16867            })?;
16868            let canonical = std::fs::canonicalize(parent)
16869                .map(|parent| parent.join(name))
16870                .map_err(|error| NodeServerError::InvalidWorkspaceRoot {
16871                    workspace_id: workspace_id.clone(),
16872                    path: root.to_owned(),
16873                    message: error.to_string(),
16874                })?;
16875            let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
16876                NodeServerError::InvalidWorkspaceRoot {
16877                    workspace_id: workspace_id.clone(),
16878                    path: path.to_string_lossy().into_owned(),
16879                    message: "canonical path is not valid Unicode".to_owned(),
16880                }
16881            })?;
16882            let canonical_root = platform::normalize_canonical_root(canonical_root);
16883            validate_workspace_root(workspace_id, &canonical_root)?;
16884            Ok(canonical_root)
16885        }
16886        Err(error) => Err(NodeServerError::InvalidWorkspaceRoot {
16887            workspace_id: workspace_id.clone(),
16888            path: root.to_owned(),
16889            message: error.to_string(),
16890        }),
16891    }
16892}
16893
16894fn validate_git_revision(field: &str, revision: Option<&str>) -> Result<(), NodeFailure> {
16895    let Some(revision) = revision else {
16896        return Ok(());
16897    };
16898    if revision.is_empty()
16899        || revision.len() > MAX_REPOSITORY_PATH_BYTES
16900        || revision.chars().any(char::is_control)
16901    {
16902        return Err(failure(
16903            NodeFailureCode::InvalidRequest,
16904            &format!(
16905                "{field} must contain 1..={MAX_REPOSITORY_PATH_BYTES} bytes and no control characters",
16906            ),
16907        ));
16908    }
16909    Ok(())
16910}
16911
16912fn git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
16913    let code = match error.kind {
16914        GitWorktreeErrorKind::Invalid => NodeFailureCode::InvalidRequest,
16915        GitWorktreeErrorKind::NotRepository => NodeFailureCode::NotGitRepository,
16916        GitWorktreeErrorKind::Conflict => NodeFailureCode::WorktreeConflict,
16917        GitWorktreeErrorKind::Protected => NodeFailureCode::WorktreeProtected,
16918        GitWorktreeErrorKind::Dirty => NodeFailureCode::WorktreeDirty,
16919        GitWorktreeErrorKind::Locked => NodeFailureCode::WorktreeLocked,
16920        GitWorktreeErrorKind::Failed => NodeFailureCode::BackendOperationFailed,
16921    };
16922    failure(code, &error.message)
16923}
16924
16925fn standalone_workspace_failure(error: StandaloneWorkspaceError) -> NodeFailure {
16926    let code = match error.kind {
16927        StandaloneWorkspaceErrorKind::InvalidRoot => NodeFailureCode::InvalidWorkspaceRoot,
16928        StandaloneWorkspaceErrorKind::InvalidInitialBranch => NodeFailureCode::InvalidRequest,
16929        StandaloneWorkspaceErrorKind::GitInitializationFailed => {
16930            NodeFailureCode::BackendOperationFailed
16931        }
16932        StandaloneWorkspaceErrorKind::RecoveryRequired => {
16933            NodeFailureCode::StandaloneWorkspaceRecoveryRequired
16934        }
16935    };
16936    failure(code, error.message)
16937}
16938
16939fn managed_git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
16940    let (code, message) = match error.kind {
16941        GitWorktreeErrorKind::Invalid => (
16942            NodeFailureCode::InvalidRequest,
16943            "managed Git input is invalid",
16944        ),
16945        GitWorktreeErrorKind::NotRepository => (
16946            NodeFailureCode::NotGitRepository,
16947            "managed source is not a Git repository",
16948        ),
16949        GitWorktreeErrorKind::Conflict => (
16950            NodeFailureCode::WorktreeConflict,
16951            "managed Git identity conflicts with existing repository state",
16952        ),
16953        GitWorktreeErrorKind::Protected => (
16954            NodeFailureCode::ManagedWorktreeOwnershipConflict,
16955            "managed worktree ownership validation failed",
16956        ),
16957        GitWorktreeErrorKind::Dirty => (
16958            NodeFailureCode::WorktreeDirty,
16959            "managed worktree contains uncommitted changes",
16960        ),
16961        GitWorktreeErrorKind::Locked => (
16962            NodeFailureCode::WorktreeLocked,
16963            "managed worktree is locked",
16964        ),
16965        GitWorktreeErrorKind::Failed => (
16966            NodeFailureCode::BackendOperationFailed,
16967            "managed Git operation failed",
16968        ),
16969    };
16970    NodeFailure { code, message: message.to_owned() }
16971}
16972
16973fn managed_spawn_receipt(
16974    resolved: &ResolvedSpawnSpec,
16975    incarnation_id: NodeIncarnationId,
16976    session: SessionAddress,
16977    lease: ManagedWorktreeLeaseSnapshot,
16978    environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
16979    bundle: Option<ResolvedBundleReceipt>,
16980    context: Option<ResolvedContextPackReceipt>,
16981) -> ManagedWorktreeSpawnReceipt {
16982    let mut managed = resolved.clone();
16983    managed.target.worktree_id = Some(lease.workspace_id.clone());
16984    ManagedWorktreeSpawnReceipt {
16985        spawn: managed.receipt_with_materialization(
16986            incarnation_id,
16987            session,
16988            environment_profile,
16989            bundle,
16990            context,
16991        ),
16992        lease,
16993    }
16994}
16995
16996fn validate_selected_worktree(
16997    source_root: &str,
16998    selected_root: &str,
16999    worktrees: &[NativeGitWorktreeSnapshot],
17000) -> Result<(), NodeFailure> {
17001    if !worktrees
17002        .iter()
17003        .any(|worktree| worktree_paths_equal(&worktree.path, source_root) && !worktree.is_bare)
17004    {
17005        return Err(failure(
17006            NodeFailureCode::WorktreeProtected,
17007            "source workspace is not an authoritative non-bare Git worktree root",
17008        ));
17009    }
17010    if !worktrees.iter().any(|worktree| {
17011        worktree_paths_equal(&worktree.path, selected_root)
17012            && !worktree.is_main
17013            && !worktree.is_bare
17014    }) {
17015        return Err(failure(
17016            NodeFailureCode::WorktreeProtected,
17017            "selected workspace is not an eligible Git worktree in the source repository",
17018        ));
17019    }
17020    Ok(())
17021}
17022
17023fn workspace_file_failure(error: WorkspaceFileReadError) -> NodeFailure {
17024    let code = match error.kind() {
17025        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17026        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17027            NodeFailureCode::RepositoryPathUnsafe
17028        }
17029        WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17030        WorkspaceFileReadErrorKind::NotRegularFile => {
17031            NodeFailureCode::RepositoryFileNotRegular
17032        }
17033        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17034            NodeFailureCode::RepositoryFileReadFailed
17035        }
17036        WorkspaceFileReadErrorKind::RevisionConflict => {
17037            NodeFailureCode::RepositoryFileRevisionConflict
17038        }
17039        WorkspaceFileReadErrorKind::AlreadyExists
17040        | WorkspaceFileReadErrorKind::ParentNotFound
17041        | WorkspaceFileReadErrorKind::ParentNotDirectory
17042        | WorkspaceFileReadErrorKind::Canceled => {
17043            NodeFailureCode::RepositoryFileReadFailed
17044        }
17045    };
17046    failure(code, "repository file read failed")
17047}
17048
17049fn workspace_file_write_failure(error: WorkspaceFileReadError) -> NodeFailure {
17050    let code = match error.kind() {
17051        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17052        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17053            NodeFailureCode::RepositoryPathUnsafe
17054        }
17055        WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17056        WorkspaceFileReadErrorKind::NotRegularFile => {
17057            NodeFailureCode::RepositoryFileNotRegular
17058        }
17059        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17060            NodeFailureCode::RepositoryFileWriteFailed
17061        }
17062        WorkspaceFileReadErrorKind::RevisionConflict => {
17063            NodeFailureCode::RepositoryFileRevisionConflict
17064        }
17065        WorkspaceFileReadErrorKind::AlreadyExists
17066        | WorkspaceFileReadErrorKind::ParentNotFound
17067        | WorkspaceFileReadErrorKind::ParentNotDirectory
17068        | WorkspaceFileReadErrorKind::Canceled => {
17069            NodeFailureCode::RepositoryFileWriteFailed
17070        }
17071    };
17072    failure(code, "repository file write failed")
17073}
17074
17075async fn settle_workspace_entry_create<T>(
17076    mut task: tokio::task::JoinHandle<Result<T, WorkspaceFileReadError>>,
17077    commit_state: Arc<AtomicU8>,
17078    deadline: Duration,
17079    timeout_message: &'static str,
17080    task_failure_message: &'static str,
17081) -> Result<T, NodeFailure> {
17082    let joined = match timeout(deadline, &mut task).await {
17083        Ok(joined) => joined,
17084        Err(_) => {
17085            if commit_state
17086                .compare_exchange(
17087                    WORKSPACE_ENTRY_CREATE_PENDING,
17088                    WORKSPACE_ENTRY_CREATE_CANCELED,
17089                    Ordering::AcqRel,
17090                    Ordering::Acquire,
17091                )
17092                .is_ok()
17093            {
17094                return Err(failure(
17095                    NodeFailureCode::RepositoryEntryCreateTimedOut,
17096                    timeout_message,
17097                ));
17098            }
17099            debug_assert_eq!(
17100                commit_state.load(Ordering::Acquire),
17101                WORKSPACE_ENTRY_CREATE_COMMITTING,
17102            );
17103            task.await
17104        }
17105    };
17106    joined
17107        .map_err(|_| {
17108            failure(
17109                NodeFailureCode::RepositoryEntryCreateFailed,
17110                task_failure_message,
17111            )
17112        })?
17113        .map_err(workspace_entry_create_failure)
17114}
17115
17116fn workspace_entry_create_failure(error: WorkspaceFileReadError) -> NodeFailure {
17117    let code = match error.kind() {
17118        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17119        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17120            NodeFailureCode::RepositoryPathUnsafe
17121        }
17122        WorkspaceFileReadErrorKind::AlreadyExists => {
17123            NodeFailureCode::RepositoryEntryAlreadyExists
17124        }
17125        WorkspaceFileReadErrorKind::ParentNotFound | WorkspaceFileReadErrorKind::NotFound => {
17126            NodeFailureCode::RepositoryParentNotFound
17127        }
17128        WorkspaceFileReadErrorKind::ParentNotDirectory
17129        | WorkspaceFileReadErrorKind::NotRegularFile => {
17130            NodeFailureCode::RepositoryParentNotDirectory
17131        }
17132        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17133            NodeFailureCode::RepositoryEntryCreateFailed
17134        }
17135        WorkspaceFileReadErrorKind::RevisionConflict => {
17136            NodeFailureCode::RepositoryEntryCreateFailed
17137        }
17138        WorkspaceFileReadErrorKind::Canceled => {
17139            NodeFailureCode::RepositoryEntryCreateTimedOut
17140        }
17141    };
17142    failure(code, "repository entry create failed")
17143}
17144
17145fn workspace_file_revision(bytes: &[u8]) -> WorkspaceFileRevision {
17146    let value = digest(&SHA256, bytes)
17147        .as_ref()
17148        .iter()
17149        .map(|byte| format!("{byte:02x}"))
17150        .collect();
17151    WorkspaceFileRevision::new(value).expect("SHA-256 produces a valid workspace revision")
17152}
17153
17154fn managed_spawn_request_digest_v2(
17155    request: &ManagedWorktreeSpawnRequestV2,
17156) -> Result<String, NodeFailure> {
17157    let encoded = serde_json::to_vec(request).map_err(|_| {
17158        failure(
17159            NodeFailureCode::InvalidRequest,
17160            "managed worktree V2 request could not be canonicalized",
17161        )
17162    })?;
17163    let mut value = String::with_capacity(71);
17164    value.push_str("sha256:");
17165    for byte in digest(&SHA256, &encoded).as_ref() {
17166        use std::fmt::Write as _;
17167        write!(&mut value, "{byte:02x}").expect("writing to a String cannot fail");
17168    }
17169    Ok(value)
17170}
17171
17172fn native_session_preview_failure(error: NativeSessionPreviewError) -> NodeFailure {
17173    let code = match error {
17174        NativeSessionPreviewError::InvalidLimit => NodeFailureCode::InvalidRequest,
17175        NativeSessionPreviewError::WorkspaceUnavailable => NodeFailureCode::UnknownWorkspace,
17176        NativeSessionPreviewError::StaleCatalog => NodeFailureCode::StaleNativeSessionCatalog,
17177        NativeSessionPreviewError::UnsupportedProvider
17178        | NativeSessionPreviewError::PreviewUnavailable
17179        | NativeSessionPreviewError::SessionNotFound
17180        | NativeSessionPreviewError::AmbiguousSession => {
17181            NodeFailureCode::BackendOperationFailed
17182        }
17183    };
17184    failure(code, "native session preview failed")
17185}
17186
17187fn host_directory_failure(error: HostDirectoryBrowseError) -> NodeFailure {
17188    let code = match error.kind() {
17189        HostDirectoryBrowseErrorKind::Invalid => NodeFailureCode::HostDirectoryInvalid,
17190        HostDirectoryBrowseErrorKind::ReadFailed => NodeFailureCode::HostDirectoryReadFailed,
17191    };
17192    failure(code, "host directory browse failed")
17193}
17194
17195fn delivery_failure(error: DeliveryStoreError) -> NodeFailure {
17196    let code = match error {
17197        DeliveryStoreError::InvalidManifest => NodeFailureCode::DeliveryManifestInvalid,
17198        DeliveryStoreError::UnknownStage => NodeFailureCode::UnknownDeliveryStage,
17199        DeliveryStoreError::Capacity | DeliveryStoreError::StageConflict => {
17200            NodeFailureCode::DeliveryStageConflict
17201        }
17202        DeliveryStoreError::UnexpectedBlob => NodeFailureCode::DeliveryBlobUnexpected,
17203        DeliveryStoreError::ChunkOutOfOrder | DeliveryStoreError::ChunkOverflow => {
17204            NodeFailureCode::DeliveryChunkOutOfOrder
17205        }
17206        DeliveryStoreError::BlobDigestMismatch => {
17207            NodeFailureCode::DeliveryBlobDigestMismatch
17208        }
17209        DeliveryStoreError::BundleDigestMismatch => {
17210            NodeFailureCode::DeliveryBundleDigestMismatch
17211        }
17212        DeliveryStoreError::StageIncomplete => NodeFailureCode::DeliveryStageIncomplete,
17213        DeliveryStoreError::Unavailable
17214        | DeliveryStoreError::Corrupt
17215        | DeliveryStoreError::Storage(_) => NodeFailureCode::DeliveryStageStorageFailed,
17216    };
17217    failure(code, "delivery operation failed")
17218}
17219
17220fn validate_workspace_root(
17221    workspace_id: &WorkspaceId,
17222    canonical_root: &str,
17223) -> Result<(), NodeServerError> {
17224    #[cfg(windows)]
17225    if canonical_root.starts_with(r"\\") {
17226        return Err(NodeServerError::InvalidWorkspaceRoot {
17227            workspace_id: workspace_id.clone(),
17228            path: canonical_root.to_owned(),
17229            message: "UNC workspace roots are unsupported by Windows PTY providers".to_owned(),
17230        });
17231    }
17232    if !platform::workspace_root_supported(canonical_root)
17233        || canonical_root.len() > WORKING_DIRECTORY_MAX_BYTES
17234    {
17235        return Err(NodeServerError::InvalidWorkspaceRoot {
17236            workspace_id: workspace_id.clone(),
17237            path: canonical_root.to_owned(),
17238            message: "canonical path must be absolute, bounded, and free of control characters"
17239                .to_owned(),
17240        });
17241    }
17242    Ok(())
17243}
17244
17245fn failure(code: NodeFailureCode, _message: &str) -> NodeFailure {
17246    NodeFailure {
17247        code,
17248        message: node_failure_category(code).to_owned(),
17249    }
17250}
17251
17252fn harness_mcp_failure(error: HarnessMcpProxyError) -> NodeFailure {
17253    let code = match error {
17254        HarnessMcpProxyError::Unavailable => NodeFailureCode::HarnessMcpUnavailable,
17255        HarnessMcpProxyError::NotFound => NodeFailureCode::ReservationNotFound,
17256        HarnessMcpProxyError::Conflict => NodeFailureCode::ReservationConflict,
17257        HarnessMcpProxyError::Expired => NodeFailureCode::ReservationExpired,
17258        HarnessMcpProxyError::BindingMismatch => NodeFailureCode::BindingMismatch,
17259        HarnessMcpProxyError::NotActivated => NodeFailureCode::NotActivated,
17260        HarnessMcpProxyError::CallNotFound => NodeFailureCode::CallNotFound,
17261        HarnessMcpProxyError::ChunkOutOfOrder => NodeFailureCode::ChunkOutOfOrder,
17262        HarnessMcpProxyError::ResponseTooLarge => NodeFailureCode::ResponseTooLarge,
17263    };
17264    failure(code, "harness MCP proxy operation failed")
17265}
17266
17267/// Numbers each harness-MCP overlay this process constructs, in
17268/// construction order, so two overlays built back to back in one node process
17269/// still get distinct trace files.
17270static NEXT_HARNESS_MCP_TRACE_SEQUENCE: AtomicU64 = AtomicU64::new(1);
17271
17272/// Builds the optional trace environment entry of one harness-MCP overlay.
17273/// The caller's launch description names both the entry and the node-process
17274/// variable that opts in: when that variable holds a non-empty directory, the
17275/// entry names a trace file under it. Returns `None` when the launch has no
17276/// trace, or the directory is unset, empty, or not valid Unicode. Never creates
17277/// the directory; that is the operator's job.
17278fn harness_mcp_trace_env_entry(launch: &HarnessMcpLaunchV1) -> Option<(OsString, OsString)> {
17279    let trace = launch.trace.as_ref()?;
17280    let trace_dir = std::env::var(&trace.dir_env).ok()?;
17281    if trace_dir.is_empty() {
17282        return None;
17283    }
17284    let sequence = NEXT_HARNESS_MCP_TRACE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
17285    let path = std::path::Path::new(&trace_dir).join(format!("harness-mcp-{sequence}.trace"));
17286    Some((OsString::from(trace.env.as_str()), path.into_os_string()))
17287}
17288
17289fn spawn_deadline_remaining(deadline: Instant) -> Result<Duration, NodeFailure> {
17290    let remaining = deadline.saturating_duration_since(Instant::now());
17291    if remaining.is_zero() {
17292        Err(failure(
17293            NodeFailureCode::SpawnDeadlineExceeded,
17294            "spawn deadline elapsed",
17295        ))
17296    } else {
17297        Ok(remaining)
17298    }
17299}
17300
17301fn spawn_dispatch_timeout(deadline: Option<Instant>) -> Result<Duration, NodeFailure> {
17302    let bounded = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
17303    match deadline {
17304        Some(deadline) => Ok(spawn_deadline_remaining(deadline)?.min(bounded)),
17305        None => Ok(bounded),
17306    }
17307}
17308
17309fn spawn_dispatch_error(error: NodeFailure, deadline: Option<Instant>) -> NodeFailure {
17310    if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
17311        failure(
17312            NodeFailureCode::SpawnDeadlineExceeded,
17313            "spawn dispatch exceeded the spawn deadline",
17314        )
17315    } else {
17316        error
17317    }
17318}
17319
17320fn spawn_runtime_capabilities(
17321    capabilities: &SpawnRequiredCapabilities,
17322) -> Result<Vec<ProviderRuntimeCapability>, NodeFailure> {
17323    capabilities
17324        .iter()
17325        .map(|capability| match capability.as_str() {
17326            SPAWN_RUNTIME_RAW_PTY_LIFECYCLE => Ok(ProviderRuntimeCapability::RawPtyLifecycle),
17327            SPAWN_RUNTIME_SEMANTIC_READINESS => Ok(ProviderRuntimeCapability::SemanticReadiness),
17328            SPAWN_RUNTIME_STRUCTURED_PROMPT => Ok(ProviderRuntimeCapability::StructuredPrompt),
17329            SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY => {
17330                Ok(ProviderRuntimeCapability::ProviderSessionIdentity)
17331            }
17332            SPAWN_RUNTIME_SEMANTIC_RESUME => Ok(ProviderRuntimeCapability::SemanticResume),
17333            _ => Err(failure(
17334                NodeFailureCode::UnsupportedSpawnCapability,
17335                "spawn required capability is unknown",
17336            )),
17337        })
17338        .collect()
17339}
17340
17341fn persistence_failure(_error: io::Error) -> NodeFailure {
17342    NodeFailure {
17343        code: NodeFailureCode::BackendOperationFailed,
17344        message: DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned(),
17345    }
17346}
17347
17348fn node_failure_category(code: NodeFailureCode) -> &'static str {
17349    match code {
17350        NodeFailureCode::InvalidRequest => "invalid-request",
17351        NodeFailureCode::UnsupportedCapability => "unsupported-capability",
17352        NodeFailureCode::HarnessMcpUnavailable => "harness-mcp-unavailable",
17353        NodeFailureCode::ReservationNotFound => "reservation-not-found",
17354        NodeFailureCode::ReservationConflict => "reservation-conflict",
17355        NodeFailureCode::ReservationExpired => "reservation-expired",
17356        NodeFailureCode::BindingMismatch => "binding-mismatch",
17357        NodeFailureCode::NotActivated => "not-activated",
17358        NodeFailureCode::CallNotFound => "call-not-found",
17359        NodeFailureCode::ChunkOutOfOrder => "chunk-out-of-order",
17360        NodeFailureCode::ResponseTooLarge => "response-too-large",
17361        NodeFailureCode::DeliveryManifestInvalid => "delivery-manifest-invalid",
17362        NodeFailureCode::UnknownDeliveryStage => "unknown-delivery-stage",
17363        NodeFailureCode::DeliveryStageConflict => "delivery-stage-conflict",
17364        NodeFailureCode::DeliveryBlobUnexpected => "delivery-blob-unexpected",
17365        NodeFailureCode::DeliveryChunkOutOfOrder => "delivery-chunk-out-of-order",
17366        NodeFailureCode::DeliveryBlobDigestMismatch => "delivery-blob-digest-mismatch",
17367        NodeFailureCode::DeliveryBundleDigestMismatch => "delivery-bundle-digest-mismatch",
17368        NodeFailureCode::DeliveryStageIncomplete => "delivery-stage-incomplete",
17369        NodeFailureCode::DeliveryStageStorageFailed => "delivery-stage-storage-failed",
17370        NodeFailureCode::Unauthorized => "unauthorized",
17371        NodeFailureCode::ObserverReadOnly => "observer-read-only",
17372        NodeFailureCode::ControllerBusy => "controller-busy",
17373        NodeFailureCode::ControllerRequired => "controller-required",
17374        NodeFailureCode::UnknownWorkspace => "unknown-workspace",
17375        NodeFailureCode::HostDirectoryInvalid => "host-directory-invalid",
17376        NodeFailureCode::HostDirectoryReadFailed => "host-directory-read-failed",
17377        NodeFailureCode::HostDirectoryReadTimedOut => "host-directory-read-timed-out",
17378        NodeFailureCode::InvalidRepositoryPath => "invalid-repository-path",
17379        NodeFailureCode::RepositoryFileNotFound => "repository-file-not-found",
17380        NodeFailureCode::RepositoryFileNotRegular => "repository-file-not-regular",
17381        NodeFailureCode::RepositoryPathUnsafe => "repository-path-unsafe",
17382        NodeFailureCode::RepositoryFileReadTimedOut => "repository-file-read-timed-out",
17383        NodeFailureCode::RepositoryFileReadFailed => "repository-file-read-failed",
17384        NodeFailureCode::RepositoryFileWriteTimedOut => "repository-file-write-timed-out",
17385        NodeFailureCode::RepositoryFileWriteFailed => "repository-file-write-failed",
17386        NodeFailureCode::RepositoryFileRevisionConflict => "repository-file-revision-conflict",
17387        NodeFailureCode::RepositoryEntryAlreadyExists => "repository-entry-already-exists",
17388        NodeFailureCode::RepositoryParentNotFound => "repository-parent-not-found",
17389        NodeFailureCode::RepositoryParentNotDirectory => "repository-parent-not-directory",
17390        NodeFailureCode::RepositoryEntryCreateTimedOut => "repository-entry-create-timed-out",
17391        NodeFailureCode::RepositoryEntryCreateFailed => "repository-entry-create-failed",
17392        NodeFailureCode::GitReadTimedOut => "git-read-timed-out",
17393        NodeFailureCode::GitReadFailed => "git-read-failed",
17394        NodeFailureCode::UnknownSpawnProfile => "unknown-spawn-profile",
17395        NodeFailureCode::SpawnProfileRevisionMismatch => "spawn-profile-revision-mismatch",
17396        NodeFailureCode::SpawnTargetMismatch => "spawn-target-mismatch",
17397        NodeFailureCode::SpawnIdempotencyConflict => "spawn-idempotency-conflict",
17398        NodeFailureCode::SpawnIdempotencyCapacity => "spawn-idempotency-capacity",
17399        NodeFailureCode::SpawnDeadlineExceeded => "spawn-deadline-exceeded",
17400        NodeFailureCode::UnsupportedSpawnCapability => "unsupported-spawn-capability",
17401        NodeFailureCode::UnsupportedTransport => "unsupported-transport",
17402        NodeFailureCode::TurnInFlight => "turn-in-flight",
17403        NodeFailureCode::UnknownEnvironmentProfile => "unknown-environment-profile",
17404        NodeFailureCode::UnknownNetworkAllowlist => "unknown-network-allowlist",
17405        NodeFailureCode::UnsupportedNetworkAllowlistMapping => {
17406            "unsupported-network-allowlist-mapping"
17407        }
17408        NodeFailureCode::BrowserStationProbeUnavailable => {
17409            "browser-station-probe-unavailable"
17410        }
17411        NodeFailureCode::BrowserStationUnreachable => "browser-station-unreachable",
17412        NodeFailureCode::BrowserStationProfileBusy => "browser-station-profile-busy",
17413        NodeFailureCode::EnvironmentProfileBindingMismatch => {
17414            "environment-profile-binding-mismatch"
17415        }
17416        NodeFailureCode::UnknownBundle => "unknown-bundle",
17417        NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
17418        NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
17419        NodeFailureCode::UnknownContextPack => "unknown-context-pack",
17420        NodeFailureCode::ContextPackBusy => "context-pack-busy",
17421        NodeFailureCode::ContextPackMaterializationFailed => {
17422            "context-pack-materialization-failed"
17423        }
17424        NodeFailureCode::InvalidWorkspaceRoot => "invalid-workspace-root",
17425        NodeFailureCode::DuplicateWorkspaceId => "duplicate-workspace-id",
17426        NodeFailureCode::DuplicateWorkspaceRoot => "duplicate-workspace-root",
17427        NodeFailureCode::WorkspaceBusy => "workspace-busy",
17428        NodeFailureCode::LastWorkspace => "last-workspace",
17429        NodeFailureCode::NotGitRepository => "not-git-repository",
17430        NodeFailureCode::WorktreeConflict => "worktree-conflict",
17431        NodeFailureCode::WorktreeProtected => "worktree-protected",
17432        NodeFailureCode::WorktreeDirty => "worktree-dirty",
17433        NodeFailureCode::WorktreeLocked => "worktree-locked",
17434        NodeFailureCode::UnknownManagedWorktreeLease => "unknown-managed-worktree-lease",
17435        NodeFailureCode::ManagedWorktreeBusy => "managed-worktree-busy",
17436        NodeFailureCode::ManagedWorktreeOwnershipConflict => "managed-worktree-ownership-conflict",
17437        NodeFailureCode::ManagedWorktreeProfileRevisionMismatch => {
17438            "managed-worktree-profile-revision-mismatch"
17439        }
17440        NodeFailureCode::ManagedWorktreeRecoveryRequired => "managed-worktree-recovery-required",
17441        NodeFailureCode::StandaloneWorkspaceRecoveryRequired => {
17442            "standalone-workspace-recovery-required"
17443        }
17444        NodeFailureCode::UnknownSession => "unknown-session",
17445        NodeFailureCode::UnknownSessionRecord => "unknown-session-record",
17446        NodeFailureCode::SessionRecordNotResumable => "session-record-not-resumable",
17447        NodeFailureCode::SessionRecordBusy => "session-record-busy",
17448        NodeFailureCode::SessionRecordConflict => "session-record-conflict",
17449        NodeFailureCode::SessionWorkspaceMismatch => "session-workspace-mismatch",
17450        NodeFailureCode::WorkspaceRegistrationRequired => {
17451            "workspace-registration-required"
17452        }
17453        NodeFailureCode::StaleNativeSessionCatalog => "stale-native-session-catalog",
17454        NodeFailureCode::StaleGeneration => "stale-generation",
17455        NodeFailureCode::BackendBusy => "backend-busy",
17456        NodeFailureCode::BackendDisconnected => "backend-disconnected",
17457        NodeFailureCode::BackendOperationFailed => "backend-operation-failed",
17458        NodeFailureCode::ShuttingDown => "shutting-down",
17459    }
17460}
17461
17462fn durable_state_server_error(error: io::Error, category: &'static str) -> NodeServerError {
17463    NodeServerError::DurableState(io::Error::new(error.kind(), category))
17464}
17465
17466fn durable_state_load_error(error: io::Error) -> NodeServerError {
17467    let category = match session_registry::state_load_refusal(&error) {
17468        Some(session_registry::StateLoadRefusal::UnsupportedSchema) => {
17469            DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR
17470        }
17471        Some(session_registry::StateLoadRefusal::PathSemanticsUnsupported) => {
17472            DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR
17473        }
17474        Some(session_registry::StateLoadRefusal::NodeIdentityMismatch) => {
17475            DURABLE_STATE_CONFLICT_ERROR
17476        }
17477        _ => DURABLE_STATE_LOAD_FAILED_ERROR,
17478    };
17479    durable_state_server_error(error, category)
17480}
17481
17482fn unix_time_ms() -> u64 {
17483    std::time::SystemTime::now()
17484        .duration_since(std::time::UNIX_EPOCH)
17485        .unwrap_or_default()
17486        .as_millis()
17487        .min(u64::MAX as u128) as u64
17488}
17489
17490fn detached_record_state(record: &ManagedSessionRecord) -> ManagedSessionState {
17491    if record.provider_session.is_some() {
17492        ManagedSessionState::Dormant
17493    } else {
17494        ManagedSessionState::Unavailable
17495    }
17496}
17497
17498fn active_record_state(
17499    identity_admitted: bool,
17500    provider_session_is_known: bool,
17501) -> ManagedSessionState {
17502    if identity_admitted && !provider_session_is_known {
17503        ManagedSessionState::IdentityPending
17504    } else {
17505        ManagedSessionState::Live
17506    }
17507}
17508
17509fn managed_worktree_record_holders(
17510    records: &[ManagedSessionRecord],
17511) -> Vec<(SessionRecordId, WorkspaceId)> {
17512    records
17513        .iter()
17514        .filter(|record| record.provider_session.is_some())
17515        .map(|record| (record.record_id.clone(), record.workspace_id.clone()))
17516        .collect()
17517}
17518
17519fn managed_spawn_record_ownership(
17520    binding: &SessionBinding,
17521    effective_runtime_policy: ProviderRuntimePolicy,
17522) -> (Option<SessionRecordId>, Option<SessionRecordId>) {
17523    debug_assert_eq!(binding.runtime_policy, effective_runtime_policy);
17524    if effective_runtime_policy.provider_session_identity {
17525        (binding.record_id.clone(), None)
17526    } else {
17527        (None, binding.record_id.clone())
17528    }
17529}
17530
17531#[derive(Debug, Error)]
17532pub enum NodeServerError {
17533    #[error("node endpoint must be a bounded absolute local transport path")]
17534    InvalidEndpoint,
17535    #[error("node access token must contain 1..=4096 bytes")]
17536    InvalidAccessToken,
17537    #[error("node HTTP observer must listen on a loopback address: {0}")]
17538    InvalidApiListen(std::net::SocketAddr),
17539    #[error("node browser bridge must listen on a loopback address: {0}")]
17540    InvalidBridgeListen(std::net::SocketAddr),
17541    #[error("node browser bridge token must contain 1..=4096 bytes")]
17542    InvalidBridgeToken,
17543    #[error("bridge underlay requires --bridge-listen (local HTTP+WS door)")]
17544    BridgeUnderlayRequiresBridgeListen,
17545    #[error("bridge underlay requires GATE4AGENT_BRIDGE_TOKEN (application barrier)")]
17546    BridgeUnderlayRequiresBridgeToken,
17547    #[error("mesh underlay auth token must contain 1..=4096 bytes")]
17548    InvalidBridgeUnderlayToken,
17549    #[error("mesh underlay transport key must be 32 bytes (64 hex digits)")]
17550    InvalidBridgeUnderlayKey,
17551    #[error("node call-home relay must be loopback or a concrete C2 tunnel address with a nonzero port: {0}")]
17552    InvalidCallHome(std::net::SocketAddr),
17553    #[error("node could not reach its call-home relay: {0}")]
17554    CallHomeTransport(#[source] std::io::Error),
17555    #[error("node durable state path must be an absolute file path: {0}")]
17556    InvalidStatePath(String),
17557    #[error("harness MCP helper must be an exact reviewed absolute regular file")]
17558    InvalidHarnessMcpHelper,
17559    #[error("network allowlist catalog path must be an absolute regular file: {0}")]
17560    InvalidNetworkAllowlistCatalogPath(String),
17561    #[error("network allowlist catalog could not be read: {0}")]
17562    NetworkAllowlistCatalogIo(#[source] std::io::Error),
17563    #[error("network allowlist catalog line {line}: {message}")]
17564    InvalidNetworkAllowlistCatalogEntry { line: usize, message: String },
17565    #[error("network allowlist catalog: {0}")]
17566    InvalidNetworkAllowlistCatalog(String),
17567    #[error("network allowlist catalog exceeds the {max}-entry limit")]
17568    NetworkAllowlistCatalogCapacity { max: usize },
17569    #[error("the local state directory is unavailable; supply an explicit state path")]
17570    LocalStateDirectoryUnavailable,
17571    #[error("the local runtime directory is unavailable; supply an explicit endpoint")]
17572    LocalRuntimeDirectoryUnavailable,
17573    #[error("node durable state failed: {0}")]
17574    DurableState(io::Error),
17575    #[error("node requires at least one configured workspace")]
17576    NoWorkspaces,
17577    #[error("workspace '{workspace_id}' root '{path}' is invalid: {message}")]
17578    InvalidWorkspaceRoot {
17579        workspace_id: WorkspaceId,
17580        path: String,
17581        message: String,
17582    },
17583    #[error("duplicate workspace ID: {0}")]
17584    DuplicateWorkspaceId(WorkspaceId),
17585    #[error("workspaces '{first}' and '{second}' resolve to the same root '{root}'")]
17586    DuplicateWorkspaceRoot {
17587        first: WorkspaceId,
17588        second: WorkspaceId,
17589        root: String,
17590    },
17591    #[error("workspace '{0}' uses managed worktrees but has no local profile")]
17592    ManagedWorktreeProfileRequired(WorkspaceId),
17593    #[error("workspace '{0}' has managed worktree profiles but is not in managed mode")]
17594    ManagedWorktreeProfileModeMismatch(WorkspaceId),
17595    #[error("workspace '{workspace_id}' has duplicate managed worktree profile '{profile_id}'")]
17596    DuplicateManagedWorktreeProfile {
17597        workspace_id: WorkspaceId,
17598        profile_id: WorktreeProfileId,
17599    },
17600    #[error("workspace '{workspace_id}' exceeds the {max}-profile managed worktree limit")]
17601    ManagedWorktreeProfileCapacity {
17602        workspace_id: WorkspaceId,
17603        max: usize,
17604    },
17605    #[error("workspace '{workspace_id}' managed worktree profile '{profile_id}' is invalid: {message}")]
17606    InvalidManagedWorktreeProfile {
17607        workspace_id: WorkspaceId,
17608        profile_id: WorktreeProfileId,
17609        message: String,
17610    },
17611    #[error("duplicate node environment profile: {0}")]
17612    DuplicateEnvironmentProfile(SpawnEnvironmentProfileId),
17613    #[error("node environment profile provider is unavailable: {0}")]
17614    EnvironmentProfileProviderUnavailable(AgentId),
17615    #[error("node environment profile capacity is {max}")]
17616    EnvironmentProfileCapacity { max: usize },
17617    #[error("node environment profiles reuse native profile ID '{0}'")]
17618    DuplicateNativeEnvironmentProfile(String),
17619    #[error("node native environment profile is invalid: {0}")]
17620    NativeEnvironmentProfile(String),
17621    #[error("node session-environment materialization root must be absolute")]
17622    InvalidSessionEnvironmentRoot,
17623    #[error("node session-environment materializer is unavailable")]
17624    SessionEnvironmentMaterializerRequired,
17625    #[error("node session-environment materializer failed to initialize")]
17626    SessionEnvironmentMaterializer,
17627    #[error("node bundle catalog is invalid: {0}")]
17628    BundleCatalog(String),
17629    #[error("node delivery store failed to initialize: {0}")]
17630    DeliveryStore(String),
17631    #[error("node context pack store failed to initialize")]
17632    ContextPackStore,
17633    #[error("active agent registry failed: {0}")]
17634    Registry(String),
17635    #[error("node provider contract manifest is invalid: {0}")]
17636    ProviderContractManifest(String),
17637    #[error("named pipe I/O failed: {0}")]
17638    Io(#[from] io::Error),
17639    #[error("node HTTP observer failed: {0}")]
17640    HttpApi(io::Error),
17641    #[error("node browser bridge failed: {0}")]
17642    BridgeApi(io::Error),
17643    #[error(transparent)]
17644    Frame(#[from] FrameError),
17645    #[error("node handshake failed: {0}")]
17646    Handshake(String),
17647    #[error("node authentication frame was not received before the bounded deadline")]
17648    AuthenticationTimedOut,
17649    #[error("node authentication primitive failed: {0}")]
17650    Authentication(String),
17651    #[error("node incarnation identity generation failed: {0}")]
17652    IncarnationIdentity(String),
17653    #[error("node pre-authentication limiter is closed")]
17654    PreauthClosed,
17655    #[error("node authenticated connection limit was reached")]
17656    AuthenticatedConnectionLimit,
17657    #[error("node Ctrl+C/CtrlBreak signal stream closed")]
17658    SignalStreamClosed,
17659    #[error("node stop-all dispatch failed: {0:?}")]
17660    ShutdownDispatch(NodeFailure),
17661    #[error("node shutdown timed out with {active_native_sessions} physical sessions retained")]
17662    ShutdownTimedOut { active_native_sessions: usize },
17663}
17664
17665impl From<crate::network_allowlist_catalog::NetworkAllowlistCatalogError> for NodeServerError {
17666    fn from(error: crate::network_allowlist_catalog::NetworkAllowlistCatalogError) -> Self {
17667        use crate::network_allowlist_catalog::NetworkAllowlistCatalogError as E;
17668        match error {
17669            E::InvalidPath(path) => Self::InvalidNetworkAllowlistCatalogPath(path),
17670            E::Io(error) => Self::NetworkAllowlistCatalogIo(error),
17671            E::InvalidEntry { line, message } => {
17672                Self::InvalidNetworkAllowlistCatalogEntry { line, message }
17673            }
17674            E::InvalidCatalog(message) => Self::InvalidNetworkAllowlistCatalog(message),
17675            E::Capacity { max } => Self::NetworkAllowlistCatalogCapacity { max },
17676        }
17677    }
17678}
17679
17680#[cfg(test)]
17681mod agent_stream_blocked_tests {
17682    use super::*;
17683    use gate4agent_types::{
17684        AdapterId, AdapterVerification, HostRequestOutcome as ProviderHostRequestOutcome,
17685        ProviderSource,
17686    };
17687
17688    fn provider_source(family: AdapterFamily, adapter: &str) -> ProviderSource {
17689        ProviderSource {
17690            family,
17691            binding: AdapterBinding::new(
17692                AdapterId::new(adapter).unwrap(),
17693                "fixture/v1",
17694                AdapterVerification::SyntheticFixture,
17695            )
17696            .unwrap(),
17697        }
17698    }
17699
17700    fn provider_control_event_at(
17701        family: AdapterFamily,
17702        adapter: &str,
17703        source_sequence: u64,
17704        provider_event: ProviderEvent,
17705    ) -> ControlEvent {
17706        ControlEvent {
17707            sequence: 41,
17708            command_id: None,
17709            instance_id: AgentInstanceId(7),
17710            generation: SessionGeneration(3),
17711            event: ControlEventKind::ProviderEvent {
17712                sequence: 8,
17713                source: provider_source(family, adapter),
17714                source_sequence,
17715                event: provider_event,
17716            },
17717        }
17718    }
17719
17720    fn provider_control_event(family: AdapterFamily, provider_event: ProviderEvent) -> ControlEvent {
17721        provider_control_event_at(family, "fixture", 9, provider_event)
17722    }
17723
17724    fn denied_host_request(
17725        by: ProviderHostDecisionAuthority,
17726        reason: Option<String>,
17727    ) -> ControlEvent {
17728        provider_control_event_at(
17729            AdapterFamily::Acp,
17730            "claude-code",
17731            2,
17732            ProviderEvent::HostRequestObserved {
17733                method: "terminal/create".to_owned(),
17734                params_json: String::new(),
17735                decision: ProviderHostRequestDecision::Denied { by },
17736                outcome: ProviderHostRequestOutcome::Executed,
17737                reason,
17738            },
17739        )
17740    }
17741
17742    fn blocked(
17743        event: &ControlEvent,
17744    ) -> (
17745        Option<String>,
17746        String,
17747        BlockAuthorityV1,
17748        Option<String>,
17749        String,
17750        Option<String>,
17751    ) {
17752        let chunk = agent_stream_chunk(event).expect("expected a Blocked chunk");
17753        chunk.validate().expect("minted chunk must validate");
17754        let AgentStreamChunkKindV1::Blocked {
17755            correlation_id,
17756            tool_class,
17757            authority,
17758            reason_kind,
17759            reason,
17760            help,
17761        } = chunk.kind
17762        else {
17763            panic!("expected AgentStreamChunkKindV1::Blocked");
17764        };
17765        (correlation_id, tool_class, authority, reason_kind, reason, help)
17766    }
17767
17768    /// Pins the fleet and every adapter each member declares: the fleet is
17769    /// exactly what `active_registry` says, and no member declares a hook
17770    /// adapter (lifecycle hooks are retired).
17771    #[test]
17772    fn the_enabled_provider_matrix_is_exact() {
17773        let registry = active_registry().unwrap();
17774        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
17775        assert_eq!(
17776            providers
17777                .iter()
17778                .map(|contract| contract.provider.as_str())
17779                .collect::<Vec<_>>(),
17780            vec!["claude", "codex", "grok", "kimi"],
17781        );
17782        assert!(adapters.iter().all(|contract| {
17783            !matches!(contract.family, AdapterFamily::Hook | AdapterFamily::ManagedHook)
17784        }));
17785    }
17786
17787    /// A gate-denied host request, WITH its reason text carried all the way
17788    /// from the ACP host, mints a `Blocked` chunk naming the gate.
17789    #[test]
17790    fn gate_denied_host_request_mints_a_blocked_chunk() {
17791        let gate_text =
17792            "blocked by dangerous-command gate: rule=filesystem-wipe, argument=rm -rf /";
17793        let event = denied_host_request(
17794            ProviderHostDecisionAuthority::Gate,
17795            Some(gate_text.to_owned()),
17796        );
17797        let chunk = agent_stream_chunk(&event).expect("expected a Blocked chunk");
17798        assert_eq!(chunk.source_sequence, 2);
17799        let (correlation_id, tool_class, authority, reason_kind, reason, help) = blocked(&event);
17800        assert_eq!(authority, BlockAuthorityV1::HostGate);
17801        assert_eq!(reason, gate_text);
17802        assert_eq!(correlation_id, None);
17803        assert_eq!(reason_kind, None);
17804        assert_eq!(help, None);
17805        assert_eq!(tool_class, "Write");
17806    }
17807
17808    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.";
17809
17810    /// The owner's classifier sample, verbatim, arriving as a
17811    /// `tool_call_update`-derived `ProviderEvent::ToolCompleted` with
17812    /// `non_execution_kind: "permission-rule"` -- mints a `Blocked` chunk with
17813    /// `authority: ProviderPermissionRule`, the `Reason:` sentence as `reason`
17814    /// and the guidance tail as `help`.
17815    #[test]
17816    fn classifier_sample_permission_rule_mints_a_blocked_chunk() {
17817        let event = provider_control_event(
17818            AdapterFamily::Acp,
17819            ProviderEvent::ToolCompleted {
17820                id: "toolu_9".to_owned(),
17821                output: CLASSIFIER_SAMPLE.to_owned(),
17822                is_error: true,
17823                duration_ms: None,
17824                agent_id: None,
17825                non_execution_kind: Some("permission-rule".to_owned()),
17826            },
17827        );
17828        let (correlation_id, _, authority, reason_kind, reason, help) = blocked(&event);
17829        assert_eq!(authority, BlockAuthorityV1::ProviderPermissionRule);
17830        assert_eq!(reason, "Blocked by classifier");
17831        assert_eq!(reason_kind, Some("permission-rule".to_owned()));
17832        assert!(help.as_deref().is_some_and(|help| help.starts_with("If you have other tasks")));
17833        assert!(correlation_id.is_some(), "a tool-correlated block must carry a correlation id");
17834    }
17835
17836    /// A `session/prompt` RPC error with `data.codexErrorInfo:
17837    /// "usageLimitExceeded"` (measured live 2026-09-05) mints a `Blocked` chunk
17838    /// with `authority: ProviderQuota`, the message verbatim as `reason` and the
17839    /// vendor code as `reason_kind`.
17840    #[test]
17841    fn codex_quota_rpc_error_mints_a_blocked_chunk() {
17842        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.";
17843        let event = provider_control_event(
17844            AdapterFamily::Acp,
17845            ProviderEvent::SessionEnded {
17846                result: "JSON-RPC error -32603: Internal error".to_owned(),
17847                cost_usd: None,
17848                is_error: true,
17849                stop_reason: Some(ProviderStopReason::ProviderError {
17850                    code: -32603,
17851                    message: message.to_owned(),
17852                    vendor_code: Some("usageLimitExceeded".to_owned()),
17853                }),
17854            },
17855        );
17856        let (_, tool_class, authority, reason_kind, reason, _) = blocked(&event);
17857        assert_eq!(authority, BlockAuthorityV1::ProviderQuota);
17858        assert_eq!(reason, message);
17859        assert_eq!(reason_kind, Some("usageLimitExceeded".to_owned()));
17860        assert_eq!(tool_class, "Session");
17861    }
17862
17863    /// ACP's `stopReason: "refusal"` mints a `Blocked` chunk with
17864    /// `authority: ProviderRefusal`.
17865    #[test]
17866    fn session_end_stop_reason_refusal_mints_a_blocked_chunk() {
17867        let event = provider_control_event(
17868            AdapterFamily::Acp,
17869            ProviderEvent::SessionEnded {
17870                result: "refusal".to_owned(),
17871                cost_usd: None,
17872                is_error: true,
17873                stop_reason: Some(ProviderStopReason::Refusal),
17874            },
17875        );
17876        let (_, _, authority, _, _, _) = blocked(&event);
17877        assert_eq!(authority, BlockAuthorityV1::ProviderRefusal);
17878    }
17879
17880    /// A failed tool completion with no typed `non_execution_kind` -- or one
17881    /// that only says the turn stopped -- is a plain execution failure, never a
17882    /// block; nor is an agent's own narrative claiming one.
17883    #[test]
17884    fn failures_and_narrative_without_a_typed_block_mint_no_blocked_chunk() {
17885        for non_execution_kind in [None, Some("interrupted".to_owned()), Some("cancelled".to_owned())] {
17886            let event = provider_control_event(
17887                AdapterFamily::Acp,
17888                ProviderEvent::ToolCompleted {
17889                    id: "toolu_1".to_owned(),
17890                    output: "command exited with status 1".to_owned(),
17891                    is_error: true,
17892                    duration_ms: Some(12),
17893                    agent_id: None,
17894                    non_execution_kind,
17895                },
17896            );
17897            assert!(agent_stream_chunk(&event).is_none());
17898        }
17899        let narrative = provider_control_event(
17900            AdapterFamily::Acp,
17901            ProviderEvent::ToolCompleted {
17902                id: "toolu_2".to_owned(),
17903                output: "the harness blocked the write, approval needed".to_owned(),
17904                is_error: false,
17905                duration_ms: Some(3),
17906                agent_id: None,
17907                non_execution_kind: None,
17908            },
17909        );
17910        assert!(agent_stream_chunk(&narrative).is_none());
17911    }
17912
17913    /// `Granted`/`Deferred` decisions never mint a `Blocked` chunk, whatever the
17914    /// execution outcome: a granted request that failed while running was
17915    /// still authorized.
17916    #[test]
17917    fn granted_and_deferred_host_requests_never_mint_a_blocked_chunk() {
17918        for (decision, outcome) in [
17919            (
17920                ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
17921                ProviderHostRequestOutcome::Executed,
17922            ),
17923            (ProviderHostRequestDecision::Deferred, ProviderHostRequestOutcome::Executed),
17924            (
17925                ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
17926                ProviderHostRequestOutcome::Failed {
17927                    error: "terminal/create spawn failed: os error 3".to_owned(),
17928                },
17929            ),
17930        ] {
17931            let event = provider_control_event_at(
17932                AdapterFamily::Acp,
17933                "claude-code",
17934                3,
17935                ProviderEvent::HostRequestObserved {
17936                    method: "fs/read_text_file".to_owned(),
17937                    params_json: String::new(),
17938                    decision: decision.clone(),
17939                    outcome,
17940                    reason: None,
17941                },
17942            );
17943            assert!(agent_stream_chunk(&event).is_none(), "{decision:?}");
17944        }
17945    }
17946
17947    /// A deferred `session/request_permission` resolved by an operator or an
17948    /// expired deadline carries no reason text -- the chunk falls back to a
17949    /// categorical sentence naming the authority rather than inventing detail.
17950    #[test]
17951    fn denied_host_request_with_no_reason_falls_back_to_a_categorical_sentence() {
17952        for (by, expected_authority, expected_reason) in [
17953            (
17954                ProviderHostDecisionAuthority::Operator,
17955                BlockAuthorityV1::Operator,
17956                "declined by the operator",
17957            ),
17958            (
17959                ProviderHostDecisionAuthority::DeadlinePolicy,
17960                BlockAuthorityV1::HostDeadline,
17961                "denied by host policy after the operator deadline expired",
17962            ),
17963        ] {
17964            let (_, _, authority, _, reason, _) = blocked(&denied_host_request(by, None));
17965            assert_eq!(authority, expected_authority);
17966            assert_eq!(reason, expected_reason);
17967        }
17968    }
17969
17970    /// An over-long reason is cut to the wire bound BEFORE the chunk is built,
17971    /// at a safe UTF-8 boundary -- `validate` rejects an over-long value
17972    /// outright rather than truncating.
17973    #[test]
17974    fn overlong_blocked_reason_is_truncated_to_the_wire_bound() {
17975        let oversized = "x".repeat(MAX_ACP_BLOCKED_REASON_BYTES + 500);
17976        let (_, _, _, _, reason, _) = blocked(&denied_host_request(
17977            ProviderHostDecisionAuthority::Policy,
17978            Some(oversized),
17979        ));
17980        assert!(reason.len() <= MAX_ACP_BLOCKED_REASON_BYTES);
17981    }
17982}
17983
17984#[cfg(test)]
17985mod standalone_capability_platform_tests {
17986    use super::*;
17987
17988    #[test]
17989    fn standalone_workspace_capability_requires_windows_identity_guards() {
17990        let capabilities = baseline_capabilities().unwrap();
17991        let advertised = capabilities.iter().any(|capability| {
17992            capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
17993        });
17994        assert_eq!(advertised, cfg!(windows));
17995
17996        let request = NodeRequest::CreateStandaloneWorkspace {
17997            workspace_id: WorkspaceId::new("standalone-platform").unwrap(),
17998            root: OpaqueHostPath::utf8("standalone-platform-root".to_owned()).unwrap(),
17999            initial_branch: None,
18000        };
18001        assert_eq!(
18002            request_uses_unnegotiated_capability(&request, &capabilities),
18003            !cfg!(windows),
18004        );
18005    }
18006}
18007
18008
18009#[cfg(test)]
18010mod station_knobs_resolve_tests {
18011    use super::*;
18012    use gate4agent_catalog::EnvMutation;
18013    use gate4agent_runtime_native::{
18014        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
18015        NativeLaunchProfileId,
18016    };
18017    use std::ffi::OsString;
18018    use std::sync::Arc;
18019
18020    struct EmptyEnvironmentResolver;
18021
18022    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
18023        fn resolve_child_environment(
18024            &self,
18025        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
18026            Ok(vec![EnvMutation {
18027                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
18028                value: None,
18029            }])
18030        }
18031    }
18032
18033    fn agent(value: &str) -> AgentId {
18034        AgentId::new(value).unwrap()
18035    }
18036
18037    fn station_knobs_fixture() -> (NodeShared, crate::protocol::SpawnProfileDefaults) {
18038        let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
18039        let profile_revision =
18040            crate::protocol::SpawnEnvironmentProfileRevision::new("local-claude-r1").unwrap();
18041        let spawn_defaults = crate::protocol::SpawnProfileDefaults {
18042            profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
18043            revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
18044            provider: agent("claude"),
18045            mode: SessionMode::Pty,
18046            terminal_size: gate4agent_types::TerminalSize {
18047                rows: 24,
18048                columns: 80,
18049            },
18050            prompt: None,
18051            bundle_id: None,
18052            context_id: None,
18053            environment_profile_id: Some(profile_id.clone()),
18054        };
18055        let spawn_profiles = SpawnProfileRegistry::new([spawn_defaults.clone()]).unwrap();
18056        let catalog = active_registry().unwrap();
18057        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18058        let control = runtime.native_launch_profile_control();
18059        let node_profile = NodeEnvironmentProfile::new(
18060            profile_id.clone(),
18061            profile_revision,
18062            agent("claude"),
18063            [NativeLaunchProfile::new(
18064                NativeLaunchProfileId::new("local-claude-pty").unwrap(),
18065                agent("claude"),
18066                TransportKind::Pty,
18067                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
18068                Arc::new(EmptyEnvironmentResolver),
18069            )
18070            .unwrap()],
18071        )
18072        .unwrap();
18073        let (binding, native_profiles, _materialization) = node_profile.into_parts();
18074        for native_profile in native_profiles {
18075            runtime.upsert_native_launch_profile(native_profile).unwrap();
18076        }
18077        let workspace = WorkspaceConfig::new(
18078            WorkspaceId::new("primary").unwrap(),
18079            std::env::current_dir().unwrap(),
18080        )
18081        .unwrap();
18082        let shared = NodeShared::new_with_incarnation(
18083            handle,
18084            "fixture-token".to_owned(),
18085            NodeId::new("node-station-knobs-test").unwrap(),
18086            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
18087            vec![workspace],
18088            vec![agent("claude")],
18089            ProviderRuntimeStatuses::default(),
18090            None,
18091            Vec::new(),
18092            Vec::new(),
18093            spawn_profiles,
18094            Some(control),
18095            None,
18096            None,
18097            None,
18098            Vec::new(),
18099            Vec::new(),
18100            Vec::new(),
18101            Vec::new(),
18102            None,
18103            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
18104        );
18105        shared
18106            .environment_profiles
18107            .write()
18108            .unwrap_or_else(|poisoned| poisoned.into_inner())
18109            .insert(binding.id.clone(), binding);
18110        (shared, spawn_defaults)
18111    }
18112
18113    /// Empty-default catalog refuses unknown network_allowlist ids.
18114    /// Feature-off dig2-station-probe keeps browser_profile stub echo.
18115    #[test]
18116    fn resolve_environment_profile_refuses_unknown_network_allowlist() {
18117        let (shared, defaults) = station_knobs_fixture();
18118        let unknown = crate::protocol::SpawnNetworkAllowlistId::new("not-registered").unwrap();
18119        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18120        let spec = crate::protocol::SpawnSpec {
18121            target: crate::protocol::SpawnTarget {
18122                node_id: shared.node_id.clone(),
18123                workspace_id: WorkspaceId::new("primary").unwrap(),
18124                worktree_id: None,
18125            },
18126            profile_id: defaults.profile_id.clone(),
18127            expected_profile_revision: defaults.revision.clone(),
18128            overrides: crate::protocol::SpawnOverrides {
18129                network_allowlist: Some(unknown),
18130                browser_profile_id: Some(browser),
18131                ..crate::protocol::SpawnOverrides::default()
18132            },
18133            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18134            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18135                "station-knobs-unknown-allowlist",
18136            )
18137            .unwrap(),
18138            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18139        };
18140        let resolved = spec.resolve(&defaults).unwrap();
18141        let failure = shared
18142            .resolve_environment_profile(&resolved)
18143            .expect_err("unknown allowlist must refuse");
18144        assert_eq!(failure.code, NodeFailureCode::UnknownNetworkAllowlist);
18145    }
18146
18147    /// Registered network_allowlist (+ browser_profile_id when feature off)
18148    /// echo onto the env-profile receipt. Empty catalog still allows omit
18149    /// (None). Empty/whitespace refuse is type-level. Feature-off
18150    /// dig2-station-probe keeps stub browser echo; feature-on refuse is
18151    /// covered by `…_refuses_browser_profile_when_station_probe_fails`.
18152    #[test]
18153    fn resolve_environment_profile_echoes_registered_station_network_and_browser_knobs() {
18154        let (shared, defaults) = station_knobs_fixture();
18155        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18156        #[cfg(not(feature = "dig2-station-probe"))]
18157        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18158        shared
18159            .network_allowlist_catalog
18160            .write()
18161            .unwrap_or_else(|poisoned| poisoned.into_inner())
18162            .insert_id(allowlist.clone())
18163            .unwrap();
18164        let spec = crate::protocol::SpawnSpec {
18165            target: crate::protocol::SpawnTarget {
18166                node_id: shared.node_id.clone(),
18167                workspace_id: WorkspaceId::new("primary").unwrap(),
18168                worktree_id: None,
18169            },
18170            profile_id: defaults.profile_id.clone(),
18171            expected_profile_revision: defaults.revision.clone(),
18172            overrides: crate::protocol::SpawnOverrides {
18173                network_allowlist: Some(allowlist.clone()),
18174                #[cfg(not(feature = "dig2-station-probe"))]
18175                browser_profile_id: Some(browser.clone()),
18176                ..crate::protocol::SpawnOverrides::default()
18177            },
18178            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18179            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-echo")
18180                .unwrap(),
18181            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18182        };
18183        let resolved = spec.resolve(&defaults).unwrap();
18184        assert_eq!(resolved.network_allowlist.as_ref(), Some(&allowlist));
18185        #[cfg(not(feature = "dig2-station-probe"))]
18186        assert_eq!(resolved.browser_profile_id.as_ref(), Some(&browser));
18187        let receipt = shared
18188            .resolve_environment_profile(&resolved)
18189            .expect("resolve ok")
18190            .expect("env profile present");
18191        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18192        #[cfg(not(feature = "dig2-station-probe"))]
18193        assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
18194        #[cfg(feature = "dig2-station-probe")]
18195        assert_eq!(receipt.browser_profile_id, None);
18196        assert_eq!(receipt.profile_id.as_str(), "local-claude");
18197
18198        let omitted = crate::protocol::SpawnSpec {
18199            overrides: crate::protocol::SpawnOverrides::default(),
18200            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-omit")
18201                .unwrap(),
18202            ..spec
18203        };
18204        let resolved_omit = omitted.resolve(&defaults).unwrap();
18205        let bare = shared
18206            .resolve_environment_profile(&resolved_omit)
18207            .expect("resolve ok")
18208            .expect("env profile present");
18209        assert_eq!(bare.network_allowlist, None);
18210        assert_eq!(bare.browser_profile_id, None);
18211    }
18212
18213    /// Feature `dig2-station-probe`: browser_profile_id set refuses when the
18214    /// cheap station probe cannot succeed. On non-Windows (this CI host) that
18215    /// is `BrowserStationProbeUnavailable`. Never cookies on C2. Lease map is
18216    /// unit-tested separately (probe refuses before lease on Linux).
18217    #[cfg(feature = "dig2-station-probe")]
18218    #[test]
18219    fn resolve_environment_profile_refuses_browser_profile_when_station_probe_fails() {
18220        let (shared, defaults) = station_knobs_fixture();
18221        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18222        let spec = crate::protocol::SpawnSpec {
18223            target: crate::protocol::SpawnTarget {
18224                node_id: shared.node_id.clone(),
18225                workspace_id: WorkspaceId::new("primary").unwrap(),
18226                worktree_id: None,
18227            },
18228            profile_id: defaults.profile_id.clone(),
18229            expected_profile_revision: defaults.revision.clone(),
18230            overrides: crate::protocol::SpawnOverrides {
18231                browser_profile_id: Some(browser),
18232                ..crate::protocol::SpawnOverrides::default()
18233            },
18234            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18235            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18236                "station-knobs-probe-refuse",
18237            )
18238            .unwrap(),
18239            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18240        };
18241        let resolved = spec.resolve(&defaults).unwrap();
18242        let failure = shared
18243            .resolve_environment_profile(&resolved)
18244            .expect_err("browser_profile with failing probe must refuse");
18245        #[cfg(windows)]
18246        {
18247            assert!(
18248                matches!(
18249                    failure.code,
18250                    NodeFailureCode::BrowserStationUnreachable
18251                        | NodeFailureCode::BrowserStationProbeUnavailable
18252                ),
18253                "unexpected code {:?}",
18254                failure.code
18255            );
18256        }
18257        #[cfg(not(windows))]
18258        {
18259            assert_eq!(
18260                failure.code,
18261                NodeFailureCode::BrowserStationProbeUnavailable
18262            );
18263        }
18264    }
18265
18266    /// Feature on: omitting browser_profile_id still resolves (no probe).
18267    #[cfg(feature = "dig2-station-probe")]
18268    #[test]
18269    fn resolve_environment_profile_skips_station_probe_when_browser_profile_omitted() {
18270        let (shared, defaults) = station_knobs_fixture();
18271        let spec = crate::protocol::SpawnSpec {
18272            target: crate::protocol::SpawnTarget {
18273                node_id: shared.node_id.clone(),
18274                workspace_id: WorkspaceId::new("primary").unwrap(),
18275                worktree_id: None,
18276            },
18277            profile_id: defaults.profile_id.clone(),
18278            expected_profile_revision: defaults.revision.clone(),
18279            overrides: crate::protocol::SpawnOverrides::default(),
18280            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18281            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18282                "station-knobs-probe-omit",
18283            )
18284            .unwrap(),
18285            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18286        };
18287        let resolved = spec.resolve(&defaults).unwrap();
18288        let receipt = shared
18289            .resolve_environment_profile(&resolved)
18290            .expect("omit browser_profile must not probe")
18291            .expect("env profile present");
18292        assert_eq!(receipt.browser_profile_id, None);
18293    }
18294
18295    /// Feature dig2-station-probe: exclusive BrowserStationLease on NodeShared
18296    /// refuses a second holder for the same opaque profile id; release on
18297    /// remove_binding allows reacquire. Never ImportSession/cookies on C2.
18298    #[cfg(feature = "dig2-station-probe")]
18299    #[test]
18300    fn browser_station_lease_exclusive_and_clears_on_remove_binding() {
18301        let (shared, _defaults) = station_knobs_fixture();
18302        let profile = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18303        let workspace_id = WorkspaceId::new("primary").unwrap();
18304        let address_a = SessionAddress {
18305            workspace_id: workspace_id.clone(),
18306            session: SessionKey {
18307                instance_id: AgentInstanceId(101),
18308                generation: SessionGeneration(1),
18309            },
18310        };
18311        let address_b = SessionAddress {
18312            workspace_id: workspace_id.clone(),
18313            session: SessionKey {
18314                instance_id: AgentInstanceId(102),
18315                generation: SessionGeneration(1),
18316            },
18317        };
18318        let guard_a = crate::dig2_station_lease::acquire_lease(
18319            &shared.browser_station_leases,
18320            profile.clone(),
18321            address_a.session.instance_id,
18322            1,
18323        )
18324        .expect("first lease");
18325        let busy = crate::dig2_station_lease::acquire_lease(
18326            &shared.browser_station_leases,
18327            profile.clone(),
18328            address_b.session.instance_id,
18329            2,
18330        )
18331        .expect_err("second holder must refuse");
18332        assert!(matches!(
18333            busy,
18334            crate::dig2_station_lease::BrowserStationLeaseError::ProfileBusy { .. }
18335        ));
18336        // Simulate a bound session so remove_binding clears the lease.
18337        shared.bind_session_with_policy(
18338            &address_a,
18339            ProviderRuntimePolicy::raw_pty(),
18340            None,
18341        );
18342        guard_a.retain();
18343        assert!(shared.remove_binding(&address_a).is_some());
18344        crate::dig2_station_lease::acquire_lease(
18345            &shared.browser_station_leases,
18346            profile,
18347            address_b.session.instance_id,
18348            3,
18349        )
18350        .expect("reacquire after clear")
18351        .retain();
18352        assert!(
18353            crate::dig2_station_lease::release_lease_for_holder(
18354                &shared.browser_station_leases,
18355                address_b.session.instance_id,
18356            )
18357        );
18358    }
18359
18360    /// Feature-off: no browser_station_leases field; stub echo documented in
18361    /// resolve_environment_profile_echoes_registered_station_network_and_browser_knobs.
18362    #[cfg(not(feature = "dig2-station-probe"))]
18363    #[test]
18364    fn browser_station_lease_feature_off_keeps_stub_without_lease_table() {
18365        let (shared, defaults) = station_knobs_fixture();
18366        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18367        let spec = crate::protocol::SpawnSpec {
18368            target: crate::protocol::SpawnTarget {
18369                node_id: shared.node_id.clone(),
18370                workspace_id: WorkspaceId::new("primary").unwrap(),
18371                worktree_id: None,
18372            },
18373            profile_id: defaults.profile_id.clone(),
18374            expected_profile_revision: defaults.revision.clone(),
18375            overrides: crate::protocol::SpawnOverrides {
18376                browser_profile_id: Some(browser.clone()),
18377                ..crate::protocol::SpawnOverrides::default()
18378            },
18379            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18380            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("lease-stub-off")
18381                .unwrap(),
18382            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18383        };
18384        let resolved = spec.resolve(&defaults).unwrap();
18385        let receipt = shared
18386            .resolve_environment_profile(&resolved)
18387            .expect("stub resolve")
18388            .expect("env profile");
18389        assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
18390    }
18391
18392
18393    /// Startup file loader registers ids into the empty-default catalog.
18394    /// Feature-off dig2 probe stays stub. Never secrets on C2.
18395    #[test]
18396    fn network_allowlist_catalog_loads_from_temp_file() {
18397        let unique = std::time::SystemTime::now()
18398            .duration_since(std::time::UNIX_EPOCH)
18399            .unwrap()
18400            .as_nanos();
18401        let path = std::env::temp_dir().join(format!(
18402            "gate4agent-node-allowlist-config-{}-{unique}.txt",
18403            std::process::id(),
18404        ));
18405        std::fs::write(
18406            &path,
18407            "# opaque station allowlist ids\negress-default\nlab-egress\n",
18408        )
18409        .unwrap();
18410        let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
18411            .expect("temp catalog file must load");
18412        let _ = std::fs::remove_file(&path);
18413        assert_eq!(loaded.len(), 2);
18414        let (shared, defaults) = station_knobs_fixture();
18415        {
18416            let mut catalog = shared
18417                .network_allowlist_catalog
18418                .write()
18419                .unwrap_or_else(|poisoned| poisoned.into_inner());
18420            *catalog = loaded;
18421        }
18422        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18423        let spec = crate::protocol::SpawnSpec {
18424            target: crate::protocol::SpawnTarget {
18425                node_id: shared.node_id.clone(),
18426                workspace_id: WorkspaceId::new("primary").unwrap(),
18427                worktree_id: None,
18428            },
18429            profile_id: defaults.profile_id.clone(),
18430            expected_profile_revision: defaults.revision.clone(),
18431            overrides: crate::protocol::SpawnOverrides {
18432                network_allowlist: Some(allowlist.clone()),
18433                ..crate::protocol::SpawnOverrides::default()
18434            },
18435            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18436            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18437                "station-knobs-catalog-file",
18438            )
18439            .unwrap(),
18440            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18441        };
18442        let resolved = spec.resolve(&defaults).unwrap();
18443        let receipt = shared
18444            .resolve_environment_profile(&resolved)
18445            .expect("registered via file load")
18446            .expect("env profile present");
18447        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18448    }
18449
18450    /// Snapshot launch inventory echoes registered catalog ids (opaque).
18451    /// Dig2 station probe is orthogonal (feature `dig2-station-probe`). Never secrets on C2.
18452    #[test]
18453    fn snapshot_launch_inventory_lists_network_allowlist_catalog_ids() {
18454        let (shared, _defaults) = station_knobs_fixture();
18455        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18456        shared
18457            .network_allowlist_catalog
18458            .write()
18459            .unwrap_or_else(|poisoned| poisoned.into_inner())
18460            .insert_id(allowlist.clone())
18461            .unwrap();
18462        let snapshot = shared.snapshot();
18463        let inventory = snapshot
18464            .launch_inventory
18465            .expect("launch inventory present");
18466        let ids = inventory
18467            .network_allowlists
18468            .expect("non-empty catalog surfaces ids");
18469        assert_eq!(ids, vec![allowlist]);
18470    }
18471
18472    /// Track B: registered id with provider_native codex_network_access refuses
18473    /// clearly for Claude (fixture provider). Unknown id still UnknownNetworkAllowlist.
18474    /// Never secrets on C2. Codex Moderate maps to `-c` overlay; RO/FullAuto refuse.
18475    #[test]
18476    fn resolve_environment_profile_refuses_unsupported_provider_native_mapping() {
18477        let (shared, defaults) = station_knobs_fixture();
18478        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-only").unwrap();
18479        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18480            id: allowlist.clone(),
18481            permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
18482                protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18483                peer: "127.0.0.1:443".to_owned(),
18484            }],
18485            provider_native: Some(
18486                crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
18487                    codex_network_access: Some(true),
18488                },
18489            ),
18490        };
18491        shared
18492            .network_allowlist_catalog
18493            .write()
18494            .unwrap_or_else(|poisoned| poisoned.into_inner())
18495            .insert(entry)
18496            .unwrap();
18497        let spec = crate::protocol::SpawnSpec {
18498            target: crate::protocol::SpawnTarget {
18499                node_id: shared.node_id.clone(),
18500                workspace_id: WorkspaceId::new("primary").unwrap(),
18501                worktree_id: None,
18502            },
18503            profile_id: defaults.profile_id.clone(),
18504            expected_profile_revision: defaults.revision.clone(),
18505            overrides: crate::protocol::SpawnOverrides {
18506                network_allowlist: Some(allowlist),
18507                ..crate::protocol::SpawnOverrides::default()
18508            },
18509            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18510            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18511                "station-knobs-unsupported-mapping",
18512            )
18513            .unwrap(),
18514            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18515        };
18516        let resolved = spec.resolve(&defaults).unwrap();
18517        assert_eq!(resolved.provider.as_str(), "claude");
18518        let failure = shared
18519            .resolve_environment_profile(&resolved)
18520            .expect_err("claude + codex_network_access must refuse");
18521        assert_eq!(
18522            failure.code,
18523            NodeFailureCode::UnsupportedNetworkAllowlistMapping
18524        );
18525    }
18526
18527    /// Codex Moderate + codex_network_access yields `-c` overlay argv.
18528    /// ReadOnly / FullAuto refuse UnsupportedNetworkAllowlistMapping.
18529    /// Never secrets on C2.
18530    #[test]
18531    fn resolve_codex_network_access_overlay_moderate_only() {
18532        let (shared, defaults) = station_knobs_fixture();
18533        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-net").unwrap();
18534        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18535            id: allowlist.clone(),
18536            permits: Vec::new(),
18537            provider_native: Some(
18538                crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
18539                    codex_network_access: Some(true),
18540                },
18541            ),
18542        };
18543        shared
18544            .network_allowlist_catalog
18545            .write()
18546            .unwrap_or_else(|poisoned| poisoned.into_inner())
18547            .insert(entry)
18548            .unwrap();
18549
18550        let overlay = shared
18551            .resolve_codex_network_access_overlay_args(
18552                &agent("codex"),
18553                SessionMode::Pty,
18554                ApprovalLevel::Moderate,
18555                Some(&allowlist),
18556            )
18557            .expect("Moderate Codex maps network_access");
18558        assert_eq!(
18559            overlay,
18560            vec![
18561                std::ffi::OsString::from("-c"),
18562                std::ffi::OsString::from("sandbox_workspace_write.network_access=true"),
18563            ]
18564        );
18565
18566        let acp = shared
18567            .resolve_codex_network_access_overlay_args(
18568                &agent("codex"),
18569                SessionMode::Acp,
18570                ApprovalLevel::Moderate,
18571                Some(&allowlist),
18572            )
18573            .unwrap();
18574        assert_eq!(acp, overlay);
18575
18576        let inline = shared
18577            .resolve_codex_network_access_overlay_args(
18578                &agent("codex"),
18579                SessionMode::Inline,
18580                ApprovalLevel::Moderate,
18581                Some(&allowlist),
18582            )
18583            .unwrap_err();
18584        assert_eq!(
18585            inline.code,
18586            NodeFailureCode::UnsupportedNetworkAllowlistMapping
18587        );
18588
18589        for level in [ApprovalLevel::ReadOnly, ApprovalLevel::FullAuto] {
18590            let spec = crate::protocol::SpawnSpec {
18591                target: crate::protocol::SpawnTarget {
18592                    node_id: shared.node_id.clone(),
18593                    workspace_id: WorkspaceId::new("primary").unwrap(),
18594                    worktree_id: None,
18595                },
18596                profile_id: defaults.profile_id.clone(),
18597                expected_profile_revision: defaults.revision.clone(),
18598                overrides: crate::protocol::SpawnOverrides {
18599                    provider: crate::protocol::SpawnOverride::Set {
18600                        value: agent("codex"),
18601                    },
18602                    approval_level: Some(level),
18603                    network_allowlist: Some(allowlist.clone()),
18604                    ..crate::protocol::SpawnOverrides::default()
18605                },
18606                deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18607                idempotency_key: crate::protocol::SpawnIdempotencyKey::new(format!(
18608                    "codex-net-refuse-{}",
18609                    match level {
18610                        ApprovalLevel::ReadOnly => "ro",
18611                        ApprovalLevel::FullAuto => "fa",
18612                        _ => "other",
18613                    }
18614                ))
18615                .unwrap(),
18616                required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18617            };
18618            let resolved = spec.resolve(&defaults).unwrap();
18619            let failure = shared
18620                .resolve_environment_profile(&resolved)
18621                .expect_err("RO/FullAuto must refuse");
18622            assert_eq!(
18623                failure.code,
18624                NodeFailureCode::UnsupportedNetworkAllowlistMapping
18625            );
18626        }
18627    }
18628
18629    /// Claude PTY + catalog permits → ProviderHome `--settings` sidecar with
18630    /// sandbox.network.allowedDomains. ACP / missing ProviderHome refuse.
18631    /// Never secrets on C2; does not invent provider_native Claude keys.
18632    #[test]
18633    fn resolve_claude_settings_network_overlay_pty_writes_sidecar() {
18634        let (shared, _defaults) = station_knobs_fixture();
18635        let allowlist =
18636            crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-domains").unwrap();
18637        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18638            id: allowlist.clone(),
18639            permits: vec![
18640                crate::network_allowlist_catalog::NetworkPermitSketch {
18641                    protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18642                    peer: "github.com:443".to_owned(),
18643                },
18644                crate::network_allowlist_catalog::NetworkPermitSketch {
18645                    protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18646                    peer: "*.npmjs.org".to_owned(),
18647                },
18648            ],
18649            provider_native: None,
18650        };
18651        shared
18652            .network_allowlist_catalog
18653            .write()
18654            .unwrap_or_else(|poisoned| poisoned.into_inner())
18655            .insert(entry)
18656            .unwrap();
18657
18658        let unique = std::time::SystemTime::now()
18659            .duration_since(std::time::UNIX_EPOCH)
18660            .unwrap()
18661            .as_nanos();
18662        let provider_home = std::env::temp_dir().join(format!(
18663            "gate4agent-claude-provider-home-{}-{unique}",
18664            std::process::id(),
18665        ));
18666        std::fs::create_dir_all(&provider_home).unwrap();
18667
18668        let overlay = shared
18669            .resolve_network_allowlist_overlay_args(
18670                &agent("claude"),
18671                SessionMode::Pty,
18672                ApprovalLevel::Moderate,
18673                Some(&allowlist),
18674                Some(provider_home.as_path()),
18675            )
18676            .expect("Claude PTY settings overlay on unix");
18677        let settings_path = provider_home.join(
18678            crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
18679        );
18680        assert_eq!(
18681            overlay,
18682            vec![
18683                std::ffi::OsString::from("--settings"),
18684                std::ffi::OsString::from(settings_path.as_os_str()),
18685            ]
18686        );
18687        let body = std::fs::read_to_string(&settings_path).unwrap();
18688        let value: serde_json::Value = serde_json::from_str(&body).unwrap();
18689        assert_eq!(value["sandbox"]["enabled"], true);
18690        assert_eq!(
18691            value["sandbox"]["network"]["allowedDomains"],
18692            serde_json::json!(["github.com", "*.npmjs.org"])
18693        );
18694        assert!(settings_path.starts_with(&provider_home));
18695
18696        let acp = shared
18697            .resolve_network_allowlist_overlay_args(
18698                &agent("claude"),
18699                SessionMode::Acp,
18700                ApprovalLevel::Moderate,
18701                Some(&allowlist),
18702                Some(provider_home.as_path()),
18703            )
18704            .expect_err("Claude ACP must refuse settings overlay");
18705        assert_eq!(
18706            acp.code,
18707            NodeFailureCode::UnsupportedNetworkAllowlistMapping
18708        );
18709
18710        let no_home = shared
18711            .resolve_network_allowlist_overlay_args(
18712                &agent("claude"),
18713                SessionMode::Pty,
18714                ApprovalLevel::Moderate,
18715                Some(&allowlist),
18716                None,
18717            )
18718            .expect_err("Claude overlay requires ProviderHome");
18719        assert_eq!(
18720            no_home.code,
18721            NodeFailureCode::UnsupportedNetworkAllowlistMapping
18722        );
18723
18724        let bare = crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-bare").unwrap();
18725        shared
18726            .network_allowlist_catalog
18727            .write()
18728            .unwrap_or_else(|poisoned| poisoned.into_inner())
18729            .insert_id(bare.clone())
18730            .unwrap();
18731        let empty = shared
18732            .resolve_network_allowlist_overlay_args(
18733                &agent("claude"),
18734                SessionMode::Pty,
18735                ApprovalLevel::Moderate,
18736                Some(&bare),
18737                Some(provider_home.as_path()),
18738            )
18739            .unwrap();
18740        assert!(empty.is_empty());
18741
18742        let _ = std::fs::remove_dir_all(&provider_home);
18743    }
18744
18745    /// Kimi / Grok with permits stay without invented argv overlays.
18746    #[test]
18747    fn resolve_kimi_grok_network_overlay_stays_empty_without_native() {
18748        let (shared, _defaults) = station_knobs_fixture();
18749        let allowlist =
18750            crate::protocol::SpawnNetworkAllowlistId::new("egress-kimi-permits").unwrap();
18751        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18752            id: allowlist.clone(),
18753            permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
18754                protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18755                peer: "example.com:443".to_owned(),
18756            }],
18757            provider_native: None,
18758        };
18759        shared
18760            .network_allowlist_catalog
18761            .write()
18762            .unwrap_or_else(|poisoned| poisoned.into_inner())
18763            .insert(entry)
18764            .unwrap();
18765        for provider in ["kimi", "grok"] {
18766            let overlay = shared
18767                .resolve_network_allowlist_overlay_args(
18768                    &agent(provider),
18769                    SessionMode::Pty,
18770                    ApprovalLevel::Moderate,
18771                    Some(&allowlist),
18772                    None,
18773                )
18774                .unwrap();
18775            assert!(
18776                overlay.is_empty(),
18777                "{provider} must not invent network argv from permits"
18778            );
18779        }
18780    }
18781
18782    /// Track B: JSON v2 catalog file loads permits; inventory still ids only.
18783    #[test]
18784    fn network_allowlist_catalog_loads_json_v2_and_lists_ids_only() {
18785        let unique = std::time::SystemTime::now()
18786            .duration_since(std::time::UNIX_EPOCH)
18787            .unwrap()
18788            .as_nanos();
18789        let path = std::env::temp_dir().join(format!(
18790            "gate4agent-node-allowlist-json-{}-{unique}.json",
18791            std::process::id(),
18792        ));
18793        std::fs::write(
18794            &path,
18795            r#"{
18796              "schema_version": 2,
18797              "entries": [
18798                {
18799                  "id": "egress-default",
18800                  "permits": [{ "protocol": "tcp", "peer": "127.0.0.1:443" }]
18801                },
18802                { "id": "lab-egress" }
18803              ]
18804            }"#,
18805        )
18806        .unwrap();
18807        let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
18808            .expect("json v2 catalog must load");
18809        let _ = std::fs::remove_file(&path);
18810        assert_eq!(loaded.len(), 2);
18811        let entry = loaded
18812            .get(&crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap())
18813            .unwrap();
18814        assert_eq!(entry.permits.len(), 1);
18815        let (shared, defaults) = station_knobs_fixture();
18816        {
18817            let mut catalog = shared
18818                .network_allowlist_catalog
18819                .write()
18820                .unwrap_or_else(|poisoned| poisoned.into_inner());
18821            *catalog = loaded;
18822        }
18823        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18824        let spec = crate::protocol::SpawnSpec {
18825            target: crate::protocol::SpawnTarget {
18826                node_id: shared.node_id.clone(),
18827                workspace_id: WorkspaceId::new("primary").unwrap(),
18828                worktree_id: None,
18829            },
18830            profile_id: defaults.profile_id.clone(),
18831            expected_profile_revision: defaults.revision.clone(),
18832            overrides: crate::protocol::SpawnOverrides {
18833                network_allowlist: Some(allowlist.clone()),
18834                ..crate::protocol::SpawnOverrides::default()
18835            },
18836            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18837            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18838                "station-knobs-catalog-json",
18839            )
18840            .unwrap(),
18841            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18842        };
18843        let resolved = spec.resolve(&defaults).unwrap();
18844        let receipt = shared
18845            .resolve_environment_profile(&resolved)
18846            .expect("permits-only entry ok for claude")
18847            .expect("env profile present");
18848        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18849        let inventory = shared
18850            .snapshot()
18851            .launch_inventory
18852            .expect("launch inventory present");
18853        let ids = inventory.network_allowlists.expect("ids surface");
18854        assert_eq!(
18855            ids,
18856            vec![
18857                crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap(),
18858                crate::protocol::SpawnNetworkAllowlistId::new("lab-egress").unwrap(),
18859            ]
18860        );
18861    }
18862
18863
18864}
18865
18866
18867#[cfg(test)]
18868mod workspace_environment_collision_tests {
18869    use super::*;
18870    use crate::session_environment::{
18871        NodeSessionMaterializationProfile, NodeSessionPathBinding, NodeSessionPathClass,
18872    };
18873    use gate4agent_catalog::EnvMutation;
18874    use gate4agent_runtime_native::{
18875        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
18876        NativeLaunchProfileId,
18877    };
18878    use std::ffi::OsString;
18879    use std::sync::Arc;
18880
18881    struct EmptyEnvironmentResolver;
18882
18883    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
18884        fn resolve_child_environment(
18885            &self,
18886        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
18887            Ok(vec![EnvMutation {
18888                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
18889                value: None,
18890            }])
18891        }
18892    }
18893
18894    fn agent(value: &str) -> AgentId {
18895        AgentId::new(value).unwrap()
18896    }
18897
18898    /// Two Claude ProviderHome profiles (`CLAUDE_CONFIG_DIR` A vs B) on one node.
18899    fn claude_config_dir_collision_fixture() -> (
18900        NodeShared,
18901        ResolvedEnvironmentProfileReceipt,
18902        ResolvedEnvironmentProfileReceipt,
18903    ) {
18904        let catalog = active_registry().unwrap();
18905        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18906        let control = runtime.native_launch_profile_control();
18907        let workspace = WorkspaceConfig::new(
18908            WorkspaceId::new("primary").unwrap(),
18909            std::env::current_dir().unwrap(),
18910        )
18911        .unwrap();
18912        let shared = NodeShared::new_with_incarnation(
18913            handle,
18914            "fixture-token".to_owned(),
18915            NodeId::new("node-env-collision-test").unwrap(),
18916            NodeIncarnationId::from_bytes([3; crate::protocol::NODE_INCARNATION_ID_BYTES]),
18917            vec![workspace],
18918            vec![agent("claude")],
18919            ProviderRuntimeStatuses::default(),
18920            None,
18921            Vec::new(),
18922            Vec::new(),
18923            SpawnProfileRegistry::default(),
18924            Some(control),
18925            None,
18926            None,
18927            None,
18928            Vec::new(),
18929            Vec::new(),
18930            Vec::new(),
18931            Vec::new(),
18932            None,
18933            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
18934        );
18935
18936        let mut receipts = Vec::new();
18937        for (suffix, native_suffix) in [("a", "a-pty"), ("b", "b-pty")] {
18938            let profile_id =
18939                SpawnEnvironmentProfileId::new(format!("claude-config-dir-{suffix}")).unwrap();
18940            let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(format!(
18941                "claude-config-dir-{suffix}-r1"
18942            ))
18943            .unwrap();
18944            let materialization = NodeSessionMaterializationProfile::new(
18945                Vec::new(),
18946                vec![NodeSessionPathBinding::new(
18947                    "CLAUDE_CONFIG_DIR",
18948                    NodeSessionPathClass::ProviderHome,
18949                )
18950                .unwrap()],
18951                Vec::new(),
18952            )
18953            .unwrap();
18954            let node_profile = NodeEnvironmentProfile::new_with_materialization(
18955                profile_id.clone(),
18956                profile_revision.clone(),
18957                agent("claude"),
18958                [NativeLaunchProfile::new(
18959                    NativeLaunchProfileId::new(format!("claude-config-dir-{native_suffix}"))
18960                        .unwrap(),
18961                    agent("claude"),
18962                    TransportKind::Pty,
18963                    vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
18964                    Arc::new(EmptyEnvironmentResolver),
18965                )
18966                .unwrap()],
18967                Some(materialization),
18968            )
18969            .unwrap();
18970            let (binding, native_profiles, materialization) = node_profile.into_parts();
18971            for native_profile in native_profiles {
18972                runtime.upsert_native_launch_profile(native_profile).unwrap();
18973            }
18974            shared
18975                .environment_profiles
18976                .write()
18977                .unwrap_or_else(|poisoned| poisoned.into_inner())
18978                .insert(binding.id.clone(), binding);
18979            shared
18980                .environment_materialization_profiles
18981                .write()
18982                .unwrap_or_else(|poisoned| poisoned.into_inner())
18983                .insert(profile_id.clone(), materialization.unwrap());
18984            receipts.push(ResolvedEnvironmentProfileReceipt {
18985                profile_id,
18986                profile_revision,
18987                network_allowlist: None,
18988                browser_profile_id: None,
18989            });
18990        }
18991        let profile_b = receipts.pop().unwrap();
18992        let profile_a = receipts.pop().unwrap();
18993        (shared, profile_a, profile_b)
18994    }
18995
18996    #[test]
18997    fn refuse_distinct_claude_config_dir_profiles_on_same_workspace_cwd() {
18998        let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
18999        let primary = WorkspaceId::new("primary").unwrap();
19000        let worktree = WorkspaceId::new("worktree-b").unwrap();
19001        let address = SessionAddress {
19002            workspace_id: primary.clone(),
19003            session: SessionKey {
19004                instance_id: AgentInstanceId(41),
19005                generation: SessionGeneration(1),
19006            },
19007        };
19008        shared.bind_session_with_policy(
19009            &address,
19010            ProviderRuntimePolicy::raw_pty(),
19011            Some(profile_a.clone()),
19012        );
19013
19014        let refused = shared
19015            .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_b))
19016            .unwrap_err();
19017        assert_eq!(refused.code, NodeFailureCode::WorkspaceBusy);
19018
19019        // Some vs None on the same cwd is also a collision.
19020        let refused_none = shared
19021            .ensure_workspace_environment_profile_compatible(&primary, None)
19022            .unwrap_err();
19023        assert_eq!(refused_none.code, NodeFailureCode::WorkspaceBusy);
19024
19025        // Same profile on the same workspace remains allowed.
19026        shared
19027            .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_a))
19028            .unwrap();
19029
19030        // Distinct workspace id (managed-worktree parallel path) allows profile B.
19031        shared
19032            .ensure_workspace_environment_profile_compatible(&worktree, Some(&profile_b))
19033            .unwrap();
19034    }
19035
19036    #[tokio::test]
19037    async fn spawn_session_refuses_conflicting_environment_profile_before_mutation() {
19038        let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19039        let primary = WorkspaceId::new("primary").unwrap();
19040        let address = SessionAddress {
19041            workspace_id: primary.clone(),
19042            session: SessionKey {
19043                instance_id: AgentInstanceId(41),
19044                generation: SessionGeneration(1),
19045            },
19046        };
19047        shared.bind_session_with_policy(
19048            &address,
19049            ProviderRuntimePolicy::raw_pty(),
19050            Some(profile_a),
19051        );
19052
19053        let failure = shared
19054            .spawn_session_with_deadline(
19055                primary,
19056                agent("claude"),
19057                SessionMode::Pty,
19058                gate4agent_types::TerminalSize {
19059                    rows: 24,
19060                    columns: 80,
19061                },
19062                None,
19063                Some(profile_b),
19064                None,
19065                None,
19066                None,
19067                None,
19068                SpawnRecordPolicy::ProviderIdentityOnly,
19069                Some(Instant::now() + Duration::from_secs(30)),
19070                &[],
19071                None,
19072                ApprovalLevel::default(),
19073                None,
19074                None,
19075            )
19076            .await
19077            .unwrap_err();
19078        assert_eq!(failure.code, NodeFailureCode::WorkspaceBusy);
19079        // Guard runs before instance id mint / bind.
19080        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
19081        assert!(shared.handle.snapshot().sessions.is_empty());
19082    }
19083}
19084
19085#[cfg(test)]
19086mod spawn_characterization_tests {
19087    use super::*;
19088    use crate::session_environment::{
19089        NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
19090        NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
19091        NodeSessionPathClass,
19092    };
19093    use gate4agent_catalog::EnvMutation;
19094    use gate4agent_runtime_native::{
19095        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
19096        NativeLaunchProfile, NativeLaunchProfileId,
19097    };
19098    use std::ffi::OsString;
19099    use std::sync::Arc;
19100
19101    struct EmptyEnvironmentResolver;
19102
19103    struct FixtureSecretResolver {
19104        deny: Arc<AtomicBool>,
19105    }
19106
19107    impl NodeSecretResolver for FixtureSecretResolver {
19108        fn resolve(
19109            &self,
19110            _reference: &NodeSecretReference,
19111        ) -> Result<NodeSecretValue, NodeSecretResolveError> {
19112            if self.deny.load(Ordering::Acquire) {
19113                Err(NodeSecretResolveError::Denied)
19114            } else {
19115                NodeSecretValue::text("fixture-secret")
19116                    .map_err(|_| NodeSecretResolveError::Unavailable)
19117            }
19118        }
19119    }
19120
19121    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19122        fn resolve_child_environment(
19123            &self,
19124        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19125            Ok(vec![EnvMutation {
19126                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19127                value: None,
19128            }])
19129        }
19130    }
19131
19132    fn agent(value: &str) -> AgentId {
19133        AgentId::new(value).unwrap()
19134    }
19135
19136    fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
19137        let unique = std::time::SystemTime::now()
19138            .duration_since(std::time::UNIX_EPOCH)
19139            .unwrap()
19140            .as_nanos();
19141        std::env::temp_dir().join(format!(
19142            "gate4agent-node-{label}-{}-{unique}",
19143            std::process::id(),
19144        ))
19145    }
19146
19147    fn materialization_test_shared(
19148        deny_secret: bool,
19149    ) -> (
19150        NodeShared,
19151        NativeRuntime,
19152        ResolvedEnvironmentProfileReceipt,
19153        PathBuf,
19154        Arc<AtomicBool>,
19155    ) {
19156        let workspace = WorkspaceConfig::new(
19157            WorkspaceId::new("primary").unwrap(),
19158            std::env::current_dir().unwrap(),
19159        )
19160        .unwrap();
19161        materialization_test_shared_with_workspace(deny_secret, workspace)
19162    }
19163
19164    fn materialization_test_shared_with_workspace(
19165        deny_secret: bool,
19166        workspace: WorkspaceConfig,
19167    ) -> (
19168        NodeShared,
19169        NativeRuntime,
19170        ResolvedEnvironmentProfileReceipt,
19171        PathBuf,
19172        Arc<AtomicBool>,
19173    ) {
19174        let root = temporary_workspace_root(if deny_secret {
19175            "materialization-denied"
19176        } else {
19177            "materialization-ready"
19178        });
19179        std::fs::create_dir_all(&root).unwrap();
19180        let materialization_root = root.join("session-environments");
19181        let deny = Arc::new(AtomicBool::new(deny_secret));
19182        let resolver = Arc::new(FixtureSecretResolver {
19183            deny: Arc::clone(&deny),
19184        });
19185        let materializer = SessionEnvironmentMaterializer::new(
19186            materialization_root,
19187            resolver,
19188        )
19189        .unwrap();
19190        let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
19191        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19192            "materialized-claude-r1",
19193        )
19194        .unwrap();
19195        let materialization = NodeSessionMaterializationProfile::new(
19196            vec![NodeSessionEnvironmentMutation::SetSecret {
19197                key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
19198                reference: NodeSecretReference::new("fixture-token").unwrap(),
19199            }],
19200            vec![NodeSessionPathBinding::new(
19201                "GATE4AGENT_PROVIDER_HOME",
19202                NodeSessionPathClass::ProviderHome,
19203            )
19204            .unwrap()],
19205            vec![NodeSessionFile::secret(
19206                NodeSessionPathClass::Config,
19207                "auth/token",
19208                NodeSecretReference::new("fixture-token").unwrap(),
19209            )
19210            .unwrap()],
19211        )
19212        .unwrap();
19213        let catalog = active_registry().unwrap();
19214        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19215        let control = runtime.native_launch_profile_control();
19216        let node_profile = NodeEnvironmentProfile::new_with_materialization(
19217            profile_id.clone(),
19218            profile_revision.clone(),
19219            agent("claude"),
19220            [NativeLaunchProfile::new(
19221                NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
19222                agent("claude"),
19223                TransportKind::Pty,
19224                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19225                Arc::new(EmptyEnvironmentResolver),
19226            )
19227            .unwrap()],
19228            Some(materialization),
19229        )
19230        .unwrap();
19231        let (binding, native_profiles, materialization) = node_profile.into_parts();
19232        for native_profile in native_profiles {
19233            runtime.upsert_native_launch_profile(native_profile).unwrap();
19234        }
19235        let shared = NodeShared::new_with_incarnation(
19236            handle,
19237            "fixture-token".to_owned(),
19238            NodeId::new("node-materialization-test").unwrap(),
19239            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19240            vec![workspace],
19241            vec![agent("claude")],
19242            ProviderRuntimeStatuses::default(),
19243            None,
19244            Vec::new(),
19245            Vec::new(),
19246            SpawnProfileRegistry::default(),
19247            Some(control),
19248            None,
19249            Some(materializer),
19250            None,
19251            Vec::new(),
19252            Vec::new(),
19253            Vec::new(),
19254            Vec::new(),
19255            None,
19256            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19257        );
19258        shared
19259            .environment_profiles
19260            .write()
19261            .unwrap_or_else(|poisoned| poisoned.into_inner())
19262            .insert(binding.id.clone(), binding);
19263        shared
19264            .environment_materialization_profiles
19265            .write()
19266            .unwrap_or_else(|poisoned| poisoned.into_inner())
19267            .insert(profile_id.clone(), materialization.unwrap());
19268        (
19269            shared,
19270            runtime,
19271            ResolvedEnvironmentProfileReceipt {
19272                profile_id,
19273                profile_revision,
19274                network_allowlist: None,
19275                browser_profile_id: None,
19276            },
19277            root,
19278            deny,
19279        )
19280    }
19281
19282
19283    // ---- Characterization of the spawn preparation block (pre-SpawnPreparer) ----
19284
19285    async fn characterization_spawn(
19286        shared: &NodeShared,
19287        mode: SessionMode,
19288        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
19289        required: &[ProviderRuntimeCapability],
19290        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
19291    ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
19292        shared
19293            .spawn_session_with_deadline(
19294                WorkspaceId::new("primary").unwrap(),
19295                agent("claude"),
19296                mode,
19297                gate4agent_types::TerminalSize { rows: 24, columns: 80 },
19298                None,
19299                environment_profile,
19300                None,
19301                None,
19302                None,
19303                Some(ProviderRuntimePolicy::raw_pty()),
19304                SpawnRecordPolicy::ProviderIdentityOnly,
19305                Some(Instant::now() + Duration::from_secs(30)),
19306                required,
19307                harness_mcp,
19308                ApprovalLevel::default(),
19309                None,
19310                None,
19311            )
19312            .await
19313    }
19314
19315    fn assert_no_session_state(shared: &NodeShared) {
19316        assert!(shared.handle.snapshot().sessions.is_empty());
19317        assert!(shared
19318            .session_bindings
19319            .lock()
19320            .unwrap_or_else(|poisoned| poisoned.into_inner())
19321            .is_empty());
19322        assert!(shared
19323            .materializations
19324            .lock()
19325            .unwrap_or_else(|poisoned| poisoned.into_inner())
19326            .is_empty());
19327    }
19328
19329    #[tokio::test]
19330    async fn spawn_unadmitted_required_capability_refuses_before_id_mint() {
19331        let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19332        let error = characterization_spawn(
19333            &shared,
19334            SessionMode::Pty,
19335            None,
19336            &[ProviderRuntimeCapability::SemanticReadiness],
19337            None,
19338        )
19339        .await
19340        .unwrap_err();
19341        assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
19342        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
19343        assert_no_session_state(&shared);
19344        drop(shared);
19345        drop(runtime);
19346        std::fs::remove_dir_all(root).unwrap();
19347    }
19348
19349    #[tokio::test]
19350    async fn spawn_denied_environment_materialization_fails_after_id_mint_with_no_state() {
19351        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
19352        let error = characterization_spawn(&shared, SessionMode::Pty, Some(receipt), &[], None)
19353            .await
19354            .unwrap_err();
19355        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
19356        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 2);
19357        assert_no_session_state(&shared);
19358        drop(shared);
19359        drop(runtime);
19360        std::fs::remove_dir_all(root).unwrap();
19361    }
19362
19363    #[test]
19364    fn remove_binding_returns_binding_and_is_idempotent_for_environment_session() {
19365        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
19366        let address = SessionAddress {
19367            workspace_id: WorkspaceId::new("primary").unwrap(),
19368            session: SessionKey {
19369                instance_id: AgentInstanceId(77),
19370                generation: SessionGeneration(1),
19371            },
19372        };
19373        shared.bind_session_with_policy(&address, ProviderRuntimePolicy::raw_pty(), Some(receipt));
19374        let removed = shared.remove_binding(&address).expect("binding removed");
19375        assert!(removed.environment_profile.is_some());
19376        assert!(shared.remove_binding(&address).is_none());
19377        assert_no_session_state(&shared);
19378        drop(shared);
19379        drop(runtime);
19380        std::fs::remove_dir_all(root).unwrap();
19381    }
19382
19383    async fn characterization_prepared_harness(provider: &str) -> PreparedHarnessMcpSpawn {
19384        use crate::harness_mcp_proxy::{HarnessMcpProxyRegistry, ReviewedHarnessMcpProgram};
19385        use crate::protocol::{HarnessMcpActivationDigest, HarnessMcpLaunchV1, HarnessMcpReservationId};
19386        let helper = ReviewedHarnessMcpProgram::review(std::env::current_exe().unwrap())
19387            .expect("review current test executable as fixture helper");
19388        let registry = HarnessMcpProxyRegistry::new(helper);
19389        let spec = crate::protocol::SpawnSpec {
19390            target: crate::protocol::SpawnTarget {
19391                node_id: NodeId::new("node-characterization").unwrap(),
19392                workspace_id: WorkspaceId::new("primary").unwrap(),
19393                worktree_id: None,
19394            },
19395            profile_id: crate::protocol::SpawnProfileId::new("claude").unwrap(),
19396            expected_profile_revision: crate::protocol::SpawnProfileRevision::new("r1").unwrap(),
19397            overrides: crate::protocol::SpawnOverrides::default(),
19398            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19399            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("characterization-harness").unwrap(),
19400            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19401        };
19402        let reservation = HarnessMcpReservationId::new(format!("hmcpres_{}", "c".repeat(24))).unwrap();
19403        let digest = HarnessMcpActivationDigest::new(format!("sha256:{}", "c".repeat(64))).unwrap();
19404        let launch = HarnessMcpLaunchV1 {
19405            server_name: "fixture-harness".to_owned(),
19406            args: vec!["--session-proxy".to_owned()],
19407            endpoint_env: "FIXTURE_SESSION_ENDPOINT".to_owned(),
19408            token_env: "FIXTURE_SESSION_TOKEN".to_owned(),
19409            program_env: "FIXTURE_MCP_PROGRAM".to_owned(),
19410            trace: None,
19411            scrub_env: Vec::new(),
19412        };
19413        let expires = unix_time_ms().saturating_add(30_000);
19414        registry.arm(reservation.clone(), digest.clone(), spec.clone(), launch, expires).await.unwrap();
19415        registry
19416            .prepare_spawn(&reservation, &digest, &spec, expires, agent(provider))
19417            .unwrap()
19418    }
19419
19420    #[tokio::test]
19421    async fn spawn_harness_overlay_refuses_inline_and_provider_mismatch_with_no_state() {
19422        let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19423        let prepared = characterization_prepared_harness("claude").await;
19424        let error = characterization_spawn(&shared, SessionMode::Inline, None, &[], Some(&prepared))
19425            .await
19426            .unwrap_err();
19427        // Inline is stopped by runtime admission before the harness check runs.
19428        assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
19429        let mismatched = characterization_prepared_harness("codex").await;
19430        let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&mismatched))
19431            .await
19432            .unwrap_err();
19433        assert_eq!(error.code, NodeFailureCode::BindingMismatch);
19434        assert_no_session_state(&shared);
19435        drop(shared);
19436        drop(runtime);
19437        std::fs::remove_dir_all(root).unwrap();
19438    }
19439
19440    #[tokio::test]
19441    async fn spawn_harness_overlay_without_launch_control_is_unavailable_with_no_state() {
19442        let (mut shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19443        shared.native_launch_profile_control = None;
19444        let prepared = characterization_prepared_harness("claude").await;
19445        let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&prepared))
19446            .await
19447            .unwrap_err();
19448        assert_eq!(error.code, NodeFailureCode::HarnessMcpUnavailable);
19449        assert_no_session_state(&shared);
19450        drop(shared);
19451        drop(runtime);
19452        std::fs::remove_dir_all(root).unwrap();
19453    }
19454}
19455
19456#[cfg(all(test, windows))]
19457mod tests {
19458    use super::*;
19459    use gate4agent_types::ProviderActivity;
19460    use crate::session_environment::{
19461        NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
19462        NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
19463        NodeSessionPathClass,
19464    };
19465    use gate4agent_catalog::EnvMutation;
19466    use gate4agent_runtime_native::{
19467        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
19468        NativeLaunchProfile, NativeLaunchProfileId,
19469    };
19470    use std::ffi::OsString;
19471    use std::sync::Arc;
19472
19473    struct EmptyEnvironmentResolver;
19474
19475    struct FixtureSecretResolver {
19476        deny: Arc<AtomicBool>,
19477    }
19478
19479    impl NodeSecretResolver for FixtureSecretResolver {
19480        fn resolve(
19481            &self,
19482            _reference: &NodeSecretReference,
19483        ) -> Result<NodeSecretValue, NodeSecretResolveError> {
19484            if self.deny.load(Ordering::Acquire) {
19485                Err(NodeSecretResolveError::Denied)
19486            } else {
19487                NodeSecretValue::text("fixture-secret")
19488                    .map_err(|_| NodeSecretResolveError::Unavailable)
19489            }
19490        }
19491    }
19492
19493    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19494        fn resolve_child_environment(
19495            &self,
19496        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19497            Ok(vec![EnvMutation {
19498                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19499                value: None,
19500            }])
19501        }
19502    }
19503
19504    fn agent(value: &str) -> AgentId {
19505        AgentId::new(value).unwrap()
19506    }
19507
19508    fn terminal_test_shared_with_mode(mode: WorktreeServiceMode) -> NodeShared {
19509        let catalog = active_registry().unwrap();
19510        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19511        let workspace = WorkspaceConfig::new(
19512            WorkspaceId::new("primary").unwrap(),
19513            std::env::current_dir().unwrap(),
19514        )
19515        .unwrap()
19516        .with_worktree_service_mode(mode);
19517        NodeShared::new(
19518            handle,
19519            "fixture-token".to_owned(),
19520            NodeId::new("node-terminal-test").unwrap(),
19521            vec![workspace],
19522            vec![agent("claude")],
19523        )
19524    }
19525
19526    fn terminal_test_shared() -> NodeShared {
19527        terminal_test_shared_with_mode(WorktreeServiceMode::Manual)
19528    }
19529
19530    #[cfg(feature = "fixture")]
19531    #[test]
19532    fn managed_worktree_v2_failure_probe_keeps_only_the_latest_code() {
19533        let probe = SpawnManagedWorktreeV2FailureProbe::default();
19534        assert_eq!(probe.latest_code(), None);
19535
19536        probe.record(Some(NodeFailureCode::ManagedWorktreeOwnershipConflict));
19537        assert_eq!(
19538            probe.latest_code(),
19539            Some(NodeFailureCode::ManagedWorktreeOwnershipConflict),
19540        );
19541
19542        probe.record(Some(NodeFailureCode::SpawnDeadlineExceeded));
19543        assert_eq!(
19544            probe.latest_code(),
19545            Some(NodeFailureCode::SpawnDeadlineExceeded),
19546        );
19547
19548        probe.record(None);
19549        assert_eq!(probe.latest_code(), None);
19550    }
19551
19552    #[test]
19553    fn launch_snapshot_is_authoritative_bounded_and_path_free() {
19554        let source = temporary_workspace_root("launch-inventory-source");
19555        let allocation = temporary_workspace_root("launch-inventory-allocation");
19556        std::fs::create_dir_all(&source).unwrap();
19557        std::fs::create_dir_all(&allocation).unwrap();
19558        let workspace_id = WorkspaceId::new("primary").unwrap();
19559        let mut workspace = WorkspaceConfig::new(workspace_id.clone(), &source)
19560            .unwrap()
19561            .with_worktree_service_mode(WorktreeServiceMode::Managed);
19562        for index in 0..MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE {
19563            workspace = workspace
19564                .with_managed_worktree_profile(
19565                    ManagedWorktreeProfile::new(
19566                        WorktreeProfileId::new(format!("profile-{index}")).unwrap(),
19567                        crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
19568                        &allocation,
19569                        "gate4agent",
19570                        "HEAD",
19571                        ManagedWorktreeRetention::RemoveWhenReleased,
19572                    )
19573                    .unwrap(),
19574                )
19575                .unwrap();
19576        }
19577        let overflow = workspace.clone().with_managed_worktree_profile(
19578            ManagedWorktreeProfile::new(
19579                WorktreeProfileId::new("overflow").unwrap(),
19580                crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
19581                &allocation,
19582                "gate4agent",
19583                "HEAD",
19584                ManagedWorktreeRetention::Retain,
19585            )
19586            .unwrap(),
19587        );
19588        assert!(matches!(
19589            overflow,
19590            Err(NodeServerError::ManagedWorktreeProfileCapacity {
19591                max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
19592                ..
19593            }),
19594        ));
19595
19596        let catalog = active_registry().unwrap();
19597        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19598        let shared = NodeShared::new(
19599            handle,
19600            "fixture-token".to_owned(),
19601            NodeId::new("node-launch-inventory").unwrap(),
19602            vec![workspace],
19603            vec![agent("claude")],
19604        );
19605        let snapshot = shared.snapshot();
19606        let launch = snapshot.launch_inventory.as_ref().unwrap();
19607        assert_eq!(launch.spawn_profiles.as_ref().unwrap().len(), 1);
19608        assert!(launch.bundles.as_ref().unwrap().is_empty());
19609        let profiles = snapshot.workspaces[0]
19610            .managed_worktree_profiles
19611            .as_ref()
19612            .unwrap();
19613        assert_eq!(
19614            snapshot.workspaces[0].worktree_service_mode,
19615            Some(WorktreeServiceMode::Managed),
19616        );
19617        assert_eq!(
19618            profiles.profiles.len(),
19619            MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
19620        );
19621        let public_inventory = serde_json::to_string(&(launch, profiles)).unwrap();
19622        for forbidden in ["allocation_root", "branch_prefix", "base", "prompt", "environment", "home"] {
19623            assert!(!public_inventory.contains(forbidden), "launch inventory leaked {forbidden}");
19624        }
19625        let legacy = snapshot_for_wire(
19626            &shared,
19627            true,
19628            true,
19629            false,
19630            false,
19631            true,
19632            false,
19633            true,
19634            false,
19635            true,
19636        );
19637        assert!(legacy.launch_inventory.is_none());
19638        assert!(legacy.workspaces[0].managed_worktree_profiles.is_none());
19639
19640        drop(shared);
19641        std::fs::remove_dir_all(source).unwrap();
19642        std::fs::remove_dir_all(allocation).unwrap();
19643    }
19644
19645    #[test]
19646    fn agent_progress_projection_omits_sensitive_provider_payloads_and_is_capability_gated() {
19647        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
19648            capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
19649        }));
19650        let source = gate4agent_types::ProviderSource {
19651            family: AdapterFamily::PtySemantic,
19652            binding: AdapterBinding::new(
19653                gate4agent_types::AdapterId::new("claude").unwrap(),
19654                "fixture/v1",
19655                gate4agent_types::AdapterVerification::SyntheticFixture,
19656            )
19657            .unwrap(),
19658        };
19659        let provider = ProviderSnapshot {
19660            sequence: 17,
19661            session: Some(ProviderSessionIdentity {
19662                key: ProviderSessionKey::SessionId,
19663                id: "PRIVATE_PROVIDER_SESSION".to_owned(),
19664                transcript_path: Some(r"C:\PRIVATE_PROVIDER_PATH".to_owned()),
19665            }),
19666            model: Some("PRIVATE_MODEL".to_owned()),
19667            tools: vec!["PRIVATE_TOOL_INVENTORY".to_owned()],
19668            completed_turns: 3,
19669            usage: gate4agent_types::TokenUsage {
19670                input_tokens: 10,
19671                output_tokens: 20,
19672                cache_read_tokens: 30,
19673                cache_write_tokens: 40,
19674                reasoning_tokens: 50,
19675                context_window: Some(200_000),
19676            },
19677            lead_activity: ProviderActivity::WaitingForInput,
19678            activity: ProviderActivity::WaitingForInput,
19679            current_prompt: Some("PRIVATE_CURRENT_PROMPT".to_owned()),
19680            active_tools: (0..9)
19681                .map(|index| gate4agent_types::ActiveProviderTool {
19682                    id: format!("private-tool-id-{index}"),
19683                    name: if index == 0 {
19684                        format!("  Read\u{0000}{}", "x".repeat(80))
19685                    } else {
19686                        format!("Tool-{index}")
19687                    },
19688                    input_json: "PRIVATE_TOOL_INPUT_JSON".to_owned(),
19689                })
19690                .collect(),
19691            interactions: vec![gate4agent_types::ProviderInteraction {
19692                id: gate4agent_types::ProviderInteractionId(17),
19693                source: source.clone(),
19694                provider_request_id: Some("PRIVATE_PROVIDER_REQUEST".to_owned()),
19695                interaction_kind: ProviderInteractionKind::Approval,
19696                tool_name: " Approve\u{0007}Tool ".to_owned(),
19697                prompt: "PRIVATE_INTERACTION_PROMPT".to_owned(),
19698                agent_id: Some("PRIVATE_INTERACTION_AGENT".to_owned()),
19699                resume_lead_activity: Some(ProviderActivity::Working),
19700                status: ProviderInteractionStatus::Pending,
19701            }],
19702            subagents: vec![gate4agent_types::ProviderSubagent {
19703                source: source.clone(),
19704                provider_agent_id: "PRIVATE_SUBAGENT_ID".to_owned(),
19705                agent_type: Some("PRIVATE_SUBAGENT_TYPE".to_owned()),
19706                description: Some("PRIVATE_SUBAGENT_DESCRIPTION".to_owned()),
19707            }],
19708            sources: vec![gate4agent_types::ProviderSourceCursor {
19709                source,
19710                sequence: 17,
19711                gap_count: 2,
19712                stale: true,
19713            }],
19714            last_event: Some(ProviderEvent::Error {
19715                message: "PRIVATE_PROVIDER_ERROR".to_owned(),
19716            }),
19717            gap_count: 2,
19718            stale: true,
19719        };
19720        let address = terminal_address(3);
19721        let entry = agent_progress_from_provider_snapshot(address.clone(), &provider).unwrap();
19722        assert_eq!(entry.address, address);
19723        assert_eq!(entry.progress.provider_sequence, 17);
19724        assert_eq!(entry.progress.active_tool_count, 9);
19725        assert_eq!(
19726            entry.progress.active_tool_labels.len(),
19727            MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS,
19728        );
19729        assert_eq!(entry.progress.subagent_count, 1);
19730        assert_eq!(entry.progress.last_event_kind, Some(AgentProgressEventKindV1::Error));
19731        assert!(entry.progress.truncated);
19732        let encoded = serde_json::to_string(&entry).unwrap();
19733        for forbidden in [
19734            "PRIVATE_PROVIDER_SESSION",
19735            "PRIVATE_PROVIDER_PATH",
19736            "PRIVATE_MODEL",
19737            "PRIVATE_TOOL_INVENTORY",
19738            "PRIVATE_CURRENT_PROMPT",
19739            "PRIVATE_TOOL_INPUT_JSON",
19740            "PRIVATE_PROVIDER_REQUEST",
19741            "PRIVATE_INTERACTION_PROMPT",
19742            "PRIVATE_INTERACTION_AGENT",
19743            "PRIVATE_SUBAGENT_ID",
19744            "PRIVATE_SUBAGENT_TYPE",
19745            "PRIVATE_SUBAGENT_DESCRIPTION",
19746            "PRIVATE_PROVIDER_ERROR",
19747            "input_json",
19748            "current_prompt",
19749            "session_id",
19750        ] {
19751            assert!(!encoded.contains(forbidden), "agent progress leaked {forbidden}");
19752        }
19753
19754        let snapshot = NodeSnapshot {
19755            node_id: NodeId::new("node-progress-test").unwrap(),
19756            enabled_providers: vec![agent("claude")],
19757            provider_runtime_statuses: ProviderRuntimeStatuses::default(),
19758            workspaces: Vec::new(),
19759            session_records: Vec::new(),
19760            managed_worktrees: Vec::new(),
19761            launch_inventory: None,
19762            agent_progress: vec![entry],
19763        };
19764        let mut legacy_reply = ResponseEnvelope {
19765            request_id: 1,
19766            result: Ok(NodeResponse::Snapshot {
19767                event_sequence: 0,
19768                controller: None,
19769                snapshot: snapshot.clone(),
19770            }),
19771        };
19772        project_response_without_agent_progress(&mut legacy_reply);
19773        let Ok(NodeResponse::Snapshot { snapshot: legacy, .. }) = legacy_reply.result else {
19774            panic!("agent progress projection changed response kind");
19775        };
19776        assert!(legacy.agent_progress.is_empty());
19777        assert_eq!(snapshot.agent_progress.len(), 1);
19778    }
19779
19780    fn terminal_address(generation: u64) -> SessionAddress {
19781        SessionAddress {
19782            workspace_id: WorkspaceId::new("primary").unwrap(),
19783            session: SessionKey {
19784                instance_id: AgentInstanceId(41),
19785                generation: SessionGeneration(generation),
19786            },
19787        }
19788    }
19789
19790    fn spawn_spec_fixture() -> SpawnSpec {
19791        SpawnSpec {
19792            target: crate::protocol::SpawnTarget {
19793                node_id: NodeId::new("node-terminal-test").unwrap(),
19794                workspace_id: WorkspaceId::new("primary").unwrap(),
19795                worktree_id: None,
19796            },
19797            profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
19798            expected_profile_revision: crate::protocol::SpawnProfileRevision::new("builtin-v1")
19799                .unwrap(),
19800            overrides: crate::protocol::SpawnOverrides::default(),
19801            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19802            idempotency_key: SpawnIdempotencyKey::new("spawn-spec-fixture").unwrap(),
19803            required_capabilities: SpawnRequiredCapabilities::default(),
19804        }
19805    }
19806
19807    fn environment_profile_test_shared(
19808    ) -> (NodeShared, NativeRuntime, ResolvedEnvironmentProfileReceipt) {
19809        let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
19810        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19811            "local-claude-r1",
19812        )
19813        .unwrap();
19814        let spawn_profiles = SpawnProfileRegistry::new([
19815            crate::protocol::SpawnProfileDefaults {
19816                profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
19817                revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
19818                provider: agent("claude"),
19819                mode: SessionMode::Pty,
19820                terminal_size: gate4agent_types::TerminalSize {
19821                    rows: 24,
19822                    columns: 80,
19823                },
19824                prompt: None,
19825                bundle_id: None,
19826                context_id: None,
19827                environment_profile_id: Some(profile_id.clone()),
19828            },
19829        ])
19830        .unwrap();
19831        let catalog = active_registry().unwrap();
19832        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19833        let control = runtime.native_launch_profile_control();
19834        let node_profile = NodeEnvironmentProfile::new(
19835            profile_id.clone(),
19836            profile_revision.clone(),
19837            agent("claude"),
19838            [NativeLaunchProfile::new(
19839                NativeLaunchProfileId::new("local-claude-pty").unwrap(),
19840                agent("claude"),
19841                TransportKind::Pty,
19842                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19843                Arc::new(EmptyEnvironmentResolver),
19844            )
19845            .unwrap()],
19846        )
19847        .unwrap();
19848        let (binding, native_profiles, _materialization) = node_profile.into_parts();
19849        for native_profile in native_profiles {
19850            runtime.upsert_native_launch_profile(native_profile).unwrap();
19851        }
19852        let workspace = WorkspaceConfig::new(
19853            WorkspaceId::new("primary").unwrap(),
19854            std::env::current_dir().unwrap(),
19855        )
19856        .unwrap();
19857        let shared = NodeShared::new_with_incarnation(
19858            handle,
19859            "fixture-token".to_owned(),
19860            NodeId::new("node-terminal-test").unwrap(),
19861            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19862            vec![workspace],
19863            vec![agent("claude")],
19864            ProviderRuntimeStatuses::default(),
19865            None,
19866            Vec::new(),
19867            Vec::new(),
19868            spawn_profiles,
19869            Some(control),
19870            None,
19871            None,
19872            None,
19873            Vec::new(),
19874            Vec::new(),
19875            Vec::new(),
19876            Vec::new(),
19877            None,
19878            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19879        );
19880        shared
19881            .environment_profiles
19882            .write()
19883            .unwrap_or_else(|poisoned| poisoned.into_inner())
19884            .insert(binding.id.clone(), binding);
19885        (
19886            shared,
19887            runtime,
19888            ResolvedEnvironmentProfileReceipt {
19889                profile_id,
19890                profile_revision,
19891                network_allowlist: None,
19892                browser_profile_id: None,
19893            },
19894        )
19895    }
19896
19897    fn materialization_test_shared(
19898        deny_secret: bool,
19899    ) -> (
19900        NodeShared,
19901        NativeRuntime,
19902        ResolvedEnvironmentProfileReceipt,
19903        PathBuf,
19904        Arc<AtomicBool>,
19905    ) {
19906        let workspace = WorkspaceConfig::new(
19907            WorkspaceId::new("primary").unwrap(),
19908            std::env::current_dir().unwrap(),
19909        )
19910        .unwrap();
19911        materialization_test_shared_with_workspace(deny_secret, workspace)
19912    }
19913
19914    fn materialization_test_shared_with_workspace(
19915        deny_secret: bool,
19916        workspace: WorkspaceConfig,
19917    ) -> (
19918        NodeShared,
19919        NativeRuntime,
19920        ResolvedEnvironmentProfileReceipt,
19921        PathBuf,
19922        Arc<AtomicBool>,
19923    ) {
19924        let root = temporary_workspace_root(if deny_secret {
19925            "materialization-denied"
19926        } else {
19927            "materialization-ready"
19928        });
19929        std::fs::create_dir_all(&root).unwrap();
19930        let materialization_root = root.join("session-environments");
19931        let deny = Arc::new(AtomicBool::new(deny_secret));
19932        let resolver = Arc::new(FixtureSecretResolver {
19933            deny: Arc::clone(&deny),
19934        });
19935        let materializer = SessionEnvironmentMaterializer::new(
19936            materialization_root,
19937            resolver,
19938        )
19939        .unwrap();
19940        let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
19941        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19942            "materialized-claude-r1",
19943        )
19944        .unwrap();
19945        let materialization = NodeSessionMaterializationProfile::new(
19946            vec![NodeSessionEnvironmentMutation::SetSecret {
19947                key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
19948                reference: NodeSecretReference::new("fixture-token").unwrap(),
19949            }],
19950            vec![NodeSessionPathBinding::new(
19951                "GATE4AGENT_PROVIDER_HOME",
19952                NodeSessionPathClass::ProviderHome,
19953            )
19954            .unwrap()],
19955            vec![NodeSessionFile::secret(
19956                NodeSessionPathClass::Config,
19957                "auth/token",
19958                NodeSecretReference::new("fixture-token").unwrap(),
19959            )
19960            .unwrap()],
19961        )
19962        .unwrap();
19963        let catalog = active_registry().unwrap();
19964        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19965        let control = runtime.native_launch_profile_control();
19966        let node_profile = NodeEnvironmentProfile::new_with_materialization(
19967            profile_id.clone(),
19968            profile_revision.clone(),
19969            agent("claude"),
19970            [NativeLaunchProfile::new(
19971                NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
19972                agent("claude"),
19973                TransportKind::Pty,
19974                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19975                Arc::new(EmptyEnvironmentResolver),
19976            )
19977            .unwrap()],
19978            Some(materialization),
19979        )
19980        .unwrap();
19981        let (binding, native_profiles, materialization) = node_profile.into_parts();
19982        for native_profile in native_profiles {
19983            runtime.upsert_native_launch_profile(native_profile).unwrap();
19984        }
19985        let shared = NodeShared::new_with_incarnation(
19986            handle,
19987            "fixture-token".to_owned(),
19988            NodeId::new("node-materialization-test").unwrap(),
19989            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19990            vec![workspace],
19991            vec![agent("claude")],
19992            ProviderRuntimeStatuses::default(),
19993            None,
19994            Vec::new(),
19995            Vec::new(),
19996            SpawnProfileRegistry::default(),
19997            Some(control),
19998            None,
19999            Some(materializer),
20000            None,
20001            Vec::new(),
20002            Vec::new(),
20003            Vec::new(),
20004            Vec::new(),
20005            None,
20006            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20007        );
20008        shared
20009            .environment_profiles
20010            .write()
20011            .unwrap_or_else(|poisoned| poisoned.into_inner())
20012            .insert(binding.id.clone(), binding);
20013        shared
20014            .environment_materialization_profiles
20015            .write()
20016            .unwrap_or_else(|poisoned| poisoned.into_inner())
20017            .insert(profile_id.clone(), materialization.unwrap());
20018        (
20019            shared,
20020            runtime,
20021            ResolvedEnvironmentProfileReceipt {
20022                profile_id,
20023                profile_revision,
20024                network_allowlist: None,
20025                browser_profile_id: None,
20026            },
20027            root,
20028            deny,
20029        )
20030    }
20031
20032    #[cfg(feature = "fixture")]
20033    #[derive(Clone, Copy)]
20034    enum CodexHomeProfile {
20035        Missing,
20036        WrongClass,
20037        Exact,
20038    }
20039
20040    #[cfg(feature = "fixture")]
20041    fn codex_bundle_test_shared(
20042        home_profile: CodexHomeProfile,
20043    ) -> (
20044        NodeShared,
20045        NativeRuntime,
20046        ResolvedEnvironmentProfileReceipt,
20047        NodeBundle,
20048        PathBuf,
20049    ) {
20050        const ROOT_MANIFEST: &[u8] = br#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"review-tools"}"#;
20051        const CLAUDE_MANIFEST: &[u8] =
20052            br#"{"name":"review-tools","version":"1.0.0","description":"Review helpers"}"#;
20053        const SKILL: &[u8] = b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n";
20054        const DIGEST: &str =
20055            "sha256:941f20868a6ef49b4329bf1bb1368515763f5b64d8279b05cd9700966083d707";
20056
20057        let (shared, mut runtime, _claude_receipt, root, _deny) =
20058            materialization_test_shared(false);
20059        let profile_id = SpawnEnvironmentProfileId::new(match home_profile {
20060            CodexHomeProfile::Missing => "codex-home-missing",
20061            CodexHomeProfile::WrongClass => "codex-home-wrong",
20062            CodexHomeProfile::Exact => "codex-home-exact",
20063        })
20064        .unwrap();
20065        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20066            "codex-home-r1",
20067        )
20068        .unwrap();
20069        let materialization = match home_profile {
20070            CodexHomeProfile::Missing => None,
20071            CodexHomeProfile::WrongClass | CodexHomeProfile::Exact => {
20072                Some(NodeSessionMaterializationProfile::new(
20073                    Vec::new(),
20074                    vec![NodeSessionPathBinding::new(
20075                        "CODEX_HOME",
20076                        if matches!(home_profile, CodexHomeProfile::Exact) {
20077                            NodeSessionPathClass::ProviderHome
20078                        } else {
20079                            NodeSessionPathClass::Config
20080                        },
20081                    )
20082                    .unwrap()],
20083                    Vec::new(),
20084                )
20085                .unwrap())
20086            }
20087        };
20088        let node_profile = NodeEnvironmentProfile::new_with_materialization(
20089            profile_id.clone(),
20090            profile_revision.clone(),
20091            agent("codex"),
20092            [NativeLaunchProfile::new(
20093                NativeLaunchProfileId::new(format!("{profile_id}-pty")).unwrap(),
20094                agent("codex"),
20095                TransportKind::Pty,
20096                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20097                Arc::new(EmptyEnvironmentResolver),
20098            )
20099            .unwrap()],
20100            materialization,
20101        )
20102        .unwrap();
20103        let (binding, native_profiles, materialization) = node_profile.into_parts();
20104        for native_profile in native_profiles {
20105            runtime.upsert_native_launch_profile(native_profile).unwrap();
20106        }
20107        shared
20108            .environment_profiles
20109            .write()
20110            .unwrap_or_else(|poisoned| poisoned.into_inner())
20111            .insert(binding.id.clone(), binding);
20112        if let Some(materialization) = materialization {
20113            shared
20114                .environment_materialization_profiles
20115                .write()
20116                .unwrap_or_else(|poisoned| poisoned.into_inner())
20117                .insert(profile_id.clone(), materialization);
20118        }
20119
20120        let bundle_source = root.join(format!("bundle-source-{profile_id}"));
20121        std::fs::create_dir_all(bundle_source.join(".claude-plugin")).unwrap();
20122        std::fs::create_dir_all(bundle_source.join("skills/review-code")).unwrap();
20123        std::fs::write(bundle_source.join("plugin.json"), ROOT_MANIFEST).unwrap();
20124        std::fs::write(
20125            bundle_source.join(".claude-plugin/plugin.json"),
20126            CLAUDE_MANIFEST,
20127        )
20128        .unwrap();
20129        std::fs::write(bundle_source.join("skills/review-code/SKILL.md"), SKILL).unwrap();
20130        crate::bundle_catalog::protect_bundle_source_tree_fixture(&bundle_source).unwrap();
20131        let bundle = NodeBundle::new(
20132            crate::protocol::SpawnBundleId::new("review-tools").unwrap(),
20133            crate::protocol::SpawnBundleRevision::new("review-tools-r1").unwrap(),
20134            crate::protocol::SpawnBundleDigest::new(DIGEST).unwrap(),
20135            &bundle_source,
20136        )
20137        .unwrap();
20138        *shared
20139            .bundle_catalog
20140            .write()
20141            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
20142            BundleCatalog::new([bundle.clone()]).unwrap();
20143        (
20144            shared,
20145            runtime,
20146            ResolvedEnvironmentProfileReceipt {
20147                profile_id,
20148                profile_revision,
20149                network_allowlist: None,
20150                browser_profile_id: None,
20151            },
20152            bundle,
20153            root,
20154        )
20155    }
20156
20157    #[cfg(feature = "fixture")]
20158    #[test]
20159    fn codex_bundle_preflight_rejects_missing_or_wrong_home_before_materialization() {
20160        for home_profile in [CodexHomeProfile::Missing, CodexHomeProfile::WrongClass] {
20161            let (shared, runtime, receipt, bundle, root) =
20162                codex_bundle_test_shared(home_profile);
20163            let error = shared
20164                .bundle_layout(
20165                    &agent("codex"),
20166                    SessionMode::Pty,
20167                    Some(&receipt),
20168                    &bundle,
20169                )
20170                .unwrap_err();
20171            assert_eq!(error.code, NodeFailureCode::BundleBindingMismatch);
20172            assert!(shared
20173                .materializations
20174                .lock()
20175                .unwrap_or_else(|poisoned| poisoned.into_inner())
20176                .is_empty());
20177            let materialization_root = root.join("session-environments");
20178            assert!(!std::fs::read_dir(&materialization_root)
20179                .unwrap()
20180                .filter_map(Result::ok)
20181                .any(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())));
20182            drop(shared);
20183            drop(runtime);
20184            let _ = std::fs::remove_dir_all(root);
20185        }
20186    }
20187
20188    #[cfg(feature = "fixture")]
20189    #[test]
20190    fn codex_bundle_exact_home_profile_materializes_only_the_skill_tree() {
20191        let (shared, runtime, receipt, bundle, root) =
20192            codex_bundle_test_shared(CodexHomeProfile::Exact);
20193        let address = SessionAddress {
20194            workspace_id: WorkspaceId::new("primary").unwrap(),
20195            session: SessionKey {
20196                instance_id: AgentInstanceId(310),
20197                generation: SessionGeneration(1),
20198            },
20199        };
20200        let (overlay, guard) = shared
20201            .prepare_session_materialization(
20202                &address,
20203                &agent("codex"),
20204                SessionMode::Pty,
20205                Some(&receipt),
20206                Some(&bundle.receipt()),
20207                None,
20208                None,
20209            )
20210            .unwrap()
20211            .unwrap();
20212        assert!(matches!(overlay, Some(PreparedNativeLaunchOverlay::Instance(_))));
20213        let ownership = shared
20214            .materializations
20215            .lock()
20216            .unwrap_or_else(|poisoned| poisoned.into_inner())
20217            .get(guard.id().unwrap())
20218            .unwrap()
20219            .clone();
20220        let (profile, _, _) = shared
20221            .materialization_profile(
20222                &agent("codex"),
20223                SessionMode::Pty,
20224                Some(&receipt),
20225                Some(&bundle.receipt()),
20226                None,
20227            )
20228            .unwrap()
20229            .unwrap();
20230        let environment = shared
20231            .session_environment_materializer
20232            .as_ref()
20233            .unwrap()
20234            .resolve_environment(&ownership, &profile)
20235            .unwrap();
20236        assert!(environment.iter().any(|mutation| {
20237            mutation.key == OsString::from("CODEX_HOME")
20238                && mutation.value.as_deref() == Some(ownership.provider_home().as_os_str())
20239        }));
20240        assert_eq!(
20241            std::fs::read(ownership.provider_home().join("skills/review-code/SKILL.md"))
20242                .unwrap(),
20243            b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n",
20244        );
20245        assert!(std::fs::read_dir(ownership.bundle_root())
20246            .unwrap()
20247            .next()
20248            .is_none());
20249        drop(guard);
20250        drop(shared);
20251        drop(runtime);
20252        let _ = std::fs::remove_dir_all(root);
20253    }
20254
20255    #[cfg(feature = "fixture")]
20256    #[test]
20257    fn codex_cold_record_resume_revalidates_the_isolated_skill_tree() {
20258        let (shared, runtime, receipt, bundle, root) =
20259            codex_bundle_test_shared(CodexHomeProfile::Exact);
20260        let bundle_receipt = bundle.receipt();
20261        let address = SessionAddress {
20262            workspace_id: WorkspaceId::new("primary").unwrap(),
20263            session: SessionKey {
20264                instance_id: AgentInstanceId(311),
20265                generation: SessionGeneration(1),
20266            },
20267        };
20268        let (_overlay, guard) = shared
20269            .prepare_session_materialization(
20270                &address,
20271                &agent("codex"),
20272                SessionMode::Pty,
20273                Some(&receipt),
20274                Some(&bundle_receipt),
20275                None,
20276                None,
20277            )
20278            .unwrap()
20279            .unwrap();
20280        let materialization_id = guard.id().unwrap().clone();
20281        let skill_path = shared
20282            .materializations
20283            .lock()
20284            .unwrap_or_else(|poisoned| poisoned.into_inner())
20285            .get(&materialization_id)
20286            .unwrap()
20287            .provider_home()
20288            .join("skills/review-code/SKILL.md");
20289        let record_id = shared
20290            .bind_spawn_session_with_materialization(
20291                &address,
20292                agent("codex"),
20293                SessionMode::Pty,
20294                ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap(),
20295                SpawnRecordPolicy::ProviderIdentityOnly,
20296                Some(receipt.clone()),
20297                Some(bundle_receipt.clone()),
20298                None,
20299                Some(materialization_id.clone()),
20300            )
20301            .unwrap()
20302            .unwrap();
20303        guard.retain();
20304        std::fs::write(skill_path, b"changed-after-record-bind").unwrap();
20305
20306        let error = match shared.resolve_record_materialization(
20307                &record_id,
20308                &agent("codex"),
20309                SessionMode::Pty,
20310                Some(&receipt),
20311                Some(&bundle_receipt),
20312                None,
20313            ) {
20314                Ok(_) => panic!("changed Codex skill tree unexpectedly revalidated"),
20315                Err(error) => error,
20316            };
20317        assert_eq!(error.code, NodeFailureCode::BundleMaterializationFailed);
20318        assert_eq!(
20319            shared
20320                .materializations
20321                .lock()
20322                .unwrap_or_else(|poisoned| poisoned.into_inner())
20323                .get(&materialization_id)
20324                .unwrap()
20325                .state(),
20326            MaterializationState::RecoveryRequired,
20327        );
20328        drop(shared);
20329        drop(runtime);
20330        let _ = std::fs::remove_dir_all(root);
20331    }
20332
20333    fn bind_record_owned_materialization_fixture(
20334        shared: &NodeShared,
20335        receipt: &ResolvedEnvironmentProfileReceipt,
20336        instance_id: u64,
20337    ) -> (SessionAddress, SessionRecordId, MaterializationId, PathBuf) {
20338        let address = SessionAddress {
20339            workspace_id: WorkspaceId::new("primary").unwrap(),
20340            session: SessionKey {
20341                instance_id: AgentInstanceId(instance_id),
20342                generation: SessionGeneration(1),
20343            },
20344        };
20345        let (overlay, materialization_guard) = shared
20346            .prepare_session_materialization(
20347                &address,
20348                &agent("claude"),
20349                SessionMode::Pty,
20350                Some(receipt),
20351                None,
20352                None,
20353                None,
20354            )
20355            .unwrap()
20356            .unwrap();
20357        let materialization_id = materialization_guard.id().unwrap().clone();
20358        let materialization_root = shared
20359            .materializations
20360            .lock()
20361            .unwrap_or_else(|poisoned| poisoned.into_inner())
20362            .get(&materialization_id)
20363            .unwrap()
20364            .root()
20365            .to_path_buf();
20366        let selection = shared
20367            .select_environment_profile(
20368                address.session.instance_id,
20369                &agent("claude"),
20370                SessionMode::Pty,
20371                Some(receipt),
20372            )
20373            .unwrap()
20374            .unwrap();
20375        assert!(shared
20376            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
20377            .unwrap()
20378            .is_none());
20379        let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
20380        let record_id = shared
20381            .bind_spawn_session_with_materialization(
20382                &address,
20383                agent("claude"),
20384                SessionMode::Pty,
20385                policy,
20386                SpawnRecordPolicy::ProviderIdentityOnly,
20387                Some(receipt.clone()),
20388                None,
20389                None,
20390                Some(materialization_id.clone()),
20391            )
20392            .unwrap()
20393            .unwrap();
20394        selection.retain();
20395        materialization_guard.retain();
20396        (address, record_id, materialization_id, materialization_root)
20397    }
20398
20399    #[test]
20400    fn child_environment_profile_capability_is_state_aware_and_legacy_safe() {
20401        let (shared, _runtime, environment_profile) = environment_profile_test_shared();
20402        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20403            capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
20404        }));
20405        let support = node_compatibility_support_for_manifest(&[], &[]).unwrap();
20406        assert_eq!(support.state_schema.versions.maximum(), NODE_STATE_SCHEMA_V10);
20407        let mut spec = spawn_spec_fixture();
20408        spec.expected_profile_revision =
20409            crate::protocol::SpawnProfileRevision::new("test-r1").unwrap();
20410        assert!(matches!(
20411            &spec.overrides.environment_profile_id,
20412            crate::protocol::SpawnOverride::Inherit,
20413        ));
20414        assert!(!NodeRequest::SpawnSpec { spec: spec.clone() }
20415            .requires_child_environment_profile_capability());
20416        assert!(request_requires_child_environment_profile(
20417            &shared,
20418            &NodeRequest::SpawnSpec { spec: spec.clone() },
20419        ));
20420
20421        let address = terminal_address(1);
20422        shared.bind_session_with_policy(
20423            &address,
20424            ProviderRuntimePolicy::raw_pty(),
20425            Some(environment_profile.clone()),
20426        );
20427        assert!(request_requires_child_environment_profile(
20428            &shared,
20429            &NodeRequest::Input {
20430                session: address.clone(),
20431                text: "x".to_owned(),
20432            },
20433        ));
20434
20435        let mut record = record("claude", "profile-record");
20436        record.active_session = Some(address.clone());
20437        record.environment_profile = Some(environment_profile.clone());
20438        let record_id = record.record_id.clone();
20439        shared.insert_record(record.clone()).unwrap();
20440        assert!(request_requires_child_environment_profile(
20441            &shared,
20442            &NodeRequest::ResumeSessionRecord {
20443                record_id,
20444                terminal_size: gate4agent_types::TerminalSize {
20445                    rows: 24,
20446                    columns: 80,
20447                },
20448                initial_prompt: None,
20449            },
20450        ));
20451
20452        let snapshot = snapshot_for_wire(
20453            &shared, false, true, true, true, false, false, false, false, true,
20454        );
20455        assert_eq!(snapshot.session_records.len(), 1);
20456        assert!(snapshot.session_records[0].environment_profile.is_none());
20457        let event = project_event_without_child_environment_profile(NodeEventEnvelope {
20458            sequence: 1,
20459            event: NodeEvent::SessionRecordUpserted { record },
20460        });
20461        let NodeEvent::SessionRecordUpserted { record } = event.event else {
20462            panic!("record projection changed the event kind");
20463        };
20464        assert!(record.environment_profile.is_none());
20465
20466        let resolved = shared.resolve_spawn_spec(&spec).unwrap();
20467        let mut reply = ResponseEnvelope {
20468            request_id: 1,
20469            result: Ok(NodeResponse::SpawnSpecAccepted {
20470                receipt: resolved.receipt_with_environment(
20471                    shared.incarnation_id,
20472                    address,
20473                    Some(environment_profile),
20474                ),
20475            }),
20476        };
20477        project_response_without_child_environment_profile(&mut reply);
20478        assert_eq!(
20479            reply.result.unwrap_err().code,
20480            NodeFailureCode::UnsupportedCapability,
20481        );
20482    }
20483
20484    #[test]
20485    fn spawn_profile_environment_authority_is_exact_and_legacy_safe() {
20486        let (shared, _runtime, environment_profile) = environment_profile_test_shared();
20487        let snapshot = shared.snapshot();
20488        let profiles = snapshot
20489            .launch_inventory
20490            .as_ref()
20491            .and_then(|inventory| inventory.spawn_profiles.as_ref())
20492            .unwrap();
20493        assert_eq!(profiles.len(), 1);
20494        assert_eq!(profiles[0].environment_profile, Some(environment_profile));
20495        assert!(snapshot.requires_child_environment_profile_capability());
20496
20497        let mut legacy = snapshot.clone();
20498        clear_snapshot_child_environment_profiles(&mut legacy);
20499        assert!(legacy
20500            .launch_inventory
20501            .as_ref()
20502            .unwrap()
20503            .spawn_profiles
20504            .as_ref()
20505            .unwrap()[0]
20506            .environment_profile
20507            .is_none());
20508        assert!(!legacy.requires_child_environment_profile_capability());
20509
20510        let profile = shared.spawn_profiles.iter().next().unwrap().clone();
20511        let bindings = shared
20512            .environment_profiles
20513            .read()
20514            .unwrap_or_else(|poisoned| poisoned.into_inner())
20515            .clone();
20516        let mut provider_mismatch = bindings.clone();
20517        provider_mismatch
20518            .values_mut()
20519            .next()
20520            .unwrap()
20521            .provider = agent("codex");
20522        assert!(resolved_spawn_profile_summary(&profile, &provider_mismatch).is_none());
20523        let mut mode_mismatch = profile;
20524        mode_mismatch.mode = SessionMode::Inline;
20525        assert!(resolved_spawn_profile_summary(&mode_mismatch, &bindings).is_none());
20526
20527        shared
20528            .environment_profiles
20529            .write()
20530            .unwrap_or_else(|poisoned| poisoned.into_inner())
20531            .clear();
20532        assert!(shared
20533            .snapshot()
20534            .launch_inventory
20535            .unwrap()
20536            .spawn_profiles
20537            .unwrap()
20538            .is_empty());
20539    }
20540
20541    #[test]
20542    fn session_bundle_capability_is_state_aware_and_legacy_safe() {
20543        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20544            capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
20545        }));
20546        let shared = terminal_test_shared();
20547        let bundle = ResolvedBundleReceipt {
20548            id: crate::protocol::SpawnBundleId::new("review-bundle").unwrap(),
20549            revision: crate::protocol::SpawnBundleRevision::new("r1").unwrap(),
20550            digest: crate::protocol::SpawnBundleDigest::new(format!(
20551                "sha256:{}",
20552                "0".repeat(64),
20553            ))
20554            .unwrap(),
20555        };
20556        let mut bundled = record("claude", "bundle-record");
20557        bundled.bundle = Some(bundle.clone());
20558        let record_id = bundled.record_id.clone();
20559        shared.insert_record(bundled.clone()).unwrap();
20560        assert!(request_requires_session_bundle(
20561            &shared,
20562            &NodeRequest::ForgetSessionRecord {
20563                record_id: record_id.clone(),
20564            },
20565        ));
20566
20567        let snapshot = snapshot_for_wire(
20568            &shared, false, true, true, true, true, false, false, false, true,
20569        );
20570        assert!(snapshot.session_records[0].bundle.is_none());
20571        let launch = snapshot.launch_inventory.as_ref().unwrap();
20572        assert!(launch.spawn_profiles.is_some());
20573        assert!(launch.bundles.is_none());
20574        let event = project_event_without_session_bundle(NodeEventEnvelope {
20575            sequence: 1,
20576            event: NodeEvent::SessionRecordUpserted { record: bundled },
20577        });
20578        let NodeEvent::SessionRecordUpserted { record } = event.event else {
20579            panic!("record projection changed the event kind");
20580        };
20581        assert!(record.bundle.is_none());
20582
20583        let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
20584        let mut reply = ResponseEnvelope {
20585            request_id: 1,
20586            result: Ok(NodeResponse::SpawnSpecAccepted {
20587                receipt: resolved.receipt_with_materialization(
20588                    shared.incarnation_id,
20589                    terminal_address(17),
20590                    None,
20591                    Some(bundle),
20592                    None,
20593                ),
20594            }),
20595        };
20596        project_response_without_session_bundle(&mut reply);
20597        assert_eq!(
20598            reply.result.unwrap_err().code,
20599            NodeFailureCode::UnsupportedCapability,
20600        );
20601    }
20602
20603    #[test]
20604    fn history_context_pack_capability_redacts_loaded_messages_and_legacy_metadata() {
20605        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20606            capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
20607        }));
20608        let context_id = SpawnContextId::new("context-review").unwrap();
20609        let context = ResolvedContextPackReceipt {
20610            id: context_id.clone(),
20611            digest: crate::protocol::SpawnContextDigest::new(format!(
20612                "sha256:{}",
20613                "c".repeat(64),
20614            ))
20615            .unwrap(),
20616            lineage: ContextPackLineageReceipt {
20617                source_node_id: NodeId::new("node-source").unwrap(),
20618                source_session: terminal_address(1),
20619                source_provider: agent("kimi"),
20620            },
20621            source_message_count: 2,
20622            retained_message_count: 2,
20623            byte_len: 256,
20624            truncated: false,
20625        };
20626        assert!(context.is_valid());
20627        let mut bound_record = record("codex", "context-record");
20628        bound_record.context_id = Some(context_id);
20629        bound_record.context = Some(context.clone());
20630        let session = gate4agent_types::SessionSnapshot {
20631            instance_id: AgentInstanceId(91),
20632            agent_id: agent("kimi"),
20633            transport: TransportKind::Pty,
20634            generation: SessionGeneration(3),
20635            status: SessionStatus::Running,
20636            pending_operation: None,
20637            pending_input: None,
20638            process_id: Some(9001),
20639            terminal_size: None,
20640            terminal_frame: None,
20641            terminal_stale: None,
20642            session_options: None,
20643            capabilities: gate4agent_types::CapabilitySnapshot::default(),
20644            history: gate4agent_types::HistorySnapshot {
20645                pending: None,
20646                candidates: vec![HistoryCandidateSummary {
20647                    id: "candidate-opaque".to_owned(),
20648                    session_id_hint: "session-hint".to_owned(),
20649                    modified_at_unix_ms: Some(1),
20650                }],
20651                loaded_candidate_id: Some("candidate-opaque".to_owned()),
20652                loaded: Some(HistorySessionRecord {
20653                    session_id: "private-vendor-session".to_owned(),
20654                    title: Some("private title".to_owned()),
20655                    cwd: Some(r"C:\private\history-root".to_owned()),
20656                    model: Some("private-model".to_owned()),
20657                    message_count: 2,
20658                    completed_turn_count: None,
20659                    total_tokens: 19,
20660                    messages: vec![
20661                        gate4agent_types::HistoryMessageRecord {
20662                            role: gate4agent_types::HistoryMessageRole::User,
20663                            text: "F7_PRIVATE_HISTORY_MESSAGE".to_owned(),
20664                        },
20665                        gate4agent_types::HistoryMessageRecord {
20666                            role: gate4agent_types::HistoryMessageRole::Assistant,
20667                            text: "private answer".to_owned(),
20668                        },
20669                    ],
20670                }),
20671                last_error: None,
20672            },
20673            resume: gate4agent_types::ResumeSnapshot::default(),
20674            foreground: gate4agent_types::ForegroundSnapshot::default(),
20675            provider: gate4agent_types::ProviderSnapshot::default(),
20676            screen_state: gate4agent_types::PtyScreenState::default(),
20677        };
20678        let original = NodeSnapshot {
20679            node_id: NodeId::new("node-terminal-test").unwrap(),
20680            enabled_providers: vec![agent("kimi"), agent("codex")],
20681            provider_runtime_statuses: ProviderRuntimeStatuses::default(),
20682            workspaces: vec![WorkspaceSnapshot {
20683                workspace_id: WorkspaceId::new("primary").unwrap(),
20684                canonical_root: opaque_windows_path(
20685                    std::env::current_dir().unwrap().to_string_lossy().into_owned(),
20686                ),
20687                sessions: vec![session],
20688                worktree_service_mode: None,
20689                managed_worktree_profiles: None,
20690            }],
20691            session_records: vec![bound_record],
20692            managed_worktrees: Vec::new(),
20693            launch_inventory: None,
20694            agent_progress: Vec::new(),
20695        };
20696
20697        let mut negotiated = original.clone();
20698        project_snapshot_history_for_wire(&mut negotiated, true);
20699        assert_eq!(negotiated.workspaces[0].sessions[0].history.candidates.len(), 1);
20700        assert!(negotiated.workspaces[0].sessions[0].history.loaded.is_none());
20701        assert_eq!(negotiated.session_records[0].context.as_ref(), Some(&context));
20702        assert!(!serde_json::to_string(&negotiated)
20703            .unwrap()
20704            .contains("F7_PRIVATE_HISTORY_MESSAGE"));
20705
20706        let mut legacy = original;
20707        project_snapshot_history_for_wire(&mut legacy, false);
20708        clear_snapshot_context_packs(&mut legacy);
20709        assert_eq!(
20710            legacy.workspaces[0].sessions[0].history,
20711            gate4agent_types::HistorySnapshot::default(),
20712        );
20713        assert!(legacy.session_records[0].context_id.is_none());
20714        assert!(legacy.session_records[0].context.is_none());
20715        let encoded = serde_json::to_string(&legacy).unwrap();
20716        assert!(!encoded.contains("F7_PRIVATE_HISTORY_MESSAGE"));
20717        assert!(!encoded.contains("context-review"));
20718
20719        let mut reply = ResponseEnvelope {
20720            request_id: 7,
20721            result: Ok(NodeResponse::ContextPackExported { context }),
20722        };
20723        project_response_without_context_pack(&mut reply);
20724        assert_eq!(
20725            reply.result.unwrap_err().code,
20726            NodeFailureCode::UnsupportedCapability,
20727        );
20728    }
20729
20730    #[test]
20731    fn forgetting_unbound_context_pack_evicts_replay_but_retained_record_is_busy() {
20732        let shared = terminal_test_shared();
20733        let history = HistorySessionRecord {
20734            session_id: "fixture-history".to_owned(),
20735            title: None,
20736            cwd: None,
20737            model: None,
20738            message_count: 1,
20739            completed_turn_count: None,
20740            total_tokens: 0,
20741            messages: vec![gate4agent_types::HistoryMessageRecord {
20742                role: gate4agent_types::HistoryMessageRole::User,
20743                text: "bounded handoff".to_owned(),
20744            }],
20745        };
20746        let pack = NodeContextPack::export(
20747            ContextPackLineageReceipt {
20748                source_node_id: shared.node_id.clone(),
20749                source_session: terminal_address(1),
20750                source_provider: agent("claude"),
20751            },
20752            &history,
20753        )
20754        .unwrap();
20755        let context = shared
20756            .context_catalog
20757            .write()
20758            .unwrap_or_else(|poisoned| poisoned.into_inner())
20759            .insert(pack)
20760            .unwrap();
20761        let mut retained = record("codex", "context-retained-record");
20762        retained.context_id = Some(context.id.clone());
20763        retained.context = Some(context.clone());
20764        let record_id = retained.record_id.clone();
20765        shared.insert_record(retained).unwrap();
20766        assert_eq!(
20767            shared.forget_context_pack(&context.id).unwrap_err().code,
20768            NodeFailureCode::ContextPackBusy,
20769        );
20770        assert!(shared
20771            .context_catalog
20772            .read()
20773            .unwrap_or_else(|poisoned| poisoned.into_inner())
20774            .get(&context.id)
20775            .is_some());
20776        shared
20777            .session_records
20778            .lock()
20779            .unwrap_or_else(|poisoned| poisoned.into_inner())
20780            .records
20781            .remove(&record_id);
20782
20783        let mut spec = spawn_spec_fixture();
20784        spec.overrides.context_id = crate::protocol::SpawnOverride::Set {
20785            value: context.id.clone(),
20786        };
20787        let resolved = shared.resolve_spawn_spec(&spec).unwrap();
20788        let receipt = resolved.receipt_with_materialization(
20789            shared.incarnation_id,
20790            terminal_address(2),
20791            None,
20792            None,
20793            Some(context.clone()),
20794        );
20795        assert!(receipt.context_binding_is_valid());
20796        shared.remember_spawn_spec(spec.clone(), Ok(receipt), Instant::now());
20797
20798        shared.forget_context_pack(&context.id).unwrap();
20799        assert!(shared
20800            .context_catalog
20801            .read()
20802            .unwrap_or_else(|poisoned| poisoned.into_inner())
20803            .get(&context.id)
20804            .is_none());
20805        assert!(shared
20806            .replay_spawn_spec(&spec, Instant::now())
20807            .unwrap()
20808            .is_none());
20809    }
20810
20811    #[test]
20812    fn environment_profile_selection_is_owned_by_the_exact_session_binding() {
20813        let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
20814        let address = terminal_address(1);
20815        let selection = shared
20816            .select_environment_profile(
20817                address.session.instance_id,
20818                &agent("claude"),
20819                SessionMode::Pty,
20820                Some(&environment_profile),
20821            )
20822            .unwrap()
20823            .unwrap();
20824        shared.bind_session_with_policy(
20825            &address,
20826            ProviderRuntimePolicy::raw_pty(),
20827            Some(environment_profile.clone()),
20828        );
20829        selection.retain();
20830        let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
20831        assert!(matches!(
20832            runtime.remove_native_launch_profile(&native_id),
20833            Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
20834        ));
20835        assert!(shared.remove_binding(&address).is_some());
20836        assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
20837
20838        let mismatched = ResolvedEnvironmentProfileReceipt {
20839            profile_id: environment_profile.profile_id,
20840            profile_revision: crate::protocol::SpawnEnvironmentProfileRevision::new(
20841                "local-claude-r2",
20842            )
20843            .unwrap(),
20844            network_allowlist: None,
20845            browser_profile_id: None,
20846        };
20847        let error = match shared.select_environment_profile(
20848                AgentInstanceId(42),
20849                &agent("claude"),
20850                SessionMode::Pty,
20851                Some(&mismatched),
20852            ) {
20853            Ok(_) => panic!("mismatched environment profile revision was selected"),
20854            Err(error) => error,
20855        };
20856        assert_eq!(
20857            error.code,
20858            NodeFailureCode::EnvironmentProfileBindingMismatch,
20859        );
20860        assert!(!shared
20861            .session_bindings
20862            .lock()
20863            .unwrap_or_else(|poisoned| poisoned.into_inner())
20864            .contains_key(&AgentInstanceId(42)));
20865    }
20866
20867    #[test]
20868    fn combined_environment_and_bundle_overlay_clears_with_its_binding() {
20869        let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
20870        let address = terminal_address(1);
20871        let selection = shared
20872            .select_environment_profile(
20873                address.session.instance_id,
20874                &agent("claude"),
20875                SessionMode::Pty,
20876                Some(&environment_profile),
20877            )
20878            .unwrap()
20879            .unwrap();
20880        let overlay = NativeInstanceLaunchOverlay::new(
20881            agent("claude"),
20882            TransportKind::Pty,
20883            vec![EnvMutation {
20884                key: OsString::from("GATE4AGENT_SESSION_TOKEN"),
20885                value: Some(OsString::from("fixture-secret")),
20886            }],
20887            vec![
20888                OsString::from("--plugin-dir"),
20889                std::env::current_dir().unwrap().into_os_string(),
20890            ],
20891        )
20892        .unwrap();
20893        let instance_overlay = shared
20894            .install_prepared_launch_overlay(
20895                address.session.instance_id,
20896                PreparedNativeLaunchOverlay::Instance(overlay),
20897            )
20898            .unwrap()
20899            .unwrap();
20900        let bundle = ResolvedBundleReceipt {
20901            id: crate::protocol::SpawnBundleId::new("combined-bundle").unwrap(),
20902            revision: crate::protocol::SpawnBundleRevision::new("combined-r1").unwrap(),
20903            digest: crate::protocol::SpawnBundleDigest::new(format!(
20904                "sha256:{}",
20905                "0".repeat(64),
20906            ))
20907            .unwrap(),
20908        };
20909        shared.bind_session_with_materialization(
20910            &address,
20911            ProviderRuntimePolicy::raw_pty(),
20912            Some(environment_profile),
20913            Some(bundle),
20914            None,
20915            None,
20916        );
20917        selection.retain();
20918        instance_overlay.retain();
20919
20920        let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
20921        assert!(matches!(
20922            runtime.remove_native_launch_profile(&native_id),
20923            Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
20924        ));
20925        assert!(shared.remove_binding(&address).is_some());
20926        assert!(!shared
20927            .native_launch_profile_control
20928            .as_ref()
20929            .unwrap()
20930            .clear_native_instance_launch_overlay(address.session.instance_id));
20931        assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
20932    }
20933
20934    #[test]
20935    fn session_environment_materialization_failure_is_pre_child_and_durable() {
20936        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
20937        let address = SessionAddress {
20938            workspace_id: WorkspaceId::new("primary").unwrap(),
20939            session: SessionKey {
20940                instance_id: AgentInstanceId(201),
20941                generation: SessionGeneration(1),
20942            },
20943        };
20944        let error = match shared.prepare_session_materialization(
20945            &address,
20946            &agent("claude"),
20947            SessionMode::Pty,
20948            Some(&receipt),
20949            None,
20950            None,
20951            None,
20952        ) {
20953            Ok(_) => panic!("denied local reference unexpectedly materialized"),
20954            Err(error) => error,
20955        };
20956        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
20957        assert!(shared.handle.snapshot().sessions.is_empty());
20958        assert!(shared
20959            .session_bindings
20960            .lock()
20961            .unwrap_or_else(|poisoned| poisoned.into_inner())
20962            .is_empty());
20963        assert!(shared
20964            .materializations
20965            .lock()
20966            .unwrap_or_else(|poisoned| poisoned.into_inner())
20967            .is_empty());
20968        drop(shared);
20969        drop(runtime);
20970        std::fs::remove_dir_all(root).unwrap();
20971    }
20972
20973    #[test]
20974    fn session_environment_binding_retains_on_stop_and_cleans_after_remove_reap() {
20975        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20976        let address = SessionAddress {
20977            workspace_id: WorkspaceId::new("primary").unwrap(),
20978            session: SessionKey {
20979                instance_id: AgentInstanceId(202),
20980                generation: SessionGeneration(1),
20981            },
20982        };
20983        let (overlay, materialization_guard) = shared
20984            .prepare_session_materialization(
20985                &address,
20986                &agent("claude"),
20987                SessionMode::Pty,
20988                Some(&receipt),
20989                None,
20990                None,
20991                None,
20992            )
20993            .unwrap()
20994            .unwrap();
20995        let materialization_id = materialization_guard.id().unwrap().clone();
20996        let materialization_root = shared
20997            .materializations
20998            .lock()
20999            .unwrap_or_else(|poisoned| poisoned.into_inner())
21000            .get(&materialization_id)
21001            .unwrap()
21002            .root()
21003            .to_path_buf();
21004        let selection = shared
21005            .select_environment_profile(
21006                address.session.instance_id,
21007                &agent("claude"),
21008                SessionMode::Pty,
21009                Some(&receipt),
21010            )
21011            .unwrap()
21012            .unwrap();
21013        assert!(shared
21014            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21015            .unwrap()
21016            .is_none());
21017        shared.bind_session_with_materialization(
21018            &address,
21019            ProviderRuntimePolicy::raw_pty(),
21020            Some(receipt),
21021            None,
21022            None,
21023            Some(materialization_id),
21024        );
21025        selection.retain();
21026        materialization_guard.retain();
21027
21028        assert!(materialization_root.is_dir());
21029        assert!(shared
21030            .session_bindings
21031            .lock()
21032            .unwrap_or_else(|poisoned| poisoned.into_inner())
21033            .contains_key(&address.session.instance_id));
21034
21035        let removed = shared.remove_binding(&address).unwrap();
21036        shared
21037            .cleanup_session_owned_materialization(&address, &removed)
21038            .unwrap();
21039        assert!(!materialization_root.exists());
21040        assert!(shared
21041            .materializations
21042            .lock()
21043            .unwrap_or_else(|poisoned| poisoned.into_inner())
21044            .is_empty());
21045        drop(shared);
21046        drop(runtime);
21047        std::fs::remove_dir_all(root).unwrap();
21048    }
21049
21050    #[tokio::test]
21051    async fn record_owned_session_environment_reuses_on_resume_and_forget_cleans() {
21052        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21053        let address = SessionAddress {
21054            workspace_id: WorkspaceId::new("primary").unwrap(),
21055            session: SessionKey {
21056                instance_id: AgentInstanceId(203),
21057                generation: SessionGeneration(1),
21058            },
21059        };
21060        let (overlay, materialization_guard) = shared
21061            .prepare_session_materialization(
21062                &address,
21063                &agent("claude"),
21064                SessionMode::Pty,
21065                Some(&receipt),
21066                None,
21067                None,
21068                None,
21069            )
21070            .unwrap()
21071            .unwrap();
21072        let materialization_id = materialization_guard.id().unwrap().clone();
21073        let materialization_root = shared
21074            .materializations
21075            .lock()
21076            .unwrap_or_else(|poisoned| poisoned.into_inner())
21077            .get(&materialization_id)
21078            .unwrap()
21079            .root()
21080            .to_path_buf();
21081        let selection = shared
21082            .select_environment_profile(
21083                address.session.instance_id,
21084                &agent("claude"),
21085                SessionMode::Pty,
21086                Some(&receipt),
21087            )
21088            .unwrap()
21089            .unwrap();
21090        assert!(shared
21091            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21092            .unwrap()
21093            .is_none());
21094        let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
21095        let record_id = shared
21096            .bind_spawn_session_with_materialization(
21097                &address,
21098                agent("claude"),
21099                SessionMode::Pty,
21100                policy,
21101                SpawnRecordPolicy::ProviderIdentityOnly,
21102                Some(receipt.clone()),
21103                None,
21104                None,
21105                Some(materialization_id.clone()),
21106            )
21107            .unwrap()
21108            .unwrap();
21109        selection.retain();
21110        materialization_guard.retain();
21111        assert_eq!(
21112            shared
21113                .materializations
21114                .lock()
21115                .unwrap_or_else(|poisoned| poisoned.into_inner())
21116                .get(&materialization_id)
21117                .unwrap()
21118                .owner(),
21119            &MaterializationOwner::Record {
21120                record_id: record_id.clone(),
21121            },
21122        );
21123
21124        let removed = shared.remove_binding(&address).unwrap();
21125        shared
21126            .cleanup_session_owned_materialization(&address, &removed)
21127            .unwrap();
21128        assert!(materialization_root.is_dir());
21129        assert!(shared
21130            .resolve_record_materialization(
21131                &record_id,
21132                &agent("claude"),
21133                SessionMode::Pty,
21134                Some(&receipt),
21135                None,
21136                None,
21137            )
21138            .unwrap()
21139            .is_some());
21140        {
21141            let mut records = shared
21142                .session_records
21143                .lock()
21144                .unwrap_or_else(|poisoned| poisoned.into_inner());
21145            let record = records.records.get_mut(&record_id).unwrap();
21146            record.active_session = None;
21147            record.state = ManagedSessionState::Unavailable;
21148        }
21149        shared.forget_session_record(&record_id).await.unwrap();
21150        assert!(!materialization_root.exists());
21151        assert!(shared
21152            .materializations
21153            .lock()
21154            .unwrap_or_else(|poisoned| poisoned.into_inner())
21155            .is_empty());
21156        drop(shared);
21157        drop(runtime);
21158        std::fs::remove_dir_all(root).unwrap();
21159    }
21160
21161    #[test]
21162    fn record_materialization_revalidation_failure_persists_recovery_required() {
21163        let (mut shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21164        let state_path = root.join("node-state-v6.json");
21165        shared.state_path = Some(state_path.clone());
21166        let (_address, _record_id, materialization_id, materialization_root) =
21167            bind_record_owned_materialization_fixture(&shared, &receipt, 205);
21168        std::fs::write(
21169            materialization_root.join(".gate4agent-materialization-owner"),
21170            b"tampered",
21171        )
21172        .unwrap();
21173
21174        let error = match shared.resolve_record_materialization(
21175            &_record_id,
21176            &agent("claude"),
21177            SessionMode::Pty,
21178            Some(&receipt),
21179            None,
21180            None,
21181        ) {
21182            Ok(_) => panic!("tampered owner marker unexpectedly revalidated"),
21183            Err(error) => error,
21184        };
21185        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21186        assert_eq!(
21187            shared
21188                .materializations
21189                .lock()
21190                .unwrap_or_else(|poisoned| poisoned.into_inner())
21191                .get(&materialization_id)
21192                .unwrap()
21193                .state(),
21194            MaterializationState::RecoveryRequired,
21195        );
21196        let loaded = session_registry::load(Some(&state_path), &shared.node_id).unwrap();
21197        assert_eq!(loaded.materializations.len(), 1);
21198        assert_eq!(
21199            loaded.materializations[0].state(),
21200            MaterializationState::RecoveryRequired,
21201        );
21202        drop(shared);
21203        drop(runtime);
21204        std::fs::remove_dir_all(root).unwrap();
21205    }
21206
21207    #[test]
21208    fn transient_record_materialization_resolution_failure_remains_ready() {
21209        let (shared, runtime, receipt, root, deny) = materialization_test_shared(false);
21210        let (_address, record_id, materialization_id, _materialization_root) =
21211            bind_record_owned_materialization_fixture(&shared, &receipt, 206);
21212        deny.store(true, Ordering::Release);
21213
21214        let error = match shared.resolve_record_materialization(
21215            &record_id,
21216            &agent("claude"),
21217            SessionMode::Pty,
21218            Some(&receipt),
21219            None,
21220            None,
21221        ) {
21222            Ok(_) => panic!("denied transient resolver unexpectedly succeeded"),
21223            Err(error) => error,
21224        };
21225        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21226        assert_eq!(
21227            shared
21228                .materializations
21229                .lock()
21230                .unwrap_or_else(|poisoned| poisoned.into_inner())
21231                .get(&materialization_id)
21232                .unwrap()
21233                .state(),
21234            MaterializationState::Ready,
21235        );
21236        drop(shared);
21237        drop(runtime);
21238        std::fs::remove_dir_all(root).unwrap();
21239    }
21240
21241    #[test]
21242    fn startup_missing_record_materialization_is_not_resumable() {
21243        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21244        let record_id = SessionRecordId::new("sr-missing-materialization").unwrap();
21245        shared
21246            .insert_record(ManagedSessionRecord {
21247                record_id: record_id.clone(),
21248                display_name: "missing materialization".to_owned(),
21249                provider: agent("claude"),
21250                mode: SessionMode::Pty,
21251                state: ManagedSessionState::Dormant,
21252                workspace_id: WorkspaceId::new("primary").unwrap(),
21253                canonical_root: opaque_windows_path(
21254                    std::env::current_dir().unwrap().to_string_lossy().into_owned(),
21255                ),
21256                provider_session: Some(gate4agent_types::ProviderSessionIdentity {
21257                    key: gate4agent_types::ProviderSessionKey::SessionId,
21258                    id: "provider-session-missing-materialization".to_owned(),
21259                    transcript_path: None,
21260                }),
21261                active_session: None,
21262                environment_profile: Some(receipt),
21263                bundle: None,
21264                context_id: None,
21265                context: None,
21266                exported_context: None,
21267                task_binding: None,
21268                created_at_unix_ms: 1,
21269                updated_at_unix_ms: 1,
21270                last_error: None,
21271            })
21272            .unwrap();
21273
21274        shared.reconcile_materializations();
21275        let record = shared.record(&record_id).unwrap();
21276        assert_eq!(record.state, ManagedSessionState::Unavailable);
21277        assert!(record.provider_session.is_none());
21278        assert_eq!(
21279            record.last_error.as_deref(),
21280            Some("environment-profile-unavailable"),
21281        );
21282        drop(shared);
21283        drop(runtime);
21284        std::fs::remove_dir_all(root).unwrap();
21285    }
21286
21287    #[tokio::test]
21288    async fn managed_worktree_cleanup_waits_for_session_environment_cleanup() {
21289        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21290        let lease = install_managed_lease(&shared);
21291        let address = SessionAddress {
21292            workspace_id: WorkspaceId::new("primary").unwrap(),
21293            session: SessionKey {
21294                instance_id: AgentInstanceId(204),
21295                generation: SessionGeneration(1),
21296            },
21297        };
21298        let (overlay, materialization_guard) = shared
21299            .prepare_session_materialization(
21300                &address,
21301                &agent("claude"),
21302                SessionMode::Pty,
21303                Some(&receipt),
21304                None,
21305                None,
21306                Some(lease.lease_id.clone()),
21307            )
21308            .unwrap()
21309            .unwrap();
21310        let materialization_id = materialization_guard.id().unwrap().clone();
21311        let materialization_root = shared
21312            .materializations
21313            .lock()
21314            .unwrap_or_else(|poisoned| poisoned.into_inner())
21315            .get(&materialization_id)
21316            .unwrap()
21317            .root()
21318            .to_path_buf();
21319        let selection = shared
21320            .select_environment_profile(
21321                address.session.instance_id,
21322                &agent("claude"),
21323                SessionMode::Pty,
21324                Some(&receipt),
21325            )
21326            .unwrap()
21327            .unwrap();
21328        assert!(shared
21329            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21330            .unwrap()
21331            .is_none());
21332        shared.bind_session_with_materialization(
21333            &address,
21334            ProviderRuntimePolicy::raw_pty(),
21335            Some(receipt),
21336            None,
21337            None,
21338            Some(materialization_id),
21339        );
21340        selection.retain();
21341        materialization_guard.retain();
21342
21343        let blocked = shared
21344            .cleanup_managed_worktree(&lease.lease_id, false)
21345            .await
21346            .unwrap_err();
21347        assert_eq!(blocked.code, NodeFailureCode::ManagedWorktreeRecoveryRequired);
21348        assert!(materialization_root.is_dir());
21349        let removed = shared.remove_binding(&address).unwrap();
21350        shared
21351            .cleanup_session_owned_materialization(&address, &removed)
21352            .unwrap();
21353        assert!(!materialization_root.exists());
21354        drop(shared);
21355        drop(runtime);
21356        std::fs::remove_dir_all(root).unwrap();
21357    }
21358
21359    #[tokio::test]
21360    async fn raw_managed_record_is_inventory_only_and_does_not_block_session_cleanup() {
21361        let git_root = temporary_workspace_root("raw-managed-inventory");
21362        let source = git_root.join("source");
21363        let allocation = git_root.join("allocation");
21364        std::fs::create_dir_all(&source).unwrap();
21365        std::fs::create_dir_all(&allocation).unwrap();
21366        run_test_git(&source, &["init"]);
21367        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
21368        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
21369        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
21370        run_test_git(&source, &["add", "seed.txt"]);
21371        run_test_git(&source, &["commit", "-m", "seed"]);
21372        let profile = ManagedWorktreeProfile::new(
21373            WorktreeProfileId::new("default").unwrap(),
21374            crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
21375            &allocation,
21376            "gate4agent",
21377            "HEAD",
21378            ManagedWorktreeRetention::RemoveWhenReleased,
21379        )
21380        .unwrap();
21381        let workspace = WorkspaceConfig::new(
21382            WorkspaceId::new("primary").unwrap(),
21383            &source,
21384        )
21385        .unwrap()
21386        .with_worktree_service_mode(WorktreeServiceMode::Managed)
21387        .with_managed_worktree_profile(profile.clone())
21388        .unwrap();
21389        let (shared, mut runtime, receipt, materialization_fixture_root, _deny) =
21390            materialization_test_shared_with_workspace(false, workspace);
21391        let target = PathBuf::from(profile.allocation_root()).join("mw-raw-inventory");
21392        let source_text = source.to_string_lossy().into_owned();
21393        let target_text = target.to_string_lossy().into_owned();
21394        let created = create_git_worktree(
21395            &source_text,
21396            &target_text,
21397            "gate4agent/mw-raw-inventory",
21398            Some("HEAD"),
21399        )
21400        .await
21401        .unwrap();
21402        let lease = ManagedWorktreeLeaseRecord {
21403            lease_id: ManagedWorktreeLeaseId::new("mw-raw-inventory").unwrap(),
21404            source_workspace_id: WorkspaceId::new("primary").unwrap(),
21405            workspace_id: WorkspaceId::new("managed-raw-inventory").unwrap(),
21406            profile_id: profile.profile_id().clone(),
21407            profile_revision: profile.revision().clone(),
21408            target_root: created.path.clone(),
21409            branch: "gate4agent/mw-raw-inventory".to_owned(),
21410            base_commit: created.head.clone(),
21411            expected_head: Some(created.head.clone()),
21412            retention: ManagedWorktreeRetention::RemoveWhenReleased,
21413            state: ManagedWorktreeLeaseState::Ready,
21414            session_holders: Vec::new(),
21415            record_holders: Vec::new(),
21416            cleanup_failure: None,
21417            created_at_unix_ms: 1,
21418            updated_at_unix_ms: 1,
21419        };
21420        shared
21421            .workspaces
21422            .write()
21423            .unwrap_or_else(|poisoned| poisoned.into_inner())
21424            .insert(lease.workspace_id.clone(), created.path.clone());
21425        *shared
21426            .managed_worktrees
21427            .lock()
21428            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21429            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
21430        let address = SessionAddress {
21431            workspace_id: lease.workspace_id.clone(),
21432            session: SessionKey {
21433                instance_id: AgentInstanceId(205),
21434                generation: SessionGeneration(1),
21435            },
21436        };
21437        let (overlay, materialization_guard) = shared
21438            .prepare_session_materialization(
21439                &address,
21440                &agent("claude"),
21441                SessionMode::Pty,
21442                Some(&receipt),
21443                None,
21444                None,
21445                Some(lease.lease_id.clone()),
21446            )
21447            .unwrap()
21448            .unwrap();
21449        let materialization_id = materialization_guard.id().unwrap().clone();
21450        let materialization_root = shared
21451            .materializations
21452            .lock()
21453            .unwrap_or_else(|poisoned| poisoned.into_inner())
21454            .get(&materialization_id)
21455            .unwrap()
21456            .root()
21457            .to_path_buf();
21458        let selection = shared
21459            .select_environment_profile(
21460                address.session.instance_id,
21461                &agent("claude"),
21462                SessionMode::Pty,
21463                Some(&receipt),
21464            )
21465            .unwrap()
21466            .unwrap();
21467        assert!(shared
21468            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21469            .unwrap()
21470            .is_none());
21471        let record_id = shared
21472            .bind_spawn_session_with_materialization(
21473                &address,
21474                agent("claude"),
21475                SessionMode::Pty,
21476                ProviderRuntimePolicy::raw_pty(),
21477                SpawnRecordPolicy::Always,
21478                Some(receipt.clone()),
21479                None,
21480                None,
21481                Some(materialization_id.clone()),
21482            )
21483            .unwrap()
21484            .unwrap();
21485        selection.retain();
21486        materialization_guard.retain();
21487        let record = shared.record(&record_id).unwrap();
21488        assert_eq!(record.state, ManagedSessionState::Live);
21489        assert!(record.provider_session.is_none());
21490        assert!(record.environment_profile.is_none());
21491        assert!(record.bundle.is_none());
21492        assert!(record.context_id.is_none());
21493        assert!(record.context.is_none());
21494        assert_eq!(record.active_session.as_ref(), Some(&address));
21495        assert_eq!(
21496            shared
21497                .materializations
21498                .lock()
21499                .unwrap_or_else(|poisoned| poisoned.into_inner())
21500                .get(&materialization_id)
21501                .unwrap()
21502                .owner(),
21503            &MaterializationOwner::Session {
21504                incarnation_id: shared.incarnation_id,
21505                instance_id: address.session.instance_id,
21506                generation: address.session.generation,
21507            },
21508        );
21509        {
21510            let mut bindings = shared
21511                .session_bindings
21512                .lock()
21513                .unwrap_or_else(|poisoned| poisoned.into_inner());
21514            bindings
21515                .get_mut(&address.session.instance_id)
21516                .unwrap()
21517                .managed_worktree_lease_id = Some(lease.lease_id.clone());
21518        }
21519        let lease_snapshot = shared
21520            .managed_worktrees
21521            .lock()
21522            .unwrap_or_else(|poisoned| poisoned.into_inner())
21523            .bind_session(
21524                &lease.lease_id,
21525                ManagedWorktreeSessionHolder {
21526                    incarnation_id: shared.incarnation_id,
21527                    instance_id: address.session.instance_id,
21528                    generation: address.session.generation,
21529                },
21530                None,
21531                unix_time_ms(),
21532            )
21533            .unwrap();
21534        assert_eq!(lease_snapshot.active_session_count, 1);
21535        assert_eq!(lease_snapshot.managed_record_count, 0);
21536        shared.persist_state().unwrap();
21537        assert!(shared
21538            .managed_worktrees
21539            .lock()
21540            .unwrap_or_else(|poisoned| poisoned.into_inner())
21541            .get(&lease.lease_id)
21542            .unwrap()
21543            .record_holders
21544            .is_empty());
21545        shared
21546            .handle
21547            .dispatch(shared.prepare_command(ControlCommand::Register {
21548                instance_id: address.session.instance_id,
21549                agent_id: agent("claude"),
21550                transport: TransportKind::Pty,
21551            }))
21552            .unwrap();
21553        runtime.tick().await;
21554        shared.reconcile_managed_record(
21555            &record_id,
21556            &address,
21557            &ControlEvent {
21558                sequence: 1,
21559                command_id: None,
21560                instance_id: address.session.instance_id,
21561                generation: address.session.generation,
21562                event: ControlEventKind::Removed,
21563            },
21564            false,
21565            false,
21566        );
21567        let remove = shared.remove_session(&address);
21568        let drive_runtime = async {
21569            runtime.tick().await;
21570        };
21571        let (removed, ()) = tokio::join!(remove, drive_runtime);
21572        removed.unwrap();
21573
21574        assert!(!materialization_root.exists());
21575        assert!(!Path::new(&created.path).exists());
21576        assert_eq!(
21577            shared
21578                .managed_worktrees
21579                .lock()
21580                .unwrap_or_else(|poisoned| poisoned.into_inner())
21581                .get(&lease.lease_id)
21582                .unwrap()
21583                .state,
21584            ManagedWorktreeLeaseState::Removed,
21585        );
21586        let renamed = shared
21587            .rename_session_record(&record_id, "raw managed inventory".to_owned())
21588            .unwrap();
21589        assert_eq!(renamed.record_id, record_id);
21590        assert_eq!(renamed.display_name, "raw managed inventory");
21591        assert_eq!(renamed.state, ManagedSessionState::Unavailable);
21592        assert!(renamed.active_session.is_none());
21593
21594        drop(shared);
21595        drop(runtime);
21596        std::fs::remove_dir_all(materialization_fixture_root).unwrap();
21597        std::fs::remove_dir_all(git_root).unwrap();
21598    }
21599
21600    fn install_managed_lease(shared: &NodeShared) -> ManagedWorktreeLeaseRecord {
21601        let root = std::env::current_dir().unwrap()
21602            .join("managed-test-target")
21603            .to_string_lossy()
21604            .into_owned();
21605        let lease = ManagedWorktreeLeaseRecord {
21606            lease_id: ManagedWorktreeLeaseId::new("mw-test").unwrap(),
21607            source_workspace_id: WorkspaceId::new("primary").unwrap(),
21608            workspace_id: WorkspaceId::new("managed-test").unwrap(),
21609            profile_id: WorktreeProfileId::new("default").unwrap(),
21610            profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
21611            target_root: root.clone(),
21612            branch: "gate4agent/mw-test".to_owned(),
21613            base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
21614            expected_head: Some("0123456789abcdef0123456789abcdef01234567".to_owned()),
21615            retention: ManagedWorktreeRetention::RemoveWhenReleased,
21616            state: ManagedWorktreeLeaseState::Ready,
21617            session_holders: Vec::new(),
21618            record_holders: Vec::new(),
21619            cleanup_failure: None,
21620            created_at_unix_ms: 1,
21621            updated_at_unix_ms: 1,
21622        };
21623        shared.workspaces.write()
21624            .unwrap_or_else(|poisoned| poisoned.into_inner())
21625            .insert(lease.workspace_id.clone(), root);
21626        *shared.managed_worktrees.lock()
21627            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21628            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
21629        lease
21630    }
21631
21632    #[cfg(feature = "fixture")]
21633    #[test]
21634    fn monitoring_fixture_managed_spawn_retains_effective_record_holder_and_converges_live() {
21635        let mut shared = terminal_test_shared();
21636        shared.fixture_semantic_hook_policy = true;
21637        let lease = install_managed_lease(&shared);
21638        let address = SessionAddress {
21639            workspace_id: lease.workspace_id.clone(),
21640            session: SessionKey {
21641                instance_id: AgentInstanceId(206),
21642                generation: SessionGeneration(1),
21643            },
21644        };
21645        let pre_admission_policy = ProviderRuntimePolicy::raw_pty();
21646        let effective_runtime_policy =
21647            shared.effective_spawn_runtime_policy(pre_admission_policy);
21648        assert_ne!(effective_runtime_policy, pre_admission_policy);
21649        assert!(effective_runtime_policy.provider_session_identity);
21650
21651        let record_id = shared
21652            .bind_spawn_session(
21653                &address,
21654                agent("claude"),
21655                SessionMode::Pty,
21656                effective_runtime_policy,
21657                SpawnRecordPolicy::Always,
21658                None,
21659            )
21660            .unwrap()
21661            .unwrap();
21662        assert_eq!(
21663            shared.record(&record_id).unwrap().state,
21664            ManagedSessionState::IdentityPending,
21665        );
21666        let (record_holder, raw_inventory_record_id) = {
21667            let mut bindings = shared
21668                .session_bindings
21669                .lock()
21670                .unwrap_or_else(|poisoned| poisoned.into_inner());
21671            let binding = bindings.get_mut(&address.session.instance_id).unwrap();
21672            binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
21673            managed_spawn_record_ownership(binding, effective_runtime_policy)
21674        };
21675        assert_eq!(record_holder.as_ref(), Some(&record_id));
21676        assert!(raw_inventory_record_id.is_none());
21677        let snapshot = shared
21678            .managed_worktrees
21679            .lock()
21680            .unwrap_or_else(|poisoned| poisoned.into_inner())
21681            .bind_session(
21682                &lease.lease_id,
21683                ManagedWorktreeSessionHolder {
21684                    incarnation_id: shared.incarnation_id,
21685                    instance_id: address.session.instance_id,
21686                    generation: address.session.generation,
21687                },
21688                record_holder,
21689                unix_time_ms(),
21690            )
21691            .unwrap();
21692        assert_eq!(snapshot.managed_record_count, 1);
21693        assert_eq!(
21694            shared
21695                .managed_worktrees
21696                .lock()
21697                .unwrap_or_else(|poisoned| poisoned.into_inner())
21698                .get(&lease.lease_id)
21699                .unwrap()
21700                .record_holders,
21701            vec![record_id.clone()],
21702        );
21703
21704        let identity = ProviderSessionIdentity {
21705            key: ProviderSessionKey::SessionId,
21706            id: "monitoring-fixture-managed-session".to_owned(),
21707            transcript_path: None,
21708        };
21709        shared.publish_control(ControlEvent {
21710            sequence: 1,
21711            command_id: None,
21712            instance_id: address.session.instance_id,
21713            generation: address.session.generation,
21714            event: ControlEventKind::ProviderEvent {
21715                sequence: 1,
21716                source: gate4agent_types::ProviderSource {
21717                    family: AdapterFamily::Hook,
21718                    binding: AdapterBinding::new(
21719                        gate4agent_types::AdapterId::new("claude-code").unwrap(),
21720                        "fixture/v1",
21721                        gate4agent_types::AdapterVerification::SyntheticFixture,
21722                    )
21723                    .unwrap(),
21724                },
21725                source_sequence: 1,
21726                event: ProviderEvent::SessionIdentityObserved {
21727                    identity: identity.clone(),
21728                },
21729            },
21730        });
21731
21732        let live = shared.record(&record_id).unwrap();
21733        assert_eq!(live.state, ManagedSessionState::Live);
21734        assert_eq!(live.provider_session.as_ref(), Some(&identity));
21735        assert_eq!(live.active_session.as_ref(), Some(&address));
21736        assert_eq!(
21737            shared
21738                .managed_worktrees
21739                .lock()
21740                .unwrap_or_else(|poisoned| poisoned.into_inner())
21741                .get(&lease.lease_id)
21742                .unwrap()
21743                .record_holders,
21744            vec![record_id],
21745        );
21746    }
21747
21748    fn managed_v2_test_shared(
21749        allocation_root: &Path,
21750        state_path: Option<PathBuf>,
21751        profile_revision: &str,
21752    ) -> NodeShared {
21753        let profile = ManagedWorktreeProfile::new(
21754            WorktreeProfileId::new("default").unwrap(),
21755            WorktreeProfileRevision::new(profile_revision).unwrap(),
21756            allocation_root,
21757            "gate4agent",
21758            "HEAD",
21759            ManagedWorktreeRetention::RemoveWhenReleased,
21760        )
21761        .unwrap();
21762        let workspace = WorkspaceConfig::new(
21763            WorkspaceId::new("primary").unwrap(),
21764            std::env::current_dir().unwrap(),
21765        )
21766        .unwrap()
21767        .with_worktree_service_mode(WorktreeServiceMode::Managed)
21768        .with_managed_worktree_profile(profile)
21769        .unwrap();
21770        let catalog = active_registry().unwrap();
21771        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
21772        let mut shared = NodeShared::new(
21773            handle,
21774            "fixture-token".to_owned(),
21775            NodeId::new("node-terminal-test").unwrap(),
21776            vec![workspace],
21777            vec![agent("claude")],
21778        );
21779        shared.state_path = state_path;
21780        shared
21781    }
21782
21783    fn managed_v2_request(idempotency_key: &str) -> ManagedWorktreeSpawnRequestV2 {
21784        let mut spawn_spec = spawn_spec_fixture();
21785        spawn_spec.idempotency_key = SpawnIdempotencyKey::new(idempotency_key).unwrap();
21786        ManagedWorktreeSpawnRequestV2 {
21787            spawn_spec,
21788            worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
21789            expected_profile_revision: WorktreeProfileRevision::new("v1").unwrap(),
21790        }
21791    }
21792
21793    #[test]
21794    fn managed_spawn_receipt_rebinds_allocated_worktree_and_roundtrips_wire() {
21795        let shared = terminal_test_shared();
21796        let mut lease = install_managed_lease(&shared);
21797        let session = SessionAddress {
21798            workspace_id: lease.workspace_id.clone(),
21799            session: SessionKey {
21800                instance_id: AgentInstanceId(73),
21801                generation: SessionGeneration(4),
21802            },
21803        };
21804        lease.state = ManagedWorktreeLeaseState::InUse;
21805        lease.session_holders.push(ManagedWorktreeSessionHolder {
21806            incarnation_id: shared.incarnation_id,
21807            instance_id: session.session.instance_id,
21808            generation: session.session.generation,
21809        });
21810        let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
21811        assert!(resolved.target.worktree_id.is_none());
21812
21813        let receipt = managed_spawn_receipt(
21814            &resolved,
21815            shared.incarnation_id,
21816            session.clone(),
21817            lease.snapshot(),
21818            None,
21819            None,
21820            None,
21821        );
21822        assert!(resolved.target.worktree_id.is_none());
21823        assert_eq!(receipt.spawn.target.workspace_id, lease.source_workspace_id);
21824        assert_eq!(receipt.spawn.target.worktree_id.as_ref(), Some(&lease.workspace_id));
21825        assert_eq!(receipt.spawn.session, session);
21826        assert_eq!(receipt.lease.workspace_id, lease.workspace_id);
21827        let encoded = serde_json::to_vec(&receipt).unwrap();
21828        let decoded: ManagedWorktreeSpawnReceipt = serde_json::from_slice(&encoded).unwrap();
21829        assert_eq!(decoded, receipt);
21830    }
21831
21832    #[tokio::test]
21833    async fn managed_spawn_v2_rejects_profile_revision_before_allocation() {
21834        let root = temporary_workspace_root("managed-v2-revision");
21835        let allocation = root.join("allocation");
21836        std::fs::create_dir_all(&allocation).unwrap();
21837        let shared = managed_v2_test_shared(
21838            &allocation,
21839            Some(root.join("node-state.json")),
21840            "v1",
21841        );
21842        let mut request = managed_v2_request("managed-v2-revision");
21843        request.expected_profile_revision = WorktreeProfileRevision::new("v2").unwrap();
21844
21845        let failure = shared.spawn_managed_worktree_v2(request).await.unwrap_err();
21846
21847        assert_eq!(
21848            failure.code,
21849            NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
21850        );
21851        assert!(shared
21852            .managed_worktrees
21853            .lock()
21854            .unwrap_or_else(|poisoned| poisoned.into_inner())
21855            .records()
21856            .is_empty());
21857        assert!(shared
21858            .managed_spawn_replays
21859            .lock()
21860            .unwrap_or_else(|poisoned| poisoned.into_inner())
21861            .is_empty());
21862        std::fs::remove_dir_all(root).unwrap();
21863    }
21864
21865    #[test]
21866    fn managed_spawn_v2_concurrent_same_key_has_one_durable_reservation() {
21867        let root = temporary_workspace_root("managed-v2-concurrent");
21868        let allocation = root.join("allocation");
21869        std::fs::create_dir_all(&allocation).unwrap();
21870        let shared = Arc::new(managed_v2_test_shared(
21871            &allocation,
21872            Some(root.join("node-state.json")),
21873            "v1",
21874        ));
21875        let request = managed_v2_request("managed-v2-concurrent");
21876        let barrier = Arc::new(std::sync::Barrier::new(3));
21877        let mut joins = Vec::new();
21878        for _ in 0..2 {
21879            let shared = Arc::clone(&shared);
21880            let request = request.clone();
21881            let barrier = Arc::clone(&barrier);
21882            joins.push(std::thread::spawn(move || {
21883                let digest = managed_spawn_request_digest_v2(&request).unwrap();
21884                barrier.wait();
21885                shared.acquire_durable_managed_spawn_v2(&request, digest)
21886            }));
21887        }
21888        barrier.wait();
21889        let results = joins
21890            .into_iter()
21891            .map(|join| join.join().unwrap())
21892            .collect::<Vec<_>>();
21893        assert_eq!(
21894            results
21895                .iter()
21896                .filter(|result| {
21897                    matches!(result, Ok(DurableManagedSpawnReplayDecision::Reserved))
21898                })
21899                .count(),
21900            1,
21901        );
21902        assert_eq!(
21903            results
21904                .iter()
21905                .filter(|result| {
21906                    matches!(result, Err(error) if error.code == NodeFailureCode::BackendBusy)
21907                })
21908                .count(),
21909            1,
21910        );
21911        assert_eq!(
21912            shared
21913                .managed_spawn_replays
21914                .lock()
21915                .unwrap_or_else(|poisoned| poisoned.into_inner())
21916                .len(),
21917            1,
21918        );
21919        assert!(shared
21920            .managed_worktrees
21921            .lock()
21922            .unwrap_or_else(|poisoned| poisoned.into_inner())
21923            .records()
21924            .is_empty());
21925        drop(shared);
21926        std::fs::remove_dir_all(root).unwrap();
21927    }
21928
21929    #[tokio::test]
21930    async fn managed_spawn_v2_linked_pending_reopen_fails_closed_without_reallocation() {
21931        let root = temporary_workspace_root("managed-v2-linked-pending");
21932        let allocation = root.join("allocation");
21933        std::fs::create_dir_all(&allocation).unwrap();
21934        let state_path = root.join("node-state.json");
21935        let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
21936        let lease = install_managed_lease(&shared);
21937        let request = managed_v2_request("managed-v2-linked-pending");
21938        assert!(matches!(
21939            shared
21940                .acquire_durable_managed_spawn_v2(
21941                    &request,
21942                    managed_spawn_request_digest_v2(&request).unwrap(),
21943                )
21944                .unwrap(),
21945            DurableManagedSpawnReplayDecision::Reserved,
21946        ));
21947        {
21948            let _transaction = shared
21949                .state_transaction
21950                .lock()
21951                .unwrap_or_else(|poisoned| poisoned.into_inner());
21952            shared
21953                .link_durable_managed_spawn_v2_lease_locked(
21954                    &request.spawn_spec.idempotency_key,
21955                    &lease.lease_id,
21956                )
21957                .unwrap();
21958            shared.persist_state_locked().unwrap();
21959        }
21960        drop(shared);
21961
21962        let mut loaded = session_registry::load(
21963            Some(&state_path),
21964            &NodeId::new("node-terminal-test").unwrap(),
21965        )
21966        .unwrap();
21967        let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
21968        *reopened
21969            .managed_worktrees
21970            .lock()
21971            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21972            ManagedWorktreeRegistry::from_records(
21973                std::mem::take(&mut loaded.managed_worktrees),
21974                std::mem::take(&mut loaded.managed_worktree_tombstones),
21975            )
21976            .unwrap();
21977        *reopened
21978            .managed_spawn_replays
21979            .lock()
21980            .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
21981            .managed_spawn_replays
21982            .into_iter()
21983            .map(|record| (record.idempotency_key.clone(), record))
21984            .collect();
21985        let lease_count = reopened
21986            .managed_worktrees
21987            .lock()
21988            .unwrap_or_else(|poisoned| poisoned.into_inner())
21989            .records()
21990            .len();
21991
21992        let failure = reopened
21993            .spawn_managed_worktree_v2(request)
21994            .await
21995            .unwrap_err();
21996
21997        assert_eq!(
21998            failure.code,
21999            NodeFailureCode::ManagedWorktreeRecoveryRequired,
22000        );
22001        assert_eq!(
22002            reopened
22003                .managed_worktrees
22004                .lock()
22005                .unwrap_or_else(|poisoned| poisoned.into_inner())
22006                .records()
22007                .len(),
22008            lease_count,
22009        );
22010        std::fs::remove_dir_all(root).unwrap();
22011    }
22012
22013    #[tokio::test]
22014    async fn managed_spawn_v2_replays_exact_receipt_after_durable_reopen_and_conflicts() {
22015        let root = temporary_workspace_root("managed-v2-reopen");
22016        let allocation = root.join("allocation");
22017        std::fs::create_dir_all(&allocation).unwrap();
22018        let state_path = root.join("node-state.json");
22019        let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22020        let lease = install_managed_lease(&shared);
22021        let request = managed_v2_request("managed-v2-reopen");
22022        let session = SessionAddress {
22023            workspace_id: lease.workspace_id.clone(),
22024            session: SessionKey {
22025                instance_id: AgentInstanceId(91),
22026                generation: SessionGeneration(1),
22027            },
22028        };
22029        let snapshot = {
22030            let mut registry = shared
22031                .managed_worktrees
22032                .lock()
22033                .unwrap_or_else(|poisoned| poisoned.into_inner());
22034            let current = registry.get_mut(&lease.lease_id).unwrap();
22035            current.state = ManagedWorktreeLeaseState::InUse;
22036            current.session_holders.push(ManagedWorktreeSessionHolder {
22037                incarnation_id: shared.incarnation_id,
22038                instance_id: session.session.instance_id,
22039                generation: session.session.generation,
22040            });
22041            current.snapshot()
22042        };
22043        let resolved = shared.resolve_spawn_spec(&request.spawn_spec).unwrap();
22044        let receipt = managed_spawn_receipt(
22045            &resolved,
22046            shared.incarnation_id,
22047            session,
22048            snapshot,
22049            None,
22050            None,
22051            None,
22052        );
22053        assert!(matches!(
22054            shared
22055                .acquire_durable_managed_spawn_v2(
22056                    &request,
22057                    managed_spawn_request_digest_v2(&request).unwrap(),
22058                )
22059                .unwrap(),
22060            DurableManagedSpawnReplayDecision::Reserved,
22061        ));
22062        {
22063            let _transaction = shared
22064                .state_transaction
22065                .lock()
22066                .unwrap_or_else(|poisoned| poisoned.into_inner());
22067            shared
22068                .link_durable_managed_spawn_v2_lease_locked(
22069                    &request.spawn_spec.idempotency_key,
22070                    &lease.lease_id,
22071                )
22072                .unwrap();
22073            shared.persist_state_locked().unwrap();
22074        }
22075        shared
22076            .commit_durable_managed_spawn_v2(&request, receipt.clone())
22077            .unwrap();
22078        let legacy_same_key = ManagedWorktreeSpawnRequest {
22079            spawn_spec: request.spawn_spec.clone(),
22080            worktree_profile_id: request.worktree_profile_id.clone(),
22081        };
22082        shared.remember_managed_spawn_attempt(
22083            Some(&request.spawn_spec.idempotency_key),
22084            legacy_same_key.clone(),
22085            Ok(receipt.clone()),
22086            Instant::now(),
22087        );
22088        assert!(shared
22089            .replay_managed_spawn(&legacy_same_key, Instant::now())
22090            .unwrap()
22091            .is_none());
22092        drop(shared);
22093
22094        let mut loaded = session_registry::load(
22095            Some(&state_path),
22096            &NodeId::new("node-terminal-test").unwrap(),
22097        )
22098        .unwrap();
22099        assert_eq!(loaded.managed_spawn_replays.len(), 1);
22100        let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22101        *reopened
22102            .managed_worktrees
22103            .lock()
22104            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22105            ManagedWorktreeRegistry::from_records(
22106                std::mem::take(&mut loaded.managed_worktrees),
22107                std::mem::take(&mut loaded.managed_worktree_tombstones),
22108            )
22109            .unwrap();
22110        *reopened
22111            .managed_spawn_replays
22112            .lock()
22113            .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22114            .managed_spawn_replays
22115            .into_iter()
22116            .map(|record| (record.idempotency_key.clone(), record))
22117            .collect();
22118        let lease_count_before = reopened
22119            .managed_worktrees
22120            .lock()
22121            .unwrap_or_else(|poisoned| poisoned.into_inner())
22122            .records()
22123            .len();
22124
22125        assert_eq!(
22126            reopened
22127                .spawn_managed_worktree_v2(request.clone())
22128                .await
22129                .unwrap(),
22130            receipt,
22131        );
22132        assert_eq!(
22133            reopened
22134                .managed_worktrees
22135                .lock()
22136                .unwrap_or_else(|poisoned| poisoned.into_inner())
22137                .records()
22138                .len(),
22139            lease_count_before,
22140        );
22141        let mut changed = request;
22142        changed.spawn_spec.deadline_ms = crate::protocol::SpawnDeadlineMs::new(29_000).unwrap();
22143        let failure = reopened
22144            .spawn_managed_worktree_v2(changed)
22145            .await
22146            .unwrap_err();
22147        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22148        assert_eq!(
22149            reopened
22150                .managed_worktrees
22151                .lock()
22152                .unwrap_or_else(|poisoned| poisoned.into_inner())
22153                .records()
22154                .len(),
22155            lease_count_before,
22156        );
22157        let mut new_key = managed_v2_request("managed-v2-new-after-profile-change");
22158        new_key.expected_profile_revision = WorktreeProfileRevision::new("v1").unwrap();
22159        let failure = reopened
22160            .spawn_managed_worktree_v2(new_key)
22161            .await
22162            .unwrap_err();
22163        assert_eq!(
22164            failure.code,
22165            NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22166        );
22167        assert_eq!(
22168            reopened
22169                .managed_worktrees
22170                .lock()
22171                .unwrap_or_else(|poisoned| poisoned.into_inner())
22172                .records()
22173                .len(),
22174            lease_count_before,
22175        );
22176        assert_eq!(
22177            reopened
22178                .managed_spawn_replays
22179                .lock()
22180                .unwrap_or_else(|poisoned| poisoned.into_inner())
22181                .len(),
22182            1,
22183        );
22184        std::fs::remove_dir_all(root).unwrap();
22185    }
22186
22187    #[test]
22188    fn legacy_managed_spawn_idempotency_remains_transient_and_exact() {
22189        let shared = terminal_test_shared();
22190        let request = ManagedWorktreeSpawnRequest {
22191            spawn_spec: spawn_spec_fixture(),
22192            worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22193        };
22194        let expected = failure(NodeFailureCode::BackendBusy, "legacy fixture failure");
22195        shared.remember_managed_spawn(
22196            request.clone(),
22197            Err(expected.clone()),
22198            Instant::now(),
22199        );
22200
22201        assert_eq!(
22202            shared
22203                .replay_managed_spawn(&request, Instant::now())
22204                .unwrap()
22205                .unwrap(),
22206            Err(expected),
22207        );
22208        assert!(shared
22209            .managed_spawn_replays
22210            .lock()
22211            .unwrap_or_else(|poisoned| poisoned.into_inner())
22212            .is_empty());
22213    }
22214
22215    #[tokio::test]
22216    async fn ordinary_spawn_and_spawn_spec_cannot_bypass_managed_lease_holder_fence() {
22217        let shared = terminal_test_shared();
22218        let lease = install_managed_lease(&shared);
22219        let raw = shared.spawn_session(
22220            lease.workspace_id.clone(),
22221            agent("claude"),
22222            SessionMode::Pty,
22223            gate4agent_types::TerminalSize { rows: 24, columns: 80 },
22224            None,
22225        ).await.unwrap_err();
22226        assert_eq!(raw.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22227        let mut spec = spawn_spec_fixture();
22228        spec.target.workspace_id = lease.workspace_id.clone();
22229        let specified = shared.spawn_from_spec(spec).await.unwrap_err();
22230        assert_eq!(specified.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22231        let mut selected = spawn_spec_fixture();
22232        selected.idempotency_key = SpawnIdempotencyKey::new("managed-selected-bypass").unwrap();
22233        selected.target.worktree_id = Some(lease.workspace_id);
22234        let selected = shared.spawn_from_spec(selected).await.unwrap_err();
22235        assert_eq!(selected.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22236        assert!(shared.handle.snapshot().sessions.is_empty());
22237    }
22238
22239    #[test]
22240    fn legacy_projection_omits_managed_snapshot_events_and_path_bearing_diagnostics() {
22241        let shared = terminal_test_shared();
22242        let lease = install_managed_lease(&shared);
22243        let snapshot = snapshot_for_wire(
22244            &shared, true, true, true, false, true, true, false, false, true,
22245        );
22246        assert!(snapshot.managed_worktrees.is_empty());
22247        assert!(snapshot.workspaces.iter().all(|workspace| {
22248            workspace.managed_worktree_profiles.is_none()
22249        }));
22250        let mut reply = ResponseEnvelope {
22251            request_id: 1,
22252            result: Ok(NodeResponse::Resync {
22253                event_sequence: 1,
22254                oldest_available_sequence: 1,
22255                snapshot: shared.snapshot(),
22256                events: vec![NodeEventEnvelope {
22257                    sequence: 1,
22258                    event: NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() },
22259                }],
22260            }),
22261        };
22262        project_response_without_managed_worktrees(&mut reply);
22263        let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
22264            panic!("projection changed the response kind");
22265        };
22266        assert!(snapshot.managed_worktrees.is_empty());
22267        assert!(events.is_empty());
22268        let mut git = git_snapshot_for_parser();
22269        git.branch = Some(lease.branch.clone());
22270        git.managed_worktree = Some(crate::protocol::ManagedWorktreeGitScope {
22271            lease_id: lease.lease_id.clone(),
22272            source_workspace_id: lease.source_workspace_id.clone(),
22273            branch: lease.branch.clone(),
22274            base_commit: GitObjectId::new(lease.base_commit.clone()).unwrap(),
22275            active_session_count: 1,
22276            managed_record_count: 0,
22277        });
22278        let mut inspection_reply = ResponseEnvelope {
22279            request_id: 2,
22280            result: Ok(NodeResponse::WorkspaceInspected {
22281                inspection: WorkspaceInspection {
22282                    workspace_id: lease.workspace_id.clone(),
22283                    entries: Vec::new(),
22284                    tree_truncated: false,
22285                    git,
22286                    truncation: None,
22287                },
22288            }),
22289        };
22290        project_response_without_managed_worktrees(&mut inspection_reply);
22291        let Ok(NodeResponse::WorkspaceInspected { inspection }) = inspection_reply.result else {
22292            panic!("projection changed the inspection response kind");
22293        };
22294        assert!(inspection.git.managed_worktree.is_none());
22295        let failure = managed_git_worktree_failure(GitWorktreeError {
22296            kind: GitWorktreeErrorKind::Failed,
22297            message: r"git failed at C:\private\managed-target".to_owned(),
22298        });
22299        assert!(!failure.message.contains("private"));
22300        assert!(!failure.message.contains("managed-target"));
22301    }
22302
22303    #[tokio::test]
22304    async fn restart_reconciliation_retains_exact_owned_worktree_and_explicit_cleanup_removes_it() {
22305        let root = temporary_workspace_root("managed-restart");
22306        let source = root.join("source");
22307        let allocation = root.join("allocation");
22308        std::fs::create_dir_all(&source).unwrap();
22309        std::fs::create_dir_all(&allocation).unwrap();
22310        run_test_git(&source, &["init"]);
22311        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
22312        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
22313        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22314        run_test_git(&source, &["add", "seed.txt"]);
22315        run_test_git(&source, &["commit", "-m", "seed"]);
22316        let profile = ManagedWorktreeProfile::new(
22317            WorktreeProfileId::new("default").unwrap(),
22318            crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22319            &allocation,
22320            "gate4agent",
22321            "HEAD",
22322            ManagedWorktreeRetention::Retain,
22323        ).unwrap();
22324        let workspace = WorkspaceConfig::new(
22325            WorkspaceId::new("primary").unwrap(),
22326            &source,
22327        ).unwrap()
22328            .with_worktree_service_mode(WorktreeServiceMode::Managed)
22329            .with_managed_worktree_profile(profile.clone()).unwrap();
22330        let target = PathBuf::from(profile.allocation_root()).join("mw-restart");
22331        let source_text = workspace.canonical_root().to_owned();
22332        let target_text = target.to_string_lossy().into_owned();
22333        let created = create_git_worktree(
22334            &source_text,
22335            &target_text,
22336            "gate4agent/mw-restart",
22337            Some("HEAD"),
22338        ).await.unwrap();
22339        let lease = ManagedWorktreeLeaseRecord {
22340            lease_id: ManagedWorktreeLeaseId::new("mw-restart").unwrap(),
22341            source_workspace_id: WorkspaceId::new("primary").unwrap(),
22342            workspace_id: WorkspaceId::new("managed-restart").unwrap(),
22343            profile_id: profile.profile_id().clone(),
22344            profile_revision: profile.revision().clone(),
22345            target_root: created.path.clone(),
22346            branch: "gate4agent/mw-restart".to_owned(),
22347            base_commit: created.head.clone(),
22348            expected_head: None,
22349            retention: ManagedWorktreeRetention::Retain,
22350            state: ManagedWorktreeLeaseState::Allocating,
22351            session_holders: Vec::new(),
22352            record_holders: Vec::new(),
22353            cleanup_failure: None,
22354            created_at_unix_ms: 1,
22355            updated_at_unix_ms: 1,
22356        };
22357        let catalog = active_registry().unwrap();
22358        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22359        let shared = NodeShared::new_with_incarnation(
22360            handle,
22361            "fixture-token".to_owned(),
22362            NodeId::new("node-managed-restart").unwrap(),
22363            NodeIncarnationId::from_bytes([7; crate::protocol::NODE_INCARNATION_ID_BYTES]),
22364            vec![workspace],
22365            vec![agent("claude")],
22366            ProviderRuntimeStatuses::default(),
22367            None,
22368            Vec::new(),
22369            Vec::new(),
22370            SpawnProfileRegistry::default(),
22371            None,
22372            None,
22373            None,
22374            None,
22375            Vec::new(),
22376            vec![lease.clone()],
22377            Vec::new(),
22378            Vec::new(),
22379            None,
22380            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
22381        );
22382        shared.reconcile_managed_worktrees().await;
22383        let retained = shared.managed_worktrees.lock()
22384            .unwrap_or_else(|poisoned| poisoned.into_inner())
22385            .get(&lease.lease_id).unwrap().clone();
22386        assert_eq!(retained.state, ManagedWorktreeLeaseState::Retained);
22387        assert!(shared.workspaces.read()
22388            .unwrap_or_else(|poisoned| poisoned.into_inner())
22389            .contains_key(&lease.workspace_id));
22390        let removed = shared.cleanup_managed_worktree(&lease.lease_id, true).await.unwrap();
22391        assert_eq!(removed.state, ManagedWorktreeLeaseState::Removed);
22392        assert!(!Path::new(&created.path).exists());
22393        std::fs::remove_dir_all(root).unwrap();
22394    }
22395
22396    fn run_test_git(root: &Path, arguments: &[&str]) {
22397        let output = std::process::Command::new("git")
22398            .arg("-C").arg(root).args(arguments).output().unwrap();
22399        assert!(
22400            output.status.success(),
22401            "git {:?} failed: {}",
22402            arguments,
22403            String::from_utf8_lossy(&output.stderr),
22404        );
22405    }
22406
22407    #[tokio::test]
22408    async fn spawn_spec_validation_fails_before_session_mutation() {
22409        let shared = terminal_test_shared();
22410        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22411            capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
22412        }));
22413        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22414            capability.as_str() == NODE_SPAWN_PROFILE_REVISION_CAPABILITY
22415        }));
22416        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22417            capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
22418        }));
22419
22420        let mut target_mismatch = spawn_spec_fixture();
22421        target_mismatch.target.node_id = NodeId::new("other-node").unwrap();
22422        let failure = shared.spawn_from_spec(target_mismatch).await.unwrap_err();
22423        assert_eq!(failure.code, NodeFailureCode::SpawnTargetMismatch);
22424
22425        let mut unknown_profile = spawn_spec_fixture();
22426        unknown_profile.idempotency_key = SpawnIdempotencyKey::new("unknown-profile").unwrap();
22427        unknown_profile.profile_id = crate::protocol::SpawnProfileId::new("missing").unwrap();
22428        let failure = shared.spawn_from_spec(unknown_profile).await.unwrap_err();
22429        assert_eq!(failure.code, NodeFailureCode::UnknownSpawnProfile);
22430
22431        let mut revision_mismatch = spawn_spec_fixture();
22432        revision_mismatch.idempotency_key =
22433            SpawnIdempotencyKey::new("profile-revision-mismatch").unwrap();
22434        revision_mismatch.expected_profile_revision =
22435            crate::protocol::SpawnProfileRevision::new("test-r2").unwrap();
22436        let failure = shared.spawn_from_spec(revision_mismatch).await.unwrap_err();
22437        assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
22438
22439        let mut unsupported_materializer = spawn_spec_fixture();
22440        unsupported_materializer.idempotency_key =
22441            SpawnIdempotencyKey::new("unsupported-materializer").unwrap();
22442        unsupported_materializer.overrides.bundle_id = crate::protocol::SpawnOverride::Set {
22443            value: crate::protocol::SpawnBundleId::new("bundle-v1").unwrap(),
22444        };
22445        let failure = shared
22446            .spawn_from_spec(unsupported_materializer)
22447            .await
22448            .unwrap_err();
22449        assert_eq!(failure.code, NodeFailureCode::UnknownBundle);
22450
22451        let failure = shared
22452            .spawn_session_with_deadline(
22453                WorkspaceId::new("primary").unwrap(),
22454                agent("claude"),
22455                SessionMode::Pty,
22456                gate4agent_types::TerminalSize {
22457                    rows: 24,
22458                    columns: 80,
22459                },
22460                None,
22461                None,
22462                None,
22463                None,
22464                None,
22465                None,
22466                SpawnRecordPolicy::ProviderIdentityOnly,
22467                Some(Instant::now()),
22468                &[],
22469                None,
22470                ApprovalLevel::default(),
22471                None,
22472                None,
22473            )
22474            .await
22475            .unwrap_err();
22476        assert_eq!(failure.code, NodeFailureCode::SpawnDeadlineExceeded);
22477        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22478        assert!(shared.handle.snapshot().sessions.is_empty());
22479    }
22480
22481    #[test]
22482    fn worktree_selection_requires_its_separate_negotiated_capability() {
22483        let mut spec = spawn_spec_fixture();
22484        spec.target.worktree_id = Some(WorkspaceId::new("selected-worktree").unwrap());
22485        let request = NodeRequest::SpawnSpec { spec };
22486        let spawn_spec_capability =
22487            CapabilityId::new(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY).unwrap();
22488        assert!(request_uses_unnegotiated_capability(
22489            &request,
22490            &[spawn_spec_capability.clone()],
22491        ));
22492        let worktree_capability =
22493            CapabilityId::new(NODE_WORKTREE_SELECTION_CAPABILITY).unwrap();
22494        assert!(request_uses_unnegotiated_capability(
22495            &request,
22496            &[spawn_spec_capability.clone(), worktree_capability.clone()],
22497        ));
22498        let profile_revision_capability =
22499            CapabilityId::new(NODE_SPAWN_PROFILE_REVISION_CAPABILITY).unwrap();
22500        assert!(request_uses_unnegotiated_capability(
22501            &request,
22502            &[
22503                spawn_spec_capability.clone(),
22504                worktree_capability.clone(),
22505                profile_revision_capability.clone(),
22506            ],
22507        ));
22508        // Default `SpawnOverrides` leaves `bundle_id`/`context_id` at
22509        // `Inherit`, not `Clear` — the profile's own defaults might still
22510        // materialize a bundle or a context pack, so both capabilities stay
22511        // conservatively required until negotiated (see
22512        // `requires_session_bundle_materialization_capability` /
22513        // `requires_history_context_pack_capability`).
22514        let bundle_materialization_capability =
22515            CapabilityId::new(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY).unwrap();
22516        assert!(request_uses_unnegotiated_capability(
22517            &request,
22518            &[
22519                spawn_spec_capability.clone(),
22520                worktree_capability.clone(),
22521                profile_revision_capability.clone(),
22522                bundle_materialization_capability.clone(),
22523            ],
22524        ));
22525        let history_context_pack_capability =
22526            CapabilityId::new(NODE_HISTORY_CONTEXT_PACK_CAPABILITY).unwrap();
22527        assert!(!request_uses_unnegotiated_capability(
22528            &request,
22529            &[
22530                spawn_spec_capability,
22531                worktree_capability,
22532                profile_revision_capability,
22533                bundle_materialization_capability,
22534                history_context_pack_capability,
22535            ],
22536        ));
22537    }
22538
22539    #[tokio::test]
22540    async fn spawn_spec_idempotency_replays_receipt_and_conflicts_before_session_mutation() {
22541        let mut shared = terminal_test_shared();
22542        let spec = spawn_spec_fixture();
22543        let receipt = shared
22544            .resolve_spawn_spec(&spec)
22545            .unwrap()
22546            .receipt(shared.incarnation_id, terminal_address(1));
22547        shared.remember_spawn_spec(spec.clone(), Ok(receipt.clone()), Instant::now());
22548
22549        let mut revised = shared.spawn_profiles.get(&spec.profile_id).unwrap().clone();
22550        revised.revision = crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
22551        shared.spawn_profiles = SpawnProfileRegistry::new([revised]).unwrap();
22552
22553        assert_eq!(shared.spawn_from_spec(spec.clone()).await.unwrap(), receipt);
22554
22555        let mut revision_conflicting = spec.clone();
22556        revision_conflicting.expected_profile_revision =
22557            crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
22558        let failure = shared.spawn_from_spec(revision_conflicting).await.unwrap_err();
22559        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22560
22561        let mut stale_cache_miss = spec.clone();
22562        stale_cache_miss.idempotency_key =
22563            SpawnIdempotencyKey::new("stale-profile-cache-miss").unwrap();
22564        let failure = shared.spawn_from_spec(stale_cache_miss).await.unwrap_err();
22565        assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
22566
22567        let mut conflicting = spec;
22568        conflicting.deadline_ms = crate::protocol::SpawnDeadlineMs::new(31_000).unwrap();
22569        let failure = shared.spawn_from_spec(conflicting).await.unwrap_err();
22570        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22571        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22572        assert!(shared.handle.snapshot().sessions.is_empty());
22573    }
22574
22575    #[tokio::test]
22576    async fn off_worktree_service_rejects_selection_and_mutation_before_git() {
22577        let shared = terminal_test_shared_with_mode(WorktreeServiceMode::Off);
22578        let mut spec = spawn_spec_fixture();
22579        spec.target.worktree_id = Some(WorkspaceId::new("unregistered-worktree").unwrap());
22580        let failure = shared.spawn_from_spec(spec).await.unwrap_err();
22581        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
22582
22583        let failure = shared
22584            .create_worktree(
22585                WorkspaceId::new("primary").unwrap(),
22586                WorkspaceId::new("new-worktree").unwrap(),
22587                "not-an-absolute-path".to_owned(),
22588                "invalid branch".to_owned(),
22589                None,
22590            )
22591            .await
22592            .unwrap_err();
22593        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
22594
22595        let failure = shared
22596            .remove_worktree(
22597                WorkspaceId::new("primary").unwrap(),
22598                "not-an-absolute-path".to_owned(),
22599            )
22600            .await
22601            .unwrap_err();
22602        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
22603        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22604        assert!(shared.handle.snapshot().sessions.is_empty());
22605    }
22606
22607    #[tokio::test]
22608    async fn manual_worktree_registration_failure_removes_the_created_worktree() {
22609        let root = temporary_workspace_root("manual-worktree-compensation");
22610        let source = root.join("source");
22611        let target = root.join("target");
22612        let invalid_state_parent = root.join("state-parent-is-a-file");
22613        let invalid_state_path = invalid_state_parent.join("state.json");
22614        std::fs::create_dir_all(&source).unwrap();
22615        std::fs::write(&invalid_state_parent, "not a directory").unwrap();
22616        run_test_git(&source, &["init"]);
22617        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
22618        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
22619        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22620        run_test_git(&source, &["add", "seed.txt"]);
22621        run_test_git(&source, &["commit", "-m", "seed"]);
22622
22623        let catalog = active_registry().unwrap();
22624        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22625        let workspace = WorkspaceConfig::new(
22626            WorkspaceId::new("primary").unwrap(),
22627            &source,
22628        ).unwrap();
22629        let mut shared = NodeShared::new(
22630            handle,
22631            "fixture-token".to_owned(),
22632            NodeId::new("node-worktree-compensation").unwrap(),
22633            vec![workspace],
22634            vec![agent("claude")],
22635        );
22636        shared.state_path = Some(invalid_state_path);
22637
22638        let error = shared.create_worktree(
22639            WorkspaceId::new("primary").unwrap(),
22640            WorkspaceId::new("compensated").unwrap(),
22641            target.to_string_lossy().into_owned(),
22642            "gate4agent/compensated".to_owned(),
22643            Some("HEAD".to_owned()),
22644        ).await.unwrap_err();
22645
22646        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
22647        assert!(!target.exists());
22648        assert!(list_git_worktrees(source.to_str().unwrap()).await.unwrap()
22649            .iter()
22650            .all(|worktree| !worktree_paths_equal(&worktree.path, &target.to_string_lossy())));
22651        std::fs::remove_dir_all(root).unwrap();
22652    }
22653
22654    #[test]
22655    fn selected_worktree_requires_authoritative_non_main_non_bare_entry() {
22656        fn worktree(path: &str, is_main: bool, is_bare: bool) -> NativeGitWorktreeSnapshot {
22657            NativeGitWorktreeSnapshot {
22658                path: path.to_owned(),
22659                head: "0123456789abcdef".to_owned(),
22660                branch: Some("refs/heads/test".to_owned()),
22661                is_bare,
22662                is_main,
22663                locked: false,
22664                lock_reason: None,
22665                prunable: false,
22666                prunable_reason: None,
22667                workspace_id: None,
22668            }
22669        }
22670
22671        let source = r"C:\repo";
22672        let selected = r"C:\repo-worktree";
22673        let listed = vec![
22674            worktree(source, true, false),
22675            worktree(selected, false, false),
22676        ];
22677        assert!(validate_selected_worktree(source, selected, &listed).is_ok());
22678
22679        let main_failure = validate_selected_worktree(source, source, &listed).unwrap_err();
22680        assert_eq!(main_failure.code, NodeFailureCode::WorktreeProtected);
22681
22682        let bare = vec![
22683            worktree(source, true, false),
22684            worktree(selected, false, true),
22685        ];
22686        let bare_failure = validate_selected_worktree(source, selected, &bare).unwrap_err();
22687        assert_eq!(bare_failure.code, NodeFailureCode::WorktreeProtected);
22688
22689        let foreign_failure =
22690            validate_selected_worktree(source, r"C:\foreign", &listed).unwrap_err();
22691        assert_eq!(foreign_failure.code, NodeFailureCode::WorktreeProtected);
22692    }
22693
22694    fn terminal_frame(sequence: u64) -> TerminalFrame {
22695        TerminalFrame {
22696            sequence,
22697            size: gate4agent_types::TerminalSize {
22698                rows: 24,
22699                columns: 80,
22700            },
22701            cursor_row: 2,
22702            cursor_column: 3,
22703            contents: format!("frame-{sequence}"),
22704            formatted: vec![1, 2, 3],
22705            scrollback_formatted: Vec::new(),
22706            alternate_screen: false,
22707            mouse_protocol_enabled: false,
22708            mouse_protocol_encoding: Default::default(),
22709            produced_at_unix_ms: 0,
22710            screen_state: gate4agent_types::PtyScreenState::default(),
22711            bracketed_paste: None,
22712        }
22713    }
22714
22715    #[test]
22716    fn terminal_frame_watermark_coalesces_and_resets_for_exact_session_address() {
22717        let shared = terminal_test_shared();
22718        let mut receiver = shared.terminal_event_tx.subscribe();
22719        let original = terminal_address(1);
22720
22721        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
22722        let first = receiver.try_recv().unwrap();
22723        assert_eq!(first.len(), 1);
22724        assert!(matches!(
22725            &first[0].event,
22726            NodeEvent::TerminalFrame { address, frame }
22727                if address == &original && frame.sequence == 7
22728        ));
22729
22730        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
22731        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(6))]);
22732        assert!(matches!(
22733            receiver.try_recv(),
22734            Err(broadcast::error::TryRecvError::Empty)
22735        ));
22736
22737        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(8))]);
22738        let advanced = receiver.try_recv().unwrap();
22739        assert_eq!(advanced[0].sequence, 2);
22740
22741        let resumed = terminal_address(2);
22742        shared.publish_terminal_frame_candidates(vec![(resumed.clone(), terminal_frame(1))]);
22743        let reset = receiver.try_recv().unwrap();
22744        assert_eq!(reset[0].sequence, 3);
22745        assert!(matches!(
22746            &reset[0].event,
22747            NodeEvent::TerminalFrame { address, frame }
22748                if address == &resumed && frame.sequence == 1
22749        ));
22750        shared.clear_terminal_frame_watermark(&resumed);
22751        assert!(!shared
22752            .terminal_frame_watermarks
22753            .lock()
22754            .unwrap_or_else(|poisoned| poisoned.into_inner())
22755            .contains_key(&resumed.session.instance_id));
22756    }
22757
22758    #[test]
22759    fn terminal_frame_batches_replace_without_entering_node_event_history() {
22760        let shared = terminal_test_shared();
22761        let mut receiver = shared.terminal_event_tx.subscribe();
22762        let address = terminal_address(1);
22763        let count = NODE_EVENT_HISTORY_MAX as u64 + 17;
22764
22765        for sequence in 1..=count {
22766            shared.publish_terminal_frame_candidates(vec![(
22767                address.clone(),
22768                terminal_frame(sequence),
22769            )]);
22770        }
22771
22772        let history = shared
22773            .history
22774            .lock()
22775            .unwrap_or_else(|poisoned| poisoned.into_inner());
22776        assert!(history.events.is_empty());
22777        assert_eq!(history.last_sequence, count);
22778        assert_eq!(history.replay_floor_sequence, 1);
22779        drop(history);
22780        let NodeResponse::Resync {
22781            event_sequence,
22782            oldest_available_sequence,
22783            events,
22784            ..
22785        } = shared.resync(0) else {
22786            unreachable!("resync helper returned another response")
22787        };
22788        assert_eq!(event_sequence, count);
22789        assert_eq!(oldest_available_sequence, 1);
22790        assert!(events.is_empty());
22791        assert!(matches!(
22792            resync_required_event(&shared).event,
22793            NodeEvent::ResyncRequired {
22794                oldest_available_sequence: 1,
22795            }
22796        ));
22797        assert!(matches!(
22798            receiver.try_recv(),
22799            Err(broadcast::error::TryRecvError::Lagged(skipped)) if skipped == count - 1
22800        ));
22801        let latest = receiver.try_recv().unwrap();
22802        assert_eq!(latest.len(), 1);
22803        assert_eq!(latest[0].sequence, count);
22804    }
22805
22806    #[test]
22807    fn real_durable_eviction_advances_authoritative_replay_floor() {
22808        let shared = terminal_test_shared();
22809        for _ in 0..=NODE_EVENT_HISTORY_MAX {
22810            shared.publish(NodeEvent::ControllerChanged { controller: None });
22811        }
22812
22813        let NodeResponse::Resync {
22814            event_sequence,
22815            oldest_available_sequence,
22816            events,
22817            ..
22818        } = shared.resync(0) else {
22819            unreachable!("resync helper returned another response")
22820        };
22821        assert_eq!(event_sequence, NODE_EVENT_HISTORY_MAX as u64 + 1);
22822        assert_eq!(oldest_available_sequence, 2);
22823        assert_eq!(events.first().map(|event| event.sequence), Some(2));
22824        assert_eq!(events.len(), NODE_EVENT_HISTORY_MAX);
22825        assert!(matches!(
22826            resync_required_event(&shared).event,
22827            NodeEvent::ResyncRequired {
22828                oldest_available_sequence: 2,
22829            }
22830        ));
22831    }
22832
22833    #[test]
22834    fn legacy_terminal_frame_connection_has_no_live_subscription() {
22835        let shared = terminal_test_shared();
22836        let without_capability = terminal_event_subscription(&shared, false);
22837        let with_capability = terminal_event_subscription(&shared, true);
22838        assert!(without_capability.is_none());
22839        assert!(with_capability.is_some());
22840        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22841            capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
22842        }));
22843    }
22844
22845    #[test]
22846    fn continuous_terminal_output_leaves_request_service_after_bounded_event_burst() {
22847        let mut pending = Vec::new();
22848        let mut event_sequence = 0_u64;
22849        for frame_sequence in 1..=4 {
22850            for instance in 1..=(NODE_CONNECTION_EVENT_BURST_MAX as u64 * 2) {
22851                event_sequence += 1;
22852                let mut address = terminal_address(1);
22853                address.session.instance_id = AgentInstanceId(instance);
22854                queue_connection_event(
22855                    &mut pending,
22856                    NodeEventEnvelope {
22857                        sequence: event_sequence,
22858                        event: NodeEvent::TerminalFrame {
22859                            address,
22860                            frame: terminal_frame(frame_sequence),
22861                        },
22862                    },
22863                    0,
22864                );
22865            }
22866        }
22867        assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX * 2);
22868        pending.sort_unstable_by_key(|event| event.sequence);
22869
22870        let mut uninterrupted = pending.clone();
22871        let first_burst = connection_event_burst_len(uninterrupted.len());
22872        uninterrupted.drain(..first_burst);
22873        let second_burst = connection_event_burst_len(uninterrupted.len());
22874        uninterrupted.drain(..second_burst);
22875        assert!(uninterrupted.is_empty());
22876
22877        let (frame_tx, mut frame_rx) =
22878            mpsc::channel::<Result<ClientFrame, FrameError>>(1);
22879        frame_tx
22880            .try_send(Ok(ClientFrame::Request(RequestEnvelope {
22881                request_id: 77,
22882                request: NodeRequest::Snapshot,
22883            })))
22884            .unwrap();
22885        let burst_len = connection_event_burst_len(pending.len());
22886        assert_eq!(burst_len, NODE_CONNECTION_EVENT_BURST_MAX);
22887        pending.drain(..burst_len);
22888        assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX);
22889        assert!(matches!(
22890            frame_rx.try_recv(),
22891            Ok(Ok(ClientFrame::Request(RequestEnvelope {
22892                request_id: 77,
22893                request: NodeRequest::Snapshot,
22894            })))
22895        ));
22896    }
22897
22898    #[test]
22899    fn terminal_lag_watermark_discards_retained_and_replayed_batches() {
22900        let mut pending = Vec::new();
22901        for sequence in [7_u64, 8] {
22902            let mut address = terminal_address(1);
22903            address.session.instance_id = AgentInstanceId(sequence);
22904            queue_connection_event(
22905                &mut pending,
22906                NodeEventEnvelope {
22907                    sequence,
22908                    event: NodeEvent::TerminalFrame {
22909                        address,
22910                        frame: terminal_frame(sequence),
22911                    },
22912                },
22913                0,
22914            );
22915        }
22916        let mut discard_through = 0;
22917        apply_connection_resync_watermark(&mut pending, &mut discard_through, 8);
22918        assert!(pending.is_empty());
22919
22920        let replayed = NodeEventEnvelope {
22921            sequence: 8,
22922            event: NodeEvent::TerminalFrame {
22923                address: terminal_address(1),
22924                frame: terminal_frame(8),
22925            },
22926        };
22927        let advanced = NodeEventEnvelope {
22928            sequence: 9,
22929            event: NodeEvent::TerminalFrame {
22930                address: terminal_address(2),
22931                frame: terminal_frame(1),
22932            },
22933        };
22934        queue_connection_event_batch(
22935            &mut pending,
22936            &[replayed, advanced.clone()],
22937            discard_through,
22938        );
22939        assert_eq!(pending, vec![advanced]);
22940    }
22941
22942    fn record(provider: &str, record_id: &str) -> ManagedSessionRecord {
22943        ManagedSessionRecord {
22944            record_id: SessionRecordId::new(record_id).unwrap(),
22945            display_name: format!("{provider} record"),
22946            provider: agent(provider),
22947            mode: SessionMode::Pty,
22948            state: ManagedSessionState::Unavailable,
22949            workspace_id: WorkspaceId::new("primary").unwrap(),
22950            canonical_root: opaque_windows_path(
22951                std::env::current_dir().unwrap().to_string_lossy().into_owned(),
22952            ),
22953            provider_session: None,
22954            active_session: None,
22955            environment_profile: None,
22956            bundle: None,
22957            context_id: None,
22958            context: None,
22959            exported_context: None,
22960            task_binding: None,
22961            created_at_unix_ms: 1,
22962            updated_at_unix_ms: 1,
22963            last_error: None,
22964        }
22965    }
22966
22967    fn retention_test_record(
22968        workspace_id: &str,
22969        record_id: &str,
22970        state: ManagedSessionState,
22971        updated_at_unix_ms: u64,
22972    ) -> ManagedSessionRecord {
22973        ManagedSessionRecord {
22974            record_id: SessionRecordId::new(record_id).unwrap(),
22975            display_name: format!("{record_id} display"),
22976            provider: agent("claude"),
22977            mode: SessionMode::Pty,
22978            state,
22979            workspace_id: WorkspaceId::new(workspace_id).unwrap(),
22980            canonical_root: opaque_windows_path(
22981                std::env::current_dir().unwrap().to_string_lossy().into_owned(),
22982            ),
22983            provider_session: None,
22984            active_session: None,
22985            environment_profile: None,
22986            bundle: None,
22987            context_id: None,
22988            context: None,
22989            exported_context: None,
22990            task_binding: None,
22991            created_at_unix_ms: updated_at_unix_ms,
22992            updated_at_unix_ms,
22993            last_error: None,
22994        }
22995    }
22996
22997    /// Two workspaces of three `Unavailable` records each, staggered by
22998    /// age, plus one never-touchable decoy per non-`Unavailable` state in
22999    /// `workspace-a` (all as old as that workspace's oldest real record --
23000    /// if the sweep's state gate ever leaked, these would be the first
23001    /// thing it wrongly retired). Ages, oldest to newest:
23002    /// `workspace-a`: a1 (500s), a2 (300s), a3 (100s).
23003    /// `workspace-b`: b1 (400s), b2 (200s), b3 (50s).
23004    fn retention_fixture_records(now: u64) -> Vec<ManagedSessionRecord> {
23005        vec![
23006            retention_test_record("workspace-a", "a1", ManagedSessionState::Unavailable, now - 500_000),
23007            retention_test_record("workspace-a", "a2", ManagedSessionState::Unavailable, now - 300_000),
23008            retention_test_record("workspace-a", "a3", ManagedSessionState::Unavailable, now - 100_000),
23009            retention_test_record("workspace-b", "b1", ManagedSessionState::Unavailable, now - 400_000),
23010            retention_test_record("workspace-b", "b2", ManagedSessionState::Unavailable, now - 200_000),
23011            retention_test_record("workspace-b", "b3", ManagedSessionState::Unavailable, now - 50_000),
23012            retention_test_record("workspace-a", "decoy-live", ManagedSessionState::Live, now - 500_000),
23013            retention_test_record(
23014                "workspace-a",
23015                "decoy-pending",
23016                ManagedSessionState::IdentityPending,
23017                now - 500_000,
23018            ),
23019            retention_test_record(
23020                "workspace-a",
23021                "decoy-dormant",
23022                ManagedSessionState::Dormant,
23023                now - 500_000,
23024            ),
23025        ]
23026    }
23027
23028    fn retention_fixture_shared() -> (NodeShared, NativeRuntime, PathBuf, u64) {
23029        let root = temporary_workspace_root("session-record-retention");
23030        std::fs::create_dir_all(&root).unwrap();
23031        let catalog = active_registry().unwrap();
23032        let (handle, runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23033        let workspace_a =
23034            WorkspaceConfig::new(WorkspaceId::new("workspace-a").unwrap(), &root).unwrap();
23035        let workspace_b =
23036            WorkspaceConfig::new(WorkspaceId::new("workspace-b").unwrap(), &root).unwrap();
23037        let mut shared = NodeShared::new(
23038            handle,
23039            "fixture-token".to_owned(),
23040            NodeId::new("node-retention-test").unwrap(),
23041            vec![workspace_a, workspace_b],
23042            vec![agent("claude")],
23043        );
23044        shared.state_path = Some(root.join("node-state-v10.json"));
23045        let now = unix_time_ms();
23046        for record in retention_fixture_records(now) {
23047            shared.insert_record(record).unwrap();
23048        }
23049        shared
23050            .persist_state_locked()
23051            .expect("fixture-only, empty-history persist against a fresh temp state path");
23052        (shared, runtime, root, now)
23053    }
23054
23055    fn retention_removed_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23056        shared
23057            .history
23058            .lock()
23059            .unwrap_or_else(|poisoned| poisoned.into_inner())
23060            .events
23061            .iter()
23062            .filter_map(|envelope| match &envelope.event {
23063                NodeEvent::SessionRecordRemoved { record_id } => {
23064                    Some(record_id.as_str().to_owned())
23065                }
23066                _ => None,
23067            })
23068            .collect()
23069    }
23070
23071    fn retention_remaining_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23072        shared
23073            .session_records
23074            .lock()
23075            .unwrap_or_else(|poisoned| poisoned.into_inner())
23076            .records
23077            .keys()
23078            .map(|record_id| record_id.as_str().to_owned())
23079            .collect()
23080    }
23081
23082    fn owned(values: &[&str]) -> std::collections::BTreeSet<String> {
23083        values.iter().map(|value| (*value).to_owned()).collect()
23084    }
23085
23086    #[tokio::test]
23087    async fn session_record_retention_keep_n_only_keeps_the_newest_per_workspace() {
23088        let (mut shared, runtime, root, _now) = retention_fixture_shared();
23089        shared.session_record_retention = SessionRecordRetentionConfig {
23090            age_ms: 0,
23091            keep_per_workspace: 1,
23092        };
23093        shared.retire_unavailable_session_records().await;
23094
23095        assert_eq!(
23096            retention_remaining_record_ids(&shared),
23097            owned(&["a3", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23098        );
23099        assert_eq!(
23100            retention_removed_record_ids(&shared),
23101            owned(&["a1", "a2", "b1", "b2"]),
23102        );
23103        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 4);
23104
23105        drop(shared);
23106        drop(runtime);
23107        std::fs::remove_dir_all(root).unwrap();
23108    }
23109
23110    /// Age-only would retire {a1, a2, b1}; keep-N-only would retire
23111    /// {a1, a2, b1, b2}. With both flags set the sweep must retire exactly
23112    /// the intersection {a1, a2, b1} -- `b2` fails the age test (it is only
23113    /// 200s old against a 250s threshold) even though it fails the keep-N
23114    /// test too, proving retirement needs BOTH, not either.
23115    #[tokio::test]
23116    async fn session_record_retention_both_set_retires_only_the_intersection() {
23117        let (mut shared, runtime, root, _now) = retention_fixture_shared();
23118        shared.session_record_retention = SessionRecordRetentionConfig {
23119            age_ms: 250_000,
23120            keep_per_workspace: 1,
23121        };
23122        shared.retire_unavailable_session_records().await;
23123
23124        assert_eq!(
23125            retention_remaining_record_ids(&shared),
23126            owned(&["a3", "b2", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23127        );
23128        assert_eq!(retention_removed_record_ids(&shared), owned(&["a1", "a2", "b1"]));
23129        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 3);
23130
23131        drop(shared);
23132        drop(runtime);
23133        std::fs::remove_dir_all(root).unwrap();
23134    }
23135
23136    #[tokio::test]
23137    async fn session_record_retention_both_disabled_is_a_no_op() {
23138        let (mut shared, runtime, root, _now) = retention_fixture_shared();
23139        shared.session_record_retention = SessionRecordRetentionConfig::default();
23140        shared.retire_unavailable_session_records().await;
23141
23142        assert_eq!(
23143            retention_remaining_record_ids(&shared),
23144            owned(&[
23145                "a1", "a2", "a3", "b1", "b2", "b3",
23146                "decoy-live", "decoy-pending", "decoy-dormant",
23147            ]),
23148        );
23149        assert!(retention_removed_record_ids(&shared).is_empty());
23150        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 0);
23151
23152        drop(shared);
23153        drop(runtime);
23154        std::fs::remove_dir_all(root).unwrap();
23155    }
23156
23157    #[test]
23158    fn session_record_context_export_selects_one_exact_history_identity() {
23159        let candidates = vec![
23160            HistoryCandidateSummary {
23161                id: "candidate-other".to_owned(),
23162                session_id_hint: "provider-other".to_owned(),
23163                modified_at_unix_ms: Some(1),
23164            },
23165            HistoryCandidateSummary {
23166                id: "candidate-exact".to_owned(),
23167                session_id_hint: "provider-exact".to_owned(),
23168                modified_at_unix_ms: Some(2),
23169            },
23170        ];
23171        assert_eq!(
23172            select_history_candidate(&candidates, Some("provider-exact")).unwrap(),
23173            "candidate-exact",
23174        );
23175    }
23176
23177    #[test]
23178    fn session_record_context_export_rejects_missing_and_ambiguous_history_identity() {
23179        let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23180            id: id.to_owned(),
23181            session_id_hint: hint.to_owned(),
23182            modified_at_unix_ms: None,
23183        };
23184        let missing = select_history_candidate(
23185            &[candidate("candidate-other", "provider-other")],
23186            Some("provider-exact"),
23187        )
23188        .unwrap_err();
23189        assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23190        let ambiguous = select_history_candidate(
23191            &[
23192                candidate("candidate-a", "provider-exact"),
23193                candidate("candidate-b", "provider-exact"),
23194            ],
23195            Some("provider-exact"),
23196        )
23197        .unwrap_err();
23198        assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23199    }
23200
23201    /// The PTY-sidecar-observation shape (no fleet provider today, but
23202    /// capability-keyed rather than vendor-locked) never carries a verified
23203    /// `provider_session`; its record-bound export instead falls back to
23204    /// "exactly one discovered candidate" (see
23205    /// `export_context_pack_for_session_record_inner`). This is the same
23206    /// ambiguity refusal as the identity-filtered path, just without an
23207    /// identity to filter by.
23208    #[test]
23209    fn session_record_context_export_selects_sole_history_candidate_without_identity() {
23210        let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23211            id: id.to_owned(),
23212            session_id_hint: hint.to_owned(),
23213            modified_at_unix_ms: None,
23214        };
23215        assert_eq!(
23216            select_history_candidate(&[candidate("candidate-sole", "sidecar-session")], None)
23217                .unwrap(),
23218            "candidate-sole",
23219        );
23220        let missing = select_history_candidate(&[], None).unwrap_err();
23221        assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23222        let ambiguous = select_history_candidate(
23223            &[
23224                candidate("candidate-a", "sidecar-session-a"),
23225                candidate("candidate-b", "sidecar-session-b"),
23226            ],
23227            None,
23228        )
23229        .unwrap_err();
23230        assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23231    }
23232
23233    #[test]
23234    fn session_record_context_export_revalidation_rejects_record_change() {
23235        let session = terminal_address(1);
23236        let identity = ProviderSessionIdentity {
23237            key: ProviderSessionKey::SessionId,
23238            id: "provider-exact".to_owned(),
23239            transcript_path: Some(r"C:\private\provider.jsonl".to_owned()),
23240        };
23241        let mut expected = record("claude", "record-context-export");
23242        expected.state = ManagedSessionState::Live;
23243        expected.provider_session = Some(identity.clone());
23244        expected.active_session = Some(session.clone());
23245        let binding = SessionBinding {
23246            workspace_id: session.workspace_id.clone(),
23247            generation: session.session.generation,
23248            runtime_policy: ProviderRuntimePolicy::raw_pty(),
23249            pending_resume: None,
23250            record_id: Some(expected.record_id.clone()),
23251            managed_worktree_lease_id: None,
23252            environment_profile: None,
23253            bundle: None,
23254            context: None,
23255            materialization_id: None,
23256        };
23257        let current_root = windows_path_text(&expected.canonical_root).to_owned();
23258        assert!(session_record_context_export_binding_is_exact(
23259            &expected,
23260            &expected,
23261            Some(&identity),
23262            &session,
23263            &binding,
23264            &expected.provider,
23265            &current_root,
23266            false,
23267        ));
23268        let mut changed = expected.clone();
23269        changed.updated_at_unix_ms += 1;
23270        assert!(!session_record_context_export_binding_is_exact(
23271            &expected,
23272            &changed,
23273            Some(&identity),
23274            &session,
23275            &binding,
23276            &expected.provider,
23277            &current_root,
23278            false,
23279        ));
23280        // A same-instant timestamp bump alone (state and active_session
23281        // otherwise unchanged) is not the clean-detachment transition
23282        // either, even with the reactive flag on: only the exact Live ->
23283        // Dormant + active_session cleared shape qualifies.
23284        assert!(!session_record_context_export_binding_is_exact(
23285            &expected,
23286            &changed,
23287            Some(&identity),
23288            &session,
23289            &binding,
23290            &expected.provider,
23291            &current_root,
23292            true,
23293        ));
23294        let mut detached = expected.clone();
23295        detached.state = ManagedSessionState::Dormant;
23296        detached.active_session = None;
23297        detached.updated_at_unix_ms += 1;
23298        assert!(!session_record_context_export_binding_is_exact(
23299            &expected,
23300            &detached,
23301            Some(&identity),
23302            &session,
23303            &binding,
23304            &expected.provider,
23305            &current_root,
23306            false,
23307        ));
23308        assert!(session_record_context_export_binding_is_exact(
23309            &expected,
23310            &detached,
23311            Some(&identity),
23312            &session,
23313            &binding,
23314            &expected.provider,
23315            &current_root,
23316            true,
23317        ));
23318    }
23319
23320    /// Mirrors `session_record_context_export_revalidation_rejects_record_change`
23321    /// for a record whose provider never carries a verified identity (the
23322    /// PTY-sidecar-observation shape -- capability-keyed, no fleet provider
23323    /// declares one today): `identity` is `None` throughout, and the
23324    /// exactness check falls back to comparing `provider_session` itself,
23325    /// which must still be `None` on both sides.
23326    #[test]
23327    fn session_record_context_export_binding_is_exact_without_identity() {
23328        let session = terminal_address(1);
23329        let mut expected = record("pty-sidecar-fixture", "record-context-export-no-identity");
23330        expected.state = ManagedSessionState::Live;
23331        expected.active_session = Some(session.clone());
23332        let binding = SessionBinding {
23333            workspace_id: session.workspace_id.clone(),
23334            generation: session.session.generation,
23335            runtime_policy: ProviderRuntimePolicy::raw_pty(),
23336            pending_resume: None,
23337            record_id: Some(expected.record_id.clone()),
23338            managed_worktree_lease_id: None,
23339            environment_profile: None,
23340            bundle: None,
23341            context: None,
23342            materialization_id: None,
23343        };
23344        let current_root = windows_path_text(&expected.canonical_root).to_owned();
23345        assert!(session_record_context_export_binding_is_exact(
23346            &expected,
23347            &expected,
23348            None,
23349            &session,
23350            &binding,
23351            &expected.provider,
23352            &current_root,
23353            false,
23354        ));
23355        // A record that gained an identity between the initial lookup and
23356        // this recheck no longer matches the `None` this shape expects to
23357        // keep seeing -- treated the same as any other mid-export drift.
23358        let mut drifted = expected.clone();
23359        drifted.provider_session = Some(ProviderSessionIdentity {
23360            key: ProviderSessionKey::SessionId,
23361            id: "unexpected-identity".to_owned(),
23362            transcript_path: None,
23363        });
23364        assert!(!session_record_context_export_binding_is_exact(
23365            &expected,
23366            &drifted,
23367            None,
23368            &session,
23369            &binding,
23370            &expected.provider,
23371            &current_root,
23372            false,
23373        ));
23374    }
23375
23376    #[test]
23377    fn session_record_context_export_preflight_requires_live_usable_source() {
23378        let mut source = record("claude", "record-context-export-preflight");
23379        source.state = ManagedSessionState::Live;
23380        assert!(session_record_context_export_source_is_usable(
23381            &source,
23382            &SessionStatus::Running,
23383            false,
23384        ));
23385        assert!(session_record_context_export_source_is_usable(
23386            &source,
23387            &SessionStatus::Exited { exit_code: Some(0) },
23388            false,
23389        ));
23390
23391        source.state = ManagedSessionState::Dormant;
23392        assert!(!session_record_context_export_source_is_usable(
23393            &source,
23394            &SessionStatus::Running,
23395            false,
23396        ));
23397        // The reactive auto-export-at-exit path (allow_clean_detachment) is
23398        // the only caller that may treat a Dormant, cleanly-exited record as
23399        // still usable — the on-demand path above never does.
23400        assert!(session_record_context_export_source_is_usable(
23401            &source,
23402            &SessionStatus::Exited { exit_code: Some(0) },
23403            true,
23404        ));
23405        assert!(!session_record_context_export_source_is_usable(
23406            &source,
23407            &SessionStatus::Exited { exit_code: Some(0) },
23408            false,
23409        ));
23410        source.state = ManagedSessionState::Live;
23411        assert!(!session_record_context_export_source_is_usable(
23412            &source,
23413            &SessionStatus::Starting,
23414            false,
23415        ));
23416    }
23417
23418    #[test]
23419    fn session_record_context_export_final_conflict_leaves_catalog_empty() {
23420        let catalog = active_registry().unwrap();
23421        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23422        let workspace = WorkspaceConfig::new(
23423            WorkspaceId::new("primary").unwrap(),
23424            std::env::current_dir().unwrap(),
23425        )
23426        .unwrap();
23427        let shared = NodeShared::new(
23428            handle,
23429            "fixture-token".to_owned(),
23430            NodeId::new("node-context-export-conflict").unwrap(),
23431            vec![workspace],
23432            vec![agent("claude")],
23433        );
23434        let history = HistorySessionRecord {
23435            session_id: "provider-exact".to_owned(),
23436            title: None,
23437            cwd: None,
23438            model: None,
23439            message_count: 1,
23440            completed_turn_count: None,
23441            total_tokens: 0,
23442            messages: vec![gate4agent_types::HistoryMessageRecord {
23443                role: gate4agent_types::HistoryMessageRole::User,
23444                text: "bounded context".to_owned(),
23445            }],
23446        };
23447        let pack = NodeContextPack::export(
23448            ContextPackLineageReceipt {
23449                source_node_id: shared.node_id.clone(),
23450                source_session: terminal_address(1),
23451                source_provider: agent("claude"),
23452            },
23453            &history,
23454        )
23455        .unwrap();
23456        let context_id = pack.receipt().id.clone();
23457        let session = terminal_address(1);
23458        let identity = ProviderSessionIdentity {
23459            key: ProviderSessionKey::SessionId,
23460            id: history.session_id.clone(),
23461            transcript_path: None,
23462        };
23463        let mut expected = record("claude", "record-context-export-final-conflict");
23464        expected.state = ManagedSessionState::Live;
23465        expected.provider_session = Some(identity.clone());
23466        expected.active_session = Some(session.clone());
23467        let mut changed = expected.clone();
23468        changed.updated_at_unix_ms += 1;
23469        shared.insert_record(changed).unwrap();
23470
23471        let error = shared
23472            .commit_context_pack_for_session_record(
23473                &expected,
23474                Some(&identity),
23475                &session,
23476                "candidate-exact",
23477                &history,
23478                pack,
23479                false,
23480            )
23481            .unwrap_err();
23482
23483        assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
23484        assert!(shared
23485            .context_catalog
23486            .read()
23487            .unwrap_or_else(|poisoned| poisoned.into_inner())
23488            .get(&context_id)
23489            .is_none());
23490    }
23491
23492    #[tokio::test]
23493    async fn session_task_binding_mints_assigns_clears_with_cas_and_persists_v9() {
23494        let root = temporary_workspace_root("session-task-binding");
23495        std::fs::create_dir_all(&root).unwrap();
23496        let state_path = root.join("node-state.json");
23497        let catalog = active_registry().unwrap();
23498        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23499        let workspace = WorkspaceConfig::new(WorkspaceId::new("primary").unwrap(), &root).unwrap();
23500        let node_id = NodeId::new("node-1").unwrap();
23501        let mut shared = NodeShared::new(
23502            handle,
23503            "fixture-token".to_owned(),
23504            node_id.clone(),
23505            vec![workspace],
23506            vec![agent("claude")],
23507        );
23508        shared.state_path = Some(state_path.clone());
23509        let record = record("claude", "sr-task");
23510        let record_id = record.record_id.clone();
23511        shared.insert_record(record).unwrap();
23512        shared.persist_state().unwrap();
23513        shared
23514            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
23515            .unwrap();
23516
23517        let minted = process_request_inner(
23518            &shared,
23519            77,
23520            ClientRole::Operator,
23521            NodeRequest::SetSessionTask {
23522                record_id: record_id.clone(),
23523                expected_revision: 0,
23524                target: SessionTaskTargetV1::New,
23525            },
23526        )
23527        .await
23528        .unwrap();
23529        let NodeResponse::SessionRecordUpdated { record: minted } = minted else {
23530            panic!("session task mutation returned another response");
23531        };
23532        let minted_binding = minted.task_binding.clone().unwrap();
23533        let minted_task_id = minted_binding.task_id.clone().unwrap();
23534        assert_eq!(minted_binding.revision, 1);
23535        assert!(minted_task_id.as_str().starts_with("task-"));
23536        assert_eq!(minted_task_id.as_str().len(), 29);
23537
23538        let event_count = shared.current_sequence();
23539        let repeated = process_request_inner(
23540            &shared,
23541            77,
23542            ClientRole::Operator,
23543            NodeRequest::SetSessionTask {
23544                record_id: record_id.clone(),
23545                expected_revision: 1,
23546                target: SessionTaskTargetV1::Existing {
23547                    task_id: minted_task_id.clone(),
23548                },
23549            },
23550        )
23551        .await
23552        .unwrap();
23553        assert_eq!(repeated, NodeResponse::SessionRecordUpdated { record: minted.clone() });
23554        assert_eq!(shared.current_sequence(), event_count);
23555
23556        let stale = process_request_inner(
23557            &shared,
23558            77,
23559            ClientRole::Operator,
23560            NodeRequest::SetSessionTask {
23561                record_id: record_id.clone(),
23562                expected_revision: 0,
23563                target: SessionTaskTargetV1::Existing {
23564                    task_id: minted_task_id,
23565                },
23566            },
23567        )
23568        .await
23569        .unwrap_err();
23570        assert_eq!(stale.code, NodeFailureCode::SessionRecordConflict);
23571
23572        let assigned_id: TaskId = "task-ffeeddccbbaa998877665544".parse().unwrap();
23573        let assigned = process_request_inner(
23574            &shared,
23575            77,
23576            ClientRole::Operator,
23577            NodeRequest::SetSessionTask {
23578                record_id: record_id.clone(),
23579                expected_revision: 1,
23580                target: SessionTaskTargetV1::Existing {
23581                    task_id: assigned_id.clone(),
23582                },
23583            },
23584        )
23585        .await
23586        .unwrap();
23587        let NodeResponse::SessionRecordUpdated { record: assigned } = assigned else {
23588            panic!("session task assignment returned another response");
23589        };
23590        assert_eq!(assigned.task_binding.as_ref().unwrap().revision, 2);
23591        assert_eq!(assigned.task_binding.as_ref().unwrap().task_id.as_ref(), Some(&assigned_id));
23592
23593        let cleared = process_request_inner(
23594            &shared,
23595            77,
23596            ClientRole::Operator,
23597            NodeRequest::SetSessionTask {
23598                record_id: record_id.clone(),
23599                expected_revision: 2,
23600                target: SessionTaskTargetV1::Clear,
23601            },
23602        )
23603        .await
23604        .unwrap();
23605        let NodeResponse::SessionRecordUpdated { record: cleared } = cleared else {
23606            panic!("session task clear returned another response");
23607        };
23608        assert_eq!(cleared.task_binding.as_ref().unwrap().revision, 3);
23609        assert!(cleared.task_binding.as_ref().unwrap().task_id.is_none());
23610        assert!(cleared.task_binding.as_ref().unwrap().changed_at_unix_ms > 0);
23611        assert_eq!(
23612            cleared.task_binding.as_ref().unwrap().changed_at_unix_ms,
23613            cleared.updated_at_unix_ms,
23614        );
23615
23616        let persisted = std::fs::read(&state_path).unwrap();
23617        assert_eq!(serde_json::from_slice::<serde_json::Value>(&persisted).unwrap()["version"], NODE_STATE_SCHEMA_V10);
23618        let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
23619        assert_eq!(loaded.records[0].task_binding, cleared.task_binding);
23620        std::fs::remove_dir_all(root).unwrap();
23621    }
23622
23623    #[test]
23624    fn session_task_capability_strips_snapshots_events_and_replies() {
23625        let mut bound = record("claude", "sr-task-projection");
23626        bound.updated_at_unix_ms = 2;
23627        bound.task_binding = Some(SessionTaskBindingV1 {
23628            revision: 1,
23629            task_id: Some("task-00112233445566778899aaff".parse().unwrap()),
23630            changed_at_unix_ms: 2,
23631        });
23632        let request = NodeRequest::SetSessionTask {
23633            record_id: bound.record_id.clone(),
23634            expected_revision: 1,
23635            target: SessionTaskTargetV1::Clear,
23636        };
23637        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23638            capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
23639        }));
23640        assert!(!request_uses_unnegotiated_capability(
23641            &request,
23642            &baseline_capabilities().unwrap(),
23643        ));
23644        assert!(request_uses_unnegotiated_capability(&request, &[]));
23645
23646        let mut snapshot = terminal_test_shared().snapshot();
23647        snapshot.session_records.push(bound.clone());
23648        let mut reply = ResponseEnvelope {
23649            request_id: 1,
23650            result: Ok(NodeResponse::Resync {
23651                event_sequence: 1,
23652                oldest_available_sequence: 1,
23653                snapshot,
23654                events: vec![NodeEventEnvelope {
23655                    sequence: 1,
23656                    event: NodeEvent::SessionRecordUpserted {
23657                        record: bound.clone(),
23658                    },
23659                }],
23660            }),
23661        };
23662        project_response_without_session_task_binding(&mut reply);
23663        let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
23664            panic!("task projection changed the response kind");
23665        };
23666        assert!(snapshot.session_records[0].task_binding.is_none());
23667        let NodeEvent::SessionRecordUpserted { record } = &events[0].event else {
23668            panic!("task projection changed the event kind");
23669        };
23670        assert!(record.task_binding.is_none());
23671
23672        let projected = project_event_without_session_task_binding(NodeEventEnvelope {
23673            sequence: 2,
23674            event: NodeEvent::SessionRecordUpserted { record: bound.clone() },
23675        });
23676        let NodeEvent::SessionRecordUpserted { record } = projected.event else {
23677            panic!("task event projection changed the event kind");
23678        };
23679        assert!(record.task_binding.is_none());
23680
23681        let mut direct = ResponseEnvelope {
23682            request_id: 2,
23683            result: Ok(NodeResponse::SessionRecordUpdated { record: bound }),
23684        };
23685        project_response_without_session_task_binding(&mut direct);
23686        let Ok(NodeResponse::SessionRecordUpdated { record }) = direct.result else {
23687            panic!("task reply projection changed the response kind");
23688        };
23689        assert!(record.task_binding.is_none());
23690    }
23691
23692    #[test]
23693    fn windows_runtime_default_node_endpoint_is_exact_and_valid() {
23694        assert_eq!(DEFAULT_NODE_ENDPOINT, r"\\.\pipe\gate4agent-node");
23695        let workspace = WorkspaceConfig::new(
23696            WorkspaceId::new("primary").unwrap(),
23697            std::env::current_dir().unwrap(),
23698        )
23699        .unwrap();
23700        let config = NodeServerConfig::new(
23701            DEFAULT_NODE_ENDPOINT,
23702            "fixture-token",
23703            NodeId::new("node-1").unwrap(),
23704            [workspace],
23705        )
23706        .unwrap();
23707        assert_eq!(config.endpoint, DEFAULT_NODE_ENDPOINT);
23708    }
23709
23710    #[cfg(all(feature = "fixture", windows))]
23711    #[test]
23712    fn provider_bundle_argv_hold_fixture_is_parent_bounded_and_waits_after_proof() {
23713        let root = temporary_workspace_root("provider-bundle-argv-hold");
23714        let outside = temporary_workspace_root("provider-bundle-argv-hold-outside");
23715        std::fs::create_dir_all(&root).unwrap();
23716        std::fs::create_dir_all(&outside).unwrap();
23717        let proof_path = root.join("provider.proof");
23718        let release_signal = root.join("release.signal");
23719        let config = NodeServerConfig::new(
23720            format!(
23721                r"\\.\pipe\gate4agent-provider-bundle-hold-{}",
23722                std::process::id(),
23723            ),
23724            "fixture-token",
23725            NodeId::new("provider-bundle-hold-node").unwrap(),
23726            [WorkspaceConfig::new(
23727                WorkspaceId::new("primary").unwrap(),
23728                std::env::current_dir().unwrap(),
23729            )
23730            .unwrap()],
23731        )
23732        .unwrap()
23733        .with_spawn_profiles(SpawnProfileRegistry::new([
23734            crate::protocol::SpawnProfileDefaults {
23735                profile_id: crate::protocol::SpawnProfileId::new("kimi-bundle-hold").unwrap(),
23736                revision: crate::protocol::SpawnProfileRevision::new("kimi-bundle-hold-r1")
23737                    .unwrap(),
23738                provider: agent("kimi"),
23739                mode: SessionMode::Pty,
23740                terminal_size: gate4agent_types::TerminalSize {
23741                    rows: 24,
23742                    columns: 80,
23743                },
23744                prompt: None,
23745                bundle_id: None,
23746                context_id: None,
23747                environment_profile_id: None,
23748            },
23749        ])
23750        .unwrap());
23751        let server = NodeServer::new_provider_bundle_argv_hold_fixture(
23752            config,
23753            agent("kimi"),
23754            proof_path.clone(),
23755            release_signal.clone(),
23756        )
23757        .unwrap();
23758        assert_eq!(server.shared.enabled_providers, [agent("kimi")]);
23759
23760        let spec = NodeServer::provider_bundle_argv_fixture_spec(
23761            agent("kimi"),
23762            proof_path.clone(),
23763            Some(release_signal.clone()),
23764        )
23765        .unwrap();
23766        let wrapper = &spec.launch.fixed_args[spec.launch.fixed_args.len() - 3];
23767        let proof_write = wrapper.find("WriteAllLines($proofPath").unwrap();
23768        let release_wait = wrapper
23769            .find("while (-not (Test-Path -LiteralPath $releaseSignal -PathType Leaf))")
23770            .unwrap();
23771        let fixture_ready = wrapper.find("fixture-ready>").unwrap();
23772        assert!(proof_write < release_wait);
23773        assert!(release_wait < fixture_ready);
23774        assert!(wrapper.contains("[DateTime]::UtcNow.AddSeconds(45)"));
23775        assert!(wrapper.contains("Start-Sleep -Milliseconds 20"));
23776        assert_eq!(
23777            &spec.launch.fixed_args[spec.launch.fixed_args.len() - 2..],
23778            &[
23779                proof_path.to_string_lossy().into_owned(),
23780                release_signal.to_string_lossy().into_owned(),
23781            ],
23782        );
23783
23784        let error = NodeServer::provider_bundle_argv_fixture_spec(
23785            agent("kimi"),
23786            proof_path,
23787            Some(outside.join("release.signal")),
23788        )
23789        .unwrap_err();
23790        assert!(matches!(
23791            error,
23792            NodeServerError::Registry(message)
23793                if message == "provider bundle argv release path must share the proof parent"
23794        ));
23795        drop(server);
23796        std::fs::remove_dir_all(root).unwrap();
23797        std::fs::remove_dir_all(outside).unwrap();
23798    }
23799
23800    #[cfg(feature = "fixture")]
23801    #[test]
23802    fn four_provider_context_fixture_registry_is_unambiguous_and_history_bound() {
23803        let root = temporary_workspace_root("context-fixture-registry");
23804        std::fs::create_dir_all(&root).unwrap();
23805        let proof_path = root.join("context.proof");
23806        let config = NodeServerConfig::new(
23807            format!(
23808                r"\\.\pipe\gate4agent-context-fixture-registry-{}",
23809                std::process::id(),
23810            ),
23811            "fixture-token",
23812            NodeId::new("context-fixture-node").unwrap(),
23813            [WorkspaceConfig::new(
23814                WorkspaceId::new("primary").unwrap(),
23815                std::env::current_dir().unwrap(),
23816            )
23817            .unwrap()],
23818        )
23819        .unwrap();
23820        let server = NodeServer::new_context_pack_fixture(config, proof_path).unwrap();
23821        let mut enabled = server
23822            .shared
23823            .enabled_providers
23824            .iter()
23825            .map(AgentId::as_str)
23826            .collect::<Vec<_>>();
23827        enabled.sort_unstable();
23828        assert_eq!(enabled, ["claude", "codex", "grok", "kimi"]);
23829        for provider in &server.shared.enabled_providers {
23830            assert!(server
23831                .shared
23832                .provider_adapter_contracts
23833                .iter()
23834                .any(|contract| {
23835                    &contract.provider == provider
23836                        && contract.family == AdapterFamily::History
23837                }));
23838        }
23839        drop(server);
23840        std::fs::remove_dir_all(root).unwrap();
23841    }
23842
23843    #[cfg(feature = "fixture")]
23844    #[test]
23845    fn monitoring_context_fixture_combines_claude_and_codex_hooks_with_history() {
23846        let root = temporary_workspace_root("monitoring-context-fixture");
23847        std::fs::create_dir_all(&root).unwrap();
23848        let proof_path = root.join("context.proof");
23849        let standard = NodeServer::context_pack_fixture_catalog(
23850            proof_path.clone(),
23851            None,
23852            false,
23853        ).unwrap();
23854        let monitoring = NodeServer::context_pack_fixture_catalog(
23855            proof_path.clone(),
23856            None,
23857            true,
23858        ).unwrap();
23859        let claude = AgentId::new("claude").unwrap();
23860        let standard_claude = standard.get(&claude).unwrap();
23861        let monitoring_claude = monitoring.get(&claude).unwrap();
23862        // Lifecycle hooks are retired (owner ruling 2026-09-25): neither the
23863        // standard nor the monitoring fixture declares a hook adapter
23864        // anymore.
23865        assert!(monitoring_claude.capabilities.adapters.hook.is_none());
23866        assert_eq!(
23867            monitoring_claude.capabilities.adapters.history,
23868            standard_claude.capabilities.adapters.history,
23869        );
23870        assert_ne!(monitoring_claude.launch, standard_claude.launch);
23871        let codex = AgentId::new("codex").unwrap();
23872        let standard_codex = standard.get(&codex).unwrap();
23873        let monitoring_codex = monitoring.get(&codex).unwrap();
23874        assert!(monitoring_codex.capabilities.adapters.hook.is_none());
23875        assert_eq!(
23876            monitoring_codex.capabilities.adapters.history,
23877            standard_codex.capabilities.adapters.history,
23878        );
23879        assert_ne!(monitoring_codex.launch, standard_codex.launch);
23880        let codex_validation = monitoring_codex.launch.fixed_args.iter()
23881            .find(|argument| argument.contains("F7_CODEX_BUNDLE_CONTEXT_VALIDATED"))
23882            .unwrap();
23883        for required in [
23884            "$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')",
23885            "$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')",
23886            "@('role', 'text')",
23887            "g4a-private-provider-session-canary",
23888            "private-provider-login-identity",
23889            "$rawProof",
23890            "$env:GATE4AGENT_HOOK_URL",
23891            "g4a-private-provider-session-canary",
23892        ] {
23893            assert!(codex_validation.contains(required), "missing privacy validation: {required}");
23894        }
23895        for provider in ["grok", "kimi"] {
23896            let provider = AgentId::new(provider).unwrap();
23897            assert_eq!(monitoring.get(&provider), standard.get(&provider));
23898        }
23899
23900        let config = NodeServerConfig::new(
23901            format!(
23902                r"\\.\pipe\gate4agent-monitoring-context-fixture-{}",
23903                std::process::id(),
23904            ),
23905            "fixture-token",
23906            NodeId::new("monitoring-context-fixture-node").unwrap(),
23907            [WorkspaceConfig::new(
23908                WorkspaceId::new("primary").unwrap(),
23909                std::env::current_dir().unwrap(),
23910            )
23911            .unwrap()],
23912        )
23913        .unwrap();
23914        let server = NodeServer::new_monitoring_context_pack_fixture(config, proof_path).unwrap();
23915        assert!(server.shared.fixture_semantic_hook_policy);
23916        drop(server);
23917        std::fs::remove_dir_all(root).unwrap();
23918    }
23919
23920    #[cfg(feature = "fixture")]
23921    #[test]
23922    fn kimi_context_only_fixture_is_bounded_and_preserves_codex_validation() {
23923        let root = temporary_workspace_root("kimi-context-only-fixture");
23924        std::fs::create_dir_all(&root).unwrap();
23925        let proof_path = root.join("context.proof");
23926        let standard = NodeServer::context_pack_fixture_catalog(
23927            proof_path.clone(),
23928            None,
23929            false,
23930        ).unwrap();
23931        let kimi = AgentId::new("kimi").unwrap();
23932        let context_only = NodeServer::context_pack_fixture_catalog(
23933            proof_path,
23934            Some(&kimi),
23935            false,
23936        )
23937        .unwrap();
23938
23939        let codex = AgentId::new("codex").unwrap();
23940        assert_eq!(
23941            standard.get(&codex).unwrap().launch.fixed_args,
23942            context_only.get(&codex).unwrap().launch.fixed_args,
23943        );
23944        let script = context_only
23945            .get(&kimi)
23946            .unwrap()
23947            .launch
23948            .fixed_args
23949            .iter()
23950            .find(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED"))
23951            .expect("Kimi context-only validation script is missing");
23952        for required in [
23953            "$bundleArgs.Count -ne 0",
23954            "GATE4AGENT_CONTEXT_ROOT",
23955            "context-pack.json",
23956            "$entries.Count -ne 1",
23957            "g4a-context-pack-v1",
23958            "$document.PSObject.Properties['cwd']",
23959            "'claude', 'codex', 'grok', 'kimi'",
23960            "$hasUser",
23961            "$hasAssistant",
23962            "SHA256",
23963            "context-only",
23964        ] {
23965            assert!(script.contains(required), "missing validation: {required}");
23966        }
23967        for forbidden in ["CODEX_HOME", "USERPROFILE", "auth", "token", "global config"] {
23968            assert!(!script.contains(forbidden), "forbidden home/auth access: {forbidden}");
23969        }
23970        assert!(!standard
23971            .get(&kimi)
23972            .unwrap()
23973            .launch
23974            .fixed_args
23975            .iter()
23976            .any(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED")));
23977
23978        std::fs::remove_dir_all(root).unwrap();
23979    }
23980
23981    #[cfg(all(feature = "fixture", windows))]
23982    #[tokio::test]
23983    async fn kimi_context_only_fixture_spawns_catalogued_context_pack_through_spawn_spec() {
23984        let root = temporary_workspace_root("kimi-context-only-spawn");
23985        let workspace_root = root.join("workspace");
23986        std::fs::create_dir_all(&workspace_root).unwrap();
23987        let materialization_root = root.join("private-materializations");
23988        let proof_path = root.join("context.proof");
23989        let node_id = NodeId::new("node-kimi-context-only-spawn").unwrap();
23990        let workspace_id = WorkspaceId::new("primary").unwrap();
23991        let profile_id = crate::protocol::SpawnProfileId::new("target-kimi").unwrap();
23992        let endpoint = format!(
23993            r"\\.\pipe\gate4agent-kimi-context-only-spawn-{}-{}",
23994            std::process::id(),
23995            unix_time_ms(),
23996        );
23997        let spawn_profiles = SpawnProfileRegistry::new([
23998            crate::protocol::SpawnProfileDefaults {
23999                profile_id: profile_id.clone(),
24000                revision: crate::protocol::SpawnProfileRevision::new("target-kimi-r1").unwrap(),
24001                provider: agent("kimi"),
24002                mode: SessionMode::Pty,
24003                terminal_size: gate4agent_types::TerminalSize {
24004                    rows: 30,
24005                    columns: 120,
24006                },
24007                prompt: None,
24008                bundle_id: None,
24009                context_id: None,
24010                environment_profile_id: None,
24011            },
24012        ])
24013        .unwrap();
24014        let mut config = NodeServerConfig::new(
24015            &endpoint,
24016            "fixture-token",
24017            node_id.clone(),
24018            [WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap()],
24019        )
24020        .unwrap()
24021        .with_spawn_profiles(spawn_profiles)
24022        .with_session_environment_materialization(
24023            materialization_root,
24024            Arc::new(FixtureSecretResolver {
24025                deny: Arc::new(AtomicBool::new(false)),
24026            }),
24027        )
24028        .unwrap();
24029        config.fixture_raw_pty_runtime = true;
24030        let server = NodeServer::new_context_only_proof_fixture(
24031            config,
24032            agent("kimi"),
24033            proof_path.clone(),
24034        )
24035        .unwrap();
24036        let shared = Arc::clone(&server.shared);
24037        let shutdown = server.shutdown_handle();
24038
24039        let history = HistorySessionRecord {
24040            session_id: "context-only-source-history".to_owned(),
24041            title: None,
24042            cwd: None,
24043            model: None,
24044            message_count: 2,
24045            completed_turn_count: None,
24046            total_tokens: 0,
24047            messages: vec![
24048                gate4agent_types::HistoryMessageRecord {
24049                    role: gate4agent_types::HistoryMessageRole::User,
24050                    text: "continue the bounded parent run through native C2".to_owned(),
24051                },
24052                gate4agent_types::HistoryMessageRecord {
24053                    role: gate4agent_types::HistoryMessageRole::Assistant,
24054                    text: "the exact parent context is ready for the child run".to_owned(),
24055                },
24056            ],
24057        };
24058        let pack = NodeContextPack::export(
24059            ContextPackLineageReceipt {
24060                source_node_id: node_id.clone(),
24061                source_session: SessionAddress {
24062                    workspace_id: workspace_id.clone(),
24063                    session: SessionKey {
24064                        instance_id: AgentInstanceId(71),
24065                        generation: SessionGeneration(1),
24066                    },
24067                },
24068                source_provider: agent("codex"),
24069            },
24070            &history,
24071        )
24072        .unwrap();
24073        let expected_context_hash = digest(&SHA256, pack.bytes())
24074            .as_ref()
24075            .iter()
24076            .map(|byte| format!("{byte:02x}"))
24077            .collect::<String>();
24078        let context = shared
24079            .context_catalog
24080            .write()
24081            .unwrap_or_else(|poisoned| poisoned.into_inner())
24082            .insert(pack)
24083            .unwrap();
24084        let spec = SpawnSpec {
24085            target: crate::protocol::SpawnTarget {
24086                node_id,
24087                workspace_id: workspace_id.clone(),
24088                worktree_id: None,
24089            },
24090            profile_id,
24091            expected_profile_revision: crate::protocol::SpawnProfileRevision::new(
24092                "target-kimi-r1",
24093            ).unwrap(),
24094            overrides: crate::protocol::SpawnOverrides {
24095                context_id: crate::protocol::SpawnOverride::Set {
24096                    value: context.id.clone(),
24097                },
24098                ..crate::protocol::SpawnOverrides::default()
24099            },
24100            deadline_ms: crate::protocol::SpawnDeadlineMs::new(10_000).unwrap(),
24101            idempotency_key: SpawnIdempotencyKey::new("kimi-context-only-spawn").unwrap(),
24102            required_capabilities: SpawnRequiredCapabilities::default(),
24103        };
24104        let resolved = match shared.resolve_spawn_spec(&spec) {
24105            Ok(resolved) => resolved,
24106            Err(error) => panic!(
24107                "Kimi context-only resolve rejected: exact_code={:?} message={}",
24108                error.code,
24109                error.message,
24110            ),
24111        };
24112        let resolved_context = match shared.resolve_context(&resolved) {
24113            Ok(Some(resolved_context)) => resolved_context,
24114            Ok(None) => panic!("Kimi context-only resolve omitted the exact ContextPack"),
24115            Err(error) => panic!(
24116                "Kimi context catalog resolve rejected: exact_code={:?} message={}",
24117                error.code,
24118                error.message,
24119            ),
24120        };
24121        assert_eq!(resolved_context, context);
24122        let runtime_policy = match shared
24123            .admit_provider_runtime(&agent("kimi"), ProviderRuntimeRequirement::RawPty)
24124            .await
24125        {
24126            Ok(runtime_policy) => runtime_policy,
24127            Err(error) => panic!(
24128                "Kimi RawPty admission rejected: exact_code={:?} message={}",
24129                error.code,
24130                error.message,
24131            ),
24132        };
24133        assert!(runtime_policy.raw_pty_lifecycle);
24134
24135        let launch_control = shared.native_launch_profile_control.as_ref().unwrap();
24136        let generic_overlay = NativeLaunchEnvironmentOverlay::new(
24137            agent("kimi"),
24138            TransportKind::Pty,
24139            vec![EnvMutation {
24140                key: OsString::from("GATE4AGENT_UNPROFILED_GENERIC"),
24141                value: Some(OsString::from("must-remain-rejected")),
24142            }],
24143        )
24144        .unwrap();
24145        assert_eq!(
24146            launch_control
24147                .install_native_launch_environment_overlay(
24148                    AgentInstanceId(72),
24149                    generic_overlay,
24150                )
24151                .unwrap_err(),
24152            gate4agent_runtime_native::NativeLaunchProfileError::EnvironmentOverlaySelectionMissing,
24153        );
24154
24155        let diagnostic_address = SessionAddress {
24156            workspace_id: workspace_id.clone(),
24157            session: SessionKey {
24158                instance_id: AgentInstanceId(73),
24159                generation: SessionGeneration(1),
24160            },
24161        };
24162        let (diagnostic_overlay, diagnostic_materialization) = match shared
24163            .prepare_session_materialization(
24164                &diagnostic_address,
24165                &agent("kimi"),
24166                SessionMode::Pty,
24167                None,
24168                None,
24169                Some(&resolved_context),
24170                None,
24171            )
24172        {
24173            Ok(Some(prepared)) => prepared,
24174            Ok(None) => panic!("Kimi context-only materialization produced no launch overlay"),
24175            Err(error) => panic!(
24176                "Kimi context-only materialization rejected: exact_code={:?} message={}",
24177                error.code,
24178                error.message,
24179            ),
24180        };
24181        let diagnostic_overlay = diagnostic_overlay
24182            .expect("Kimi context-only materialization omitted its environment overlay");
24183        assert!(matches!(
24184            &diagnostic_overlay,
24185            PreparedNativeLaunchOverlay::Environment(_),
24186        ));
24187        match shared.install_prepared_launch_overlay(
24188            diagnostic_address.session.instance_id,
24189            diagnostic_overlay,
24190        ) {
24191            Ok(None) => {}
24192            Ok(Some(_)) => panic!("Kimi context-only overlay unexpectedly retained argv authority"),
24193            Err(error) => panic!(
24194                "Kimi context-only overlay installation rejected: exact_code={:?} message={}",
24195                error.code,
24196                error.message,
24197            ),
24198        }
24199        assert!(launch_control.clear_native_instance_launch_overlay(
24200            diagnostic_address.session.instance_id,
24201        ));
24202        drop(diagnostic_materialization);
24203        assert!(shared
24204            .materializations
24205            .lock()
24206            .unwrap_or_else(|poisoned| poisoned.into_inner())
24207            .is_empty());
24208
24209        let node_task = tokio::spawn(server.run());
24210        let ready_client = timeout(Duration::from_secs(5), async {
24211            loop {
24212                match gate4agent_node_wire::LocalNodeClient::connect(
24213                    &endpoint,
24214                    &shared.node_id,
24215                    ClientRole::Observer,
24216                    "fixture-token",
24217                )
24218                .await
24219                {
24220                    Ok(client) => break client,
24221                    Err(_) if !node_task.is_finished() => sleep(Duration::from_millis(10)).await,
24222                    Err(error) => panic!("Kimi context-only Node stopped before readiness: {error}"),
24223                }
24224            }
24225        })
24226        .await
24227        .expect("Kimi context-only Node did not become ready");
24228        assert_eq!(
24229            ready_client
24230                .hello()
24231                .snapshot
24232                .provider_runtime_statuses
24233                .iter()
24234                .find(|status| status.provider().as_str() == "kimi")
24235                .expect("Kimi runtime status is missing")
24236                .mode(),
24237            crate::protocol::ProviderRuntimeMode::RawPassthrough,
24238        );
24239        drop(ready_client);
24240
24241        let receipt = match shared.spawn_from_spec(spec).await {
24242            Ok(receipt) => receipt,
24243            Err(error) => panic!(
24244                "Kimi context-only SpawnSpec rejected: exact_code={:?} message={}",
24245                error.code,
24246                error.message,
24247            ),
24248        };
24249        assert_eq!(receipt.provider, agent("kimi"));
24250        assert_eq!(receipt.context.as_ref(), Some(&context));
24251        assert!(receipt.context_binding_is_valid());
24252        timeout(Duration::from_secs(5), async {
24253            while !proof_path.is_file() {
24254                sleep(Duration::from_millis(10)).await;
24255            }
24256        })
24257        .await
24258        .expect("Kimi context-only fixture did not write its proof");
24259
24260        let proof = std::fs::read_to_string(&proof_path).unwrap();
24261        let lines = proof.lines().collect::<Vec<_>>();
24262        assert_eq!(lines.len(), 7);
24263        assert_eq!(lines[0], "context-only");
24264        assert!(Path::new(lines[1]).is_absolute());
24265        assert_ne!(
24266            std::fs::canonicalize(Path::new(lines[1])).unwrap(),
24267            std::fs::canonicalize(&workspace_root).unwrap(),
24268        );
24269        assert_eq!(
24270            std::fs::canonicalize(Path::new(lines[2])).unwrap(),
24271            std::fs::canonicalize(&workspace_root).unwrap(),
24272        );
24273        assert_eq!(lines[3], expected_context_hash);
24274        assert_eq!(lines[4], "g4a-context-pack-v1");
24275        assert_eq!(lines[5], "codex");
24276        assert_eq!(lines[6], "2");
24277        assert!(shared.handle.snapshot().sessions.iter().any(|session| {
24278            session.instance_id == receipt.session.session.instance_id
24279                && session.agent_id.as_str() == "kimi"
24280        }));
24281
24282        shutdown.request_shutdown().await.unwrap();
24283        timeout(Duration::from_secs(5), node_task)
24284            .await
24285            .unwrap()
24286            .unwrap()
24287            .unwrap();
24288        drop(shutdown);
24289        drop(shared);
24290        std::fs::remove_dir_all(root).unwrap();
24291    }
24292
24293    #[cfg(feature = "fixture")]
24294    #[test]
24295    fn clean_exit_fixture_constructor_is_fixture_scoped_and_path_bounded() {
24296        let root = temporary_workspace_root("clean-exit-constructor");
24297        std::fs::create_dir_all(&root).unwrap();
24298        let config = construction_test_config("clean-exit-constructor")
24299            .with_state_path(root.join("node-state.json"))
24300            .unwrap();
24301        let server = NodeServer::new_clean_exit_fixture(
24302            config,
24303            root.clone(),
24304            root.join("started.marker"),
24305            root.join("release.signal"),
24306        )
24307        .unwrap();
24308        assert_eq!(
24309            server
24310                .shared
24311                .enabled_providers
24312                .iter()
24313                .map(AgentId::as_str)
24314                .collect::<Vec<_>>(),
24315            ["claude"],
24316        );
24317        drop(server);
24318        std::fs::remove_dir_all(root).unwrap();
24319    }
24320
24321    #[test]
24322    fn production_registry_provider_contract_manifest_is_exact() {
24323        let registry = active_registry().unwrap();
24324        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
24325        assert_eq!(
24326            providers
24327                .iter()
24328                .map(|contract| (contract.provider.as_str(), contract.revision.as_str()))
24329                .collect::<Vec<_>>(),
24330            vec![
24331                ("claude", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24332                ("codex", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24333                ("grok", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24334                ("kimi", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24335            ]
24336        );
24337        assert_eq!(
24338            adapters
24339                .iter()
24340                .map(|contract| (
24341                    contract.provider.as_str(),
24342                    contract.family,
24343                    contract.adapter_id.as_str(),
24344                    contract.revision.as_str(),
24345                ))
24346                .collect::<Vec<_>>(),
24347            vec![
24348                ("claude", AdapterFamily::PtySemantic, "claude-code", "gate4agent-adapter/v1"),
24349                ("claude", AdapterFamily::Pipe, "claude-code", "gate4agent-adapter/v1"),
24350                ("claude", AdapterFamily::Acp, "claude-code", "gate4agent-adapter/v1"),
24351                ("claude", AdapterFamily::OneShot, "claude", "gate4agent-inline/claude-code-2.1/v1"),
24352                ("claude", AdapterFamily::History, "claude-code", "gate4agent-adapter/v1"),
24353                ("claude", AdapterFamily::Resume, "claude-code", "gate4agent-adapter/v1"),
24354                ("claude", AdapterFamily::SessionOptions, "claude-code", "gate4agent-session-options/orca-d8629c4/v1"),
24355                ("codex", AdapterFamily::PtySemantic, "codex", "gate4agent-adapter/v1"),
24356                ("codex", AdapterFamily::Pipe, "codex", "gate4agent-adapter/v1"),
24357                ("codex", AdapterFamily::Acp, "codex", "gate4agent-adapter/v1"),
24358                ("codex", AdapterFamily::OneShot, "codex", "gate4agent-inline/codex-cli-0.144/v1"),
24359                ("codex", AdapterFamily::History, "codex", "gate4agent-adapter/v1"),
24360                ("codex", AdapterFamily::Resume, "codex", "gate4agent-adapter/v1"),
24361                ("codex", AdapterFamily::SessionOptions, "codex", "gate4agent-session-options/orca-d8629c4/v1"),
24362                ("grok", AdapterFamily::Acp, "grok", "gate4agent-adapter/v1"),
24363                ("grok", AdapterFamily::History, "grok", "gate4agent-adapter/v1"),
24364                ("grok", AdapterFamily::Resume, "grok", "gate4agent-adapter/v1"),
24365                ("kimi", AdapterFamily::PtySemantic, "kimi", "gate4agent-adapter/v1"),
24366                ("kimi", AdapterFamily::Pipe, "kimi", "gate4agent-adapter/v1"),
24367                ("kimi", AdapterFamily::Acp, "kimi", "gate4agent-adapter/v1"),
24368                ("kimi", AdapterFamily::OneShot, "kimi", "gate4agent-inline/kimi-code-0.31/v1"),
24369                ("kimi", AdapterFamily::History, "kimi", "gate4agent-adapter/v1"),
24370                ("kimi", AdapterFamily::Resume, "kimi", "gate4agent-adapter/v1"),
24371            ]
24372        );
24373    }
24374
24375    fn construction_test_config(name: &str) -> NodeServerConfig {
24376        let workspace = WorkspaceConfig::new(
24377            WorkspaceId::new("primary").unwrap(),
24378            std::env::current_dir().unwrap(),
24379        )
24380        .unwrap();
24381        NodeServerConfig::new(
24382            format!(r"\\.\pipe\gate4agent-{name}"),
24383            "fixture-token",
24384            NodeId::new(name).unwrap(),
24385            [workspace],
24386        )
24387        .unwrap()
24388    }
24389
24390    fn maximum_size_provider_manifest_registry() -> AgentRegistry {
24391        let adapter = AdapterBinding::new(
24392            gate4agent_types::AdapterId::new("a".repeat(96)).unwrap(),
24393            "r".repeat(crate::protocol::MAX_ADAPTER_CONTRACT_REVISION_BYTES),
24394            gate4agent_types::AdapterVerification::SyntheticFixture,
24395        )
24396        .unwrap();
24397        let agent_ids = ["claude", "codex", "kimi"]
24398            .into_iter()
24399            .map(|agent_id| AgentId::new(agent_id).unwrap())
24400            .collect::<Vec<_>>();
24401        let adapters = gate4agent_catalog::AdapterRegistry::new(
24402            [
24403                AdapterFamily::PtySemantic,
24404                AdapterFamily::Pipe,
24405                AdapterFamily::OneShot,
24406                AdapterFamily::Acp,
24407                AdapterFamily::Hook,
24408                AdapterFamily::ManagedHook,
24409                AdapterFamily::History,
24410                AdapterFamily::Resume,
24411                AdapterFamily::SessionOptions,
24412                AdapterFamily::CapabilityProbe,
24413            ]
24414            .into_iter()
24415            .map(|family| gate4agent_catalog::AdapterDescriptor {
24416                family,
24417                binding: adapter.clone(),
24418                agents: agent_ids.clone(),
24419            }),
24420        )
24421        .unwrap();
24422        let specs = agent_ids
24423            .iter()
24424            .map(|agent_id| {
24425                let agent_id = agent_id.as_str();
24426                let mut spec = builtin_registry().get_by_id(agent_id).unwrap().clone();
24427                spec.revision =
24428                    "r".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES);
24429                spec.capabilities.transports.pty_adapter = Some(adapter.clone());
24430                spec.capabilities.transports.pipe = Some(gate4agent_types::PipeTransportSpec {
24431                    adapter: adapter.clone(),
24432                    protocol: gate4agent_types::PipeProtocol::SemanticNdjson,
24433                    launch_override: None,
24434                    prompt_delivery: gate4agent_types::PipePromptDelivery::None,
24435                });
24436                spec.capabilities.transports.acp = Some(gate4agent_types::AcpTransportSpec {
24437                    adapter: adapter.clone(),
24438                    launch_override: None,
24439                });
24440                spec.capabilities.adapters.hook = Some(adapter.clone());
24441                spec.capabilities.adapters.managed_hook = Some(adapter.clone());
24442                spec.capabilities.adapters.one_shot = Some(adapter.clone());
24443                spec.capabilities.adapters.history = Some(adapter.clone());
24444                spec.capabilities.adapters.resume = Some(adapter.clone());
24445                spec.capabilities.adapters.session_options = Some(adapter.clone());
24446                spec.capabilities.adapters.capability_probe = Some(adapter.clone());
24447                spec
24448            })
24449            .collect::<Vec<_>>();
24450        AgentRegistry::new_with_adapters(specs, &adapters).unwrap()
24451    }
24452
24453    /// The real fleet's manifest is accepted by a real server construction.
24454    ///
24455    /// The count is asserted against `active_registry` itself rather than a
24456    /// literal, so this stays a test that construction succeeds for whatever
24457    /// the fleet is -- which is the property worth guarding -- instead of a
24458    /// second place the fleet size is written down and has to be kept in
24459    /// step with the first.
24460    #[test]
24461    fn node_server_construction_accepts_the_production_manifest() {
24462        let registry = active_registry().unwrap();
24463        let (providers, _) = provider_contract_manifest(&registry).unwrap();
24464        assert_eq!(providers.len(), registry.iter().count());
24465        assert!(!providers.is_empty());
24466        let server = NodeServer::new_with_registry(
24467            construction_test_config("manifest-production-bound"),
24468            registry,
24469        )
24470        .unwrap();
24471        drop(server);
24472    }
24473
24474    #[test]
24475    fn node_server_construction_rejects_count_valid_manifest_over_handshake_capacity() {
24476        let registry = maximum_size_provider_manifest_registry();
24477        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
24478        assert_eq!(providers.len(), 3);
24479        assert_eq!(adapters.len(), 30);
24480        assert!(adapters.len() <= crate::protocol::MAX_PROVIDER_ADAPTER_CONTRACTS);
24481        let error = match NodeServer::new_with_registry(
24482            construction_test_config("manifest-handshake-overflow"),
24483            registry,
24484        ) {
24485            Ok(_) => panic!("oversized negotiated manifest was accepted"),
24486            Err(error) => error,
24487        };
24488        assert!(matches!(
24489            error,
24490            NodeServerError::ProviderContractManifest(message)
24491                if message.contains("authentication binding length")
24492                    && message.contains("8192-byte limit")
24493        ));
24494    }
24495
24496    #[test]
24497    fn provider_contract_manifest_rejects_wire_revision_overflow_before_startup() {
24498        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24499        spec.revision = "x".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES + 1);
24500        let registry = AgentRegistry::new([spec]).unwrap();
24501        assert!(matches!(
24502            provider_contract_manifest(&registry),
24503            Err(NodeServerError::ProviderContractManifest(message))
24504                if message.contains("exceeds the 128-byte limit")
24505        ));
24506    }
24507
24508    #[test]
24509    fn provider_contract_manifest_rejects_invalid_wire_revision_before_startup() {
24510        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24511        spec.revision = "invalid revision".to_owned();
24512        let registry = AgentRegistry::new([spec]).unwrap();
24513        assert!(matches!(
24514            provider_contract_manifest(&registry),
24515            Err(NodeServerError::ProviderContractManifest(message))
24516                if message.contains("bounded lowercase ASCII")
24517        ));
24518    }
24519
24520    #[test]
24521    fn provider_contract_manifest_rejects_conflicting_duplicate_family_before_startup() {
24522        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24523        let transport_binding = spec.capabilities.adapters.one_shot.clone().unwrap();
24524        let conflicting_binding = builtin_registry()
24525            .get_by_id("codex")
24526            .unwrap()
24527            .capabilities
24528            .adapters
24529            .one_shot
24530            .clone()
24531            .unwrap();
24532        let pipe = spec.capabilities.transports.pipe.as_mut().unwrap();
24533        pipe.protocol = gate4agent_types::PipeProtocol::OneShotText;
24534        pipe.adapter = transport_binding;
24535        spec.capabilities.adapters.one_shot = Some(conflicting_binding);
24536        let registry = AgentRegistry::new([spec]).unwrap();
24537        assert!(matches!(
24538            provider_contract_manifest(&registry),
24539            Err(NodeServerError::ProviderContractManifest(message))
24540                if message.contains("conflicting OneShot adapter bindings")
24541        ));
24542    }
24543
24544    #[test]
24545    fn public_node_failures_expose_only_fixed_categories() {
24546        let provider = failure(
24547            NodeFailureCode::BackendOperationFailed,
24548            r"provider bearer-secret failed at C:\private\session.jsonl",
24549        );
24550        assert_eq!(provider.message, "backend-operation-failed");
24551        assert!(!provider.message.contains("bearer-secret"));
24552        assert!(!provider.message.contains(r"C:\private"));
24553        let persistence = persistence_failure(io::Error::new(
24554            io::ErrorKind::Other,
24555            r"state write failed at C:\private\state-v1.json",
24556        ));
24557        assert_eq!(persistence.message, DURABLE_STATE_COMMIT_FAILED_ERROR);
24558        assert!(!persistence.message.contains(r"C:\private"));
24559
24560        for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::Unsupported] {
24561            let generic = durable_state_load_error(io::Error::new(kind, "host I/O failure"));
24562            assert_eq!(
24563                generic.to_string(),
24564                "node durable state failed: durable-state-load-failed",
24565            );
24566        }
24567    }
24568
24569    #[test]
24570    fn terminal_byte_sequences_are_bounded_before_dispatch() {
24571        let sequence = b"\x1b[1;5D".to_vec();
24572        assert_eq!(
24573            terminal_bytes_action(sequence.clone()).unwrap(),
24574            InputAction::TerminalBytes(sequence),
24575        );
24576        assert_eq!(
24577            terminal_bytes_action(Vec::new()).unwrap_err().code,
24578            NodeFailureCode::InvalidRequest,
24579        );
24580        assert_eq!(
24581            terminal_bytes_action(vec![0; MAX_NODE_TERMINAL_BYTES + 1])
24582                .unwrap_err()
24583                .code,
24584            NodeFailureCode::InvalidRequest,
24585        );
24586    }
24587
24588    #[test]
24589    fn node_server_rejects_a_second_owner_of_the_same_durable_state_path() {
24590        let root = temporary_workspace_root("exclusive-state-owner");
24591        std::fs::create_dir_all(&root).unwrap();
24592        let workspace = WorkspaceConfig::new(
24593            WorkspaceId::new("primary").unwrap(),
24594            &root,
24595        )
24596        .unwrap();
24597        let state_path = root.join("state-v1.json");
24598        let endpoint = format!(
24599            r"\\.\pipe\gate4agent-node-state-lock-{}-{}",
24600            std::process::id(),
24601            std::time::SystemTime::now()
24602                .duration_since(std::time::UNIX_EPOCH)
24603                .unwrap()
24604                .as_nanos(),
24605        );
24606        let config = NodeServerConfig::new(
24607            endpoint,
24608            "fixture-token",
24609            NodeId::new("node-1").unwrap(),
24610            [workspace],
24611        )
24612        .unwrap()
24613        .with_state_path(&state_path)
24614        .unwrap();
24615
24616        let first = NodeServer::new(config.clone()).unwrap();
24617        let error = match NodeServer::new(config.clone()) {
24618            Ok(_) => panic!("a second node server acquired the same durable state path"),
24619            Err(error) => error,
24620        };
24621        assert!(matches!(
24622            &error,
24623            NodeServerError::DurableState(error)
24624                if error.kind() == io::ErrorKind::WouldBlock
24625        ));
24626        assert_eq!(
24627            error.to_string(),
24628            "node durable state failed: durable-state-lock-failed",
24629        );
24630        assert!(!error.to_string().contains(&state_path.to_string_lossy().into_owned()));
24631        drop(first);
24632        let second = NodeServer::new(config).unwrap();
24633        drop(second);
24634        std::fs::remove_dir_all(root).unwrap();
24635    }
24636
24637    #[test]
24638    fn session_binding_is_workspace_scoped_and_removed_fail_closed() {
24639        let catalog = active_registry().unwrap();
24640        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24641        let workspace_id = WorkspaceId::new("primary").unwrap();
24642        let workspace = WorkspaceConfig::new(
24643            workspace_id.clone(),
24644            std::env::current_dir().unwrap(),
24645        )
24646        .unwrap();
24647        let shared = NodeShared::new(
24648            handle,
24649            "fixture-token".to_owned(),
24650            NodeId::new("node-1").unwrap(),
24651            vec![workspace.clone()],
24652            vec![agent("claude"), agent("codex"), agent("kimi")],
24653        );
24654        let instance_id = AgentInstanceId(17);
24655        let generation = SessionGeneration(3);
24656        assert!(shared.address_for(instance_id, generation).is_none());
24657        shared.bind_session(instance_id, workspace_id.clone(), generation);
24658        let address = SessionAddress {
24659            workspace_id,
24660            session: SessionKey { instance_id, generation },
24661        };
24662        assert_eq!(
24663            shared.workspace_root(&address.workspace_id).unwrap(),
24664            workspace.canonical_root,
24665        );
24666        assert_eq!(shared.address_for(instance_id, generation), Some(address.clone()));
24667        shared.remove_binding(&address);
24668        assert!(shared.address_for(instance_id, generation).is_none());
24669    }
24670
24671    #[test]
24672    fn open_provider_request_gate_is_state_aware_and_fail_closed() {
24673        let address = SessionAddress {
24674            workspace_id: WorkspaceId::new("primary").unwrap(),
24675            session: SessionKey {
24676                instance_id: AgentInstanceId(7),
24677                generation: SessionGeneration(2),
24678            },
24679        };
24680        let record_id = SessionRecordId::new("record-a").unwrap();
24681        let size = gate4agent_types::TerminalSize {
24682            rows: 24,
24683            columns: 80,
24684        };
24685        let address_requests = [
24686            NodeRequest::Resume {
24687                session: address.clone(),
24688                terminal_size: size,
24689                initial_prompt: None,
24690            },
24691            NodeRequest::Prompt { session: address.clone(), text: "p".to_owned() },
24692            NodeRequest::Paste { session: address.clone(), text: "p".to_owned() },
24693            NodeRequest::Input { session: address.clone(), text: "p".to_owned() },
24694            NodeRequest::TerminalBytes { session: address.clone(), bytes: vec![1] },
24695            NodeRequest::TerminalControl {
24696                session: address.clone(),
24697                control: TerminalControl::Enter,
24698            },
24699            NodeRequest::Resize { session: address.clone(), size },
24700            NodeRequest::Interrupt { session: address.clone() },
24701            NodeRequest::Stop { session: address.clone(), force: false },
24702            NodeRequest::Remove { session: address },
24703        ];
24704        for request in &address_requests {
24705            assert!(request_requires_open_provider_ids_with(
24706                request,
24707                |_| Some(agent("third-party-agent")),
24708                |_| Some(agent("claude")),
24709            ));
24710            assert!(!request_requires_open_provider_ids_with(
24711                request,
24712                |_| Some(agent("claude")),
24713                |_| Some(agent("claude")),
24714            ));
24715            assert!(request_requires_open_provider_ids_with(
24716                request,
24717                |_| None,
24718                |_| Some(agent("claude")),
24719            ));
24720        }
24721
24722        let record_requests = [
24723            NodeRequest::RenameSessionRecord {
24724                record_id: record_id.clone(),
24725                display_name: "renamed".to_owned(),
24726            },
24727            NodeRequest::ResumeSessionRecord {
24728                record_id: record_id.clone(),
24729                terminal_size: size,
24730                initial_prompt: None,
24731            },
24732            NodeRequest::ForgetSessionRecord { record_id },
24733        ];
24734        for request in &record_requests {
24735            assert!(request_requires_open_provider_ids_with(
24736                request,
24737                |_| Some(agent("claude")),
24738                |_| Some(agent("grok")),
24739            ));
24740            assert!(!request_requires_open_provider_ids_with(
24741                request,
24742                |_| Some(agent("claude")),
24743                |_| Some(agent("kimi")),
24744            ));
24745            assert!(request_requires_open_provider_ids_with(
24746                request,
24747                |_| Some(agent("claude")),
24748                |_| None,
24749            ));
24750        }
24751
24752        assert!(request_requires_open_provider_ids_with(
24753            &NodeRequest::Spawn {
24754                workspace_id: WorkspaceId::new("primary").unwrap(),
24755                provider: agent("third-party-agent"),
24756                mode: SessionMode::Pty,
24757                terminal_size: size,
24758                initial_prompt: None,
24759            },
24760            |_| None,
24761            |_| None,
24762        ));
24763        assert!(!request_requires_open_provider_ids_with(
24764            &NodeRequest::Snapshot,
24765            |_| None,
24766            |_| None,
24767        ));
24768        assert!(request_requires_open_provider_ids_with(
24769            &NodeRequest::ForgetContextPack {
24770                context_id: SpawnContextId::new("ctx-forget").unwrap(),
24771            },
24772            |_| None,
24773            |_| None,
24774        ));
24775    }
24776
24777    #[test]
24778    fn legacy_projection_filters_open_record_replies_and_event_history() {
24779        let catalog = active_registry().unwrap();
24780        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24781        let workspace = WorkspaceConfig::new(
24782            WorkspaceId::new("primary").unwrap(),
24783            std::env::current_dir().unwrap(),
24784        )
24785        .unwrap();
24786        let shared = NodeShared::new(
24787            handle,
24788            "fixture-token".to_owned(),
24789            NodeId::new("node-1").unwrap(),
24790            vec![workspace],
24791            vec![agent("claude"), agent("grok")],
24792        );
24793        let legacy = record("claude", "legacy-record");
24794        let open = record("grok", "open-record");
24795        let legacy_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
24796            record: legacy.clone(),
24797        });
24798        let open_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
24799            record: open.clone(),
24800        });
24801        let legacy_removed = shared.publish(NodeEvent::SessionRecordRemoved {
24802            record_id: legacy.record_id.clone(),
24803        });
24804        let open_removed = shared.publish(NodeEvent::SessionRecordRemoved {
24805            record_id: open.record_id.clone(),
24806        });
24807        assert!(project_event_legacy_provider_ids(&shared, legacy_upsert.clone()).is_some());
24808        assert!(project_event_legacy_provider_ids(&shared, open_upsert.clone()).is_none());
24809        assert!(project_event_legacy_provider_ids(&shared, legacy_removed.clone()).is_some());
24810        assert!(project_event_legacy_provider_ids(&shared, open_removed.clone()).is_none());
24811
24812        let mut record_reply = ResponseEnvelope {
24813            request_id: 1,
24814            result: Ok(NodeResponse::SessionRecordUpdated { record: open }),
24815        };
24816        project_response_legacy_provider_ids(&shared, &mut record_reply);
24817        assert!(matches!(
24818            record_reply.result,
24819            Err(NodeFailure { code: NodeFailureCode::UnsupportedCapability, .. })
24820        ));
24821
24822        let mut resync = ResponseEnvelope {
24823            request_id: 2,
24824            result: Ok(NodeResponse::Resync {
24825                event_sequence: open_removed.sequence,
24826                oldest_available_sequence: 1,
24827                snapshot: shared.snapshot(),
24828                events: vec![legacy_upsert, open_upsert, legacy_removed, open_removed],
24829            }),
24830        };
24831        project_response_legacy_provider_ids(&shared, &mut resync);
24832        let Ok(NodeResponse::Resync { snapshot, events, .. }) = resync.result else {
24833            panic!("legacy projection changed the resync response kind");
24834        };
24835        assert!(snapshot.session_records.iter().all(|item| {
24836            provider_id_is_legacy(&item.provider)
24837        }));
24838        assert_eq!(events.len(), 2);
24839        assert!(events.iter().all(|event| matches!(
24840            &event.event,
24841            NodeEvent::SessionRecordUpserted { record }
24842                if provider_id_is_legacy(&record.provider)
24843        ) || matches!(&event.event, NodeEvent::SessionRecordRemoved { .. })));
24844    }
24845
24846    #[test]
24847    fn session_binding_capacity_is_bounded_by_the_control_session_limit() {
24848        let catalog = active_registry().unwrap();
24849        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24850        let workspace_id = WorkspaceId::new("primary").unwrap();
24851        let workspace = WorkspaceConfig::new(
24852            workspace_id.clone(),
24853            std::env::current_dir().unwrap(),
24854        )
24855        .unwrap();
24856        let shared = NodeShared::new(
24857            handle,
24858            "fixture-token".to_owned(),
24859            NodeId::new("node-1").unwrap(),
24860            vec![workspace],
24861            vec![agent("claude")],
24862        );
24863        for index in 0..CONTROL_SESSIONS_MAX {
24864            shared.bind_session(
24865                AgentInstanceId((index + 1) as u64),
24866                workspace_id.clone(),
24867                SessionGeneration(1),
24868            );
24869        }
24870        let error = shared.ensure_binding_capacity().unwrap_err();
24871        assert_eq!(error.code, NodeFailureCode::BackendBusy);
24872    }
24873
24874    #[tokio::test]
24875    async fn provider_spawn_admission_rejects_before_session_mutation() {
24876        let catalog = active_registry().unwrap();
24877        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24878        let workspace = WorkspaceConfig::new(
24879            WorkspaceId::new("primary").unwrap(),
24880            std::env::current_dir().unwrap(),
24881        )
24882        .unwrap();
24883        let mut shared = NodeShared::new(
24884            handle,
24885            "fixture-token".to_owned(),
24886            NodeId::new("node-provider-admission").unwrap(),
24887            vec![workspace],
24888            vec![agent("claude")],
24889        );
24890        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
24891            crate::protocol::ProviderRuntimeStatus::unavailable(agent("claude")),
24892        ])
24893        .unwrap();
24894        let terminal_size = gate4agent_types::TerminalSize {
24895            rows: 24,
24896            columns: 80,
24897        };
24898
24899        let unavailable = shared
24900            .spawn_session(
24901                WorkspaceId::new("primary").unwrap(),
24902                agent("claude"),
24903                SessionMode::Pty,
24904                terminal_size,
24905                None,
24906            )
24907            .await
24908            .unwrap_err();
24909        assert_eq!(unavailable.code, NodeFailureCode::BackendOperationFailed);
24910        assert_eq!(unavailable.message, "backend-operation-failed");
24911
24912        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
24913            crate::protocol::ProviderRuntimeStatus::raw_passthrough(
24914                agent("claude"),
24915                Some(crate::protocol::ProviderRuntimeVersion::new("999.0.0").unwrap()),
24916            ),
24917        ])
24918        .unwrap();
24919        let semantic = shared
24920            .spawn_session(
24921                WorkspaceId::new("primary").unwrap(),
24922                agent("claude"),
24923                SessionMode::Inline,
24924                terminal_size,
24925                None,
24926            )
24927            .await
24928            .unwrap_err();
24929        assert_eq!(semantic.code, NodeFailureCode::UnsupportedCapability);
24930        assert_eq!(semantic.message, "unsupported-capability");
24931
24932        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
24933        assert!(shared
24934            .session_bindings
24935            .lock()
24936            .unwrap_or_else(|poisoned| poisoned.into_inner())
24937            .is_empty());
24938        assert!(shared
24939            .session_records
24940            .lock()
24941            .unwrap_or_else(|poisoned| poisoned.into_inner())
24942            .records
24943            .is_empty());
24944        assert!(shared.handle.snapshot().sessions.is_empty());
24945    }
24946
24947    #[tokio::test]
24948    async fn raw_spawn_binding_is_visible_with_a_durable_live_record() {
24949        let catalog = active_registry().unwrap();
24950        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24951        let workspace_id = WorkspaceId::new("primary").unwrap();
24952        let workspace = WorkspaceConfig::new(
24953            workspace_id.clone(),
24954            std::env::current_dir().unwrap(),
24955        )
24956        .unwrap();
24957        let shared = NodeShared::new(
24958            handle,
24959            "fixture-token".to_owned(),
24960            NodeId::new("node-raw-spawn-record").unwrap(),
24961            vec![workspace],
24962            vec![agent("claude")],
24963        );
24964        let address = SessionAddress {
24965            workspace_id,
24966            session: SessionKey {
24967                instance_id: AgentInstanceId(71),
24968                generation: SessionGeneration::default(),
24969            },
24970        };
24971
24972        let record_id = shared
24973            .bind_spawn_session(
24974                &address,
24975                agent("claude"),
24976                SessionMode::Pty,
24977                ProviderRuntimePolicy::raw_pty(),
24978                SpawnRecordPolicy::Always,
24979                None,
24980            )
24981            .unwrap()
24982            .unwrap();
24983        shared
24984            .handle
24985            .dispatch(shared.prepare_command(ControlCommand::Register {
24986                instance_id: address.session.instance_id,
24987                agent_id: agent("claude"),
24988                transport: TransportKind::Pty,
24989            }))
24990            .unwrap();
24991        runtime.tick().await;
24992
24993        assert!(shared.handle.snapshot().sessions.iter().any(|session| {
24994            session.instance_id == address.session.instance_id
24995                && session.generation == address.session.generation
24996        }));
24997        let record = shared.record(&record_id).unwrap();
24998        assert_eq!(record.display_name, "claude #1");
24999        assert_eq!(record.state, ManagedSessionState::Live);
25000        assert_eq!(record.active_session.as_ref(), Some(&address));
25001        assert!(record.provider_session.is_none());
25002        let binding_record_id = shared
25003            .session_bindings
25004            .lock()
25005            .unwrap_or_else(|poisoned| poisoned.into_inner())
25006            .get(&address.session.instance_id)
25007            .and_then(|binding| binding.record_id.clone());
25008        assert_eq!(binding_record_id.as_ref(), Some(&record_id));
25009
25010        let renamed = shared
25011            .rename_session_record(&record_id, "raw review".to_owned())
25012            .unwrap();
25013        assert_eq!(renamed.record_id, record_id);
25014        assert_eq!(renamed.display_name, "raw review");
25015        shared.reconcile_managed_record(
25016            &record_id,
25017            &address,
25018            &ControlEvent {
25019                sequence: 1,
25020                command_id: None,
25021                instance_id: address.session.instance_id,
25022                generation: address.session.generation,
25023                event: ControlEventKind::Running { process_id: None },
25024            },
25025            false,
25026            false,
25027        );
25028        let running = shared.record(&record_id).unwrap();
25029        assert_eq!(running.state, ManagedSessionState::Live);
25030        assert_eq!(running.active_session.as_ref(), Some(&address));
25031        assert_eq!(running.display_name, "raw review");
25032
25033        shared.reconcile_managed_record(
25034            &record_id,
25035            &address,
25036            &ControlEvent {
25037                sequence: 2,
25038                command_id: None,
25039                instance_id: address.session.instance_id,
25040                generation: address.session.generation,
25041                event: ControlEventKind::Exited {
25042                    exit_code: Some(0),
25043                    forced: false,
25044                },
25045            },
25046            false,
25047            false,
25048        );
25049        let detached = shared.record(&record_id).unwrap();
25050        assert_eq!(detached.state, ManagedSessionState::Unavailable);
25051        assert!(detached.active_session.is_none());
25052        assert_eq!(detached.display_name, "raw review");
25053        let renamed_detached = shared
25054            .rename_session_record(&record_id, "raw review detached".to_owned())
25055            .unwrap();
25056        assert_eq!(renamed_detached.record_id, record_id);
25057        assert_eq!(renamed_detached.state, ManagedSessionState::Unavailable);
25058        assert!(renamed_detached.active_session.is_none());
25059        assert_eq!(renamed_detached.display_name, "raw review detached");
25060        assert_eq!(shared.snapshot().session_records, vec![renamed_detached]);
25061    }
25062
25063    #[test]
25064    fn legacy_raw_spawn_binding_remains_untracked() {
25065        let catalog = active_registry().unwrap();
25066        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25067        let workspace_id = WorkspaceId::new("primary").unwrap();
25068        let workspace = WorkspaceConfig::new(
25069            workspace_id.clone(),
25070            std::env::current_dir().unwrap(),
25071        )
25072        .unwrap();
25073        let shared = NodeShared::new(
25074            handle,
25075            "fixture-token".to_owned(),
25076            NodeId::new("node-legacy-raw-spawn").unwrap(),
25077            vec![workspace],
25078            vec![agent("claude")],
25079        );
25080        let address = SessionAddress {
25081            workspace_id,
25082            session: SessionKey {
25083                instance_id: AgentInstanceId(72),
25084                generation: SessionGeneration::default(),
25085            },
25086        };
25087
25088        let record_id = shared
25089            .bind_spawn_session(
25090                &address,
25091                agent("claude"),
25092                SessionMode::Pty,
25093                ProviderRuntimePolicy::raw_pty(),
25094                SpawnRecordPolicy::ProviderIdentityOnly,
25095                None,
25096            )
25097            .unwrap();
25098
25099        assert!(record_id.is_none());
25100        let binding_record_id = shared
25101            .session_bindings
25102            .lock()
25103            .unwrap_or_else(|poisoned| poisoned.into_inner())
25104            .get(&address.session.instance_id)
25105            .and_then(|binding| binding.record_id.clone());
25106        assert!(binding_record_id.is_none());
25107        assert!(shared.snapshot().session_records.is_empty());
25108    }
25109
25110    #[tokio::test]
25111    async fn raw_runtime_policy_rejects_prompt_paste_and_resume_before_mutation() {
25112        let catalog = active_registry().unwrap();
25113        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25114        let workspace_id = WorkspaceId::new("primary").unwrap();
25115        let workspace = WorkspaceConfig::new(
25116            workspace_id.clone(),
25117            std::env::current_dir().unwrap(),
25118        )
25119        .unwrap();
25120        let mut shared = NodeShared::new(
25121            handle,
25122            "fixture-token".to_owned(),
25123            NodeId::new("node-raw-semantic-gate").unwrap(),
25124            vec![workspace],
25125            vec![agent("claude")],
25126        );
25127        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25128            crate::protocol::ProviderRuntimeStatus::raw_passthrough(agent("claude"), None),
25129        ])
25130        .unwrap();
25131        shared
25132            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25133            .unwrap();
25134        let address = SessionAddress {
25135            workspace_id,
25136            session: SessionKey {
25137                instance_id: AgentInstanceId(72),
25138                generation: SessionGeneration::default(),
25139            },
25140        };
25141        shared.bind_session_with_policy(
25142            &address,
25143            ProviderRuntimePolicy::raw_pty(),
25144            None,
25145        );
25146        shared
25147            .handle
25148            .dispatch(shared.prepare_command(ControlCommand::Register {
25149                instance_id: address.session.instance_id,
25150                agent_id: agent("claude"),
25151                transport: TransportKind::Pty,
25152            }))
25153            .unwrap();
25154        runtime.tick().await;
25155        let command_id_before = shared.next_command_id.load(Ordering::Acquire);
25156        let revision_before = shared.handle.snapshot().revision;
25157        let history_sequence_before = shared.current_sequence();
25158        let terminal_size = gate4agent_types::TerminalSize {
25159            rows: 24,
25160            columns: 80,
25161        };
25162
25163        for request in [
25164            NodeRequest::Prompt {
25165                session: address.clone(),
25166                text: "prompt".to_owned(),
25167            },
25168            NodeRequest::Paste {
25169                session: address.clone(),
25170                text: "paste".to_owned(),
25171            },
25172            // `initial_prompt: Some(_)` selects `ResumeWithPrompt`, which needs
25173            // the same semantic capabilities as `Prompt`/`Paste`. A bare
25174            // (promptless) resume is a raw PTY relaunch and IS admitted under
25175            // `raw_pty()` — see `raw_pty_admits_provider_native_resume_without_prompt_only`
25176            // in `provider_runtime.rs`; it is deliberately not exercised here.
25177            NodeRequest::Resume {
25178                session: address.clone(),
25179                terminal_size,
25180                initial_prompt: Some("resume".to_owned()),
25181            },
25182        ] {
25183            let error = process_request_inner(
25184                &shared,
25185                77,
25186                ClientRole::Operator,
25187                request,
25188            )
25189            .await
25190            .unwrap_err();
25191            assert_eq!(error.code, NodeFailureCode::UnsupportedCapability);
25192        }
25193
25194        assert_eq!(shared.next_command_id.load(Ordering::Acquire), command_id_before);
25195        assert_eq!(shared.handle.snapshot().revision, revision_before);
25196        assert_eq!(shared.current_sequence(), history_sequence_before);
25197        let bindings = shared
25198            .session_bindings
25199            .lock()
25200            .unwrap_or_else(|poisoned| poisoned.into_inner());
25201        let binding = bindings.get(&address.session.instance_id).unwrap();
25202        assert_eq!(binding.generation, address.session.generation);
25203        assert!(binding.pending_resume.is_none());
25204        assert!(binding.record_id.is_none());
25205    }
25206
25207    #[tokio::test]
25208    async fn windows_rejects_unix_bytes_workspace_path_before_filesystem_access() {
25209        let catalog = active_registry().unwrap();
25210        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25211        let primary = WorkspaceConfig::new(
25212            WorkspaceId::new("primary").unwrap(),
25213            std::env::current_dir().unwrap(),
25214        )
25215        .unwrap();
25216        let shared = NodeShared::new(
25217            handle,
25218            "fixture-token".to_owned(),
25219            NodeId::new("node-1").unwrap(),
25220            vec![primary],
25221            vec![agent("claude")],
25222        );
25223        shared
25224            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25225            .unwrap();
25226        let before = shared.snapshot();
25227        let error = process_request_inner(
25228            &shared,
25229            77,
25230            ClientRole::Operator,
25231            NodeRequest::RegisterWorkspace {
25232                workspace_id: WorkspaceId::new("foreign").unwrap(),
25233                root: OpaqueHostPath::unix_bytes(b"/srv/repo".to_vec()).unwrap(),
25234            },
25235        )
25236        .await
25237        .unwrap_err();
25238        assert_eq!(error.code, NodeFailureCode::InvalidRequest);
25239        assert_eq!(error.message, "invalid-request");
25240        assert_eq!(shared.snapshot().workspaces, before.workspaces);
25241    }
25242
25243    #[tokio::test]
25244    async fn dynamic_workspaces_reject_duplicates_busy_and_last_then_publish_ordered_events() {
25245        let catalog = active_registry().unwrap();
25246        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25247        let primary_id = WorkspaceId::new("primary").unwrap();
25248        let primary = WorkspaceConfig::new(
25249            primary_id.clone(),
25250            std::env::current_dir().unwrap(),
25251        )
25252        .unwrap();
25253        let shared = NodeShared::new(
25254            handle,
25255            "fixture-token".to_owned(),
25256            NodeId::new("node-1").unwrap(),
25257            vec![primary.clone()],
25258            vec![agent("claude")],
25259        );
25260        let secondary_id = WorkspaceId::new("secondary").unwrap();
25261        let secondary = shared
25262            .register_workspace(
25263                secondary_id.clone(),
25264                std::env::temp_dir().to_string_lossy().into_owned(),
25265            )
25266            .await
25267            .unwrap();
25268        assert_eq!(secondary.workspace_id, secondary_id);
25269        assert!(secondary.sessions.is_empty());
25270        assert_eq!(shared.snapshot().workspaces.len(), 2);
25271
25272        let duplicate_id = shared
25273            .register_workspace(
25274                secondary_id.clone(),
25275                primary.canonical_root().to_owned(),
25276            )
25277            .await
25278            .unwrap_err();
25279        assert_eq!(duplicate_id.code, NodeFailureCode::DuplicateWorkspaceId);
25280        let duplicate_root = shared
25281            .register_workspace(
25282                WorkspaceId::new("duplicate-root").unwrap(),
25283                windows_path_text(&secondary.canonical_root).to_owned(),
25284            )
25285            .await
25286            .unwrap_err();
25287        assert_eq!(duplicate_root.code, NodeFailureCode::DuplicateWorkspaceRoot);
25288
25289        let address = SessionAddress {
25290            workspace_id: secondary_id.clone(),
25291            session: SessionKey {
25292                instance_id: AgentInstanceId(41),
25293                generation: SessionGeneration(1),
25294            },
25295        };
25296        shared.bind_session(
25297            address.session.instance_id,
25298            secondary_id.clone(),
25299            address.session.generation,
25300        );
25301        let busy = shared.unregister_workspace(&secondary_id).unwrap_err();
25302        assert_eq!(busy.code, NodeFailureCode::WorkspaceBusy);
25303        shared.remove_binding(&address);
25304        shared.unregister_workspace(&secondary_id).unwrap();
25305        assert_eq!(shared.snapshot().workspaces.len(), 1);
25306
25307        let last = shared.unregister_workspace(&primary_id).unwrap_err();
25308        assert_eq!(last.code, NodeFailureCode::LastWorkspace);
25309        let history = shared
25310            .history
25311            .lock()
25312            .unwrap_or_else(|poisoned| poisoned.into_inner());
25313        assert!(matches!(
25314            &history.events[0],
25315            NodeEventEnvelope {
25316                sequence: 1,
25317                event: NodeEvent::WorkspaceAdded { workspace },
25318            } if workspace.workspace_id == secondary_id
25319        ));
25320        assert!(matches!(
25321            &history.events[1],
25322            NodeEventEnvelope {
25323                sequence: 2,
25324                event: NodeEvent::WorkspaceRemoved { workspace_id },
25325            } if workspace_id == &secondary_id
25326        ));
25327    }
25328
25329    #[tokio::test]
25330    async fn provider_session_reference_index_is_controller_gated_and_idempotent() {
25331        let secondary_root = temporary_workspace_root("provider-session-reference-index");
25332        std::fs::create_dir_all(&secondary_root).unwrap();
25333        let catalog = active_registry().unwrap();
25334        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25335        let primary_id = WorkspaceId::new("primary").unwrap();
25336        let secondary_id = WorkspaceId::new("secondary").unwrap();
25337        let shared = NodeShared::new(
25338            handle,
25339            "fixture-token".to_owned(),
25340            NodeId::new("node-session-reference-index").unwrap(),
25341            vec![
25342                WorkspaceConfig::new(primary_id.clone(), std::env::current_dir().unwrap())
25343                    .unwrap(),
25344                WorkspaceConfig::new(secondary_id.clone(), &secondary_root).unwrap(),
25345            ],
25346            vec![agent("claude"), agent("codex")],
25347        );
25348        let identity = ProviderSessionIdentity {
25349            key: gate4agent_types::ProviderSessionKey::SessionId,
25350            id: "provider-session-42".to_owned(),
25351            transcript_path: None,
25352        };
25353        let index_request = |workspace_id: WorkspaceId,
25354                             provider: AgentId,
25355                             identity: ProviderSessionIdentity,
25356                             display_name: &str| {
25357            NodeRequest::IndexProviderSession {
25358                workspace_id,
25359                provider,
25360                identity,
25361                display_name: display_name.to_owned(),
25362            }
25363        };
25364        let request = index_request(
25365            primary_id.clone(),
25366            agent("claude"),
25367            identity.clone(),
25368            "release shepherd",
25369        );
25370        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
25371            capability.as_str() == NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY
25372        }));
25373        let observer = process_request_inner(
25374            &shared,
25375            77,
25376            ClientRole::Observer,
25377            request.clone(),
25378        )
25379        .await
25380        .unwrap_err();
25381        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
25382        let uncontrolled = process_request_inner(
25383            &shared,
25384            77,
25385            ClientRole::Operator,
25386            request.clone(),
25387        )
25388        .await
25389        .unwrap_err();
25390        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
25391        shared
25392            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25393            .unwrap();
25394
25395        let indexed = process_request_inner(
25396            &shared,
25397            77,
25398            ClientRole::Operator,
25399            request.clone(),
25400        )
25401        .await
25402        .unwrap();
25403        let NodeResponse::ProviderSessionIndexed { record } = indexed else {
25404            panic!("provider session reference index returned another response");
25405        };
25406        assert_eq!(record.state, ManagedSessionState::Dormant);
25407        assert_eq!(record.mode, SessionMode::Pty);
25408        assert_eq!(record.workspace_id, primary_id);
25409        assert_eq!(record.provider_session.as_ref(), Some(&identity));
25410        assert!(record.active_session.is_none());
25411        assert!(record.environment_profile.is_none());
25412        assert!(record.bundle.is_none());
25413        assert!(record.context.is_none());
25414
25415        let repeated = process_request_inner(
25416            &shared,
25417            77,
25418            ClientRole::Operator,
25419            index_request(
25420                primary_id.clone(),
25421                agent("claude"),
25422                identity.clone(),
25423                "ignored replacement",
25424            ),
25425        )
25426        .await
25427        .unwrap();
25428        assert!(matches!(
25429            repeated,
25430            NodeResponse::ProviderSessionIndexed { record: ref existing }
25431                if existing.record_id == record.record_id
25432                    && existing.display_name == "release shepherd"
25433        ));
25434        let conflicting_workspace = process_request_inner(
25435            &shared,
25436            77,
25437            ClientRole::Operator,
25438            index_request(
25439                secondary_id,
25440                agent("claude"),
25441                identity.clone(),
25442                "release shepherd",
25443            ),
25444        )
25445        .await
25446        .unwrap_err();
25447        assert_eq!(conflicting_workspace.code, NodeFailureCode::SessionRecordConflict);
25448        let conflicting_provider = process_request_inner(
25449            &shared,
25450            77,
25451            ClientRole::Operator,
25452            index_request(
25453                primary_id.clone(),
25454                agent("codex"),
25455                identity.clone(),
25456                "release shepherd",
25457            ),
25458        )
25459        .await
25460        .unwrap_err();
25461        assert_eq!(conflicting_provider.code, NodeFailureCode::SessionRecordConflict);
25462        let transcript_rejected = process_request_inner(
25463            &shared,
25464            77,
25465            ClientRole::Operator,
25466            index_request(
25467                primary_id,
25468                agent("claude"),
25469                ProviderSessionIdentity {
25470                    transcript_path: Some("C:/provider/transcript.jsonl".to_owned()),
25471                    ..identity
25472                },
25473                "release shepherd",
25474            ),
25475        )
25476        .await
25477        .unwrap_err();
25478        assert_eq!(transcript_rejected.code, NodeFailureCode::InvalidRequest);
25479        let upserts = shared
25480            .history
25481            .lock()
25482            .unwrap_or_else(|poisoned| poisoned.into_inner())
25483            .events
25484            .iter()
25485            .filter(|event| matches!(
25486                &event.event,
25487                NodeEvent::SessionRecordUpserted { record: published }
25488                    if published.record_id == record.record_id
25489            ))
25490            .count();
25491        assert_eq!(upserts, 1);
25492        std::fs::remove_dir_all(secondary_root).unwrap();
25493    }
25494
25495    #[tokio::test]
25496    async fn standalone_workspace_is_independent_registered_and_controller_gated() {
25497        let fixture_root = temporary_workspace_root("standalone-independent");
25498        let target = fixture_root.join("independent");
25499        std::fs::create_dir_all(&fixture_root).unwrap();
25500        let catalog = active_registry().unwrap();
25501        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25502        let selected = WorkspaceConfig::new(
25503            WorkspaceId::new("primary").unwrap(),
25504            std::env::current_dir().unwrap(),
25505        )
25506        .unwrap();
25507        let selected_root = selected.canonical_root().to_owned();
25508        let shared = NodeShared::new(
25509            handle,
25510            "fixture-token".to_owned(),
25511            NodeId::new("node-standalone").unwrap(),
25512            vec![selected],
25513            vec![agent("claude")],
25514        );
25515        let workspace_id = WorkspaceId::new("independent").unwrap();
25516        let request = NodeRequest::CreateStandaloneWorkspace {
25517            workspace_id: workspace_id.clone(),
25518            root: opaque_windows_path(target.to_string_lossy().into_owned()),
25519            initial_branch: Some("main".to_owned()),
25520        };
25521        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
25522            capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
25523        }));
25524        assert!(!request_uses_unnegotiated_capability(
25525            &request,
25526            &baseline_capabilities().unwrap(),
25527        ));
25528        assert!(request_uses_unnegotiated_capability(&request, &[]));
25529
25530        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
25531            .await
25532            .unwrap_err();
25533        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
25534        assert!(!target.exists());
25535        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
25536            .await
25537            .unwrap_err();
25538        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
25539        assert!(!target.exists());
25540        shared
25541            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25542            .unwrap();
25543
25544        let response = process_request_inner(&shared, 77, ClientRole::Operator, request)
25545            .await
25546            .unwrap();
25547        let NodeResponse::StandaloneWorkspaceCreated { workspace } = response else {
25548            panic!("standalone workspace creation returned another response");
25549        };
25550        assert_eq!(workspace.workspace_id, workspace_id);
25551        assert!(target.join(".git").is_dir());
25552        let branch = run_git_bounded(
25553            target.to_str().unwrap(),
25554            &["branch", "--show-current"],
25555            GIT_OUTPUT_MAX_BYTES,
25556        )
25557        .await
25558        .unwrap();
25559        assert!(branch.success);
25560        assert_eq!(String::from_utf8(branch.stdout).unwrap().trim(), "main");
25561        let worktrees = list_git_worktrees(target.to_str().unwrap()).await.unwrap();
25562        assert_eq!(worktrees.len(), 1);
25563        let listed_root = WorkspaceConfig::new(
25564            WorkspaceId::new("listed-independent").unwrap(),
25565            &worktrees[0].path,
25566        )
25567        .unwrap();
25568        assert!(platform::roots_equal(
25569            listed_root.canonical_root(),
25570            windows_path_text(&workspace.canonical_root),
25571        ));
25572        assert!(!platform::roots_equal(listed_root.canonical_root(), &selected_root));
25573        assert!(shared.snapshot().workspaces.iter().any(|snapshot| {
25574            snapshot.workspace_id == workspace_id
25575                && snapshot.canonical_root == workspace.canonical_root
25576        }));
25577        assert!(shared
25578            .history
25579            .lock()
25580            .unwrap_or_else(|poisoned| poisoned.into_inner())
25581            .events
25582            .iter()
25583            .any(|event| matches!(
25584                &event.event,
25585                NodeEvent::WorkspaceAdded { workspace }
25586                    if workspace.workspace_id == workspace_id
25587            )));
25588        std::fs::remove_dir_all(fixture_root).unwrap();
25589    }
25590
25591    #[tokio::test]
25592    async fn standalone_workspace_registration_failure_preserves_guarded_repository_for_recovery() {
25593        let fixture_root = temporary_workspace_root("standalone-compensation");
25594        let created_target = fixture_root.join("created-target");
25595        let existing_target = fixture_root.join("existing-target");
25596        let invalid_state_parent = fixture_root.join("state-parent-is-a-file");
25597        let invalid_state_path = invalid_state_parent.join("state.json");
25598        std::fs::create_dir_all(&existing_target).unwrap();
25599        std::fs::write(&invalid_state_parent, "not a directory").unwrap();
25600        let catalog = active_registry().unwrap();
25601        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25602        let primary = WorkspaceConfig::new(
25603            WorkspaceId::new("primary").unwrap(),
25604            std::env::current_dir().unwrap(),
25605        )
25606        .unwrap();
25607        let mut shared = NodeShared::new(
25608            handle,
25609            "fixture-token".to_owned(),
25610            NodeId::new("node-standalone-compensation").unwrap(),
25611            vec![primary],
25612            vec![agent("claude")],
25613        );
25614        shared.state_path = Some(invalid_state_path);
25615
25616        let created_recovery = shared
25617            .create_standalone_workspace(
25618                WorkspaceId::new("compensated").unwrap(),
25619                created_target.to_string_lossy().into_owned(),
25620                Some("main".to_owned()),
25621            )
25622            .await
25623            .unwrap_err();
25624        assert_eq!(
25625            created_recovery.code,
25626            NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
25627        );
25628        assert!(created_target.join(".git").is_dir());
25629        assert!(shared.snapshot().workspaces.iter().all(|workspace| {
25630            workspace.workspace_id.as_str() != "compensated"
25631        }));
25632
25633        let existing_recovery = shared
25634            .create_standalone_workspace(
25635                WorkspaceId::new("restored").unwrap(),
25636                existing_target.to_string_lossy().into_owned(),
25637                None,
25638            )
25639            .await
25640            .unwrap_err();
25641        assert_eq!(
25642            existing_recovery.code,
25643            NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
25644        );
25645        assert!(existing_target.is_dir());
25646        assert!(existing_target.join(".git").is_dir());
25647        assert!(shared.snapshot().workspaces.iter().all(|workspace| {
25648            workspace.workspace_id.as_str() != "restored"
25649        }));
25650
25651        let registered_empty = fixture_root.join("registered-empty");
25652        std::fs::create_dir(&registered_empty).unwrap();
25653        let catalog = active_registry().unwrap();
25654        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25655        let registered = WorkspaceConfig::new(
25656            WorkspaceId::new("registered-empty").unwrap(),
25657            &registered_empty,
25658        )
25659        .unwrap();
25660        let duplicate_shared = NodeShared::new(
25661            handle,
25662            "fixture-token".to_owned(),
25663            NodeId::new("node-standalone-duplicate-root").unwrap(),
25664            vec![registered],
25665            vec![agent("claude")],
25666        );
25667        let duplicate = duplicate_shared
25668            .create_standalone_workspace(
25669                WorkspaceId::new("duplicate-root").unwrap(),
25670                registered_empty.to_string_lossy().into_owned(),
25671                None,
25672            )
25673            .await
25674            .unwrap_err();
25675        assert_eq!(duplicate.code, NodeFailureCode::DuplicateWorkspaceRoot);
25676        assert!(std::fs::read_dir(&registered_empty).unwrap().next().is_none());
25677        std::fs::remove_dir_all(fixture_root).unwrap();
25678    }
25679
25680    #[test]
25681    fn managed_record_id_collision_preserves_the_existing_record() {
25682        let catalog = active_registry().unwrap();
25683        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25684        let workspace = WorkspaceConfig::new(
25685            WorkspaceId::new("primary").unwrap(),
25686            std::env::current_dir().unwrap(),
25687        )
25688        .unwrap();
25689        let shared = NodeShared::new(
25690            handle,
25691            "fixture-token".to_owned(),
25692            NodeId::new("node-1").unwrap(),
25693            vec![workspace.clone()],
25694            vec![agent("claude")],
25695        );
25696        let record_id = SessionRecordId::new("sr-collision").unwrap();
25697        let original = ManagedSessionRecord {
25698            record_id: record_id.clone(),
25699            display_name: "original".to_owned(),
25700            provider: agent("claude"),
25701            mode: SessionMode::Pty,
25702            state: ManagedSessionState::Dormant,
25703            workspace_id: workspace.workspace_id,
25704            canonical_root: opaque_windows_path(workspace.canonical_root),
25705            provider_session: None,
25706            active_session: None,
25707            environment_profile: None,
25708            bundle: None,
25709            context_id: None,
25710            context: None,
25711            exported_context: None,
25712            task_binding: None,
25713            created_at_unix_ms: 1,
25714            updated_at_unix_ms: 1,
25715            last_error: None,
25716        };
25717        shared.insert_record(original.clone()).unwrap();
25718        let mut collision = original.clone();
25719        collision.display_name = "replacement".to_owned();
25720        let error = shared.insert_record(collision).unwrap_err();
25721        assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
25722        assert_eq!(shared.record(&record_id).unwrap(), original);
25723    }
25724
25725    #[test]
25726    fn managed_record_runtime_errors_are_exposed_only_as_allowlisted_categories() {
25727        let catalog = active_registry().unwrap();
25728        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25729        let workspace = WorkspaceConfig::new(
25730            WorkspaceId::new("primary").unwrap(),
25731            std::env::current_dir().unwrap(),
25732        )
25733        .unwrap();
25734        let shared = NodeShared::new(
25735            handle,
25736            "fixture-token".to_owned(),
25737            NodeId::new("node-1").unwrap(),
25738            vec![workspace.clone()],
25739            vec![agent("claude")],
25740        );
25741        let record_id = SessionRecordId::new("sr-runtime-error").unwrap();
25742        shared
25743            .insert_record(ManagedSessionRecord {
25744                record_id: record_id.clone(),
25745                display_name: "runtime error".to_owned(),
25746                provider: agent("claude"),
25747                mode: SessionMode::Pty,
25748                state: ManagedSessionState::IdentityPending,
25749                workspace_id: workspace.workspace_id,
25750                canonical_root: opaque_windows_path(workspace.canonical_root),
25751                provider_session: None,
25752                active_session: None,
25753                environment_profile: None,
25754                bundle: None,
25755                context_id: None,
25756                context: None,
25757                exported_context: None,
25758                task_binding: None,
25759                created_at_unix_ms: 1,
25760                updated_at_unix_ms: 1,
25761                last_error: None,
25762            })
25763            .unwrap();
25764
25765        shared
25766            .mark_record_error(
25767                &record_id,
25768                r"provider failed with bearer-secret-123 at C:\private\session.jsonl",
25769            )
25770            .unwrap();
25771        let record = shared.record(&record_id).unwrap();
25772        assert_eq!(record.state, ManagedSessionState::Unavailable);
25773        assert_eq!(
25774            record.last_error.as_deref(),
25775            Some("provider-runtime-failed"),
25776        );
25777        let snapshot = shared.snapshot();
25778        assert_eq!(snapshot.session_records, vec![record]);
25779    }
25780
25781    /// Regression coverage for the "operator sees `deadline` instead of the
25782    /// kernel's own named rejection" defect: a spawn-dispatch waiter must be
25783    /// able to learn a command's asynchronous kernel rejection (here,
25784    /// specifically classified as `UnsupportedTransport`) exactly once, and
25785    /// must not still find it after that -- proving the fast path this
25786    /// mechanism exists for (`spawn_session_with_deadline`'s commit-wait
25787    /// loop) drains the reason instead of leaving it to be rediscovered (or
25788    /// missed) on a later poll.
25789    #[test]
25790    fn rejected_command_reason_is_classified_and_consumed_exactly_once() {
25791        let catalog = active_registry().unwrap();
25792        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25793        let workspace = WorkspaceConfig::new(
25794            WorkspaceId::new("primary").unwrap(),
25795            std::env::current_dir().unwrap(),
25796        )
25797        .unwrap();
25798        let shared = NodeShared::new(
25799            handle,
25800            "fixture-token".to_owned(),
25801            NodeId::new("node-1").unwrap(),
25802            vec![workspace],
25803            vec![agent("claude")],
25804        );
25805
25806        assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
25807
25808        shared.record_rejected_command(
25809            CommandId(1),
25810            "agent 'claude' does not support transport Acp".to_owned(),
25811            true,
25812        );
25813        // An unrelated command_id must not see another command's reason.
25814        assert!(shared.take_rejected_command_reason(CommandId(2)).is_none());
25815
25816        let reason = shared.take_rejected_command_reason(CommandId(1)).unwrap();
25817        assert_eq!(reason.message, "agent 'claude' does not support transport Acp");
25818        assert!(reason.unsupported_transport);
25819
25820        // Consumed: a second read for the same command_id finds nothing.
25821        assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
25822
25823        // A rejection unrelated to transport support keeps its own
25824        // classification distinct, so the reader can choose the generic
25825        // `BackendOperationFailed` code instead of misreporting
25826        // `UnsupportedTransport`.
25827        shared.record_rejected_command(
25828            CommandId(3),
25829            "backend busy".to_owned(),
25830            false,
25831        );
25832        let unclassified = shared.take_rejected_command_reason(CommandId(3)).unwrap();
25833        assert!(!unclassified.unsupported_transport);
25834    }
25835
25836    #[test]
25837    fn provider_identity_update_is_ignored_without_exact_session_policy() {
25838        let catalog = active_registry().unwrap();
25839        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25840        let workspace = WorkspaceConfig::new(
25841            WorkspaceId::new("primary").unwrap(),
25842            std::env::current_dir().unwrap(),
25843        )
25844        .unwrap();
25845        let shared = NodeShared::new(
25846            handle,
25847            "fixture-token".to_owned(),
25848            NodeId::new("node-identity-policy").unwrap(),
25849            vec![workspace.clone()],
25850            vec![agent("claude")],
25851        );
25852        let address = SessionAddress {
25853            workspace_id: workspace.workspace_id.clone(),
25854            session: SessionKey {
25855                instance_id: AgentInstanceId(90),
25856                generation: SessionGeneration(1),
25857            },
25858        };
25859        let record_id = SessionRecordId::new("sr-policy-denied").unwrap();
25860        shared
25861            .insert_record(ManagedSessionRecord {
25862                record_id: record_id.clone(),
25863                display_name: "policy denied".to_owned(),
25864                provider: agent("claude"),
25865                mode: SessionMode::Pty,
25866                state: ManagedSessionState::IdentityPending,
25867                workspace_id: workspace.workspace_id,
25868                canonical_root: opaque_windows_path(workspace.canonical_root),
25869                provider_session: None,
25870                active_session: Some(address.clone()),
25871                environment_profile: None,
25872                bundle: None,
25873                context_id: None,
25874                context: None,
25875                exported_context: None,
25876                task_binding: None,
25877                created_at_unix_ms: 1,
25878                updated_at_unix_ms: 1,
25879                last_error: None,
25880            })
25881            .unwrap();
25882        let event = ControlEvent {
25883            sequence: 1,
25884            command_id: None,
25885            instance_id: address.session.instance_id,
25886            generation: address.session.generation,
25887            event: ControlEventKind::ProviderEvent {
25888                sequence: 1,
25889                source: gate4agent_types::ProviderSource {
25890                    family: AdapterFamily::PtySemantic,
25891                    binding: AdapterBinding::new(
25892                        gate4agent_types::AdapterId::new("claude").unwrap(),
25893                        "fixture/v1",
25894                        gate4agent_types::AdapterVerification::SyntheticFixture,
25895                    )
25896                    .unwrap(),
25897                },
25898                source_sequence: 1,
25899                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
25900                    identity: gate4agent_types::ProviderSessionIdentity {
25901                        key: gate4agent_types::ProviderSessionKey::SessionId,
25902                        id: "provider-session".to_owned(),
25903                        transcript_path: None,
25904                    },
25905                },
25906            },
25907        };
25908
25909        shared.reconcile_managed_record(&record_id, &address, &event, false, false);
25910
25911        let record = shared.record(&record_id).unwrap();
25912        assert!(record.provider_session.is_none());
25913        assert_eq!(record.state, ManagedSessionState::IdentityPending);
25914        assert_eq!(record.active_session.as_ref(), Some(&address));
25915    }
25916
25917    #[test]
25918    fn provider_identity_cannot_rebind_a_record_across_workspaces() {
25919        let secondary_root = temporary_workspace_root("identity-scope");
25920        std::fs::create_dir_all(&secondary_root).unwrap();
25921        let catalog = active_registry().unwrap();
25922        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25923        let primary = WorkspaceConfig::new(
25924            WorkspaceId::new("primary").unwrap(),
25925            std::env::current_dir().unwrap(),
25926        )
25927        .unwrap();
25928        let secondary = WorkspaceConfig::new(
25929            WorkspaceId::new("secondary").unwrap(),
25930            &secondary_root,
25931        )
25932        .unwrap();
25933        let shared = NodeShared::new(
25934            handle,
25935            "fixture-token".to_owned(),
25936            NodeId::new("node-1").unwrap(),
25937            vec![primary.clone(), secondary.clone()],
25938            vec![agent("claude")],
25939        );
25940        let identity = gate4agent_types::ProviderSessionIdentity {
25941            key: gate4agent_types::ProviderSessionKey::SessionId,
25942            id: "provider-session".to_owned(),
25943            transcript_path: None,
25944        };
25945        let dormant_id = SessionRecordId::new("sr-dormant").unwrap();
25946        shared
25947            .insert_record(ManagedSessionRecord {
25948                record_id: dormant_id.clone(),
25949                display_name: "dormant".to_owned(),
25950                provider: agent("claude"),
25951                mode: SessionMode::Pty,
25952                state: ManagedSessionState::Dormant,
25953                workspace_id: primary.workspace_id,
25954                canonical_root: opaque_windows_path(primary.canonical_root),
25955                provider_session: Some(identity.clone()),
25956                active_session: None,
25957                environment_profile: None,
25958                bundle: None,
25959                context_id: None,
25960                context: None,
25961                exported_context: None,
25962                task_binding: None,
25963                created_at_unix_ms: 1,
25964                updated_at_unix_ms: 1,
25965                last_error: None,
25966            })
25967            .unwrap();
25968        let address = SessionAddress {
25969            workspace_id: secondary.workspace_id.clone(),
25970            session: SessionKey {
25971                instance_id: AgentInstanceId(91),
25972                generation: SessionGeneration(1),
25973            },
25974        };
25975        let current_id = SessionRecordId::new("sr-current").unwrap();
25976        shared
25977            .insert_record(ManagedSessionRecord {
25978                record_id: current_id.clone(),
25979                display_name: "current".to_owned(),
25980                provider: agent("claude"),
25981                mode: SessionMode::Pty,
25982                state: ManagedSessionState::IdentityPending,
25983                workspace_id: secondary.workspace_id,
25984                canonical_root: opaque_windows_path(secondary.canonical_root),
25985                provider_session: None,
25986                active_session: Some(address.clone()),
25987                environment_profile: None,
25988                bundle: None,
25989                context_id: None,
25990                context: None,
25991                exported_context: None,
25992                task_binding: None,
25993                created_at_unix_ms: 2,
25994                updated_at_unix_ms: 2,
25995                last_error: None,
25996            })
25997            .unwrap();
25998        shared.bind_managed_session(
25999            &address,
26000            current_id.clone(),
26001            ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26002            None,
26003        );
26004        let event = ControlEvent {
26005            sequence: 1,
26006            command_id: None,
26007            instance_id: address.session.instance_id,
26008            generation: address.session.generation,
26009            event: ControlEventKind::ProviderEvent {
26010                sequence: 1,
26011                source: gate4agent_types::ProviderSource {
26012                    family: gate4agent_types::AdapterFamily::PtySemantic,
26013                    binding: gate4agent_types::AdapterBinding::new(
26014                        gate4agent_types::AdapterId::new("claude").unwrap(),
26015                        "fixture/v1",
26016                        gate4agent_types::AdapterVerification::SyntheticFixture,
26017                    )
26018                    .unwrap(),
26019                },
26020                source_sequence: 1,
26021                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26022                    identity,
26023                },
26024            },
26025        };
26026        shared.reconcile_managed_record(&current_id, &address, &event, true, false);
26027
26028        let binding = shared
26029            .session_bindings
26030            .lock()
26031            .unwrap_or_else(|poisoned| poisoned.into_inner())
26032            .get(&address.session.instance_id)
26033            .cloned()
26034            .unwrap();
26035        assert_eq!(binding.record_id.as_ref(), Some(&current_id));
26036        assert_eq!(shared.record(&dormant_id).unwrap().state, ManagedSessionState::Dormant);
26037        let current = shared.record(&current_id).unwrap();
26038        assert_eq!(current.state, ManagedSessionState::Unavailable);
26039        assert_eq!(current.active_session.as_ref(), Some(&address));
26040        assert_eq!(
26041            current.last_error.as_deref(),
26042            Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR),
26043        );
26044        std::fs::remove_dir_all(secondary_root).unwrap();
26045    }
26046
26047    #[tokio::test]
26048    async fn semantic_identity_with_transcript_path_reuses_record_and_survives_reload() {
26049        let root = temporary_workspace_root("semantic-provider-session");
26050        std::fs::create_dir_all(&root).unwrap();
26051        let state_path = root.join("state-v1.json");
26052        let catalog = active_registry().unwrap();
26053        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26054        let workspace = WorkspaceConfig::new(
26055            WorkspaceId::new("primary").unwrap(),
26056            &root,
26057        )
26058        .unwrap();
26059        let node_id = NodeId::new("node-semantic-identity").unwrap();
26060        let shared = NodeShared::new_with_incarnation(
26061            handle,
26062            "fixture-token".to_owned(),
26063            node_id.clone(),
26064            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26065            vec![workspace.clone()],
26066            vec![agent("claude")],
26067            ProviderRuntimeStatuses::default(),
26068            None,
26069            Vec::new(),
26070            Vec::new(),
26071            SpawnProfileRegistry::default(),
26072            None,
26073            None,
26074            None,
26075            Some(state_path.clone()),
26076            Vec::new(),
26077            Vec::new(),
26078            Vec::new(),
26079            Vec::new(),
26080            None,
26081            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
26082        );
26083        let persisted_identity = gate4agent_types::ProviderSessionIdentity {
26084            key: gate4agent_types::ProviderSessionKey::SessionId,
26085            id: "provider-session".to_owned(),
26086            transcript_path: None,
26087        };
26088        let durable_id = SessionRecordId::new("sr-durable").unwrap();
26089        shared
26090            .insert_record(ManagedSessionRecord {
26091                record_id: durable_id.clone(),
26092                display_name: "durable".to_owned(),
26093                provider: agent("claude"),
26094                mode: SessionMode::Pty,
26095                state: ManagedSessionState::Dormant,
26096                workspace_id: workspace.workspace_id.clone(),
26097                canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26098                provider_session: Some(persisted_identity.clone()),
26099                active_session: None,
26100                environment_profile: None,
26101                bundle: None,
26102                context_id: None,
26103                context: None,
26104                exported_context: None,
26105                task_binding: None,
26106                created_at_unix_ms: 1,
26107                updated_at_unix_ms: 1,
26108                last_error: None,
26109            })
26110            .unwrap();
26111        let address = SessionAddress {
26112            workspace_id: workspace.workspace_id.clone(),
26113            session: SessionKey {
26114                instance_id: AgentInstanceId(92),
26115                generation: SessionGeneration(2),
26116            },
26117        };
26118        let pending_id = SessionRecordId::new("sr-pending").unwrap();
26119        shared
26120            .insert_record(ManagedSessionRecord {
26121                record_id: pending_id.clone(),
26122                display_name: "pending".to_owned(),
26123                provider: agent("claude"),
26124                mode: SessionMode::Pty,
26125                state: ManagedSessionState::IdentityPending,
26126                workspace_id: workspace.workspace_id.clone(),
26127                canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26128                provider_session: None,
26129                active_session: Some(address.clone()),
26130                environment_profile: None,
26131                bundle: None,
26132                context_id: None,
26133                context: None,
26134                exported_context: None,
26135                task_binding: None,
26136                created_at_unix_ms: 2,
26137                updated_at_unix_ms: 2,
26138                last_error: None,
26139            })
26140            .unwrap();
26141        shared.bind_managed_session(
26142            &address,
26143            pending_id.clone(),
26144            ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26145            None,
26146        );
26147        let observed_identity = gate4agent_types::ProviderSessionIdentity {
26148            transcript_path: Some(r"C:\private\provider-transcript.jsonl".to_owned()),
26149            ..persisted_identity
26150        };
26151        let event = ControlEvent {
26152            sequence: 1,
26153            command_id: None,
26154            instance_id: address.session.instance_id,
26155            generation: address.session.generation,
26156            event: ControlEventKind::ProviderEvent {
26157                sequence: 1,
26158                source: gate4agent_types::ProviderSource {
26159                    family: gate4agent_types::AdapterFamily::PtySemantic,
26160                    binding: gate4agent_types::AdapterBinding::new(
26161                        gate4agent_types::AdapterId::new("claude").unwrap(),
26162                        "fixture/v1",
26163                        gate4agent_types::AdapterVerification::SyntheticFixture,
26164                    )
26165                    .unwrap(),
26166                },
26167                source_sequence: 1,
26168                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26169                    identity: observed_identity.clone(),
26170                },
26171            },
26172        };
26173        shared.reconcile_managed_record(&pending_id, &address, &event, true, false);
26174
26175        let snapshot = shared.snapshot();
26176        assert_eq!(snapshot.session_records.len(), 1);
26177        let live = &snapshot.session_records[0];
26178        assert_eq!(live.record_id, durable_id);
26179        assert_eq!(live.state, ManagedSessionState::Live);
26180        assert_eq!(live.active_session.as_ref(), Some(&address));
26181        assert_eq!(live.provider_session.as_ref(), Some(&observed_identity));
26182        let binding = shared
26183            .session_bindings
26184            .lock()
26185            .unwrap_or_else(|poisoned| poisoned.into_inner())
26186            .get(&address.session.instance_id)
26187            .cloned()
26188            .unwrap();
26189        assert_eq!(binding.record_id.as_ref(), Some(&durable_id));
26190
26191        let mut duplicate = live.clone();
26192        duplicate.record_id = SessionRecordId::new("sr-duplicate").unwrap();
26193        duplicate.display_name = "duplicate".to_owned();
26194        duplicate.state = ManagedSessionState::Dormant;
26195        duplicate.active_session = None;
26196        duplicate.provider_session.as_mut().unwrap().transcript_path = None;
26197        let duplicate_error = shared.insert_record(duplicate).unwrap_err();
26198        assert_eq!(duplicate_error.code, NodeFailureCode::SessionRecordConflict);
26199        assert_eq!(shared.snapshot().session_records.len(), 1);
26200
26201        let terminal_size = gate4agent_types::TerminalSize {
26202            rows: 24,
26203            columns: 80,
26204        };
26205        let (first_busy, second_busy) = tokio::join!(
26206            shared.resume_session_record(&durable_id, terminal_size, None),
26207            shared.resume_session_record(&durable_id, terminal_size, None),
26208        );
26209        for busy in [first_busy.unwrap_err(), second_busy.unwrap_err()] {
26210            assert_eq!(busy.code, NodeFailureCode::SessionRecordBusy);
26211            assert_eq!(busy.message, "session-record-busy");
26212        }
26213
26214        let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
26215        assert_eq!(loaded.records.len(), 1);
26216        assert_eq!(loaded.records[0].record_id, durable_id);
26217        assert_eq!(loaded.records[0].state, ManagedSessionState::Dormant);
26218        assert_eq!(
26219            loaded.records[0]
26220                .provider_session
26221                .as_ref()
26222                .unwrap()
26223                .transcript_path,
26224            None,
26225        );
26226        let catalog = active_registry().unwrap();
26227        let (reloaded_handle, _reloaded_runtime) =
26228            NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26229        let reloaded = NodeShared::new_with_incarnation(
26230            reloaded_handle,
26231            "fixture-token".to_owned(),
26232            node_id,
26233            NodeIncarnationId::from_bytes([1; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26234            vec![workspace],
26235            vec![agent("claude")],
26236            ProviderRuntimeStatuses::default(),
26237            None,
26238            Vec::new(),
26239            Vec::new(),
26240            SpawnProfileRegistry::default(),
26241            None,
26242            None,
26243            None,
26244            None,
26245            loaded.records,
26246            loaded.managed_worktrees,
26247            loaded.managed_worktree_tombstones,
26248            loaded.materializations,
26249            loaded.warning,
26250            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
26251        );
26252        assert_eq!(reloaded.snapshot().session_records.len(), 1);
26253        assert_eq!(reloaded.snapshot().session_records[0].record_id, durable_id);
26254        std::fs::remove_dir_all(root).unwrap();
26255    }
26256
26257    #[tokio::test]
26258    async fn workspace_reregistration_requires_the_recorded_root_and_restores_records() {
26259        let secondary_root = temporary_workspace_root("workspace-restore");
26260        let other_root = temporary_workspace_root("workspace-conflict");
26261        std::fs::create_dir_all(&secondary_root).unwrap();
26262        std::fs::create_dir_all(&other_root).unwrap();
26263        let catalog = active_registry().unwrap();
26264        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26265        let primary = WorkspaceConfig::new(
26266            WorkspaceId::new("primary").unwrap(),
26267            std::env::current_dir().unwrap(),
26268        )
26269        .unwrap();
26270        let secondary = WorkspaceConfig::new(
26271            WorkspaceId::new("secondary").unwrap(),
26272            &secondary_root,
26273        )
26274        .unwrap();
26275        let shared = NodeShared::new(
26276            handle,
26277            "fixture-token".to_owned(),
26278            NodeId::new("node-1").unwrap(),
26279            vec![primary, secondary.clone()],
26280            vec![agent("claude")],
26281        );
26282        let record_id = SessionRecordId::new("sr-workspace").unwrap();
26283        shared
26284            .insert_record(ManagedSessionRecord {
26285                record_id: record_id.clone(),
26286                display_name: "workspace record".to_owned(),
26287                provider: agent("claude"),
26288                mode: SessionMode::Pty,
26289                state: ManagedSessionState::Dormant,
26290                workspace_id: secondary.workspace_id.clone(),
26291                canonical_root: opaque_windows_path(secondary.canonical_root.clone()),
26292                provider_session: Some(gate4agent_types::ProviderSessionIdentity {
26293                    key: gate4agent_types::ProviderSessionKey::SessionId,
26294                    id: "provider-session".to_owned(),
26295                    transcript_path: None,
26296                }),
26297                active_session: None,
26298                environment_profile: None,
26299                bundle: None,
26300                context_id: None,
26301                context: None,
26302                exported_context: None,
26303                task_binding: None,
26304                created_at_unix_ms: 1,
26305                updated_at_unix_ms: 1,
26306                last_error: None,
26307            })
26308            .unwrap();
26309        shared.unregister_workspace(&secondary.workspace_id).unwrap();
26310        let unavailable = shared.record(&record_id).unwrap();
26311        assert_eq!(unavailable.state, ManagedSessionState::Unavailable);
26312        assert_eq!(windows_path_text(&unavailable.canonical_root), secondary.canonical_root);
26313
26314        let conflict = shared
26315            .register_workspace(
26316                secondary.workspace_id.clone(),
26317                other_root.to_string_lossy().into_owned(),
26318            )
26319            .await
26320            .unwrap_err();
26321        assert_eq!(conflict.code, NodeFailureCode::SessionRecordConflict);
26322        shared
26323            .register_workspace(
26324                secondary.workspace_id,
26325                secondary_root.to_string_lossy().into_owned(),
26326            )
26327            .await
26328            .unwrap();
26329        let restored = shared.record(&record_id).unwrap();
26330        assert_eq!(restored.state, ManagedSessionState::Dormant);
26331        assert!(restored.last_error.is_none());
26332        std::fs::remove_dir_all(secondary_root).unwrap();
26333        std::fs::remove_dir_all(other_root).unwrap();
26334    }
26335
26336    #[test]
26337    fn dynamic_workspace_root_is_rejected_before_filesystem_canonicalization() {
26338        let error = validate_workspace_request_root(
26339            &"x".repeat(MAX_WORKSPACE_ROOT_BYTES + 1),
26340        )
26341        .unwrap_err();
26342        assert_eq!(error.code, NodeFailureCode::InvalidWorkspaceRoot);
26343        let control = validate_workspace_request_root("C:\\repo\nchild").unwrap_err();
26344        assert_eq!(control.code, NodeFailureCode::InvalidWorkspaceRoot);
26345    }
26346
26347    #[test]
26348    fn concurrent_publish_and_resync_never_acknowledge_an_unappended_event() {
26349        let catalog = active_registry().unwrap();
26350        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26351        let workspace = WorkspaceConfig::new(
26352            WorkspaceId::new("primary").unwrap(),
26353            std::env::current_dir().unwrap(),
26354        )
26355        .unwrap();
26356        let shared = NodeShared::new(
26357            handle,
26358            "fixture-token".to_owned(),
26359            NodeId::new("node-1").unwrap(),
26360            vec![workspace],
26361            vec![agent("claude")],
26362        );
26363        let publishers = 4_u64;
26364        let events_per_publisher = 256_u64;
26365        let start = std::sync::Barrier::new(publishers as usize + 1);
26366        let finished = AtomicU64::new(0);
26367
26368        std::thread::scope(|scope| {
26369            for _ in 0..publishers {
26370                scope.spawn(|| {
26371                    start.wait();
26372                    for _ in 0..events_per_publisher {
26373                        shared.publish(NodeEvent::ControllerChanged { controller: None });
26374                    }
26375                    finished.fetch_add(1, Ordering::Release);
26376                });
26377            }
26378            start.wait();
26379            while finished.load(Ordering::Acquire) != publishers {
26380                assert_resync_history_is_linearized(shared.resync(0));
26381                std::thread::yield_now();
26382            }
26383        });
26384        let response = shared.resync(0);
26385        assert_resync_history_is_linearized(response.clone());
26386        let NodeResponse::Resync { event_sequence, events, .. } = response else {
26387            unreachable!("resync helper returned another response");
26388        };
26389        assert_eq!(event_sequence, publishers * events_per_publisher);
26390        assert_eq!(events.len() as u64, event_sequence);
26391    }
26392
26393    fn assert_resync_history_is_linearized(response: NodeResponse) {
26394        let NodeResponse::Resync { event_sequence, events, .. } = response else {
26395            unreachable!("resync helper returned another response");
26396        };
26397        assert!(events.windows(2).all(|pair| {
26398            pair[0].sequence.checked_add(1) == Some(pair[1].sequence)
26399        }));
26400        assert_eq!(events.last().map_or(0, |event| event.sequence), event_sequence);
26401    }
26402
26403    #[test]
26404    fn canonical_workspace_roots_are_safe_for_cmd_exe() {
26405        assert_eq!(
26406            platform::normalize_canonical_root(r"\\?\C:\repo\worktree".to_owned()),
26407            r"C:\repo\worktree",
26408        );
26409        assert_eq!(
26410            platform::normalize_canonical_root(r"\\?\UNC\server\share\repo".to_owned()),
26411            r"\\server\share\repo",
26412        );
26413        assert!(validate_workspace_root(
26414            &WorkspaceId::new("network").unwrap(),
26415            r"\\server\share\repo",
26416        )
26417        .is_err());
26418        let workspace = WorkspaceConfig::new(
26419            WorkspaceId::new("primary").unwrap(),
26420            std::env::current_dir().unwrap(),
26421        )
26422        .unwrap();
26423        assert!(!workspace.canonical_root.starts_with(r"\\?\"));
26424    }
26425
26426    #[test]
26427    fn resume_rebinds_only_on_the_expected_authorized_generation() {
26428        let catalog = active_registry().unwrap();
26429        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26430        let workspace_id = WorkspaceId::new("primary").unwrap();
26431        let workspace = WorkspaceConfig::new(
26432            workspace_id.clone(),
26433            std::env::current_dir().unwrap(),
26434        )
26435        .unwrap();
26436        let shared = NodeShared::new(
26437            handle,
26438            "fixture-token".to_owned(),
26439            NodeId::new("node-1").unwrap(),
26440            vec![workspace],
26441            vec![agent("claude")],
26442        );
26443        let instance_id = AgentInstanceId(17);
26444        let original = SessionAddress {
26445            workspace_id,
26446            session: SessionKey {
26447                instance_id,
26448                generation: SessionGeneration(3),
26449            },
26450        };
26451        shared.bind_session(
26452            instance_id,
26453            original.workspace_id.clone(),
26454            original.session.generation,
26455        );
26456        shared
26457            .arm_resume(
26458                &original,
26459                CommandId(41),
26460                ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26461            )
26462            .unwrap();
26463
26464        shared.publish_control(ControlEvent {
26465            sequence: 1,
26466            command_id: None,
26467            instance_id,
26468            generation: SessionGeneration(4),
26469            event: ControlEventKind::Running { process_id: None },
26470        });
26471        assert_eq!(
26472            shared.address_for(instance_id, SessionGeneration(3)),
26473            Some(original.clone()),
26474        );
26475        assert!(shared.address_for(instance_id, SessionGeneration(4)).is_none());
26476
26477        shared.publish_control(ControlEvent {
26478            sequence: 2,
26479            command_id: Some(CommandId(42)),
26480            instance_id,
26481            generation: SessionGeneration(3),
26482            event: ControlEventKind::CommandRejected {
26483                message: "unrelated command".to_owned(),
26484            },
26485        });
26486
26487        shared.publish_control(ControlEvent {
26488            sequence: 3,
26489            command_id: None,
26490            instance_id,
26491            generation: SessionGeneration(4),
26492            event: ControlEventKind::ResumeAuthorized {
26493                session: gate4agent_types::ResumeSessionSummary {
26494                    key: gate4agent_types::ProviderSessionKey::SessionId,
26495                    id: "fixture-session".to_owned(),
26496                },
26497            },
26498        });
26499        assert!(shared.address_for(instance_id, SessionGeneration(3)).is_none());
26500        let rebound = shared.address_for(instance_id, SessionGeneration(4)).unwrap();
26501        assert_eq!(rebound.workspace_id, original.workspace_id);
26502        let history = shared
26503            .history
26504            .lock()
26505            .unwrap_or_else(|poisoned| poisoned.into_inner());
26506        assert!(history.events.iter().any(|envelope| matches!(
26507            &envelope.event,
26508            NodeEvent::Control { address, event }
26509                if address == &rebound
26510                    && matches!(&event.event, ControlEventKind::ResumeAuthorized { .. })
26511        )));
26512    }
26513
26514    #[test]
26515    fn all_node_pty_prompts_use_bracketed_paste_framing() {
26516        for agent in ["claude", "codex", "kimi"] {
26517            assert_eq!(
26518                prompt_framing(&AgentId::new(agent).unwrap()),
26519                PromptFraming::BracketedPaste
26520            );
26521        }
26522    }
26523
26524    fn generous_walk_deadline() -> Instant {
26525        Instant::now() + Duration::from_secs(30)
26526    }
26527
26528    #[test]
26529    fn workspace_tree_is_relative_and_skips_heavy_directories() {
26530        let root = temporary_workspace_root("tree");
26531        std::fs::create_dir_all(root.join("src")).unwrap();
26532        std::fs::create_dir_all(root.join(".git/objects")).unwrap();
26533        std::fs::create_dir_all(root.join("target/debug")).unwrap();
26534        std::fs::create_dir_all(root.join("node_modules/package")).unwrap();
26535        std::fs::write(root.join("src/lib.rs"), b"pub fn fixture() {}\n").unwrap();
26536        std::fs::write(root.join("README.md"), b"fixture\n").unwrap();
26537        // A renamed/agent-scoped build directory (`target-a2`-style, per the
26538        // real repository's own build layout) that carries the Cache
26539        // Directory Tagging Specification marker cargo writes into every
26540        // `--target-dir` it creates: must be skipped even though its name
26541        // does not exactly match `target`.
26542        std::fs::create_dir_all(root.join("target-a2/debug")).unwrap();
26543        std::fs::write(root.join("target-a2/CACHEDIR.TAG"), b"Signature: 8a477f597d28d172789f06886806bc55\n").unwrap();
26544        // A directory that merely *starts with* `target` but carries no
26545        // CACHEDIR.TAG: the new prefix-adjacent check must not false-match
26546        // on the name alone, so this stays in the response.
26547        std::fs::create_dir_all(root.join("target-notes")).unwrap();
26548        std::fs::write(root.join("target-notes/plan.md"), b"not a build directory\n").unwrap();
26549
26550        let (entries, truncated, _budget) =
26551            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
26552        assert!(!truncated);
26553        assert!(entries.iter().any(|entry| {
26554            entry.relative_path.as_utf8() == Some("src")
26555                && entry.kind == WorkspaceEntryKind::Directory
26556        }));
26557        assert!(entries.iter().any(|entry| {
26558            entry.relative_path.as_utf8() == Some("src/lib.rs")
26559                && entry.kind == WorkspaceEntryKind::File
26560        }));
26561        assert!(entries.iter().any(|entry| {
26562            entry.relative_path.as_utf8() == Some("target-notes")
26563                && entry.kind == WorkspaceEntryKind::Directory
26564        }));
26565        assert!(entries.iter().any(|entry| {
26566            entry.relative_path.as_utf8() == Some("target-notes/plan.md")
26567                && entry.kind == WorkspaceEntryKind::File
26568        }));
26569        assert!(entries.iter().all(|entry| {
26570            let path = entry.relative_path.as_utf8().unwrap();
26571            !path.starts_with(".git")
26572                && !(path == "target" || path.starts_with("target/"))
26573                && !(path == "target-a2" || path.starts_with("target-a2/"))
26574                && !path.starts_with("node_modules")
26575        }));
26576
26577        std::fs::remove_dir_all(&root).unwrap();
26578    }
26579
26580    #[test]
26581    fn workspace_walk_stops_at_its_entry_cap_and_marks_a_partial_result() {
26582        let root = temporary_workspace_root("tree-entry-cap");
26583        std::fs::create_dir_all(&root).unwrap();
26584        for index in 0..20 {
26585            std::fs::write(root.join(format!("file-{index:02}.txt")), b"fixture\n").unwrap();
26586        }
26587
26588        let (entries, truncated, budget) =
26589            collect_workspace_entries(&root, generous_walk_deadline(), 5);
26590        assert!(truncated);
26591        assert!(budget.entry_cap_exceeded);
26592        assert!(!budget.time_budget_exceeded);
26593        assert!(entries.len() <= 5);
26594
26595        let inspection_truncation = workspace_inspection_truncation(&budget, false, Duration::from_millis(1))
26596            .expect("an entry-cap-truncated walk must report a truncation marker");
26597        assert!(inspection_truncation.walk_entry_cap_exceeded);
26598        assert!(!inspection_truncation.walk_time_budget_exceeded);
26599        assert!(!inspection_truncation.git_time_budget_exceeded);
26600        assert!(inspection_truncation.entries_visited >= 5);
26601
26602        std::fs::remove_dir_all(&root).unwrap();
26603    }
26604
26605    #[test]
26606    fn workspace_walk_within_budget_reports_no_truncation() {
26607        let root = temporary_workspace_root("tree-no-truncation");
26608        std::fs::create_dir_all(&root).unwrap();
26609        std::fs::write(root.join("only-file.txt"), b"fixture\n").unwrap();
26610
26611        let (_entries, truncated, budget) =
26612            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
26613        assert!(!truncated);
26614        assert!(workspace_inspection_truncation(&budget, false, Duration::from_millis(1)).is_none());
26615
26616        std::fs::remove_dir_all(&root).unwrap();
26617    }
26618
26619    #[test]
26620    fn workspace_walk_emits_strict_global_byte_order_for_prefix_named_siblings() {
26621        let root = temporary_workspace_root("tree-prefix-siblings");
26622        std::fs::create_dir_all(root.join("gate4agent-c2/src")).unwrap();
26623        std::fs::write(root.join("gate4agent-c2/src/lib.rs"), b"fixture\n").unwrap();
26624        std::fs::create_dir_all(root.join("gate4agent-c2-client")).unwrap();
26625        std::fs::write(root.join("gate4agent-c2-client/lib.rs"), b"fixture\n").unwrap();
26626
26627        let (entries, truncated, _budget) =
26628            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
26629        assert!(!truncated);
26630        let paths = entries
26631            .iter()
26632            .map(|entry| entry.relative_path.as_utf8().unwrap().to_owned())
26633            .collect::<Vec<_>>();
26634        assert!(paths.windows(2).all(|pair| pair[0] < pair[1]));
26635        assert_eq!(
26636            paths,
26637            vec![
26638                "gate4agent-c2".to_owned(),
26639                "gate4agent-c2-client".to_owned(),
26640                "gate4agent-c2-client/lib.rs".to_owned(),
26641                "gate4agent-c2/src".to_owned(),
26642                "gate4agent-c2/src/lib.rs".to_owned(),
26643            ],
26644        );
26645
26646        std::fs::remove_dir_all(&root).unwrap();
26647    }
26648
26649    #[test]
26650    fn git_parsers_cap_status_and_commit_results() {
26651        let mut status_output = Vec::new();
26652        for index in 0..=GIT_STATUS_MAX_ENTRIES {
26653            status_output.extend_from_slice(format!(" M src/file-{index}.rs\0").as_bytes());
26654        }
26655        let mut snapshot = git_snapshot_for_parser();
26656        parse_git_status(&status_output, &mut snapshot);
26657        assert_eq!(snapshot.status.len(), GIT_STATUS_MAX_ENTRIES);
26658        assert!(snapshot.truncated);
26659        assert_eq!(snapshot.status[0].index_status, " ");
26660        assert_eq!(snapshot.status[0].worktree_status, "M");
26661
26662        snapshot.truncated = false;
26663        let commit_output = (0..=GIT_COMMIT_MAX_ENTRIES)
26664            .map(|index| format!("abc{index}\u{1f}commit {index}\n"))
26665            .collect::<String>();
26666        parse_git_commits(commit_output.as_bytes(), &mut snapshot);
26667        assert_eq!(snapshot.recent_commits.len(), GIT_COMMIT_MAX_ENTRIES);
26668        assert!(snapshot.truncated);
26669    }
26670
26671    #[test]
26672    fn porcelain_v1_z_parser_preserves_current_then_previous_rename_and_copy_order() {
26673        let mut snapshot = git_snapshot_for_parser();
26674        parse_git_status(
26675            b"R  src/current name.rs\0src/previous name.rs\0 C src/copy.rs\0src/original.rs\0?? src/line\nname.rs\0",
26676            &mut snapshot,
26677        );
26678
26679        assert!(!snapshot.truncated);
26680        assert_eq!(snapshot.status.len(), 3);
26681        assert_eq!(snapshot.status[0].index_status, "R");
26682        assert_eq!(snapshot.status[0].worktree_status, " ");
26683        assert_eq!(snapshot.status[0].path.as_utf8(), Some("src/current name.rs"));
26684        assert_eq!(
26685            snapshot.status[0]
26686                .previous_path
26687                .as_ref()
26688                .and_then(RepositoryPath::as_utf8),
26689            Some("src/previous name.rs"),
26690        );
26691        assert_eq!(snapshot.status[1].index_status, " ");
26692        assert_eq!(snapshot.status[1].worktree_status, "C");
26693        assert_eq!(snapshot.status[1].path.as_utf8(), Some("src/copy.rs"));
26694        assert_eq!(
26695            snapshot.status[1]
26696                .previous_path
26697                .as_ref()
26698                .and_then(RepositoryPath::as_utf8),
26699            Some("src/original.rs"),
26700        );
26701        assert_eq!(snapshot.status[2].path.as_utf8(), Some("src/line\nname.rs"));
26702        assert!(snapshot.status[2].previous_path.is_none());
26703    }
26704
26705    #[test]
26706    fn porcelain_v1_z_parser_never_invents_invalid_or_incomplete_path_identity() {
26707        let mut snapshot = git_snapshot_for_parser();
26708        parse_git_status(
26709            b"?? valid.rs\0?? invalid-\xff.rs\0?? invalid\\windows.rs\0R  current.rs\0",
26710            &mut snapshot,
26711        );
26712
26713        assert!(snapshot.truncated);
26714        assert_eq!(snapshot.status.len(), 1);
26715        assert_eq!(snapshot.status[0].path.as_utf8(), Some("valid.rs"));
26716        assert!(snapshot.status[0].previous_path.is_none());
26717    }
26718
26719    #[tokio::test]
26720    async fn observer_can_browse_host_directories_without_controller() {
26721        let root = temporary_workspace_root("host-directory-observer");
26722        std::fs::create_dir_all(root.join("directory-b")).unwrap();
26723        std::fs::create_dir_all(root.join("directory-a")).unwrap();
26724        std::fs::write(root.join("ordinary-file"), b"not a directory").unwrap();
26725        let workspace_id = WorkspaceId::new("primary").unwrap();
26726        let workspace = WorkspaceConfig::new(workspace_id, &root).unwrap();
26727        let catalog = active_registry().unwrap();
26728        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26729        let shared = NodeShared::new(
26730            handle,
26731            "fixture-token".to_owned(),
26732            NodeId::new("node-1").unwrap(),
26733            vec![workspace],
26734            vec![agent("claude")],
26735        );
26736        let request = NodeRequest::BrowseHostDirectories {
26737            directory: Some(OpaqueHostPath::utf8(root.to_string_lossy().into_owned()).unwrap()),
26738            after: None,
26739        };
26740        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
26741            capability.as_str() == CAPABILITY_HOST_DIRECTORY_BROWSE_V1
26742        }));
26743        assert!(!request_uses_unnegotiated_capability(
26744            &request,
26745            &baseline_capabilities().unwrap(),
26746        ));
26747        assert!(request_uses_unnegotiated_capability(&request, &[]));
26748
26749        let response = process_request_inner(&shared, 77, ClientRole::Observer, request)
26750            .await
26751            .unwrap();
26752        let NodeResponse::HostDirectoriesBrowsed { listing } = response else {
26753            panic!("host directory browse returned another response");
26754        };
26755        assert_eq!(
26756            listing
26757                .entries
26758                .iter()
26759                .map(|entry| entry.display_name.as_str())
26760                .collect::<Vec<_>>(),
26761            vec!["directory-a", "directory-b"],
26762        );
26763        assert!(listing.entries.iter().all(|entry| !entry.is_link));
26764        assert!(!listing.incomplete);
26765        std::fs::remove_dir_all(root).unwrap();
26766    }
26767
26768    #[tokio::test]
26769    async fn observer_can_inspect_only_a_registered_workspace() {
26770        let root = temporary_workspace_root("observer");
26771        std::fs::create_dir_all(root.join("src")).unwrap();
26772        std::fs::write(root.join("src/main.rs"), b"fn main() {}\n").unwrap();
26773        let workspace_id = WorkspaceId::new("primary").unwrap();
26774        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
26775        let catalog = active_registry().unwrap();
26776        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26777        let shared = NodeShared::new(
26778            handle,
26779            "fixture-token".to_owned(),
26780            NodeId::new("node-1").unwrap(),
26781            vec![workspace],
26782            vec![agent("claude")],
26783        );
26784
26785        let response = process_request_inner(
26786            &shared,
26787            77,
26788            ClientRole::Observer,
26789            NodeRequest::InspectWorkspace {
26790                workspace_id: workspace_id.clone(),
26791            },
26792        )
26793        .await
26794        .unwrap();
26795        let NodeResponse::WorkspaceInspected { inspection } = response else {
26796            panic!("workspace inspection returned another response");
26797        };
26798        assert_eq!(inspection.workspace_id, workspace_id);
26799        assert!(inspection.entries.iter().any(|entry| {
26800            entry.relative_path.as_utf8() == Some("src/main.rs")
26801        }));
26802
26803        let unknown = process_request_inner(
26804            &shared,
26805            77,
26806            ClientRole::Observer,
26807            NodeRequest::InspectWorkspace {
26808                workspace_id: WorkspaceId::new("unknown").unwrap(),
26809            },
26810        )
26811        .await
26812        .unwrap_err();
26813        assert_eq!(unknown.code, NodeFailureCode::UnknownWorkspace);
26814        std::fs::remove_dir_all(&root).unwrap();
26815    }
26816
26817    #[test]
26818    fn context_pack_source_accepts_only_running_or_successful_exit() {
26819        assert!(context_pack_source_status_is_usable(&SessionStatus::Running));
26820        assert!(context_pack_source_status_is_usable(&SessionStatus::Exited {
26821            exit_code: Some(0),
26822        }));
26823        assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
26824            exit_code: Some(1),
26825        }));
26826        assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
26827            exit_code: None,
26828        }));
26829        assert!(!context_pack_source_status_is_usable(&SessionStatus::Failed {
26830            message: "provider failed".to_owned(),
26831        }));
26832        assert!(!context_pack_source_status_is_usable(&SessionStatus::Registered));
26833        assert!(!context_pack_source_status_is_usable(&SessionStatus::Starting));
26834        assert!(!context_pack_source_status_is_usable(&SessionStatus::Stopping));
26835    }
26836
26837    #[test]
26838    fn context_pack_repository_rejects_changed_head_with_same_git_snapshot() {
26839        let git = git_snapshot_for_parser();
26840        let first = ContextPackRepositoryObservation {
26841            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
26842            git: git.clone(),
26843            files: Vec::new(),
26844        };
26845        let second = ContextPackRepositoryObservation {
26846            head: ContextPackRepositoryHead::Commit("b".repeat(40)),
26847            git,
26848            files: Vec::new(),
26849        };
26850
26851        let error = stable_context_pack_repository(first, second).unwrap_err();
26852
26853        assert_eq!(
26854            error.code,
26855            NodeFailureCode::ContextPackMaterializationFailed,
26856        );
26857    }
26858
26859    #[test]
26860    fn context_pack_repository_rejects_changed_file_with_same_git_snapshot() {
26861        let git = git_snapshot_for_parser();
26862        let first = ContextPackRepositoryObservation {
26863            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
26864            git: git.clone(),
26865            files: vec![ContextPackRepositoryFileSource::utf8(
26866                "README.md",
26867                "first\n".to_owned(),
26868                6,
26869            )],
26870        };
26871        let second = ContextPackRepositoryObservation {
26872            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
26873            git,
26874            files: vec![ContextPackRepositoryFileSource::utf8(
26875                "README.md",
26876                "other\n".to_owned(),
26877                6,
26878            )],
26879        };
26880
26881        let error = stable_context_pack_repository(first, second).unwrap_err();
26882
26883        assert_eq!(
26884            error.code,
26885            NodeFailureCode::ContextPackMaterializationFailed,
26886        );
26887    }
26888
26889    #[tokio::test]
26890    async fn native_session_catalog_zero_active_session_fixture() {
26891        let workspace_root = temporary_workspace_root("native-catalog-workspace");
26892        let history_root = temporary_workspace_root("native-catalog-history");
26893        let outside_root = temporary_workspace_root("native-catalog-outside");
26894        std::fs::create_dir_all(&workspace_root).unwrap();
26895        std::fs::create_dir_all(&history_root).unwrap();
26896        std::fs::create_dir_all(&outside_root).unwrap();
26897        let transcript = |session_id: &str, cwd: &Path, title: &str| {
26898            [
26899                serde_json::json!({
26900                    "type": "user",
26901                    "sessionId": session_id,
26902                    "cwd": cwd.to_str().unwrap(),
26903                    "message": { "content": "raw user text must not cross the catalog wire" }
26904                }),
26905                serde_json::json!({
26906                    "type": "assistant",
26907                    "sessionId": session_id,
26908                    "cwd": cwd.to_str().unwrap(),
26909                    "message": { "model": "claude-sonnet", "content": "raw answer" }
26910                }),
26911                serde_json::json!({
26912                    "type": "ai-title",
26913                    "sessionId": session_id,
26914                    "aiTitle": title
26915                }),
26916            ]
26917            .into_iter()
26918            .map(|record| record.to_string())
26919            .collect::<Vec<_>>()
26920            .join("\n")
26921        };
26922        std::fs::write(
26923            history_root.join("stable-session.jsonl"),
26924            transcript("stable-session", &workspace_root, "Workspace review"),
26925        )
26926        .unwrap();
26927        std::fs::write(
26928            history_root.join("outside-session.jsonl"),
26929            transcript("outside-session", &outside_root, "Outside review"),
26930        )
26931        .unwrap();
26932        let history = NativeHistoryConfig::new(vec![
26933            NativeHistoryRoot::new(
26934                gate4agent_types::AdapterId::new("claude-code").unwrap(),
26935                HistorySourceLayout::SingleNdjson,
26936                history_root.clone(),
26937            )
26938            .unwrap(),
26939        ])
26940        .unwrap();
26941        let catalog = active_registry().unwrap();
26942        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26943        assert!(handle.snapshot().sessions.is_empty());
26944        let workspace_id = WorkspaceId::new("primary").unwrap();
26945        let workspace = WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap();
26946        let mut shared = NodeShared::new(
26947            handle,
26948            "fixture-token".to_owned(),
26949            NodeId::new("node-native-catalog").unwrap(),
26950            vec![workspace],
26951            vec![agent("claude")],
26952        );
26953        shared.native_session_catalog = Some(Arc::new(Mutex::new(
26954            NativeSessionCatalogAuthority::new(history),
26955        )));
26956        let route = NativeSessionCatalogRoute::workspace(
26957            workspace_id.clone(),
26958            agent("claude"),
26959        );
26960        let request = NodeRequest::CatalogNativeSessions {
26961            route: route.clone(),
26962            limit: 2,
26963        };
26964        let observer = process_request_inner(&shared, 7, ClientRole::Observer, request.clone())
26965            .await
26966            .unwrap_err();
26967        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26968        let response = process_request_inner(&shared, 7, ClientRole::Operator, request)
26969            .await
26970            .unwrap();
26971        let NodeResponse::NativeSessionsCataloged { entries, summary, .. } = response else {
26972            panic!("native catalog returned a different response");
26973        };
26974        let summary = summary.expect("node must emit native session catalog summary metadata");
26975        assert_eq!(entries.len(), 1);
26976        assert!(gate4agent_types::validate_candidate_id(&entries[0].selection_id).is_ok());
26977        assert_eq!(entries[0].external_group, None);
26978        assert_eq!(entries[0].record_id, None);
26979        assert_eq!(entries[0].title, None);
26980        assert_eq!(entries[0].model, None);
26981        assert_eq!(entries[0].message_count, 0);
26982        let encoded_entries = serde_json::to_string(&entries).unwrap();
26983        assert!(!encoded_entries.contains("stable-session"));
26984        assert!(!encoded_entries.contains(history_root.to_string_lossy().as_ref()));
26985        let page_request = NodeRequest::PageNativeSessions {
26986            route: route.clone(),
26987            window: crate::protocol::NativeSessionCatalogWindow::Recent,
26988            catalog_revision: summary.catalog_revision,
26989            recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
26990            after_selection_id: None,
26991            limit: 2,
26992        };
26993        let observer = process_request_inner(
26994            &shared,
26995            7,
26996            ClientRole::Observer,
26997            page_request.clone(),
26998        )
26999        .await
27000        .unwrap_err();
27001        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27002        let page_response = process_request_inner(
27003            &shared,
27004            7,
27005            ClientRole::Operator,
27006            page_request.clone(),
27007        )
27008        .await
27009        .unwrap();
27010        assert!(matches!(
27011            page_response,
27012            NodeResponse::NativeSessionsPaged { page, .. }
27013                if page.entries.len() == 1 && !page.has_more && page.remaining_count == 0
27014        ));
27015        let stale = process_request_inner(
27016            &shared,
27017            7,
27018            ClientRole::Operator,
27019            NodeRequest::PageNativeSessions {
27020                route: route.clone(),
27021                window: crate::protocol::NativeSessionCatalogWindow::Recent,
27022                catalog_revision: summary.catalog_revision.wrapping_add(1),
27023                recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27024                after_selection_id: None,
27025                limit: 2,
27026            },
27027        )
27028        .await
27029        .unwrap_err();
27030        assert_eq!(stale.code, NodeFailureCode::StaleNativeSessionCatalog);
27031        let selection = NativeSessionSelection {
27032            route: route.clone(),
27033            catalog_revision: summary.catalog_revision,
27034            recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27035            selection_id: entries[0].selection_id.clone(),
27036        };
27037        let preview_response = process_request_inner(
27038            &shared,
27039            7,
27040            ClientRole::Operator,
27041            NodeRequest::PreviewNativeSession {
27042                selection: selection.clone(),
27043                message_limit: 1,
27044            },
27045        )
27046        .await
27047        .unwrap();
27048        let NodeResponse::NativeSessionPreviewed { preview, .. } = preview_response else {
27049            panic!("native preview returned a different response");
27050        };
27051        assert_eq!(preview.messages.len(), 1);
27052        assert!(preview.message_count_exact);
27053        assert_eq!(preview.messages[0].text, "raw answer");
27054        assert!(preview.truncated);
27055
27056        let index_request = NodeRequest::IndexNativeSession {
27057            selection: selection.clone(),
27058            display_name: "Saved Claude".to_owned(),
27059        };
27060        let observer = process_request_inner(
27061            &shared,
27062            7,
27063            ClientRole::Observer,
27064            index_request.clone(),
27065        )
27066        .await
27067        .unwrap_err();
27068        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27069        let without_controller = process_request_inner(
27070            &shared,
27071            7,
27072            ClientRole::Operator,
27073            index_request.clone(),
27074        )
27075        .await
27076        .unwrap_err();
27077        assert_eq!(without_controller.code, NodeFailureCode::ControllerRequired);
27078        let acquired = process_request_inner(
27079            &shared,
27080            7,
27081            ClientRole::Operator,
27082            NodeRequest::AcquireController { lease_ms: 5_000 },
27083        )
27084        .await
27085        .unwrap();
27086        assert!(matches!(acquired, NodeResponse::Controller { controller: Some(_) }));
27087        let indexed = process_request_inner(
27088            &shared,
27089            7,
27090            ClientRole::Operator,
27091            index_request,
27092        )
27093        .await
27094        .unwrap();
27095        assert!(indexed.native_session_index_contract_is_valid());
27096        let NodeResponse::NativeSessionIndexed {
27097            selection: echoed_selection,
27098            record,
27099        } = indexed else {
27100            panic!("native session index returned a different response");
27101        };
27102        assert_eq!(echoed_selection, selection);
27103        let recataloged = process_request_inner(
27104            &shared,
27105            7,
27106            ClientRole::Operator,
27107            NodeRequest::CatalogNativeSessions {
27108                route,
27109                limit: 2,
27110            },
27111        )
27112        .await
27113        .unwrap();
27114        let NodeResponse::NativeSessionsCataloged { entries, .. } = recataloged else {
27115            panic!("native catalog returned a different response after indexing");
27116        };
27117        assert_eq!(entries[0].record_id.as_ref(), Some(&record.record_id));
27118
27119        let unregistered_route = NativeSessionCatalogRoute::unregistered(agent("claude"));
27120        let external_catalog = process_request_inner(
27121            &shared,
27122            7,
27123            ClientRole::Operator,
27124            NodeRequest::CatalogNativeSessions {
27125                route: unregistered_route.clone(),
27126                limit: 2,
27127            },
27128        )
27129        .await
27130        .unwrap();
27131        let external_json = serde_json::to_string(&external_catalog).unwrap();
27132        assert!(!external_json.contains("outside-session"));
27133        assert!(!external_json.contains(outside_root.to_string_lossy().as_ref()));
27134        assert!(!external_json.contains(history_root.to_string_lossy().as_ref()));
27135        assert!(!external_json.contains("\"session_id\""));
27136        assert!(!external_json.contains("\"cwd\""));
27137        assert!(!external_json.contains("\"path\""));
27138        let NodeResponse::NativeSessionsCataloged {
27139            route: echoed_route,
27140            entries: external_entries,
27141            summary: Some(external_summary),
27142        } = external_catalog
27143        else {
27144            panic!("unregistered native catalog returned a different response");
27145        };
27146        assert_eq!(echoed_route, unregistered_route);
27147        assert_eq!(external_entries.len(), 1);
27148        assert_eq!(external_entries[0].record_id, None);
27149        let external_group = external_entries[0]
27150            .external_group
27151            .as_ref()
27152            .expect("unregistered session must have an external group");
27153        assert_eq!(
27154            external_group.kind,
27155            gate4agent_types::NativeSessionExternalGroupKind::Project,
27156        );
27157        assert!(external_group.group_id.starts_with("external-"));
27158        assert_eq!(
27159            external_group.display_name,
27160            outside_root.file_name().unwrap().to_string_lossy(),
27161        );
27162        let external_selection = NativeSessionSelection {
27163            route: unregistered_route,
27164            catalog_revision: external_summary.catalog_revision,
27165            recent_cutoff_unix_ms: external_summary.recent_cutoff_unix_ms,
27166            selection_id: external_entries[0].selection_id.clone(),
27167        };
27168        let external_preview = process_request_inner(
27169            &shared,
27170            7,
27171            ClientRole::Operator,
27172            NodeRequest::PreviewNativeSession {
27173                selection: external_selection.clone(),
27174                message_limit: 1,
27175            },
27176        )
27177        .await
27178        .unwrap();
27179        let external_preview_json = serde_json::to_string(&external_preview).unwrap();
27180        assert!(!external_preview_json.contains("outside-session"));
27181        assert!(!external_preview_json.contains(outside_root.to_string_lossy().as_ref()));
27182        assert!(!external_preview_json.contains("\"session_id\""));
27183        let NodeResponse::NativeSessionPreviewed { preview, .. } = external_preview else {
27184            panic!("unregistered native preview returned a different response");
27185        };
27186        assert_eq!(preview.messages.len(), 1);
27187        let external_index = process_request_inner(
27188            &shared,
27189            7,
27190            ClientRole::Operator,
27191            NodeRequest::IndexNativeSession {
27192                selection: external_selection,
27193                display_name: "External Claude".to_owned(),
27194            },
27195        )
27196        .await
27197        .unwrap_err();
27198        assert_eq!(
27199            external_index.code,
27200            NodeFailureCode::WorkspaceRegistrationRequired,
27201        );
27202        let record_response = process_request_inner(
27203            &shared,
27204            7,
27205            ClientRole::Operator,
27206            NodeRequest::PreviewSessionRecord {
27207                record_id: record.record_id.clone(),
27208                message_limit: 2,
27209            },
27210        )
27211        .await
27212        .unwrap();
27213        let NodeResponse::SessionRecordPreviewed { record_id, preview } = record_response else {
27214            panic!("record preview returned a different response");
27215        };
27216        assert_eq!(record_id, record.record_id);
27217        assert_eq!(preview.messages.len(), 2);
27218        let encoded = serde_json::to_string(&preview).unwrap();
27219        assert!(!encoded.contains("stable-session"));
27220        assert!(!encoded.contains(history_root.to_string_lossy().as_ref()));
27221        let NodeResponse::Resync { events, .. } = shared.resync(0) else {
27222            panic!("record preview history summary returned a different response");
27223        };
27224        let summaries = events
27225            .iter()
27226            .filter_map(|envelope| match &envelope.event {
27227                NodeEvent::SessionRecordHistorySummarized { record_id, summary }
27228                    if record_id == &record.record_id =>
27229                {
27230                    Some(*summary)
27231                }
27232                _ => None,
27233            })
27234            .collect::<Vec<_>>();
27235        assert_eq!(summaries, vec![SessionHistorySummaryV1::from(&preview)]);
27236        let summary_json = serde_json::to_string(&summaries).unwrap();
27237        for private in [
27238            "stable-session",
27239            "raw user text must not cross the catalog wire",
27240            "raw answer",
27241            "Workspace review",
27242            "claude-sonnet",
27243        ] {
27244            assert!(!summary_json.contains(private), "{private}");
27245        }
27246        let identity = record.provider_session.clone().unwrap();
27247        assert_eq!(
27248            shared.revalidate_session_record_preview(&record, &identity),
27249            Ok(())
27250        );
27251        shared.remove_record_memory(&record.record_id);
27252        assert_eq!(
27253            shared
27254                .revalidate_session_record_preview(&record, &identity)
27255                .unwrap_err()
27256                .code,
27257            NodeFailureCode::UnknownSessionRecord
27258        );
27259        let released = process_request_inner(
27260            &shared,
27261            7,
27262            ClientRole::Operator,
27263            NodeRequest::ReleaseController,
27264        )
27265        .await
27266        .unwrap();
27267        assert!(matches!(released, NodeResponse::Controller { controller: None }));
27268        assert!(shared.controller_state().is_none());
27269        std::fs::remove_dir_all(workspace_root).unwrap();
27270        std::fs::remove_dir_all(history_root).unwrap();
27271        std::fs::remove_dir_all(outside_root).unwrap();
27272    }
27273
27274    #[tokio::test]
27275    async fn native_session_catalog_concurrent_operation_fails_fast_without_waiting_on_mutex() {
27276        let history_root = temporary_workspace_root("native-catalog-concurrent-operation");
27277        std::fs::create_dir_all(&history_root).unwrap();
27278        let history = NativeHistoryConfig::new(vec![
27279            NativeHistoryRoot::new(
27280                gate4agent_types::AdapterId::new("claude-code").unwrap(),
27281                HistorySourceLayout::SingleNdjson,
27282                history_root.clone(),
27283            )
27284            .unwrap(),
27285        ])
27286        .unwrap();
27287        let catalog = Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)));
27288        let held = catalog.lock().unwrap();
27289        let result = timeout(
27290            Duration::from_secs(1),
27291            run_native_session_catalog_operation(
27292                Arc::clone(&catalog),
27293                "native session catalog",
27294                |_| Ok::<(), std::convert::Infallible>(()),
27295            ),
27296        )
27297        .await
27298        .expect("concurrent native catalog request must not wait for the operation deadline")
27299        .unwrap_err();
27300        assert_eq!(result.code, NodeFailureCode::BackendBusy);
27301        drop(held);
27302        std::fs::remove_dir_all(history_root).unwrap();
27303    }
27304
27305    fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
27306        let unique = std::time::SystemTime::now()
27307            .duration_since(std::time::UNIX_EPOCH)
27308            .unwrap()
27309            .as_nanos();
27310        std::env::temp_dir().join(format!(
27311            "gate4agent-node-{label}-{}-{unique}",
27312            std::process::id(),
27313        ))
27314    }
27315
27316    fn git_snapshot_for_parser() -> GitSnapshot {
27317        GitSnapshot {
27318            is_repository: true,
27319            branch: Some("main".to_owned()),
27320            status: Vec::new(),
27321            recent_commits: Vec::new(),
27322            worktrees: Vec::new(),
27323            managed_worktree: None,
27324            truncated: false,
27325            diagnostic: None,
27326        }
27327    }
27328
27329    #[tokio::test]
27330    async fn workspace_inspection_scopes_only_exact_active_managed_branch() {
27331        let root = temporary_workspace_root("managed-git-scope");
27332        std::fs::create_dir_all(&root).unwrap();
27333        run_test_git(&root, &["init"]);
27334        run_test_git(&root, &["config", "user.email", "gate4agent@example.invalid"]);
27335        run_test_git(&root, &["config", "user.name", "Gate4Agent Test"]);
27336        std::fs::write(root.join("seed.txt"), "seed\n").unwrap();
27337        run_test_git(&root, &["add", "seed.txt"]);
27338        run_test_git(&root, &["commit", "-m", "seed"]);
27339        run_test_git(&root, &["checkout", "-b", "gate4agent/a"]);
27340
27341        let workspace_id = WorkspaceId::new("managed-a").unwrap();
27342        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27343        let catalog = active_registry().unwrap();
27344        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27345        let shared = NodeShared::new(
27346            handle,
27347            "fixture-token".to_owned(),
27348            NodeId::new("node-1").unwrap(),
27349            vec![workspace],
27350            vec![agent("claude")],
27351        );
27352        let mut lease = ManagedWorktreeLeaseRecord {
27353            lease_id: ManagedWorktreeLeaseId::new("mw-a").unwrap(),
27354            source_workspace_id: WorkspaceId::new("primary").unwrap(),
27355            workspace_id: workspace_id.clone(),
27356            profile_id: WorktreeProfileId::new("default").unwrap(),
27357            profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
27358            target_root: root.to_string_lossy().into_owned(),
27359            branch: "gate4agent/a".to_owned(),
27360            base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
27361            expected_head: None,
27362            retention: ManagedWorktreeRetention::Retain,
27363            state: ManagedWorktreeLeaseState::InUse,
27364            session_holders: vec![ManagedWorktreeSessionHolder {
27365                incarnation_id: shared.incarnation_id,
27366                instance_id: AgentInstanceId(7),
27367                generation: SessionGeneration(1),
27368            }],
27369            record_holders: Vec::new(),
27370            cleanup_failure: None,
27371            created_at_unix_ms: 1,
27372            updated_at_unix_ms: 1,
27373        };
27374        *shared
27375            .managed_worktrees
27376            .lock()
27377            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
27378            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
27379
27380        let inspection = shared.inspect_workspace(workspace_id.clone()).await.unwrap();
27381        let scope = inspection.git.managed_worktree.unwrap();
27382        assert_eq!(scope.lease_id, lease.lease_id);
27383        assert_eq!(scope.branch, "gate4agent/a");
27384        assert_eq!(scope.active_session_count, 1);
27385
27386        lease.branch = "gate4agent/b".to_owned();
27387        *shared
27388            .managed_worktrees
27389            .lock()
27390            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
27391            ManagedWorktreeRegistry::from_records(vec![lease], Vec::new()).unwrap();
27392        let mismatch = shared.inspect_workspace(workspace_id).await.unwrap();
27393        assert!(mismatch.git.managed_worktree.is_none());
27394        std::fs::remove_dir_all(root).unwrap();
27395    }
27396
27397    #[tokio::test]
27398    async fn workspace_file_write_requires_controller_and_rejects_stale_revision() {
27399        let root = temporary_workspace_root("workspace-file-write-controller");
27400        std::fs::create_dir_all(root.join("src")).unwrap();
27401        std::fs::write(root.join("src/lib.rs"), b"old\n").unwrap();
27402        let workspace_id = WorkspaceId::new("primary").unwrap();
27403        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27404        let catalog = active_registry().unwrap();
27405        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27406        let shared = NodeShared::new(
27407            handle,
27408            "fixture-token".to_owned(),
27409            NodeId::new("node-1").unwrap(),
27410            vec![workspace],
27411            vec![agent("claude")],
27412        );
27413        let path = RepositoryPath::utf8("src/lib.rs".to_owned()).unwrap();
27414        let expected_revision = workspace_file_revision(b"old\n");
27415        let request = NodeRequest::WriteWorkspaceFile {
27416            workspace_id: workspace_id.clone(),
27417            path: path.clone(),
27418            expected_revision: expected_revision.clone(),
27419            text: "new\n".to_owned(),
27420        };
27421        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
27422            .await
27423            .unwrap_err();
27424        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27425        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27426            .await
27427            .unwrap_err();
27428        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
27429        shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
27430        let response = process_request_inner(&shared, 77, ClientRole::Operator, request).await.unwrap();
27431        let NodeResponse::WorkspaceFileWritten { file } = response else {
27432            panic!("workspace file write returned another response");
27433        };
27434        assert_eq!(file.revision, Some(workspace_file_revision(b"new\n")));
27435        assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
27436        let stale = process_request_inner(
27437            &shared,
27438            77,
27439            ClientRole::Operator,
27440            NodeRequest::WriteWorkspaceFile {
27441                workspace_id,
27442                path,
27443                expected_revision,
27444                text: "lost update\n".to_owned(),
27445            },
27446        )
27447        .await
27448        .unwrap_err();
27449        assert_eq!(stale.code, NodeFailureCode::RepositoryFileRevisionConflict);
27450        assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
27451        std::fs::remove_dir_all(root).unwrap();
27452    }
27453
27454    #[tokio::test]
27455    async fn workspace_entry_create_requires_controller_and_never_overwrites() {
27456        let root = temporary_workspace_root("workspace-entry-create-controller");
27457        std::fs::create_dir_all(root.join("src")).unwrap();
27458        let workspace_id = WorkspaceId::new("primary").unwrap();
27459        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27460        let catalog = active_registry().unwrap();
27461        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27462        let shared = NodeShared::new(
27463            handle,
27464            "fixture-token".to_owned(),
27465            NodeId::new("node-1").unwrap(),
27466            vec![workspace],
27467            vec![agent("claude")],
27468        );
27469        let path = RepositoryPath::utf8("src/new.rs".to_owned()).unwrap();
27470        let request = NodeRequest::CreateWorkspaceFile {
27471            workspace_id: workspace_id.clone(),
27472            path: path.clone(),
27473        };
27474        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
27475            capability.as_str() == NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY
27476        }));
27477        assert!(!request_uses_unnegotiated_capability(
27478            &request,
27479            &baseline_capabilities().unwrap(),
27480        ));
27481        assert!(request_uses_unnegotiated_capability(&request, &[]));
27482        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
27483            .await
27484            .unwrap_err();
27485        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27486        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27487            .await
27488            .unwrap_err();
27489        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
27490        shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
27491
27492        let response = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27493            .await
27494            .unwrap();
27495        let NodeResponse::WorkspaceFileCreated { file } = response else {
27496            panic!("workspace file create returned another response");
27497        };
27498        assert_eq!(file.workspace_id, workspace_id);
27499        assert_eq!(file.path, path);
27500        assert_eq!(file.revision, Some(workspace_file_revision(&[])));
27501        assert_eq!(std::fs::read(root.join("src/new.rs")).unwrap(), Vec::<u8>::new());
27502
27503        let duplicate = process_request_inner(&shared, 77, ClientRole::Operator, request)
27504            .await
27505            .unwrap_err();
27506        assert_eq!(duplicate.code, NodeFailureCode::RepositoryEntryAlreadyExists);
27507
27508        let directory_path = RepositoryPath::utf8("src/nested".to_owned()).unwrap();
27509        let response = process_request_inner(
27510            &shared,
27511            77,
27512            ClientRole::Operator,
27513            NodeRequest::CreateWorkspaceDirectory {
27514                workspace_id: workspace_id.clone(),
27515                path: directory_path.clone(),
27516            },
27517        )
27518        .await
27519        .unwrap();
27520        assert_eq!(
27521            response,
27522            NodeResponse::WorkspaceDirectoryCreated {
27523                workspace_id: workspace_id.clone(),
27524                entry: WorkspaceEntry {
27525                    relative_path: directory_path,
27526                    kind: WorkspaceEntryKind::Directory,
27527                },
27528            },
27529        );
27530        assert!(root.join("src/nested").is_dir());
27531
27532        let missing_parent = process_request_inner(
27533            &shared,
27534            77,
27535            ClientRole::Operator,
27536            NodeRequest::CreateWorkspaceFile {
27537                workspace_id: workspace_id.clone(),
27538                path: RepositoryPath::utf8("missing/new.rs".to_owned()).unwrap(),
27539            },
27540        )
27541        .await
27542        .unwrap_err();
27543        assert_eq!(missing_parent.code, NodeFailureCode::RepositoryParentNotFound);
27544        let non_directory_parent = process_request_inner(
27545            &shared,
27546            77,
27547            ClientRole::Operator,
27548            NodeRequest::CreateWorkspaceFile {
27549                workspace_id,
27550                path: RepositoryPath::utf8("src/new.rs/child".to_owned()).unwrap(),
27551            },
27552        )
27553        .await
27554        .unwrap_err();
27555        assert_eq!(
27556            non_directory_parent.code,
27557            NodeFailureCode::RepositoryParentNotDirectory,
27558        );
27559        std::fs::remove_dir_all(root).unwrap();
27560    }
27561
27562    #[tokio::test]
27563    async fn workspace_entry_create_timeout_cancels_before_commit_without_late_mutation() {
27564        let root = temporary_workspace_root("workspace-entry-create-timeout-cancel");
27565        std::fs::create_dir_all(root.join("src")).unwrap();
27566        #[cfg(windows)]
27567        let root = std::fs::canonicalize(root).unwrap();
27568        let path = RepositoryPath::utf8("src/late.rs".to_owned()).unwrap();
27569        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
27570        let barrier = Arc::new(std::sync::Barrier::new(2));
27571        let done = Arc::new(AtomicBool::new(false));
27572        let task_commit_state = Arc::clone(&commit_state);
27573        let task_barrier = Arc::clone(&barrier);
27574        let task_done = Arc::clone(&done);
27575        let task_root = root.clone();
27576        let task = tokio::task::spawn_blocking(move || {
27577            task_barrier.wait();
27578            let result = create_workspace_file_on_disk(
27579                &task_root,
27580                &path,
27581                &task_commit_state,
27582            );
27583            task_done.store(true, Ordering::Release);
27584            result
27585        });
27586
27587        let error = settle_workspace_entry_create(
27588            task,
27589            Arc::clone(&commit_state),
27590            Duration::from_millis(10),
27591            "fixture deadline",
27592            "fixture task failed",
27593        )
27594        .await
27595        .unwrap_err();
27596        assert_eq!(error.code, NodeFailureCode::RepositoryEntryCreateTimedOut);
27597        assert_eq!(
27598            commit_state.load(Ordering::Acquire),
27599            WORKSPACE_ENTRY_CREATE_CANCELED,
27600        );
27601
27602        barrier.wait();
27603        timeout(Duration::from_secs(1), async {
27604            while !done.load(Ordering::Acquire) {
27605                tokio::task::yield_now().await;
27606            }
27607        })
27608        .await
27609        .expect("canceled blocking create did not settle");
27610        assert!(!root.join("src/late.rs").exists());
27611        std::fs::remove_dir_all(root).unwrap();
27612    }
27613}
27614
27615#[cfg(test)]
27616mod provisional_spawn_tests {
27617    use super::*;
27618    use std::sync::Arc;
27619
27620    fn shared() -> NodeShared {
27621        let catalog = active_registry().unwrap();
27622        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27623        let workspace = WorkspaceConfig::new(
27624            WorkspaceId::new("primary").unwrap(),
27625            std::env::current_dir().unwrap(),
27626        )
27627        .unwrap()
27628        .with_worktree_service_mode(WorktreeServiceMode::Manual);
27629        NodeShared::new(
27630            handle,
27631            "fixture-token".to_owned(),
27632            NodeId::new("node-provisional-test").unwrap(),
27633            vec![workspace],
27634            vec![AgentId::new("claude").unwrap()],
27635        )
27636    }
27637
27638    /// Provisional draft map: sessionize by instance id only; 50 sequential
27639    /// mark/claim cycles never collide, and a claimed draft is never reaped.
27640    #[test]
27641    fn provisional_spawn_keys_are_unique_and_sessionize_prevents_reap() {
27642        let shared = shared();
27643        let ws = WorkspaceId::new("primary").unwrap();
27644        let mut ids = Vec::with_capacity(50);
27645        for i in 1u64..=50 {
27646            let id = AgentInstanceId(i);
27647            ids.push(id);
27648            let address = SessionAddress {
27649                workspace_id: ws.clone(),
27650                session: SessionKey {
27651                    instance_id: id,
27652                    generation: SessionGeneration(1),
27653                },
27654            };
27655            shared.mark_spawn_provisional(address);
27656        }
27657        assert_eq!(
27658            shared
27659                .provisional_spawns
27660                .lock()
27661                .unwrap_or_else(|p| p.into_inner())
27662                .len(),
27663            50
27664        );
27665        for id in ids.iter().step_by(2) {
27666            shared.sessionize_spawn(*id);
27667        }
27668        assert_eq!(
27669            shared
27670                .provisional_spawns
27671                .lock()
27672                .unwrap_or_else(|p| p.into_inner())
27673                .len(),
27674            25
27675        );
27676        assert!(shared.take_due_provisional_spawns().is_empty());
27677        {
27678            let mut map = shared
27679                .provisional_spawns
27680                .lock()
27681                .unwrap_or_else(|p| p.into_inner());
27682            let past = Instant::now() - Duration::from_secs(30);
27683            for (_, (_, at)) in map.iter_mut() {
27684                *at = past;
27685            }
27686        }
27687        let due = shared.take_due_provisional_spawns();
27688        assert_eq!(due.len(), 25);
27689        let due_ids: std::collections::BTreeSet<_> =
27690            due.iter().map(|a| a.session.instance_id.0).collect();
27691        let expected: std::collections::BTreeSet<_> =
27692            ids.iter().skip(1).step_by(2).map(|id| id.0).collect();
27693        assert_eq!(due_ids, expected);
27694        assert!(shared
27695            .provisional_spawns
27696            .lock()
27697            .unwrap_or_else(|p| p.into_inner())
27698            .is_empty());
27699    }
27700
27701    /// Twenty parallel provisional marks must each get their own instance key;
27702    /// killing/sessionizing one id must not touch the others.
27703    #[test]
27704    fn provisional_spawn_parallel_marks_isolate_kills_by_instance_id() {
27705        let shared = Arc::new(shared());
27706        let ws = WorkspaceId::new("primary").unwrap();
27707        let mut handles = Vec::new();
27708        for i in 1u64..=20 {
27709            let shared = Arc::clone(&shared);
27710            let ws = ws.clone();
27711            handles.push(std::thread::spawn(move || {
27712                let id = AgentInstanceId(i);
27713                let address = SessionAddress {
27714                    workspace_id: ws,
27715                    session: SessionKey {
27716                        instance_id: id,
27717                        generation: SessionGeneration(1),
27718                    },
27719                };
27720                shared.mark_spawn_provisional(address);
27721                id
27722            }));
27723        }
27724        let mut ids = Vec::new();
27725        for h in handles {
27726            ids.push(h.join().unwrap());
27727        }
27728        ids.sort_by_key(|id| id.0);
27729        assert_eq!(ids.len(), 20);
27730        ids.dedup();
27731        assert_eq!(ids.len(), 20, "parallel marks must not share instance keys");
27732        assert_eq!(
27733            shared
27734                .provisional_spawns
27735                .lock()
27736                .unwrap_or_else(|p| p.into_inner())
27737                .len(),
27738            20
27739        );
27740        shared.sessionize_spawn(AgentInstanceId(7));
27741        let left: Vec<_> = shared
27742            .provisional_spawns
27743            .lock()
27744            .unwrap_or_else(|p| p.into_inner())
27745            .keys()
27746            .map(|id| id.0)
27747            .collect();
27748        assert!(!left.contains(&7));
27749        assert_eq!(left.len(), 19);
27750    }
27751}
27752