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 backend_paths;
213pub mod node_wake;
214
215tokio::task_local! {
216    /// Launch options (model / effort) of a `SpawnWithOptions` request, scoped around that
217    /// spawn so the provider's start carries them (g4a maps them to the CLI's own flags).
218    static SPAWN_SESSION_OPTIONS: Option<gate4agent_types::SessionOptionSelection>;
219}
220
221/// `SpawnSessionOptions` -> g4a selection. Effort needs a model (g4a options hang off a model).
222fn spawn_selection(
223    options: &gate4agent_node_protocol::SpawnSessionOptions,
224) -> Result<Option<gate4agent_types::SessionOptionSelection>, NodeFailure> {
225    options.validate().map_err(|e| failure(NodeFailureCode::InvalidRequest, e))?;
226    match (&options.model, &options.effort) {
227        (None, None) => Ok(None),
228        (None, Some(_)) => Err(failure(NodeFailureCode::InvalidRequest, "spawn effort needs a model")),
229        (Some(model), effort) => {
230            let mut sel = gate4agent_types::SessionOptionSelection::new(model.clone());
231            if let Some(e) = effort {
232                sel = sel.with_value("effort", e.clone());
233            }
234            Ok(Some(sel))
235        }
236    }
237}
238
239mod browser_bridge;
240
241use gate4agent_node_core::drive_loop_profile;
242
243#[path = "bundle_delivery.rs"]
244mod bundle_delivery;
245
246#[path = "context_pack_store.rs"]
247mod context_pack_store;
248
249#[cfg(windows)]
250pub use crate::platform::DEFAULT_NODE_ENDPOINT;
251
252const NODE_EVENT_HISTORY_MAX: usize = 4_096;
253const NODE_BROADCAST_CAPACITY: usize = 1_024;
254const NODE_TERMINAL_BROADCAST_CAPACITY: usize = 1;
255/// Unlike `NODE_TERMINAL_BROADCAST_CAPACITY`'s single replaceable slot (a
256/// terminal frame is a full-screen snapshot; only the latest ever matters),
257/// an `AgentStreamChunkV1` is one delta in an ordered sequence -- collapsing
258/// two pending sends into one would silently drop content a subscriber can
259/// never recover. This needs headroom for a burst instead.
260const NODE_AGENT_STREAM_BROADCAST_CAPACITY: usize = 256;
261/// The node never has more than `MAX_HARNESS_MCP_PENDING_CALLS_PER_NODE`
262/// `NodeEvent::HarnessMcpReadCall`s in flight at once (`begin_call`'s own
263/// admission check), and `publish_transient` sends each exactly once as a
264/// single-envelope batch -- so this many slots covers the entire pending
265/// set without ever lagging the one subscriber that matters, the harness's
266/// own connection, the way sharing the durable `event_tx`'s bus could.
267const NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY: usize = MAX_HARNESS_MCP_PENDING_CALLS_PER_NODE;
268const NODE_CONNECTION_EVENT_BURST_MAX: usize = 16;
269const CONTROL_EVENT_SUBSCRIPTION_CAPACITY: usize = 1_024;
270const MAX_PREAUTH_CONNECTIONS: usize = 32;
271const MAX_AUTHENTICATED_CONNECTIONS: usize = 16;
272const AUTH_FRAME_TIMEOUT_MS: u64 = 5_000;
273const FRAME_BODY_TIMEOUT_MS: u64 = 5_000;
274const CONNECTION_SHUTDOWN_GRACE_MS: u64 = 250;
275const SPAWN_DISPATCH_TIMEOUT_MS: u64 = 2_000;
276/// Fresh Spawn stays provisional until HQ successfully attaches (first Resize /
277/// TerminalBytes). This is an *attach-never-arrived* timeout from spawn time —
278/// not the disconnect reconnect grace (HQ owns that, starting only on a real
279/// client WS close). A provider conversation id is a session; a PTY with no
280/// attached HQ view is still a draft and is reaped here if attach never lands.
281const PROVISIONAL_SPAWN_GRACE_MS: u64 = 30_000;
282/// How often `drive_runtime_until_shutdown`'s loop re-evaluates the session
283/// record retention policy (`NodeServerConfig::session_record_retention`).
284/// A sweep cadence, not a policy value -- the policy itself lives entirely
285/// in the operator-configured age/keep-N flags; this just bounds how stale
286/// the in-memory view of dead `Unavailable` records can get between sweeps
287/// on an otherwise-idle node, mirroring the once-per-startup sweep that
288/// also runs alongside `reconcile_managed_worktrees`.
289const SESSION_RECORD_RETENTION_SWEEP_PERIOD: Duration = Duration::from_secs(5 * 60);
290/// TTL for `NodeShared::rejected_command_reasons` entries -- see that
291/// field's own doc for why a spawn waiter needs this at all. Must outlive
292/// the longest a waiter can still be polling for this exact command's
293/// outcome: `gate4agent_node_protocol::MAX_SPAWN_DEADLINE_MS` (120s) is the
294/// largest `deadline_ms` any caller may request, and the margin on top
295/// absorbs the waiter's own poll cadence plus clock skew between the drive
296/// loop (writer) and the waiter (reader).
297const REJECTED_COMMAND_REASON_TTL_MS: u64 = 150_000;
298/// Bounds `NodeShared::rejected_command_reasons` against an unbounded
299/// backlog of asynchronously-rejected commands nobody ever collects (a
300/// waiter that stops polling, or a burst of always-rejected commands from a
301/// misbehaving caller): once at capacity, the oldest entry is evicted to
302/// make room for the newest, mirroring the eviction rule already used by
303/// `spawn_idempotency`.
304const REJECTED_COMMAND_REASON_MAX_ENTRIES: usize = 256;
305/// How long `wait_until_removed` waits between two `Remove` re-dispatches
306/// for the same session. The presence poll runs far faster (2ms); this
307/// only paces the sending, so an accepted `Remove` still in flight is not
308/// piled on by a dozen more that land on an already-dropped instance.
309const REMOVE_REDISPATCH_INTERVAL_MS: u64 = 100;
310const SPAWN_IDEMPOTENCY_MAX_ENTRIES: usize = 256;
311const SPAWN_IDEMPOTENCY_TTL_MS: u64 = 15 * 60 * 1_000;
312const PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS: u64 = 2_500;
313const MUTATION_SETTLE_TIMEOUT_MS: u64 = 5_000;
314const READINESS_SETTLE_HEADROOM_MS: u64 = 2_000;
315const MANAGED_RESUME_SETTLE_TIMEOUT_MS: u64 = 30_000;
316const WORKSPACE_INSPECTION_MAX_CONCURRENCY: usize = 4;
317const WORKSPACE_FILE_READ_TIMEOUT_MS: u64 = 2_000;
318const HOST_DIRECTORY_BROWSE_TIMEOUT_MS: u64 = 2_000;
319const NATIVE_SESSION_CATALOG_TIMEOUT_MS: u64 = 30_000;
320const WORKSPACE_TREE_MAX_DEPTH: usize = 6;
321const WORKSPACE_TREE_MAX_ENTRIES: usize = 512;
322/// Default total wall-clock budget for `inspect_workspace`'s walk+git
323/// chain, overridable via `GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS`.
324const WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT: u64 = 8_000;
325/// The harness relay enforces its own flat response deadline
326/// (`HOST_RUN_READ_RESPONSE_DEADLINE`, `hatchery-harness-service::runtime`,
327/// 12s) above this node-side budget. The two constants live in different
328/// crates with no shared definition, so clamp any override here comfortably
329/// below that external ceiling — a misconfigured value above it would
330/// reopen the exact deadline race this budget exists to close.
331const WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX: u64 = 11_000;
332/// Default cap on directory entries visited (not just returned) by one
333/// walk, overridable via `GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP`.
334const WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT: usize = 50_000;
335const WORKSPACE_INSPECTION_ENTRY_CAP_MAX: usize = 1_000_000;
336const GIT_STATUS_MAX_ENTRIES: usize = 128;
337const GIT_COMMIT_MAX_ENTRIES: usize = 12;
338const GIT_OUTPUT_MAX_BYTES: usize = 64 * 1_024;
339const GIT_DIAGNOSTIC_MAX_BYTES: usize = 1_024;
340const WORKSPACE_FILE_WRITE_TIMEOUT_MS: u64 = 5_000;
341const WORKSPACE_ENTRY_CREATE_TIMEOUT_MS: u64 = 5_000;
342const GIT_COMMAND_TIMEOUT_MS: u64 = 1_500;
343const WORKSPACE_UNAVAILABLE_ERROR: &str = "workspace-unavailable";
344const PROVIDER_SESSION_SCOPE_CONFLICT_ERROR: &str = "provider-session-scope-conflict";
345const PROVIDER_SESSION_LIVE_CONFLICT_ERROR: &str = "provider-session-live-conflict";
346const MANAGED_SESSION_CAPACITY_ERROR: &str = "managed-session-capacity";
347const PROVIDER_IDENTITY_ALLOCATION_ERROR: &str =
348    "provider-session-identity-allocation-failed";
349const PROVIDER_RESUME_REJECTED_ERROR: &str = "provider-resume-rejected";
350const DURABLE_STATE_COMMIT_FAILED_ERROR: &str = "durable-state-commit-failed";
351const DURABLE_STATE_LOCK_FAILED_ERROR: &str = "durable-state-lock-failed";
352const DURABLE_STATE_LOAD_FAILED_ERROR: &str = "durable-state-load-failed";
353const DURABLE_STATE_CONFLICT_ERROR: &str = "durable-state-conflict";
354const DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR: &str = "durable-state-schema-unsupported";
355const DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR: &str =
356    "durable-state-path-semantics-unsupported";
357
358fn opaque_windows_path(value: String) -> OpaqueHostPath {
359    OpaqueHostPath::utf8(value).expect("validated Windows node path must remain wire-safe")
360}
361
362fn agent_progress_from_provider_snapshot(
363    address: SessionAddress,
364    provider: &ProviderSnapshot,
365) -> Option<SessionAgentProgress> {
366    let mut truncated = false;
367    let active_tool_count = bounded_progress_count(provider.active_tools.len(), &mut truncated);
368    let mut active_tool_labels = Vec::with_capacity(
369        provider
370            .active_tools
371            .len()
372            .min(MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS),
373    );
374    for tool in &provider.active_tools {
375        if active_tool_labels.len() == MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS {
376            truncated = true;
377            break;
378        }
379        if let Some(label) = sanitize_progress_tool_label(&tool.name, &mut truncated) {
380            active_tool_labels.push(label);
381        }
382    }
383    let attention = provider
384        .interactions
385        .iter()
386        .find(|interaction| {
387            matches!(
388                interaction.status,
389                ProviderInteractionStatus::Pending | ProviderInteractionStatus::Resolving { .. }
390            )
391        })
392        .map(|interaction| AgentProgressAttentionV1 {
393            kind: match interaction.interaction_kind {
394                ProviderInteractionKind::Approval => AgentProgressAttentionKindV1::Approval,
395                ProviderInteractionKind::Question => AgentProgressAttentionKindV1::Question,
396            },
397            tool_label: sanitize_progress_tool_label(&interaction.tool_name, &mut truncated),
398        });
399    let subagent_count = bounded_progress_count(provider.subagents.len(), &mut truncated);
400    let usage = (provider.completed_turns > 0
401        || provider.usage.input_tokens > 0
402        || provider.usage.output_tokens > 0
403        || provider.usage.cache_read_tokens > 0
404        || provider.usage.cache_write_tokens > 0
405        || provider.usage.reasoning_tokens > 0)
406        .then_some(AgentProgressUsageV1 {
407            input_tokens: provider.usage.input_tokens,
408            output_tokens: provider.usage.output_tokens,
409            cache_read_tokens: provider.usage.cache_read_tokens,
410            cache_write_tokens: provider.usage.cache_write_tokens,
411            reasoning_tokens: provider.usage.reasoning_tokens,
412        });
413    let progress = AgentProgressV1 {
414        provider_sequence: provider.sequence,
415        activity: provider.activity,
416        completed_turns: provider.completed_turns,
417        usage,
418        current: AgentProgressCurrentV1::from(provider.activity),
419        active_tool_labels,
420        active_tool_count,
421        attention,
422        subagent_count,
423        last_event_kind: provider.last_event.as_ref().and_then(agent_progress_event_kind),
424        gap_count: provider.gap_count,
425        stale: provider.stale,
426        truncated,
427    };
428    let entry = SessionAgentProgress { address, progress };
429    serde_json::to_vec(&entry)
430        .ok()
431        .filter(|encoded| encoded.len() <= MAX_AGENT_PROGRESS_ENTRY_BYTES)
432        .map(|_| entry)
433}
434
435fn bounded_progress_count(count: usize, truncated: &mut bool) -> u32 {
436    match u32::try_from(count) {
437        Ok(count) => count,
438        Err(_) => {
439            *truncated = true;
440            u32::MAX
441        }
442    }
443}
444
445fn agent_progress_event_kind(event: &ProviderEvent) -> Option<AgentProgressEventKindV1> {
446    if matches!(event, ProviderEvent::ContextWindowUsage { .. }) {
447        return None;
448    }
449    Some(match event {
450        ProviderEvent::SessionStarted { .. } => AgentProgressEventKindV1::SessionStarted,
451        ProviderEvent::SessionIdentityObserved { .. } => {
452            AgentProgressEventKindV1::SessionIdentityObserved
453        }
454        ProviderEvent::TurnStarted { .. } => AgentProgressEventKindV1::TurnStarted,
455        ProviderEvent::WorkingObserved => AgentProgressEventKindV1::WorkingObserved,
456        ProviderEvent::Text { .. } => AgentProgressEventKindV1::Text,
457        ProviderEvent::Thinking { .. } => AgentProgressEventKindV1::Thinking,
458        ProviderEvent::ToolStarted { .. } => AgentProgressEventKindV1::ToolStarted,
459        ProviderEvent::ToolCompleted { .. } => AgentProgressEventKindV1::ToolCompleted,
460        ProviderEvent::TurnCompleted { .. } => AgentProgressEventKindV1::TurnCompleted,
461        ProviderEvent::TurnInterrupted => AgentProgressEventKindV1::TurnInterrupted,
462        ProviderEvent::SessionEnded { .. } => AgentProgressEventKindV1::SessionEnded,
463        ProviderEvent::Error { .. } => AgentProgressEventKindV1::Error,
464        ProviderEvent::Ready => AgentProgressEventKindV1::Ready,
465        ProviderEvent::InteractionRequested { .. } => {
466            AgentProgressEventKindV1::InteractionRequested
467        }
468        ProviderEvent::InteractionResolved { .. } => AgentProgressEventKindV1::InteractionResolved,
469        ProviderEvent::SubagentStarted { .. } => AgentProgressEventKindV1::SubagentStarted,
470        ProviderEvent::SubagentStopped { .. } => AgentProgressEventKindV1::SubagentStopped,
471        ProviderEvent::RateLimited { .. } => AgentProgressEventKindV1::RateLimited,
472        ProviderEvent::HostRequestObserved { .. } => AgentProgressEventKindV1::HostRequestObserved,
473        ProviderEvent::UnrecognizedNotification { .. } => {
474            AgentProgressEventKindV1::UnrecognizedNotification
475        }
476        ProviderEvent::ContextWindowUsage { .. } => unreachable!("handled above"),
477        // ACP session/update coverage beyond text/tool/turn streaming
478        // (`plan`, `available_commands_update`, `current_mode_update`,
479        // `session_info_update`, `usage_update`, `config_option_update`,
480        // `user_message_chunk`). `AgentProgressEventKindV1` is a versioned
481        // wire enum without a `#[serde(other)]` fallback; minting new
482        // variants for it is a deliberate wire-contract decision for
483        // whoever owns this protocol, not a side effect of parsing more of
484        // ACP's own wire -- until that decision is made, these fold into
485        // the same "we saw something, no dedicated kind for it yet" bucket
486        // as `UnrecognizedNotification`.
487        ProviderEvent::UserMessage { .. }
488        | ProviderEvent::Plan { .. }
489        | ProviderEvent::AvailableCommandsUpdated { .. }
490        | ProviderEvent::ModeChanged { .. }
491        | ProviderEvent::SessionInfoUpdated { .. }
492        | ProviderEvent::UsageUpdated { .. }
493        | ProviderEvent::ConfigOptionsUpdated { .. } => {
494            AgentProgressEventKindV1::UnrecognizedNotification
495        }
496    })
497}
498
499/// Maps this harness's own `HostDecisionAuthority` onto the wider
500/// Maps a host request decider onto the `BlockAuthorityV1` a `Blocked` stream
501/// chunk carries -- the four authorities a `Denied` decision is ever made
502/// under, one-for-one. The remaining `BlockAuthorityV1` variants
503/// (`ProviderClassifier`, `ProviderPermissionRule`, `ProviderSandbox`,
504/// `ProviderRefusal`, `UserRejected`, `ProviderQuota`, `Unknown`) describe a
505/// block the PROVIDER side of the wire reported -- this function never
506/// produces one, because `ProviderHostDecisionAuthority` has no vocabulary for
507/// them; the provider-side blocks below mint those directly from typed fields.
508/// Whether a provider source is a live session transport -- the only kind
509/// whose events reach the agent stream. History, resume, session-option and
510/// capability-probe sources are not sessions and stream nothing.
511fn provider_family_is_live(family: AdapterFamily) -> bool {
512    match family {
513        AdapterFamily::PtySemantic
514        | AdapterFamily::Hook
515        | AdapterFamily::ManagedHook
516        | AdapterFamily::Pipe
517        | AdapterFamily::OneShot
518        | AdapterFamily::Acp => true,
519        AdapterFamily::History
520        | AdapterFamily::Resume
521        | AdapterFamily::SessionOptions
522        | AdapterFamily::CapabilityProbe => false,
523    }
524}
525
526fn block_authority(by: ProviderHostDecisionAuthority) -> BlockAuthorityV1 {
527    match by {
528        ProviderHostDecisionAuthority::Gate => BlockAuthorityV1::HostGate,
529        ProviderHostDecisionAuthority::Policy => BlockAuthorityV1::HostPolicy,
530        ProviderHostDecisionAuthority::Operator => BlockAuthorityV1::Operator,
531        ProviderHostDecisionAuthority::DeadlinePolicy => BlockAuthorityV1::HostDeadline,
532    }
533}
534
535/// The `Blocked::reason` for a `Denied` decision this build's own
536/// `AgentEvent::RpcIncomingRequest::reason` did not attach text to --
537/// reachable today for `Operator`/`HostDeadline`: a deferred `session/
538/// request_permission` request resolved by `AcpSession::
539/// resolve_pending_request`/`expire_deadlines` (`src/acp/session.rs`),
540/// neither of which computes free text (an operator answers approve/reject,
541/// never a comment; an unattended deadline asks nobody anything). `reason`
542/// is a REQUIRED field on the wire (see `AgentStreamChunkKindV1::Blocked`)
543/// -- a categorical fallback naming the authority is the
544/// honest minimum, never a placeholder standing in for detail nobody
545/// reported.
546fn action_blocked_fallback_reason(authority: BlockAuthorityV1) -> &'static str {
547    match authority {
548        BlockAuthorityV1::HostGate => "denied by the dangerous-command gate",
549        BlockAuthorityV1::HostPolicy => "denied by host policy",
550        BlockAuthorityV1::HostDeadline => {
551            "denied by host policy after the operator deadline expired"
552        }
553        BlockAuthorityV1::Operator => "declined by the operator",
554        BlockAuthorityV1::ProviderClassifier => "denied by the provider's classifier",
555        BlockAuthorityV1::ProviderPermissionRule => "denied by a provider permission rule",
556        BlockAuthorityV1::ProviderSandbox => "denied by the provider's sandbox",
557        BlockAuthorityV1::ProviderRefusal => "refused by the provider",
558        BlockAuthorityV1::UserRejected => "rejected by the user",
559        BlockAuthorityV1::ProviderQuota => "the provider's quota or rate limit was exhausted",
560        BlockAuthorityV1::Unknown => "blocked; no reason was reported",
561    }
562}
563
564/// Computes `AgentStreamChunkKindV1::Blocked`'s `tool_class`/`authority`/`reason`
565/// for a `HostRequestObserved` `Denied` decision.
566fn host_request_denied_block(
567    method: &str,
568    by: ProviderHostDecisionAuthority,
569    reason: Option<&str>,
570) -> (String, BlockAuthorityV1, String) {
571    let authority = block_authority(by);
572    let reason_source = reason.unwrap_or_else(|| action_blocked_fallback_reason(authority));
573    // `AgentStreamChunkKindV1::validate` REJECTS an over-long reason outright
574    // rather than truncating -- this cut happens here, at the producer, before
575    // the validator ever sees the text.
576    let (reason_text, _) =
577        truncate_text(reason_source, MAX_ACP_BLOCKED_REASON_BYTES);
578    (tool_class_label(method), authority, reason_text)
579}
580
581// ---------------------------------------------------------------------------
582// K2 -- provider-side blocks read off ACP `tool_call_update._meta.
583// nonExecutionKind` (`ProviderEvent::ToolCompleted::non_execution_kind`) and
584// `SessionEnded::stop_reason` (`stopReason: "refusal"`, or a `session/
585// prompt` RPC error whose vendor code names a quota/limit/rate
586// exhaustion). See
587// `docs/gate4agent/plans/gate4agent-blocked-action-event-2026-09-02.md`
588// §3 slice K2.
589// ---------------------------------------------------------------------------
590
591/// Classifies ACP's `nonExecutionKind` into the typed authority a block
592/// this build mints under, or `None` when the tool truly did not run for a
593/// reason that is NOT a block -- `"interrupted"`/`"cancelled"` mean the
594/// TURN stopped (session closed, the operator cancelled), not that a
595/// policy/classifier/rule refused the call, so those (and any unrecognized
596/// future value) stay a plain `ToolCompleted { success: false }` only, per
597/// this plan's own rule that a tool failure is never inflated into a block
598/// this build cannot actually attribute.
599fn provider_tool_block_authority(kind: Option<&str>) -> Option<(BlockAuthorityV1, &'static str)> {
600    match kind? {
601        "user-rejected" => Some((BlockAuthorityV1::UserRejected, "user-rejected")),
602        "permission-rule" => Some((BlockAuthorityV1::ProviderPermissionRule, "permission-rule")),
603        _ => None,
604    }
605}
606
607/// Splits a provider's own denial sentence into `reason`/`help`, when it
608/// matches the fixed shape Claude Code's classifier/permission-rule denial
609/// uses -- verified live, the owner's sample fixture
610/// (`docs/gate4agent/research/gate4agent-blocked-action-signals-2026-09-02.md`
611/// appendix): "...denied by <authority>. Reason: <reason>. <help...>".
612/// `authority` is NEVER derived from this text (see this module's own rule
613/// that authority comes only from a typed field) -- only `reason`/`help`
614/// are. Falls back to the full text as `reason` with no `help` when the
615/// shape does not match (a provider whose text does not carry this exact
616/// wording) -- the honest minimum: the tool truly did not run, so SOME
617/// reason is always reported, never fabricated detail beyond what arrived.
618fn split_provider_denial_text(text: &str) -> (String, Option<String>) {
619    let trimmed = text.trim();
620    let Some((_, after_reason)) = trimmed.split_once("Reason:") else {
621        return (trimmed.to_owned(), None);
622    };
623    let tail = after_reason.trim_start();
624    let Some((reason, help)) = tail.split_once(". ") else {
625        // "Reason:" present but no ". " to split help off -- the whole
626        // tail is the reason, e.g. "Reason: Blocked by classifier." with
627        // no help text following on some providers/versions.
628        let reason = tail.strip_suffix('.').unwrap_or(tail).trim();
629        return (reason.to_owned(), None);
630    };
631    let help = help.trim();
632    if help.is_empty() {
633        (reason.trim().to_owned(), None)
634    } else {
635        (reason.trim().to_owned(), Some(help.to_owned()))
636    }
637}
638
639/// `tool_class` for a provider-side `ActionBlocked` this build has no typed
640/// tool name for -- `ProviderEvent::ToolCompleted` carries only `id`, never
641/// `name` (see `ProviderEvent::ToolStarted`, which does, but a tool's own
642/// name is never threaded through to its completion). Reads the class off
643/// the guidance tail's own "add a <Class> permission rule" sentence when
644/// present (the shape the owner's sample fixture uses) -- text matching
645/// fills `tool_class` here, same as `reason`/`help`, NEVER `authority` (see
646/// this module's own rule). Falls back to the same generic `"Tool"` class
647/// `ToolCompleted`'s own observation already uses when nothing names one.
648fn tool_class_from_denial_help(help: Option<&str>) -> String {
649    help.and_then(|text| text.split("add a ").nth(1))
650        .and_then(|tail| tail.split(' ').next())
651        .filter(|word| !word.is_empty())
652        .map(str::to_owned)
653        .unwrap_or_else(|| "Tool".to_owned())
654}
655
656/// Whether a `session/prompt` RPC error's vendor code (or, absent one, its
657/// message) names a quota/rate-limit exhaustion -- Codex's
658/// `data.codexErrorInfo: "usageLimitExceeded"` (measured live 2026-09-05)
659/// contains "limit", the word this build actually keys on; "quota"/"rate"
660/// are kept for whichever OTHER vendor's own code names one of those
661/// instead, never confirmed live, but the honest generalization of the
662/// one confirmed sample.
663fn provider_error_is_quota(vendor_code: Option<&str>, message: &str) -> bool {
664    fn names_quota(s: &str) -> bool {
665        let lower = s.to_ascii_lowercase();
666        lower.contains("limit") || lower.contains("quota") || lower.contains("rate")
667    }
668    vendor_code.is_some_and(names_quota) || names_quota(message)
669}
670
671/// The `Blocked` chunk this build mints when a turn stopped via a
672/// typed provider fact rather than a tool call -- `stopReason: "refusal"`
673/// (`authority: ProviderRefusal`) or a `session/prompt` RPC error whose
674/// vendor code (or message) names a quota/limit/rate exhaustion
675/// (`authority: ProviderQuota`, `reason` = the error message verbatim,
676/// `reason_kind` = the vendor code). `None` for every other stop --
677/// `end_turn`, `max_tokens`, `max_turn_requests`, `cancelled`, an untagged
678/// `Other`, or an RPC error whose vendor code/message names none of the
679/// three words: `SessionEnded` stays a plain `Exited`/turn-end fact for
680/// those, never inflated into a block this build cannot actually
681/// attribute. `ProviderRefusal`'s `reason` has no source text to read on
682/// `SessionEnded` itself (ACP's `refusal` stopReason carries none) -- the
683/// honest minimum names the fact that happened, never invented detail; a
684/// later slice that threads the turn's last agent text chunk through can
685/// replace it without changing this function's contract.
686fn provider_session_end_block(
687    stop_reason: &ProviderStopReason,
688) -> Option<(BlockAuthorityV1, String, String)> {
689    match stop_reason {
690        ProviderStopReason::Refusal => Some((
691            BlockAuthorityV1::ProviderRefusal,
692            "refusal".to_owned(),
693            "the provider ended the turn with stopReason: refusal".to_owned(),
694        )),
695        ProviderStopReason::ProviderError { message, vendor_code, .. }
696            if provider_error_is_quota(vendor_code.as_deref(), message) =>
697        {
698            let reason_kind = vendor_code.clone().unwrap_or_else(|| "provider-error".to_owned());
699            Some((BlockAuthorityV1::ProviderQuota, reason_kind, message.clone()))
700        }
701        _ => None,
702    }
703}
704
705/// Builds the `AgentStreamChunkV1` `event`'s `ProviderEvent` carries onto the
706/// `agent-stream-events-v1` channel -- see
707/// `docs/gate4agent/plans/gate4agent-acp-control-plane-on-the-wire-2026-09-02.md`
708/// §5. Two invariants keep the chunk correlatable with the `NodeEvent::Control`
709/// event it derives from, so a client joining the two sees one id and one
710/// ordering:
711///
712/// - `source_sequence` on the chunk is the `source_sequence` the provider
713///   event itself carries.
714/// - `InteractionPrompt::correlation_id` is minted by
715///   `correlation::interaction_correlation` with the
716///   `(instance_id, generation, provider_sequence)` triple -- never the
717///   provider's own `request_id`, whose opacity on the wire is pinned by a
718///   dedicated test.
719///
720/// Returns `None` for every `ControlEventKind` other than `ProviderEvent`
721/// (nothing else carries stream content), for a source that is not a live
722/// session transport, and for the `ProviderEvent` variants below with no
723/// declared chunk kind to carry them, or whose declared chunk kind this build
724/// has no source for yet:
725///
726/// - `ModelCatalog { current, available }` has no `ProviderEvent` source at
727///   all -- grok's vendor model switch is not bridged into `ProviderEvent`
728///   yet, the same gap the plan names explicitly.
729/// - `UserMessage { text, is_delta }` is an echo of the OPERATOR's own
730///   prompt, replayed on session resume -- `Text`/`Thinking` are documented
731///   as "what the agent is saying", so mapping the operator's words onto
732///   `Text` would misrepresent whose content it is. No dedicated
733///   user-message kind is declared, so this stays unproduced rather than
734///   mislabeled.
735///
736/// `HostRequestObserved { .. }` is its own arm below: a `Granted`/`Deferred`
737/// decision returns `None` (nothing for the operator to react to), a `Denied`
738/// decision returns `Some(Blocked { .. })`.
739///
740/// `ToolCompleted { .. }`/`SessionEnded { .. }` are likewise their own arms
741/// below (K2): each returns `None` unless
742/// `provider_tool_block_authority`/`provider_session_end_block` classifies the
743/// event as an actual block (ACP's `_meta.nonExecutionKind`, or `stopReason`/an
744/// RPC error naming a quota).
745fn agent_stream_chunk(event: &ControlEvent) -> Option<AgentStreamChunkV1> {
746    let ControlEventKind::ProviderEvent {
747        sequence: provider_sequence,
748        source,
749        source_sequence,
750        event: provider_event,
751    } = &event.event
752    else {
753        return None;
754    };
755    if !provider_family_is_live(source.family) {
756        return None;
757    }
758    let kind = match provider_event {
759        ProviderEvent::Text { text, is_delta } => AgentStreamChunkKindV1::Text {
760            text: text.clone(),
761            is_delta: *is_delta,
762        },
763        ProviderEvent::Thinking { text } => {
764            AgentStreamChunkKindV1::Thinking { text: text.clone() }
765        }
766        ProviderEvent::InteractionRequested {
767            interaction_kind,
768            tool_name,
769            title,
770            prompt,
771            options,
772            ..
773        } => AgentStreamChunkKindV1::InteractionPrompt {
774            correlation_id: interaction_correlation(
775                event.instance_id,
776                event.generation,
777                *provider_sequence,
778            ),
779            interaction_kind: *interaction_kind,
780            tool_name: tool_name.clone(),
781            // `title`/`options` are carried straight through from
782            // `ProviderEvent::InteractionRequested`, as the agent itself
783            // gave them on `session/request_permission`'s `PermissionTool
784            // Call.title`/`options` (see `gate4agent-shell-native`'s
785            // `provider_event`, the only ACP-transport producer). A PTY- or
786            // hook-sourced interaction carries `title: None` and
787            // `options: []` from its own producer because that source has
788            // none to offer, never because this mapping dropped one.
789            title: title.clone(),
790            prompt: prompt.clone(),
791            options: options
792                .iter()
793                .map(|option| AgentStreamInteractionOptionV1 {
794                    option_id: option.option_id.clone(),
795                    name: option.name.clone(),
796                    kind: option.kind.clone(),
797                })
798                .collect(),
799        },
800        ProviderEvent::ConfigOptionsUpdated { options } => {
801            AgentStreamChunkKindV1::ConfigOptions { options: options.clone() }
802        }
803        // `available` is the mode catalogue `AcpSession::available_modes()`
804        // read back at `session/new` and threaded through by
805        // `gate4agent-shell-native`'s `provider_event` (see that crate's
806        // `ProviderEvent::ModeChanged` arm) -- an empty list here is the
807        // agent's own honest "no modes announced", never "not read": ACP
808        // orders `session/new` before any `session/update`, and the
809        // catalogue is fixed at handshake, so a `ModeChanged` event cannot
810        // exist before the same session object's catalogue is already
811        // populated (see `gate4agent_types::ProviderEvent::ModeChanged`'s
812        // own doc comment for the ordering argument in full).
813        ProviderEvent::ModeChanged { mode_id, available } => AgentStreamChunkKindV1::ModeCatalog {
814            current: Some(mode_id.clone()),
815            available: available
816                .iter()
817                .map(|mode| AgentStreamNamedIdV1 {
818                    id: mode.id.clone(),
819                    name: mode.name.clone(),
820                    description: mode.description.clone(),
821                })
822                .collect(),
823        },
824        // A denied host request is a block -- see this function's own doc
825        // comment for why `HostRequestObserved` is not in the exclusion list
826        // below.
827        ProviderEvent::HostRequestObserved { method, decision, reason, .. } => {
828            let ProviderHostRequestDecision::Denied { by } = decision else {
829                return None;
830            };
831            let (tool_class, authority, reason_text) =
832                host_request_denied_block(method, *by, reason.as_deref());
833            AgentStreamChunkKindV1::Blocked {
834                correlation_id: None,
835                tool_class,
836                authority,
837                reason_kind: None,
838                reason: reason_text,
839                help: None,
840            }
841        }
842        // Same relationship as `HostRequestObserved` above, driven by ACP's
843        // own `_meta.nonExecutionKind` instead of the host's
844        // `HostRequestDecision`.
845        ProviderEvent::ToolCompleted { id, output, non_execution_kind, .. } => {
846            let (authority, reason_kind) =
847                provider_tool_block_authority(non_execution_kind.as_deref())?;
848            let (reason, help) = split_provider_denial_text(output);
849            let (reason, _) =
850                truncate_text(&reason, MAX_ACP_BLOCKED_REASON_BYTES);
851            let help = help.map(|help| {
852                truncate_text(&help, MAX_ACP_BLOCKED_HELP_BYTES).0
853            });
854            AgentStreamChunkKindV1::Blocked {
855                correlation_id: Some(tool_correlation(event, source, id)),
856                tool_class: tool_class_from_denial_help(help.as_deref()),
857                authority,
858                reason_kind: Some(reason_kind.to_owned()),
859                reason,
860                help,
861            }
862        }
863        // Same relationship, driven by `stopReason: "refusal"` or a
864        // `session/prompt` RPC error naming a quota/limit/rate exhaustion
865        // instead of a tool-level fact.
866        ProviderEvent::SessionEnded { stop_reason, .. } => {
867            let (authority, reason_kind, reason) =
868                stop_reason.as_ref().and_then(provider_session_end_block)?;
869            let (reason, _) =
870                truncate_text(&reason, MAX_ACP_BLOCKED_REASON_BYTES);
871            AgentStreamChunkKindV1::Blocked {
872                correlation_id: None,
873                tool_class: "Session".to_owned(),
874                authority,
875                reason_kind: Some(reason_kind),
876                reason,
877                help: None,
878            }
879        }
880        ProviderEvent::SessionStarted { .. }
881        | ProviderEvent::SessionIdentityObserved { .. }
882        | ProviderEvent::TurnStarted { .. }
883        | ProviderEvent::WorkingObserved
884        | ProviderEvent::ToolStarted { .. }
885        | ProviderEvent::TurnCompleted { .. }
886        | ProviderEvent::ContextWindowUsage { .. }
887        | ProviderEvent::TurnInterrupted
888        | ProviderEvent::Error { .. }
889        | ProviderEvent::Ready
890        | ProviderEvent::InteractionResolved { .. }
891        | ProviderEvent::SubagentStarted { .. }
892        | ProviderEvent::SubagentStopped { .. }
893        | ProviderEvent::RateLimited { .. }
894        | ProviderEvent::UnrecognizedNotification { .. }
895        | ProviderEvent::UserMessage { .. }
896        | ProviderEvent::Plan { .. }
897        | ProviderEvent::AvailableCommandsUpdated { .. }
898        | ProviderEvent::SessionInfoUpdated { .. }
899        | ProviderEvent::UsageUpdated { .. } => return None,
900    };
901    Some(AgentStreamChunkV1 { source_sequence: *source_sequence, kind })
902}
903
904async fn run_native_session_catalog_operation<T, E, F>(
905    catalog: Arc<Mutex<NativeSessionCatalogAuthority>>,
906    operation: &'static str,
907    execute: F,
908) -> Result<T, NodeFailure>
909where
910    T: Send + 'static,
911    E: std::fmt::Display + Send + 'static,
912    F: FnOnce(&mut NativeSessionCatalogAuthority) -> Result<T, E> + Send + 'static,
913{
914    let task = tokio::task::spawn_blocking(move || {
915        let mut catalog = match catalog.try_lock() {
916            Ok(catalog) => catalog,
917            Err(std::sync::TryLockError::Poisoned(poisoned)) => poisoned.into_inner(),
918            Err(std::sync::TryLockError::WouldBlock) => {
919                return Err(failure(
920                    NodeFailureCode::BackendBusy,
921                    "native session catalog operation is already running",
922                ));
923            }
924        };
925        execute(&mut catalog).map_err(|error| {
926            failure(
927                NodeFailureCode::BackendOperationFailed,
928                &format!("{operation} failed: {error}"),
929            )
930        })
931    });
932    timeout(Duration::from_millis(NATIVE_SESSION_CATALOG_TIMEOUT_MS), task)
933        .await
934        .map_err(|_| {
935            failure(
936                NodeFailureCode::BackendBusy,
937                &format!("{operation} exceeded its bounded deadline"),
938            )
939        })?
940        .map_err(|_| {
941            failure(
942                NodeFailureCode::BackendOperationFailed,
943                &format!("{operation} task failed"),
944            )
945        })?
946}
947
948fn windows_path_text(path: &OpaqueHostPath) -> &str {
949    path.as_utf8()
950        .expect("Windows node only creates UTF-8 opaque host paths")
951}
952
953fn require_windows_path(path: OpaqueHostPath) -> Result<String, NodeFailure> {
954    path.as_utf8()
955        .map(str::to_owned)
956        .ok_or_else(|| failure(
957            NodeFailureCode::InvalidRequest,
958            "this node requires UTF-8 host paths",
959        ))
960}
961
962fn protocol_worktree(mut worktree: NativeGitWorktreeSnapshot) -> GitWorktreeSnapshot {
963    GitWorktreeSnapshot {
964        path: opaque_windows_path(worktree.path),
965        head: worktree.head,
966        branch: worktree.branch,
967        is_bare: worktree.is_bare,
968        is_main: worktree.is_main,
969        locked: worktree.locked,
970        lock_reason: worktree.lock_reason,
971        prunable: worktree.prunable,
972        prunable_reason: worktree.prunable_reason,
973        workspace_id: worktree.workspace_id.take(),
974    }
975}
976
977#[derive(Clone, Debug, Eq, PartialEq)]
978pub struct WorkspaceConfig {
979    workspace_id: WorkspaceId,
980    canonical_root: String,
981    worktree_service_mode: WorktreeServiceMode,
982    managed_worktree_profiles: BTreeMap<WorktreeProfileId, ManagedWorktreeProfile>,
983}
984
985impl WorkspaceConfig {
986    pub fn new(
987        workspace_id: WorkspaceId,
988        root: impl AsRef<Path>,
989    ) -> Result<Self, NodeServerError> {
990        let root = root.as_ref();
991        if !root.is_absolute() {
992            return Err(NodeServerError::InvalidWorkspaceRoot {
993                workspace_id,
994                path: root.to_string_lossy().into_owned(),
995                message: "path is not absolute".to_owned(),
996            });
997        }
998        let canonical = std::fs::canonicalize(root).map_err(|error| {
999            NodeServerError::InvalidWorkspaceRoot {
1000                workspace_id: workspace_id.clone(),
1001                path: root.to_string_lossy().into_owned(),
1002                message: error.to_string(),
1003            }
1004        })?;
1005        if !canonical.is_dir() {
1006            return Err(NodeServerError::InvalidWorkspaceRoot {
1007                workspace_id,
1008                path: root.to_string_lossy().into_owned(),
1009                message: "path is not a directory".to_owned(),
1010            });
1011        }
1012        let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
1013            NodeServerError::InvalidWorkspaceRoot {
1014                workspace_id: workspace_id.clone(),
1015                path: path.to_string_lossy().into_owned(),
1016                message: "canonical path is not valid Unicode".to_owned(),
1017            }
1018        })?;
1019        let canonical_root = platform::normalize_canonical_root(canonical_root);
1020        validate_workspace_root(&workspace_id, &canonical_root)?;
1021        Ok(Self {
1022            workspace_id,
1023            canonical_root,
1024            worktree_service_mode: WorktreeServiceMode::Manual,
1025            managed_worktree_profiles: BTreeMap::new(),
1026        })
1027    }
1028
1029    pub fn workspace_id(&self) -> &WorkspaceId {
1030        &self.workspace_id
1031    }
1032
1033    pub fn canonical_root(&self) -> &str {
1034        &self.canonical_root
1035    }
1036
1037    pub fn with_worktree_service_mode(mut self, mode: WorktreeServiceMode) -> Self {
1038        self.worktree_service_mode = mode;
1039        self
1040    }
1041
1042    pub fn with_managed_worktree_profile(
1043        mut self,
1044        profile: ManagedWorktreeProfile,
1045    ) -> Result<Self, NodeServerError> {
1046        profile.validate_for_workspace(&self.canonical_root).map_err(|message| {
1047            NodeServerError::InvalidManagedWorktreeProfile {
1048                workspace_id: self.workspace_id.clone(),
1049                profile_id: profile.profile_id().clone(),
1050                message,
1051            }
1052        })?;
1053        if self.managed_worktree_profiles.contains_key(profile.profile_id()) {
1054            return Err(NodeServerError::DuplicateManagedWorktreeProfile {
1055                workspace_id: self.workspace_id,
1056                profile_id: profile.profile_id().clone(),
1057            });
1058        }
1059        if self.managed_worktree_profiles.len()
1060            == MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE
1061        {
1062            return Err(NodeServerError::ManagedWorktreeProfileCapacity {
1063                workspace_id: self.workspace_id,
1064                max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
1065            });
1066        }
1067        self.managed_worktree_profiles
1068            .insert(profile.profile_id().clone(), profile);
1069        Ok(self)
1070    }
1071}
1072
1073#[derive(Clone)]
1074pub struct NodeServerConfig {
1075    pub endpoint: String,
1076    api_listen: Option<std::net::SocketAddr>,
1077    /// Opt-in node-envelope HTTP+WS browser bridge (separate from `--api-listen`
1078    /// ops HTTP and from framed C2). Loopback only; default `None` = off.
1079    bridge_listen: Option<std::net::SocketAddr>,
1080    /// Optional bridge shared secret (`GATE4AGENT_BRIDGE_TOKEN`). Distinct from
1081    /// NODE_TOKEN; never logged. `None` = loopback bind is the only gate.
1082    bridge_token: Option<String>,
1083    /// Tip 6: optional mesh underlay UDP bind that relays to local `--bridge-listen`.
1084    /// Requires bridge_listen + bridge_token. HQ never accepts (NodePeer).
1085    bridge_underlay: Option<browser_bridge::BridgeUnderlayAccept>,
1086    /// A relay to CALL, rather than wait to be called by. `None` keeps the
1087    /// node purely a listener, which is what it has always been.
1088    call_home: Option<std::net::SocketAddr>,
1089    /// When set, call-home authenticates by signing the relay's challenge with this
1090    /// identity (enrolled node) and falls back to the token handshake against a relay
1091    /// that sends no challenge.
1092    call_home_identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
1093    pub node_id: NodeId,
1094    pub workspaces: Vec<WorkspaceConfig>,
1095    access_token: String,
1096    pub runtime: NativeRuntimeConfig,
1097    session_record_retention: SessionRecordRetentionConfig,
1098    state_path: Option<PathBuf>,
1099    spawn_profiles: SpawnProfileRegistry,
1100    session_environment: Option<NodeSessionEnvironmentConfig>,
1101    history: Option<NativeHistoryConfig>,
1102    harness_mcp_helper: Option<ReviewedHarnessMcpProgram>,
1103    /// Station network allowlist catalog (opaque ids). Empty default.
1104    /// Loaded at startup from `--network-allowlist-catalog` / env
1105    /// `GATE4AGENT_NODE_NETWORK_ALLOWLIST_CATALOG` file. Never secrets.
1106    network_allowlist_catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog,
1107    #[cfg(feature = "fixture")]
1108    fixture_raw_pty_runtime: bool,
1109}
1110
1111#[derive(Clone)]
1112struct NodeSessionEnvironmentConfig {
1113    root: PathBuf,
1114    resolver: Arc<dyn NodeSecretResolver>,
1115}
1116
1117impl NodeServerConfig {
1118    pub fn new(
1119        endpoint: impl Into<String>,
1120        access_token: impl Into<String>,
1121        node_id: NodeId,
1122        workspaces: impl IntoIterator<Item = WorkspaceConfig>,
1123    ) -> Result<Self, NodeServerError> {
1124        let endpoint = endpoint.into();
1125        let access_token = access_token.into();
1126        if !platform::validate_endpoint(&endpoint) {
1127            return Err(NodeServerError::InvalidEndpoint);
1128        }
1129        if access_token.is_empty() || access_token.len() > 4_096 {
1130            return Err(NodeServerError::InvalidAccessToken);
1131        }
1132        let workspaces = workspaces.into_iter().collect::<Vec<_>>();
1133        if workspaces.is_empty() {
1134            return Err(NodeServerError::NoWorkspaces);
1135        }
1136        let mut workspace_ids = BTreeMap::new();
1137        let mut workspace_roots = BTreeMap::new();
1138        for workspace in &workspaces {
1139            if workspace.worktree_service_mode == WorktreeServiceMode::Managed
1140                && workspace.managed_worktree_profiles.is_empty()
1141            {
1142                return Err(NodeServerError::ManagedWorktreeProfileRequired(
1143                    workspace.workspace_id.clone(),
1144                ));
1145            }
1146            if workspace.worktree_service_mode != WorktreeServiceMode::Managed
1147                && !workspace.managed_worktree_profiles.is_empty()
1148            {
1149                return Err(NodeServerError::ManagedWorktreeProfileModeMismatch(
1150                    workspace.workspace_id.clone(),
1151                ));
1152            }
1153            if workspace_ids
1154                .insert(workspace.workspace_id.clone(), ())
1155                .is_some()
1156            {
1157                return Err(NodeServerError::DuplicateWorkspaceId(
1158                    workspace.workspace_id.clone(),
1159                ));
1160            }
1161            let root_key = platform::root_identity(&workspace.canonical_root);
1162            if let Some(existing) = workspace_roots.insert(
1163                root_key,
1164                workspace.workspace_id.clone(),
1165            ) {
1166                return Err(NodeServerError::DuplicateWorkspaceRoot {
1167                    first: existing,
1168                    second: workspace.workspace_id.clone(),
1169                    root: workspace.canonical_root.clone(),
1170                });
1171            }
1172        }
1173        Ok(Self {
1174            endpoint,
1175            api_listen: None,
1176            bridge_listen: None,
1177            bridge_token: None,
1178            bridge_underlay: None,
1179            call_home: None,
1180            call_home_identity: None,
1181            node_id,
1182            workspaces,
1183            access_token,
1184            // PTY output is polled by the runtime's effect worker; the upstream
1185            // default (20ms) added ~10-20ms to every keystroke echo.
1186            runtime: NativeRuntimeConfig {
1187                worker_poll_interval_ms: 4,
1188                ..NativeRuntimeConfig::default()
1189            },
1190            session_record_retention: SessionRecordRetentionConfig::default(),
1191            state_path: None,
1192            spawn_profiles: SpawnProfileRegistry::default(),
1193            session_environment: None,
1194            history: None,
1195            harness_mcp_helper: None,
1196            network_allowlist_catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog::new(),
1197            #[cfg(feature = "fixture")]
1198            fixture_raw_pty_runtime: false,
1199        })
1200    }
1201
1202    pub fn with_api_listen(
1203        mut self,
1204        api_listen: std::net::SocketAddr,
1205    ) -> Result<Self, NodeServerError> {
1206        if !api_listen.ip().is_loopback() {
1207            return Err(NodeServerError::InvalidApiListen(api_listen));
1208        }
1209        self.api_listen = Some(api_listen);
1210        Ok(self)
1211    }
1212
1213    /// Opt-in node-envelope HTTP+WS bridge listener. Loopback only (same spirit
1214    /// as `with_api_listen`). Does not enable by default — libraries and the
1215    /// binary leave this `None` until `--bridge-listen` / `with_bridge_listen`.
1216    pub fn with_bridge_listen(
1217        mut self,
1218        bridge_listen: std::net::SocketAddr,
1219    ) -> Result<Self, NodeServerError> {
1220        if !bridge_listen.ip().is_loopback() {
1221            return Err(NodeServerError::InvalidBridgeListen(bridge_listen));
1222        }
1223        self.bridge_listen = Some(bridge_listen);
1224        Ok(self)
1225    }
1226
1227    /// Optional bridge shared secret, distinct from the C2/node access token.
1228    /// Never log the value. Empty / oversize refused.
1229    pub fn with_bridge_token(
1230        mut self,
1231        bridge_token: impl Into<String>,
1232    ) -> Result<Self, NodeServerError> {
1233        let bridge_token = bridge_token.into();
1234        if bridge_token.is_empty() || bridge_token.len() > 4_096 {
1235            return Err(NodeServerError::InvalidBridgeToken);
1236        }
1237        self.bridge_token = Some(bridge_token);
1238        Ok(self)
1239    }
1240
1241    /// Tip 6: point bridge reachability over mesh underlay (UDP+AEAD relay to
1242    /// local loopback `--bridge-listen`). Requires bridge listen + bridge token
1243    /// already configured. Transport key is 32 raw bytes (never logged).
1244    /// Underlay auth token is distinct from BRIDGE_TOKEN (crypto ≠ auth).
1245    pub fn with_bridge_underlay(
1246        mut self,
1247        bind: std::net::SocketAddr,
1248        transport_key: [u8; 32],
1249        underlay_token: impl Into<String>,
1250    ) -> Result<Self, NodeServerError> {
1251        if self.bridge_listen.is_none() {
1252            return Err(NodeServerError::BridgeUnderlayRequiresBridgeListen);
1253        }
1254        if self.bridge_token.is_none() {
1255            return Err(NodeServerError::BridgeUnderlayRequiresBridgeToken);
1256        }
1257        let underlay_token = underlay_token.into();
1258        if underlay_token.is_empty() || underlay_token.len() > 4_096 {
1259            return Err(NodeServerError::InvalidBridgeUnderlayToken);
1260        }
1261        // Port 0 is allowed (ephemeral). Role lock (no HQ accept) lives in underlay.
1262        self.bridge_underlay = Some(browser_bridge::BridgeUnderlayAccept {
1263            bind,
1264            transport_key,
1265            underlay_token,
1266        });
1267        Ok(self)
1268    }
1269
1270    /// Also dial `relay` and serve this wire over the connection, in
1271    /// addition to accepting on the local endpoint.
1272    ///
1273    /// Who opens the socket and who is the protocol's server are separate
1274    /// questions, and this is the node's half of separating them. The node
1275    /// stays the server either way -- it answers `NodeRequest`s and emits
1276    /// `NodeEvent`s exactly as it does for a relay that dialled it. All
1277    /// that changes is that a node with no address anyone can reach makes
1278    /// the connection itself, and names itself once it arrives (see
1279    /// `gate4agent_node_wire::write_call_home_announce`).
1280    ///
1281    /// Loopback only, and that is not an oversight to be flagged and
1282    /// worked around. This wire carries terminal contents, keystrokes and
1283    /// file bytes as plaintext JSON; its mutual challenge-response
1284    /// authenticates both ends and encrypts nothing. Every listener in
1285    /// this stack is loopback-guarded for that reason, and a dialler must
1286    /// hold the same line -- a node calling a public relay would put the
1287    /// whole control plane on the wire in the clear. Reaching a relay on
1288    /// another host is a transport question, and it stays unanswered here
1289    /// on purpose: this change is about direction, not distance.
1290    pub fn with_call_home(
1291        mut self,
1292        relay: std::net::SocketAddr,
1293    ) -> Result<Self, NodeServerError> {
1294        // Loopback, or the concrete C2 WireGuard tunnel address. Unspecified
1295        // and port 0 refused — plaintext wire must not dial every interface.
1296        if relay.port() == 0 || relay.ip().is_unspecified() {
1297            return Err(NodeServerError::InvalidCallHome(relay));
1298        }
1299        self.call_home = Some(relay);
1300        Ok(self)
1301    }
1302
1303    /// Authenticate call-home with a node-held identity instead of a standing token.
1304    pub fn with_call_home_identity(mut self, identity: Arc<hatchery_enroll::NodeIdentity>) -> Self {
1305        self.call_home_identity = Some(identity);
1306        self
1307    }
1308
1309    pub fn with_state_path(
1310        mut self,
1311        state_path: impl Into<PathBuf>,
1312    ) -> Result<Self, NodeServerError> {
1313        let state_path = state_path.into();
1314        if !state_path.is_absolute() || state_path.file_name().is_none() {
1315            return Err(NodeServerError::InvalidStatePath(
1316                state_path.to_string_lossy().into_owned(),
1317            ));
1318        }
1319        self.state_path = Some(state_path);
1320        Ok(self)
1321    }
1322
1323    pub fn with_spawn_profiles(mut self, spawn_profiles: SpawnProfileRegistry) -> Self {
1324        self.spawn_profiles = spawn_profiles;
1325        self
1326    }
1327
1328    /// Sets the age/keep-N policy the drive loop's retention sweep uses to
1329    /// retire dead `ManagedSessionState::Unavailable` records. Both fields
1330    /// default to `0` (disabled) via `SessionRecordRetentionConfig::default`
1331    /// when this is never called -- a node never deletes a durable record
1332    /// until an operator opts in with a real value.
1333    pub fn with_session_record_retention(
1334        mut self,
1335        retention: SessionRecordRetentionConfig,
1336    ) -> Self {
1337        self.session_record_retention = retention;
1338        self
1339    }
1340
1341    pub fn with_session_environment_materialization(
1342        mut self,
1343        root: impl Into<PathBuf>,
1344        resolver: Arc<dyn NodeSecretResolver>,
1345    ) -> Result<Self, NodeServerError> {
1346        let root = root.into();
1347        if !root.is_absolute() {
1348            return Err(NodeServerError::InvalidSessionEnvironmentRoot);
1349        }
1350        self.session_environment = Some(NodeSessionEnvironmentConfig { root, resolver });
1351        Ok(self)
1352    }
1353
1354    /// Enables bounded provider-history discovery and loading from explicit
1355    /// host-owned roots. Ambient vendor homes are never inferred here.
1356    pub fn with_history(mut self, history: NativeHistoryConfig) -> Self {
1357        self.history = Some(history);
1358        self
1359    }
1360
1361    /// Enables the H3B read proxy with one exact operator-reviewed helper file.
1362    /// Linux/macOS use owner-only UDS; Windows uses owner-only named pipes.
1363    /// Both platforms review the helper the same way (absolute regular file +
1364    /// identity hash) — the old `cfg(not(windows))` hard-refuse was a leftover
1365    /// gate after Unix listener/peer-cred support landed.
1366    pub fn with_harness_mcp_helper(
1367        mut self,
1368        helper_program: impl Into<PathBuf>,
1369    ) -> Result<Self, NodeServerError> {
1370        self.harness_mcp_helper = Some(
1371            ReviewedHarnessMcpProgram::review(helper_program.into())
1372                .map_err(|_| NodeServerError::InvalidHarnessMcpHelper)?,
1373        );
1374        Ok(self)
1375    }
1376
1377    /// Install an in-memory station network allowlist catalog (opaque ids +
1378    /// optional node-local permits / provider-native mapping). Empty remains
1379    /// the default — unknown spawn ids refuse at resolve. Never cookies /
1380    /// OAuth / proxy credentials. C2 / inventory still list ids only.
1381    pub fn with_network_allowlist_catalog(
1382        mut self,
1383        catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog,
1384    ) -> Result<Self, NodeServerError> {
1385        if catalog.len() > crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES {
1386            return Err(NodeServerError::NetworkAllowlistCatalogCapacity {
1387                max: crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES,
1388            });
1389        }
1390        self.network_allowlist_catalog = catalog;
1391        Ok(self)
1392    }
1393
1394    /// Load station network allowlist catalog from an absolute regular file
1395    /// (v1 id-list or v2 JSON schema). Unset path is not used here — callers
1396    /// that want env fallback use `resolve_network_allowlist_catalog`.
1397    pub fn with_network_allowlist_catalog_file(
1398        self,
1399        path: impl AsRef<std::path::Path>,
1400    ) -> Result<Self, NodeServerError> {
1401        let catalog = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(path)
1402            .map_err(NodeServerError::from)?;
1403        self.with_network_allowlist_catalog(catalog)
1404    }
1405}
1406
1407pub fn default_state_path(node_id: &NodeId) -> Result<PathBuf, NodeServerError> {
1408    platform::default_state_path(node_id.as_str())
1409        .ok_or(NodeServerError::LocalStateDirectoryUnavailable)
1410}
1411
1412pub fn default_node_endpoint() -> Result<PathBuf, NodeServerError> {
1413    platform::default_node_endpoint().ok_or(NodeServerError::LocalRuntimeDirectoryUnavailable)
1414}
1415
1416fn delivery_store_root_for_state_path(state_path: &Path) -> Option<PathBuf> {
1417    let parent = state_path.parent()?;
1418    let mut name = state_path.file_name()?.to_os_string();
1419    name.push(".delivery-store");
1420    Some(parent.join(name))
1421}
1422
1423fn context_pack_store_root_for_state_path(state_path: &Path) -> Option<PathBuf> {
1424    let parent = state_path.parent()?;
1425    let mut name = state_path.file_name()?.to_os_string();
1426    name.push(".context-pack-store");
1427    Some(parent.join(name))
1428}
1429
1430pub struct NodeServer {
1431    config: NodeServerConfig,
1432    runtime: NativeRuntime,
1433    shared: Arc<NodeShared>,
1434    events: EventSubscription,
1435    state_path_lock: Option<session_registry::StatePathLock>,
1436}
1437
1438#[cfg(feature = "fixture")]
1439#[derive(Clone, Default)]
1440pub struct SpawnManagedWorktreeV2FailureProbe {
1441    latest_code: Arc<Mutex<Option<NodeFailureCode>>>,
1442}
1443
1444#[cfg(feature = "fixture")]
1445impl SpawnManagedWorktreeV2FailureProbe {
1446    pub fn latest_code(&self) -> Option<NodeFailureCode> {
1447        *self
1448            .latest_code
1449            .lock()
1450            .unwrap_or_else(|poisoned| poisoned.into_inner())
1451    }
1452
1453    fn record(&self, code: Option<NodeFailureCode>) {
1454        *self
1455            .latest_code
1456            .lock()
1457            .unwrap_or_else(|poisoned| poisoned.into_inner()) = code;
1458    }
1459}
1460
1461#[cfg(all(feature = "fixture", windows))]
1462struct IsolatedCodexHomeFixtureEnvironment;
1463
1464#[cfg(all(feature = "fixture", windows))]
1465impl gate4agent_runtime_native::NativeChildEnvironmentResolver
1466    for IsolatedCodexHomeFixtureEnvironment
1467{
1468    fn resolve_child_environment(
1469        &self,
1470    ) -> Result<
1471        Vec<gate4agent_catalog::EnvMutation>,
1472        gate4agent_runtime_native::NativeChildEnvironmentResolveError,
1473    > {
1474        Ok(vec![gate4agent_catalog::EnvMutation {
1475            key: OsString::from("USERPROFILE"),
1476            value: None,
1477        }])
1478    }
1479}
1480
1481impl NodeServer {
1482    /// The letter-wake handle for the door, when m4a accounts are on. `token` is what callers
1483    /// must present.
1484    /// Turn on the m4a signer proxy for PTY spawns (replaces `M4A_SESSION_CAP`).
1485    #[cfg(unix)]
1486    pub fn set_signer_proxy(&self, proxy: Arc<crate::keystore_proxy::SignerProxy>) -> bool {
1487        self.shared.signer_proxy.set(proxy).is_ok()
1488    }
1489
1490    pub fn wake_handle(&self, token: String, policy: Arc<crate::wake_pipeline::WakePolicy>) -> Option<node_wake::NodeWake> {
1491        self.shared.m4a_accounts.as_ref()?;
1492        Some(node_wake::NodeWake::new(Arc::clone(&self.shared), token, policy))
1493    }
1494
1495    pub fn new(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1496        let catalog = active_registry()?;
1497        Self::new_with_registry(config, catalog)
1498    }
1499
1500    #[cfg(feature = "fixture")]
1501    pub fn new_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1502        let mut spec = gate4agent_testkit::interactive_agent_spec();
1503        spec.id = AgentId::new("claude").map_err(|error| NodeServerError::Registry(error.to_string()))?;
1504        spec.display_name = "Controlled Claude PTY fixture".to_owned();
1505        Self::new_fixture_with_spec(config, spec)
1506    }
1507
1508    #[cfg(feature = "fixture")]
1509    pub fn new_clean_exit_fixture(
1510        config: NodeServerConfig,
1511        fixture_root: PathBuf,
1512        started_marker: PathBuf,
1513        release_signal: PathBuf,
1514    ) -> Result<Self, NodeServerError> {
1515        let mut spec = gate4agent_testkit::controlled_clean_exit_agent_spec(
1516            &fixture_root,
1517            &started_marker,
1518            &release_signal,
1519        )
1520        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1521        spec.id = AgentId::new("claude")
1522            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1523        spec.display_name = "Controlled Claude clean-exit fixture".to_owned();
1524        Self::new_fixture_with_spec(config, spec)
1525    }
1526
1527    /// A `claude` source whose session establishes a verified provider
1528    /// identity via an early `SessionStart` Hook post (so it is eligible for
1529    /// auto-export-at-exit and its native history is independently
1530    /// loadable), waits for `release_signal`, then exits cleanly — paired
1531    /// on the same Node with a plain `codex` target that never validates
1532    /// anything about what it receives. Built for A2 durable ContextPack
1533    /// E2E coverage: the test itself inspects materialized files and
1534    /// managed-session-record fields directly, rather than having the
1535    /// target script self-validate.
1536    #[cfg(all(feature = "fixture", windows))]
1537    pub fn new_durable_context_pack_clean_exit_fixture(
1538        config: NodeServerConfig,
1539        fixture_root: PathBuf,
1540        started_marker: PathBuf,
1541        release_signal: PathBuf,
1542        session_id: &str,
1543    ) -> Result<Self, NodeServerError> {
1544        let mut source_spec = gate4agent_testkit::identified_clean_exit_agent_spec(
1545            &fixture_root,
1546            &started_marker,
1547            &release_signal,
1548            session_id,
1549        )
1550        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1551        let source_id = AgentId::new("claude")
1552            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1553        let source_provider = builtin_registry().get(&source_id).ok_or_else(|| {
1554            NodeServerError::Registry(
1555                "durable context pack clean-exit fixture source adapter is unavailable"
1556                    .to_owned(),
1557            )
1558        })?;
1559        source_spec.capabilities.adapters.history =
1560            source_provider.capabilities.adapters.history.clone();
1561        source_spec.id = source_id;
1562        source_spec.display_name =
1563            "Controlled Claude durable context-pack clean-exit fixture".to_owned();
1564        // Both specs launch via `powershell.exe`; the registry's ambiguity
1565        // check keys on `detection.command` (+ aliases), not `launch.program`
1566        // -- give each a distinct detection identity, same precedent as
1567        // `context_pack_fixture_catalog`'s per-provider
1568        // `gate4agent-{provider}-context-fixture.cmd` commands.
1569        source_spec.detection.command =
1570            "gate4agent-claude-durable-context-pack-fixture.cmd".to_owned();
1571        source_spec.detection.aliases.clear();
1572
1573        let mut target_spec = gate4agent_testkit::interactive_agent_spec();
1574        target_spec.id = AgentId::new("codex")
1575            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1576        target_spec.display_name =
1577            "Controlled Codex durable context-pack target fixture".to_owned();
1578        target_spec.detection.command =
1579            "gate4agent-codex-durable-context-pack-fixture.cmd".to_owned();
1580        target_spec.detection.aliases.clear();
1581
1582        let catalog = AgentRegistry::new([source_spec, target_spec])
1583            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1584        let mut server = Self::new_with_registry(config, catalog)?;
1585        Arc::get_mut(&mut server.shared)
1586            .expect("fixture Node shared state must remain uniquely owned before run")
1587            .fixture_semantic_hook_policy = true;
1588        Ok(server)
1589    }
1590
1591    #[cfg(feature = "fixture")]
1592    pub fn new_monitoring_hook_fixture(
1593        config: NodeServerConfig,
1594    ) -> Result<Self, NodeServerError> {
1595        let mut spec = gate4agent_testkit::monitoring_hook_agent_spec();
1596        spec.id = AgentId::new("claude")
1597            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1598        let mut server = Self::new_fixture_with_spec(config, spec)?;
1599        Arc::get_mut(&mut server.shared)
1600            .expect("fixture Node shared state must remain uniquely owned before run")
1601            .fixture_semantic_hook_policy = true;
1602        Ok(server)
1603    }
1604
1605    #[cfg(all(feature = "fixture", windows))]
1606    pub fn new_provider_bundle_argv_fixture(
1607        config: NodeServerConfig,
1608        agent_id: AgentId,
1609        proof_path: PathBuf,
1610    ) -> Result<Self, NodeServerError> {
1611        let spec = Self::provider_bundle_argv_fixture_spec(agent_id, proof_path, None)?;
1612        Self::new_fixture_with_spec(config, spec)
1613    }
1614
1615    #[cfg(all(feature = "fixture", windows))]
1616    pub fn new_provider_bundle_argv_hold_fixture(
1617        config: NodeServerConfig,
1618        agent_id: AgentId,
1619        proof_path: PathBuf,
1620        release_signal: PathBuf,
1621    ) -> Result<Self, NodeServerError> {
1622        let spec = Self::provider_bundle_argv_fixture_spec(
1623            agent_id,
1624            proof_path,
1625            Some(release_signal),
1626        )?;
1627        Self::new_fixture_with_spec(config, spec)
1628    }
1629
1630    #[cfg(all(feature = "fixture", windows))]
1631    fn provider_bundle_argv_fixture_spec(
1632        agent_id: AgentId,
1633        proof_path: PathBuf,
1634        release_signal: Option<PathBuf>,
1635    ) -> Result<gate4agent_types::AgentSpec, NodeServerError> {
1636        if !matches!(agent_id.as_str(), "claude" | "codex" | "kimi") {
1637            return Err(NodeServerError::Registry(
1638                "provider bundle argv fixture requires Claude, Codex, or Kimi".to_owned(),
1639            ));
1640        }
1641        let mut spec = gate4agent_testkit::interactive_agent_spec();
1642        spec.id = agent_id.clone();
1643        spec.display_name = format!("Controlled {agent_id} bundle argv fixture");
1644        let provider = builtin_registry()
1645            .get(&agent_id)
1646            .ok_or_else(|| {
1647                NodeServerError::Registry(
1648                    "provider bundle argv fixture adapter is unavailable".to_owned(),
1649                )
1650            })?;
1651        spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1652        if !proof_path.is_absolute()
1653            || !proof_path
1654                .parent()
1655                .is_some_and(|parent| parent.is_dir())
1656        {
1657            return Err(NodeServerError::Registry(
1658                "provider bundle argv proof path requires an existing absolute parent"
1659                    .to_owned(),
1660            ));
1661        }
1662        if let Some(release_signal) = release_signal.as_ref() {
1663            if !release_signal.is_absolute()
1664                || !release_signal
1665                    .parent()
1666                    .is_some_and(|parent| parent.is_dir())
1667            {
1668                return Err(NodeServerError::Registry(
1669                    "provider bundle argv release path requires an existing absolute parent"
1670                        .to_owned(),
1671                ));
1672            }
1673            if release_signal.exists() {
1674                return Err(NodeServerError::Registry(
1675                    "provider bundle argv release path must not exist".to_owned(),
1676                ));
1677            }
1678            let proof_parent = std::fs::canonicalize(
1679                proof_path
1680                    .parent()
1681                    .expect("validated provider bundle proof parent"),
1682            )
1683            .map_err(|_| {
1684                NodeServerError::Registry(
1685                    "provider bundle argv proof parent is unavailable".to_owned(),
1686                )
1687            })?;
1688            let release_parent = std::fs::canonicalize(
1689                release_signal
1690                    .parent()
1691                    .expect("validated provider bundle release parent"),
1692            )
1693            .map_err(|_| {
1694                NodeServerError::Registry(
1695                    "provider bundle argv release parent is unavailable".to_owned(),
1696                )
1697            })?;
1698            if proof_parent != release_parent {
1699                return Err(NodeServerError::Registry(
1700                    "provider bundle argv release path must share the proof parent".to_owned(),
1701                ));
1702            }
1703        }
1704        let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1705            NodeServerError::Registry(
1706                "provider bundle argv proof path is not valid Unicode".to_owned(),
1707            )
1708        })?;
1709        let release_signal = release_signal.map(|release_signal| {
1710            release_signal.into_os_string().into_string().map_err(|_| {
1711                NodeServerError::Registry(
1712                    "provider bundle argv release path is not valid Unicode".to_owned(),
1713                )
1714            })
1715        }).transpose()?;
1716        let script = spec
1717            .launch
1718            .fixed_args
1719            .pop()
1720            .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
1721        let bundle_validation = if agent_id.as_str() == "codex" {
1722            "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') }"
1723        } else if agent_id.as_str() == "claude" {
1724            "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 }"
1725        } else {
1726            "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 }"
1727        };
1728        if let Some(release_signal) = release_signal {
1729            spec.launch.fixed_args.push(format!(
1730                "& {{ 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} }}",
1731            ));
1732            spec.launch.fixed_args.push(proof_path);
1733            spec.launch.fixed_args.push(release_signal);
1734        } else {
1735            spec.launch.fixed_args.push(format!(
1736                "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {bundle_validation}; {script} }}",
1737            ));
1738            spec.launch.fixed_args.push(proof_path);
1739        }
1740        Ok(spec)
1741    }
1742
1743    #[cfg(all(feature = "fixture", windows))]
1744    pub fn new_context_pack_fixture(
1745        config: NodeServerConfig,
1746        proof_path: PathBuf,
1747    ) -> Result<Self, NodeServerError> {
1748        let catalog = Self::context_pack_fixture_catalog(proof_path, None, false)?;
1749        Self::new_with_registry(config, catalog)
1750    }
1751
1752    #[cfg(all(feature = "fixture", windows))]
1753    pub fn new_monitoring_context_pack_fixture(
1754        config: NodeServerConfig,
1755        proof_path: PathBuf,
1756    ) -> Result<Self, NodeServerError> {
1757        let catalog = Self::context_pack_fixture_catalog(proof_path, None, true)?;
1758        let mut server = Self::new_with_registry(config, catalog)?;
1759        Arc::get_mut(&mut server.shared)
1760            .expect("fixture Node shared state must remain uniquely owned before run")
1761            .fixture_semantic_hook_policy = true;
1762        Ok(server)
1763    }
1764
1765    #[cfg(all(feature = "fixture", windows))]
1766    pub fn install_isolated_codex_home_environment_fixture(
1767        &mut self,
1768        profile_id: SpawnEnvironmentProfileId,
1769        profile_revision: crate::protocol::SpawnEnvironmentProfileRevision,
1770    ) -> Result<(), NodeServerError> {
1771        let provider = AgentId::new("codex")
1772            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1773        let native_profile = gate4agent_runtime_native::NativeLaunchProfile::new(
1774            gate4agent_runtime_native::NativeLaunchProfileId::new(format!(
1775                "{}-pty",
1776                profile_id.as_str(),
1777            ))
1778            .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1779            provider.clone(),
1780            TransportKind::Pty,
1781            vec![OsString::from("USERPROFILE")],
1782            Arc::new(IsolatedCodexHomeFixtureEnvironment),
1783        )
1784        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1785        let materialization = NodeSessionMaterializationProfile::new(
1786            Vec::new(),
1787            vec![crate::session_environment::NodeSessionPathBinding::new(
1788                "CODEX_HOME",
1789                crate::session_environment::NodeSessionPathClass::ProviderHome,
1790            )
1791            .map_err(|error| NodeServerError::Registry(error.to_string()))?],
1792            Vec::new(),
1793        )
1794        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1795        self.install_environment_profile(
1796            NodeEnvironmentProfile::new_with_materialization(
1797                profile_id,
1798                profile_revision,
1799                provider,
1800                [native_profile],
1801                Some(materialization),
1802            )
1803            .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1804        )
1805    }
1806
1807    #[cfg(all(feature = "fixture", windows))]
1808    pub fn new_context_only_proof_fixture(
1809        config: NodeServerConfig,
1810        target_provider: AgentId,
1811        proof_path: PathBuf,
1812    ) -> Result<Self, NodeServerError> {
1813        if target_provider.as_str() != "kimi" {
1814            return Err(NodeServerError::Registry(
1815                "context-only proof fixture requires Kimi".to_owned(),
1816            ));
1817        }
1818        let catalog = Self::context_pack_fixture_catalog(
1819            proof_path,
1820            Some(&target_provider),
1821            false,
1822        )?;
1823        Self::new_with_registry(config, catalog)
1824    }
1825
1826    #[cfg(all(feature = "fixture", windows))]
1827    fn context_pack_fixture_catalog(
1828        proof_path: PathBuf,
1829        context_only_target: Option<&AgentId>,
1830        monitoring_claude_source: bool,
1831    ) -> Result<AgentRegistry, NodeServerError> {
1832        if !proof_path.is_absolute()
1833            || !proof_path
1834                .parent()
1835                .is_some_and(|parent| parent.is_dir())
1836        {
1837            return Err(NodeServerError::Registry(
1838                "context pack proof path requires an existing absolute parent".to_owned(),
1839            ));
1840        }
1841        let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1842            NodeServerError::Registry(
1843                "context pack proof path is not valid Unicode".to_owned(),
1844            )
1845        })?;
1846        let providers = ["claude", "codex", "grok", "kimi"];
1847        let builtins = builtin_registry();
1848        let mut specs = Vec::with_capacity(providers.len());
1849        for provider_id in providers {
1850            let agent_id = AgentId::new(provider_id)
1851                .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1852            let provider = builtins.get(&agent_id).ok_or_else(|| {
1853                NodeServerError::Registry(
1854                    "context pack fixture provider adapter is unavailable".to_owned(),
1855                )
1856            })?;
1857            let monitored_provider =
1858                monitoring_claude_source && matches!(provider_id, "claude" | "codex");
1859            let mut spec = if monitored_provider {
1860                gate4agent_testkit::monitoring_hook_agent_spec()
1861            } else {
1862                gate4agent_testkit::interactive_agent_spec()
1863            };
1864            spec.id = agent_id.clone();
1865            spec.display_name = format!("Controlled {agent_id} context pack fixture");
1866            spec.detection.command = format!("gate4agent-{provider_id}-context-fixture.cmd");
1867            spec.detection.aliases.clear();
1868            spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1869            if provider_id == "codex" {
1870                let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1871                    NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1872                })?;
1873                let validation = r#"
1874$bundleArgs = @($bundleArgs)
1875if ($bundleArgs.Count -ne 0) { exit 91 }
1876$codexHome = [Environment]::GetEnvironmentVariable('CODEX_HOME', 'Process')
1877$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1878$cwd = (Get-Location).ProviderPath
1879if ([string]::IsNullOrEmpty($codexHome) -or -not [IO.Path]::IsPathRooted($codexHome) -or -not (Test-Path -LiteralPath $codexHome -PathType Container)) { exit 92 }
1880$skillPath = Join-Path $codexHome 'skills/review-code/SKILL.md'
1881if (-not (Test-Path -LiteralPath $skillPath -PathType Leaf)) { exit 93 }
1882$homeFiles = @(Get-ChildItem -LiteralPath $codexHome -Recurse -Force -File)
1883if ($homeFiles.Count -ne 1 -or [IO.Path]::GetFullPath($homeFiles[0].FullName) -ine [IO.Path]::GetFullPath($skillPath)) { exit 94 }
1884$sha = [Security.Cryptography.SHA256]::Create()
1885try { $skillHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($skillPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1886if ($skillHash -cne 'd78fe03be106b673fcf5415c8359e99f0298b5071cd11822c58ebcb27e52a68d') { exit 95 }
1887if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 96 }
1888$contextPath = Join-Path $contextRoot 'context-pack.json'
1889if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 97 }
1890$contextFiles = @(Get-ChildItem -LiteralPath $contextRoot -Recurse -Force -File)
1891if ($contextFiles.Count -ne 1 -or [IO.Path]::GetFullPath($contextFiles[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 98 }
1892$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1893$codexFull = [IO.Path]::GetFullPath($codexHome).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1894$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1895$separator = [IO.Path]::DirectorySeparatorChar
1896if ($contextFull.Equals($codexFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($codexFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 99 }
1897if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 100 }
1898$rawContext = [IO.File]::ReadAllText($contextPath)
1899if ($rawContext.Contains('g4a-private-provider-session-canary') -or $rawContext.Contains('private-provider-login-identity')) { exit 101 }
1900try { $document = $rawContext | ConvertFrom-Json -ErrorAction Stop } catch { exit 102 }
1901$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')
1902$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')
1903foreach ($property in @($document.PSObject.Properties.Name)) {
1904    if ($allowedTopLevel -cnotcontains [string]$property) { exit 103 }
1905}
1906foreach ($property in $requiredTopLevel) {
1907    if ($null -eq $document.PSObject.Properties[$property]) { exit 104 }
1908}
1909if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 105 }
1910if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 106 }
1911$messages = @($document.retained_messages)
1912if ($messages.Count -eq 0) { exit 107 }
1913foreach ($message in $messages) {
1914    $messageProperties = @($message.PSObject.Properties.Name)
1915    foreach ($property in $messageProperties) {
1916        if (@('role', 'text') -cnotcontains [string]$property) { exit 108 }
1917    }
1918    if ($messageProperties.Count -ne 2 -or @('user', 'assistant') -cnotcontains [string]$message.role -or [string]::IsNullOrEmpty([string]$message.text)) { exit 109 }
1919}
1920$sha = [Security.Cryptography.SHA256]::Create()
1921try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1922[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))
1923$rawProof = [IO.File]::ReadAllText($proofPath)
1924if ($rawProof.Contains('g4a-private-provider-session-canary') -or $rawProof.Contains('private-provider-login-identity')) { exit 110 }
1925[Console]::Out.WriteLine('F7_CODEX_BUNDLE_CONTEXT_VALIDATED')
1926"#;
1927                spec.launch.fixed_args.push(format!(
1928                    "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1929                ));
1930                spec.launch.fixed_args.push(proof_path.clone());
1931            } else if context_only_target.map(AgentId::as_str) == Some(provider_id) {
1932                let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1933                    NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1934                })?;
1935                let validation = r#"
1936$bundleArgs = @($bundleArgs)
1937if ($bundleArgs.Count -ne 0) { exit 91 }
1938$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1939$cwd = (Get-Location).ProviderPath
1940if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 92 }
1941$contextPath = Join-Path $contextRoot 'context-pack.json'
1942if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 93 }
1943$entries = @(Get-ChildItem -LiteralPath $contextRoot -Force)
1944if ($entries.Count -ne 1 -or $entries[0] -isnot [IO.FileInfo] -or [IO.Path]::GetFullPath($entries[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 94 }
1945$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1946$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1947$separator = [IO.Path]::DirectorySeparatorChar
1948if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase) -or $cwdFull.StartsWith($contextFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 95 }
1949try { $document = [IO.File]::ReadAllText($contextPath) | ConvertFrom-Json -ErrorAction Stop } catch { exit 96 }
1950if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 97 }
1951if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 98 }
1952$messages = @($document.retained_messages)
1953if ($messages.Count -eq 0) { exit 99 }
1954$hasUser = $false
1955$hasAssistant = $false
1956foreach ($message in $messages) {
1957    if ([string]::IsNullOrWhiteSpace([string]$message.text)) { exit 100 }
1958    if ([string]$message.role -ceq 'user') { $hasUser = $true }
1959    elseif ([string]$message.role -ceq 'assistant') { $hasAssistant = $true }
1960    else { exit 101 }
1961}
1962if (-not $hasUser -or -not $hasAssistant) { exit 102 }
1963$sha = [Security.Cryptography.SHA256]::Create()
1964try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1965[IO.File]::WriteAllLines($proofPath, [string[]]@('context-only', $contextRoot, $cwd, $contextHash, [string]$document.schema, [string]$document.source_provider, [string]$messages.Count))
1966[Console]::Out.WriteLine('F7_KIMI_CONTEXT_ONLY_VALIDATED')
1967"#;
1968                spec.launch.fixed_args.push(format!(
1969                    "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1970                ));
1971                spec.launch.fixed_args.push(proof_path.clone());
1972            }
1973            specs.push(spec);
1974        }
1975        let catalog = AgentRegistry::new(specs)
1976            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1977        Ok(catalog)
1978    }
1979
1980    #[cfg(feature = "fixture")]
1981    pub fn new_exact_launcher_fixture(
1982        config: NodeServerConfig,
1983        agent_id: AgentId,
1984        launcher: String,
1985    ) -> Result<Self, NodeServerError> {
1986        if !matches!(agent_id.as_str(), "claude" | "codex" | "grok" | "kimi") {
1987            return Err(NodeServerError::Registry(
1988                "exact launcher fixture requires a supported provider".to_owned(),
1989            ));
1990        }
1991        if launcher.contains('\0') {
1992            return Err(NodeServerError::Registry(
1993                "exact launcher fixture path contains NUL".to_owned(),
1994            ));
1995        }
1996        let launcher_path = Path::new(&launcher);
1997        if !launcher_path.is_absolute() {
1998            return Err(NodeServerError::Registry(
1999                "exact launcher fixture path must be absolute".to_owned(),
2000            ));
2001        }
2002        let metadata = std::fs::metadata(launcher_path).map_err(|_| {
2003            NodeServerError::Registry("exact launcher fixture path is unavailable".to_owned())
2004        })?;
2005        if !metadata.is_file() {
2006            return Err(NodeServerError::Registry(
2007                "exact launcher fixture path must be a regular file".to_owned(),
2008            ));
2009        }
2010        let mut spec = builtin_registry()
2011            .get(&agent_id)
2012            .ok_or_else(|| {
2013                NodeServerError::Registry(
2014                    "exact launcher fixture provider is unavailable".to_owned(),
2015                )
2016            })?
2017            .clone();
2018        spec.launch.program = launcher;
2019        Self::new_fixture_with_spec(config, spec)
2020    }
2021
2022    /// Shared H3B validation and spec construction for
2023    /// `new_harness_mcp_proxy_fixture` and
2024    /// `new_harness_mcp_acp_launcher_fixture`: an absolute, exact-regular-
2025    /// file provider launcher plus at most 32 Unicode, NUL-free fixed
2026    /// arguments (65,536 bytes total), replacing `spec.launch` on a clone of
2027    /// the named builtin catalog entry -- detection command and expected
2028    /// process matcher rebased on the launcher's own basename, exactly the
2029    /// same shape `new_harness_mcp_proxy_fixture` always built.
2030    #[cfg(feature = "fixture")]
2031    fn h3b_provider_spec_with_program_and_args(
2032        agent_id: AgentId,
2033        provider_program: PathBuf,
2034        fixed_args: Vec<OsString>,
2035    ) -> Result<AgentSpec, NodeServerError> {
2036        if !provider_program.is_absolute() || fixed_args.len() > 32 {
2037            return Err(NodeServerError::Registry(
2038                "H3B fixture requires an absolute provider and at most 32 arguments".to_owned(),
2039            ));
2040        }
2041        let metadata = std::fs::symlink_metadata(&provider_program).map_err(|_| {
2042            NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
2043        })?;
2044        if !metadata.file_type().is_file() || metadata.file_type().is_symlink() {
2045            return Err(NodeServerError::Registry(
2046                "H3B fixture provider must be an exact regular file".to_owned(),
2047            ));
2048        }
2049        let program = provider_program.into_os_string().into_string().map_err(|_| {
2050            NodeServerError::Registry("H3B fixture provider path is not Unicode".to_owned())
2051        })?;
2052        let fixed_args = fixed_args.into_iter().map(|argument| {
2053            argument.into_string().map_err(|_| NodeServerError::Registry(
2054                "H3B fixture argument is not Unicode".to_owned(),
2055            ))
2056        }).collect::<Result<Vec<_>, _>>()?;
2057        let argument_bytes = fixed_args.iter()
2058            .try_fold(0usize, |total, argument| total.checked_add(argument.len()));
2059        if program.contains('\0')
2060            || fixed_args.iter().any(|argument| argument.contains('\0'))
2061            || !matches!(argument_bytes, Some(total) if total <= 65_536)
2062        {
2063            return Err(NodeServerError::Registry(
2064                "H3B fixture provider or arguments are invalid".to_owned(),
2065            ));
2066        }
2067        let mut spec = builtin_registry().get(&agent_id).ok_or_else(|| {
2068            NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
2069        })?.clone();
2070        let process_name = Path::new(&program).file_name().and_then(|name| name.to_str())
2071            .ok_or_else(|| NodeServerError::Registry(
2072                "H3B fixture provider basename is unavailable".to_owned(),
2073            ))?.to_owned();
2074        spec.detection.command = process_name.clone();
2075        spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
2076            name: process_name,
2077        }];
2078        spec.launch.program = program;
2079        spec.launch.fixed_args = fixed_args;
2080        Ok(spec)
2081    }
2082
2083    #[cfg(feature = "fixture")]
2084    pub fn new_harness_mcp_proxy_fixture(
2085        config: NodeServerConfig,
2086        provider_program: PathBuf,
2087        fixed_args: Vec<OsString>,
2088        harness_mcp_program: PathBuf,
2089    ) -> Result<Self, NodeServerError> {
2090        let mut config = config.with_harness_mcp_helper(harness_mcp_program)?;
2091        let agent_id = AgentId::new("codex")
2092            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2093        let mut spec =
2094            Self::h3b_provider_spec_with_program_and_args(agent_id, provider_program, fixed_args)?;
2095        spec.display_name = "Controlled H3B MCP proxy fixture".to_owned();
2096        config.fixture_raw_pty_runtime = true;
2097        Self::new_fixture_with_spec(config, spec)
2098    }
2099
2100    /// The ACP-mode sibling of `new_harness_mcp_proxy_fixture`: the same
2101    /// `--harness-mcp-helper` wiring and the same absolute-program-plus-
2102    /// fixed-args launcher replacement as `new_exact_launcher_fixture`, but
2103    /// admitting `ProviderRuntimeRequirement::Acp` the honest way instead of
2104    /// hardcoding it. `new_harness_mcp_proxy_fixture` forces `config.
2105    /// fixture_raw_pty_runtime = true`, which tears out the live
2106    /// `ProviderRuntimeMonitor` entirely (`NodeServer::new_with_registry`)
2107    /// and makes `NodeShared::admit_provider_runtime`'s no-monitor branch
2108    /// hardcode `acp_transport = false` for every provider, refusing
2109    /// `ProviderRuntimeRequirement::Acp` unconditionally regardless of what
2110    /// the catalog declares. This constructor leaves `fixture_raw_pty_
2111    /// runtime` at its default `false`, so a real `ProviderRuntimeMonitor`
2112    /// is built from the (cloned, launcher-replaced) catalog spec, and
2113    /// `ProviderRuntimeMonitor::supports_acp_transport` reads the honest,
2114    /// unmodified catalog fact: every builtin provider this accepts
2115    /// (`claude`, `codex`, `grok`, `kimi`) already declares `capabilities.
2116    /// transports.acp` in `gate4agent-catalog::builtin`, so no capability is
2117    /// invented here -- this constructor only removes the fixture-only
2118    /// bypass standing in its way. `provider_runtime::policy_for_transport`'s
2119    /// `TransportKind::Acp` arm (applied unconditionally at spawn-prep time
2120    /// for every ACP session, fixture or not) is what actually grants the
2121    /// ACP-shaped `ProviderRuntimePolicy` downstream of that admission --
2122    /// this constructor never calls it directly, and does not need to.
2123    #[cfg(feature = "fixture")]
2124    pub fn new_harness_mcp_acp_launcher_fixture(
2125        config: NodeServerConfig,
2126        helper_program: PathBuf,
2127        provider: AgentId,
2128        program: PathBuf,
2129        args: Vec<OsString>,
2130    ) -> Result<Self, NodeServerError> {
2131        if !matches!(provider.as_str(), "claude" | "codex" | "grok" | "kimi") {
2132            return Err(NodeServerError::Registry(
2133                "ACP harness-MCP launcher fixture requires a supported provider".to_owned(),
2134            ));
2135        }
2136        let config = config.with_harness_mcp_helper(helper_program)?;
2137        let mut spec = Self::h3b_provider_spec_with_program_and_args(provider, program, args)?;
2138        let launch = spec.launch.clone();
2139        let acp = spec.capabilities.transports.acp.as_mut().ok_or_else(|| {
2140            NodeServerError::Registry(
2141                "ACP harness-MCP launcher fixture provider does not declare an ACP transport"
2142                    .to_owned(),
2143            )
2144        })?;
2145        // Routes the ACP spawn itself through the exact replaced launcher
2146        // (`AcpSession::spawn_with_launch` -> a bare `Command::new(&launch.
2147        // program)`, `gate4agent`'s `src/acp/spawn.rs`) instead of the
2148        // catalog's own vendor-npm resolution (`acp_command`, same file) --
2149        // without this the ACP transport ignores `spec.launch` entirely and
2150        // tries to spawn the real vendor CLI (e.g. `npx -y @agentclientprotocol/
2151        // codex-acp@1.10.0`), which this fixture must never reach.
2152        acp.launch_override = Some(launch);
2153        spec.display_name = "Controlled ACP harness-MCP launcher fixture".to_owned();
2154        Self::new_fixture_with_spec(config, spec)
2155    }
2156
2157    #[cfg(feature = "fixture")]
2158    pub fn new_resume_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
2159        let mut spec = gate4agent_testkit::interactive_agent_spec();
2160        let claude_id = AgentId::new("claude")
2161            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2162        let claude = builtin_registry()
2163            .get(&claude_id)
2164            .ok_or_else(|| NodeServerError::Registry("Claude fixture adapter is unavailable".to_owned()))?;
2165        spec.id = claude_id;
2166        spec.display_name = "Resumable Claude PTY fixture".to_owned();
2167        spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
2168            name: "claude".to_owned(),
2169        }];
2170        spec.capabilities.transports.pty_adapter = claude
2171            .capabilities
2172            .transports
2173            .pty_adapter
2174            .clone();
2175        spec.capabilities.adapters.resume = claude
2176            .capabilities
2177            .adapters
2178            .resume
2179            .clone();
2180        let script = spec
2181            .launch
2182            .fixed_args
2183            .pop()
2184            .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
2185        spec.launch.fixed_args.push(format!(
2186            "& {{ param([Parameter(ValueFromRemainingArguments=$true)][string[]]$ignored) {script} }}",
2187        ));
2188        Self::new_fixture_with_spec(config, spec)
2189    }
2190
2191    #[cfg(feature = "fixture")]
2192    pub fn new_cmd_cwd_fixture(
2193        config: NodeServerConfig,
2194        command: String,
2195    ) -> Result<Self, NodeServerError> {
2196        let mut spec = gate4agent_testkit::interactive_agent_spec();
2197        spec.id = AgentId::new("claude")
2198            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2199        spec.display_name = "Controlled cmd cwd fixture".to_owned();
2200        spec.detection.command = "gate4agent-cwd-fixture.cmd".to_owned();
2201        spec.launch.program = command;
2202        spec.launch.fixed_args.clear();
2203        Self::new_fixture_with_spec(config, spec)
2204    }
2205
2206    #[cfg(feature = "fixture")]
2207    fn new_fixture_with_spec(
2208        config: NodeServerConfig,
2209        spec: gate4agent_types::AgentSpec,
2210    ) -> Result<Self, NodeServerError> {
2211        let catalog = AgentRegistry::new([spec])
2212            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2213        Self::new_with_registry(config, catalog)
2214    }
2215
2216    fn new_with_registry(
2217        config: NodeServerConfig,
2218        catalog: AgentRegistry,
2219    ) -> Result<Self, NodeServerError> {
2220        for profile in config.spawn_profiles.iter() {
2221            if catalog.get(&profile.provider).is_none() {
2222                return Err(NodeServerError::Registry(
2223                    "spawn profile references an unavailable provider".to_owned(),
2224                ));
2225            }
2226        }
2227        let input_settle_timeout_ms = catalog
2228            .iter()
2229            .map(|spec| spec.readiness.timeout_ms)
2230            .max()
2231            .unwrap_or(MUTATION_SETTLE_TIMEOUT_MS)
2232            .saturating_add(READINESS_SETTLE_HEADROOM_MS)
2233            .max(MUTATION_SETTLE_TIMEOUT_MS);
2234        let (provider_contracts, provider_adapter_contracts) =
2235            provider_contract_manifest(&catalog)?;
2236        let compatibility_support = node_compatibility_support_for_manifest(
2237            &provider_contracts,
2238            &provider_adapter_contracts,
2239        )?;
2240        validate_node_negotiated_handshake_capacity(&compatibility_support)
2241            .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2242        let enabled_providers = provider_contracts
2243            .iter()
2244            .map(|contract| contract.provider.clone())
2245            .collect();
2246        #[cfg(feature = "fixture")]
2247        let fixture_raw_pty_runtime = config.fixture_raw_pty_runtime;
2248        #[cfg(not(feature = "fixture"))]
2249        let fixture_raw_pty_runtime = false;
2250        let (provider_runtime_monitor, provider_runtime_statuses) = if fixture_raw_pty_runtime {
2251            let statuses = ProviderRuntimeStatuses::new(catalog.iter().map(|spec| {
2252                crate::protocol::ProviderRuntimeStatus::raw_passthrough(spec.id.clone(), None)
2253            })).expect("fixture provider catalog remains bounded");
2254            (None, statuses)
2255        } else {
2256            let monitor = Arc::new(ProviderRuntimeMonitor::new(&catalog));
2257            let statuses = monitor.collect();
2258            (Some(monitor), statuses)
2259        };
2260        let state_path_lock =
2261            session_registry::StatePathLock::acquire(config.state_path.as_deref())
2262                .map_err(|error| {
2263                    durable_state_server_error(error, DURABLE_STATE_LOCK_FAILED_ERROR)
2264                })?;
2265        let (delivery_store, delivered_bundles) = match config.state_path.as_ref() {
2266            Some(state_path) => {
2267                let delivery_root = delivery_store_root_for_state_path(state_path)
2268                    .ok_or_else(|| {
2269                        NodeServerError::DeliveryStore(
2270                            "delivery store root could not be derived from state path".to_owned(),
2271                        )
2272                    })?;
2273                let (store, bundles) = DeliveryStore::open(delivery_root).map_err(|error| {
2274                    let detail = match &error {
2275                        DeliveryStoreError::Storage(io_error) => {
2276                            format!("{error}: {io_error}")
2277                        }
2278                        _ => error.to_string(),
2279                    };
2280                    NodeServerError::DeliveryStore(detail)
2281                })?;
2282                (Some(store), bundles)
2283            }
2284            None => (None, Vec::new()),
2285        };
2286        let delivered_catalog = BundleCatalog::new(delivered_bundles).map_err(|error| {
2287            NodeServerError::DeliveryStore(format!("bundle catalog: {error}"))
2288        })?;
2289        let (context_pack_store, durable_context_packs) = match config.state_path.as_ref() {
2290            Some(state_path) => {
2291                let context_pack_root = context_pack_store_root_for_state_path(state_path)
2292                    .ok_or(NodeServerError::ContextPackStore)?;
2293                let (store, packs) = ContextPackStore::open(context_pack_root)
2294                    .map_err(|_| NodeServerError::ContextPackStore)?;
2295                (Some(store), packs)
2296            }
2297            None => (None, Vec::new()),
2298        };
2299        let mut durable_context_catalog = ContextPackCatalog::default();
2300        for pack in durable_context_packs {
2301            durable_context_catalog
2302                .insert(pack)
2303                .map_err(|_| NodeServerError::ContextPackStore)?;
2304        }
2305        let session_environment_materializer = config
2306            .session_environment
2307            .as_ref()
2308            .map(|environment| {
2309                SessionEnvironmentMaterializer::new(
2310                    environment.root.clone(),
2311                    Arc::clone(&environment.resolver),
2312                )
2313                .map_err(|_| NodeServerError::SessionEnvironmentMaterializer)
2314            })
2315            .transpose()?;
2316        // Session launchers go through the unprivileged `exec-as` wrapper when run-as is set
2317        // (the provider version probe above already used the original catalog).
2318        #[cfg(unix)]
2319        let catalog = crate::isolation::wrap_registry(catalog).map_err(NodeServerError::Registry)?;
2320        let native_session_catalog = config.history.clone().map(|history| {
2321            Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)))
2322        });
2323        let (handle, runtime) = match config.history.clone() {
2324            Some(history) => NativeRuntime::new_with_history(catalog, config.runtime, history),
2325            None => NativeRuntime::new(catalog, config.runtime),
2326        };
2327        let native_launch_profile_control = runtime.native_launch_profile_control();
2328        let harness_mcp_registry = config
2329            .harness_mcp_helper
2330            .clone()
2331            .map(HarnessMcpProxyRegistry::new);
2332        let events = handle.subscribe(CONTROL_EVENT_SUBSCRIPTION_CAPACITY);
2333        let incarnation_id = random_incarnation_id()
2334            .map_err(NodeServerError::IncarnationIdentity)?;
2335        let mut loaded = session_registry::load(config.state_path.as_deref(), &config.node_id)
2336            .map_err(durable_state_load_error)?;
2337        let managed_worktrees = std::mem::take(&mut loaded.managed_worktrees);
2338        let managed_worktree_tombstones =
2339            std::mem::take(&mut loaded.managed_worktree_tombstones);
2340        let materializations = std::mem::take(&mut loaded.materializations);
2341        let managed_spawn_replays = std::mem::take(&mut loaded.managed_spawn_replays);
2342        let (workspaces, records, persistence_warning) =
2343            merge_durable_state(&config.workspaces, loaded)?;
2344        let mut shared = NodeShared::new_with_incarnation(
2345            handle,
2346            config.access_token.clone(),
2347            config.node_id.clone(),
2348            incarnation_id,
2349            workspaces,
2350            enabled_providers,
2351            provider_runtime_statuses,
2352            provider_runtime_monitor,
2353            provider_contracts,
2354            provider_adapter_contracts,
2355            config.spawn_profiles.clone(),
2356            Some(native_launch_profile_control),
2357            native_session_catalog,
2358            session_environment_materializer,
2359            config.state_path.clone(),
2360            records,
2361            managed_worktrees,
2362            managed_worktree_tombstones,
2363            materializations,
2364            persistence_warning,
2365            input_settle_timeout_ms,
2366        );
2367        shared.harness_mcp_registry = harness_mcp_registry.clone();
2368        shared.session_record_retention = config.session_record_retention;
2369        shared.network_allowlist_catalog =
2370            RwLock::new(config.network_allowlist_catalog.clone());
2371        shared.bundle_catalog = RwLock::new(delivered_catalog);
2372        shared.delivery_store = Mutex::new(delivery_store);
2373        shared.context_catalog = RwLock::new(durable_context_catalog);
2374        shared.context_pack_store = Mutex::new(context_pack_store);
2375        shared.managed_spawn_replays = Mutex::new(
2376            managed_spawn_replays
2377                .into_iter()
2378                .map(|record| (record.idempotency_key.clone(), record))
2379                .collect(),
2380        );
2381        let shared = Arc::new(shared);
2382        if let Some(registry) = harness_mcp_registry {
2383            let weak = Arc::downgrade(&shared);
2384            registry.set_event_sink(Arc::new(move |event| match weak.upgrade() {
2385                Some(shared) => shared.publish_transient(event),
2386                None => false,
2387            }));
2388        }
2389        if shared.persistence_error().is_none() {
2390            shared.persist_state().map_err(|error| {
2391                durable_state_server_error(error, DURABLE_STATE_COMMIT_FAILED_ERROR)
2392            })?;
2393        }
2394        Ok(Self {
2395            config,
2396            runtime,
2397            shared,
2398            events,
2399            state_path_lock,
2400        })
2401    }
2402
2403    pub fn shutdown_handle(&self) -> NodeShutdownHandle {
2404        NodeShutdownHandle {
2405            shared: Arc::clone(&self.shared),
2406        }
2407    }
2408
2409    #[cfg(feature = "fixture")]
2410    pub fn spawn_managed_worktree_v2_failure_probe_fixture(
2411        &self,
2412    ) -> SpawnManagedWorktreeV2FailureProbe {
2413        self.shared
2414            .fixture_spawn_managed_worktree_v2_failure_probe
2415            .clone()
2416    }
2417
2418    /// Installs one immutable host-local environment profile before the server runs.
2419    ///
2420    /// Native resolvers remain inside the runtime; the shared node registry keeps
2421    /// only public IDs, revisions, provider bindings, and opaque native profile IDs.
2422    pub fn install_environment_profile(
2423        &mut self,
2424        profile: NodeEnvironmentProfile,
2425    ) -> Result<(), NodeServerError> {
2426        if !self.shared.enabled_providers.contains(profile.provider()) {
2427            return Err(NodeServerError::EnvironmentProfileProviderUnavailable(
2428                profile.provider().clone(),
2429            ));
2430        }
2431        let (binding, native_profiles, materialization) = profile.into_parts();
2432        if materialization.is_some() && self.shared.session_environment_materializer.is_none() {
2433            return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2434        }
2435        let mut profiles = self
2436            .shared
2437            .environment_profiles
2438            .write()
2439            .unwrap_or_else(|poisoned| poisoned.into_inner());
2440        if profiles.contains_key(&binding.id) {
2441            return Err(NodeServerError::DuplicateEnvironmentProfile(
2442                binding.id.clone(),
2443            ));
2444        }
2445        if profiles.len() >= MAX_NODE_ENVIRONMENT_PROFILES {
2446            return Err(NodeServerError::EnvironmentProfileCapacity {
2447                max: MAX_NODE_ENVIRONMENT_PROFILES,
2448            });
2449        }
2450        for native_id in binding.native_profile_ids() {
2451            if profiles.values().any(|current| {
2452                current.native_profile_ids().any(|current_id| current_id == native_id)
2453            }) {
2454                return Err(NodeServerError::DuplicateNativeEnvironmentProfile(
2455                    native_id.to_string(),
2456                ));
2457            }
2458        }
2459
2460        let mut installed = Vec::with_capacity(native_profiles.len());
2461        for native_profile in native_profiles {
2462            let native_id = native_profile.id().clone();
2463            if let Err(error) = self.runtime.upsert_native_launch_profile(native_profile) {
2464                for installed_id in installed.into_iter().rev() {
2465                    let _ = self.runtime.remove_native_launch_profile(&installed_id);
2466                }
2467                return Err(NodeServerError::NativeEnvironmentProfile(error.to_string()));
2468            }
2469            installed.push(native_id);
2470        }
2471        if let Some(materialization) = materialization {
2472            self.shared
2473                .environment_materialization_profiles
2474                .write()
2475                .unwrap_or_else(|poisoned| poisoned.into_inner())
2476                .insert(binding.id.clone(), materialization);
2477        }
2478        profiles.insert(binding.id.clone(), binding);
2479        Ok(())
2480    }
2481
2482    /// Installs one immutable host-local bundle before the server runs.
2483    pub fn install_bundle(&mut self, bundle: NodeBundle) -> Result<(), NodeServerError> {
2484        if self.shared.session_environment_materializer.is_none() {
2485            return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2486        }
2487        let mut catalog = self
2488            .shared
2489            .bundle_catalog
2490            .write()
2491            .unwrap_or_else(|poisoned| poisoned.into_inner());
2492        catalog
2493            .insert_idempotent(bundle)
2494            .map_err(|error| NodeServerError::BundleCatalog(error.to_string()))?;
2495        Ok(())
2496    }
2497
2498    pub async fn run_until_ctrl_signal(self) -> Result<(), NodeServerError> {
2499        let shutdown = self.shutdown_handle();
2500        let run = self.run();
2501        tokio::pin!(run);
2502        tokio::select! {
2503            result = &mut run => result,
2504            signal = wait_for_ctrl_signal() => {
2505                signal?;
2506                let shutdown_result = shutdown.request_shutdown().await;
2507                let run_result = run.await;
2508                shutdown_result.and(run_result)
2509            }
2510        }
2511    }
2512
2513    pub async fn run(self) -> Result<(), NodeServerError> {
2514        let NodeServer {
2515            config,
2516            mut runtime,
2517            shared,
2518            events,
2519            state_path_lock: _state_path_lock,
2520        } = self;
2521        shared.reconcile_materializations();
2522        shared.reconcile_managed_worktrees().await;
2523        shared.retire_unavailable_session_records().await;
2524        shared.reconcile_context_pack_exports().await;
2525        // Lifecycle hooks are retired (owner ruling 2026-09-25): the node
2526        // observes sessions through ACP where a provider has it; there is no
2527        // hook ingress.
2528        let endpoint = config.endpoint.clone();
2529        let call_home = config.call_home;
2530        let call_home_identity = config.call_home_identity.clone();
2531        let api_listen = config.api_listen;
2532        let bridge_listen = config.bridge_listen;
2533        let bridge_underlay = config.bridge_underlay;
2534        let bridge_auth = browser_bridge::BridgeAuth::from_optional(config.bridge_token)
2535            .map_err(|_| NodeServerError::InvalidBridgeToken)?;
2536        let accept_shared = Arc::clone(&shared);
2537        let api_shared = Arc::clone(&shared);
2538        let bridge_shared = Arc::clone(&shared);
2539        let shutdown_shared = Arc::clone(&shared);
2540        let shutdown_timeout = Duration::from_millis(
2541            config.runtime.provider_shutdown_timeout_ms.max(1),
2542        );
2543        let result = {
2544            let runtime_loop = drive_runtime_until_shutdown(
2545                &mut runtime,
2546                events,
2547                Arc::clone(&shared),
2548                shutdown_timeout,
2549            );
2550            let accept_loop = accept_connections(&endpoint, call_home, call_home_identity, accept_shared);
2551            let api_loop = http_api::run(api_listen, api_shared);
2552            let bridge_loop = browser_bridge::run(bridge_listen, bridge_auth, bridge_shared, bridge_underlay);
2553            tokio::pin!(runtime_loop);
2554            tokio::pin!(accept_loop);
2555            tokio::pin!(api_loop);
2556            tokio::pin!(bridge_loop);
2557            tokio::select! {
2558                runtime_result = &mut runtime_loop => {
2559                    let shutdown_result = shutdown_shared
2560                        .begin_shutdown()
2561                        .await
2562                        .map_err(NodeServerError::ShutdownDispatch);
2563                    let (accept_result, api_result, bridge_result) =
2564                        tokio::join!(&mut accept_loop, &mut api_loop, &mut bridge_loop);
2565                    match runtime_result {
2566                        Err(error) => Err(error),
2567                        Ok(()) => shutdown_result
2568                            .and(accept_result)
2569                            .and(api_result.map_err(NodeServerError::HttpApi))
2570                            .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2571                    }
2572                }
2573                accept_result = &mut accept_loop => {
2574                    let shutdown_result = shutdown_shared
2575                        .begin_shutdown()
2576                        .await
2577                        .map_err(NodeServerError::ShutdownDispatch);
2578                    let (runtime_result, api_result, bridge_result) =
2579                        tokio::join!(&mut runtime_loop, &mut api_loop, &mut bridge_loop);
2580                    match accept_result {
2581                        Err(error) => Err(error),
2582                        Ok(()) => shutdown_result
2583                            .and(runtime_result)
2584                            .and(api_result.map_err(NodeServerError::HttpApi))
2585                            .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2586                    }
2587                }
2588                api_result = &mut api_loop => {
2589                    let shutdown_result = shutdown_shared
2590                        .begin_shutdown()
2591                        .await
2592                        .map_err(NodeServerError::ShutdownDispatch);
2593                    let (runtime_result, accept_result, bridge_result) =
2594                        tokio::join!(&mut runtime_loop, &mut accept_loop, &mut bridge_loop);
2595                    match api_result {
2596                        Err(error) => Err(NodeServerError::HttpApi(error)),
2597                        Ok(()) => shutdown_result
2598                            .and(runtime_result)
2599                            .and(accept_result)
2600                            .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2601                    }
2602                }
2603                bridge_result = &mut bridge_loop => {
2604                    let shutdown_result = shutdown_shared
2605                        .begin_shutdown()
2606                        .await
2607                        .map_err(NodeServerError::ShutdownDispatch);
2608                    let (runtime_result, accept_result, api_result) =
2609                        tokio::join!(&mut runtime_loop, &mut accept_loop, &mut api_loop);
2610                    match bridge_result {
2611                        Err(error) => Err(NodeServerError::BridgeApi(error)),
2612                        Ok(()) => shutdown_result
2613                            .and(runtime_result)
2614                            .and(accept_result)
2615                            .and(api_result.map_err(NodeServerError::HttpApi)),
2616                    }
2617                }
2618            }
2619        };
2620        // Persistence gap fix: durable DeliveryStore / ContextPackStore hold
2621        // exclusive flock (Unix) / share-mode locks (Windows) inside NodeShared.
2622        // NodeShutdownHandle also clones that Arc, so without an explicit release
2623        // here a supervisor that keeps the shutdown handle cannot reopen the same
2624        // state path after run() returns (EAGAIN / Resource temporarily unavailable).
2625        // StatePathLock already drops with this stack frame (`_state_path_lock`).
2626        if let Ok(mut guard) = shared.delivery_store.lock() {
2627            *guard = None;
2628        }
2629        if let Ok(mut guard) = shared.context_pack_store.lock() {
2630            *guard = None;
2631        }
2632        result
2633    }
2634}
2635
2636async fn drive_runtime_until_shutdown(
2637    runtime: &mut NativeRuntime,
2638    events: EventSubscription,
2639    shared: Arc<NodeShared>,
2640    shutdown_timeout: Duration,
2641) -> Result<(), NodeServerError> {
2642    // Published once, never per iteration. The worker loops inside
2643    // `NativeRuntime` fold into this profile continuously; turning it into
2644    // percentiles is what costs, and that is `GET /metrics`'s bill to pay.
2645    let _ = shared
2646        .shell_efficiency_profile
2647        .set(runtime.shell_efficiency_profile());
2648    let mut shutdown_started = None;
2649    let mut last_retention_sweep = Instant::now();
2650    let mut last_provisional_reap = Instant::now();
2651    loop {
2652        // Every phase below is timed unconditionally -- see
2653        // `drive_loop_profile`'s own doc comment for why an always-on,
2654        // allocation-free ring is cheap enough to never need a feature
2655        // flag. This is the only place these numbers can be taken: the
2656        // phases are calls made directly from this loop, not something a
2657        // callee could time on our behalf.
2658        let runtime_tick_start = Instant::now();
2659        let tick_result = runtime.tick().await;
2660        let runtime_tick_elapsed = runtime_tick_start.elapsed();
2661
2662        let event_drain_start = Instant::now();
2663        // Every command the kernel refused, named. `NativeRuntimeTick`
2664        // has carried these outcomes all along and nothing read them, so a
2665        // rejected command -- a hook event denied by runtime policy, a
2666        // stale generation, an unsupported capability -- vanished at the
2667        // last step of the loop that produced it. A refusal the operator
2668        // cannot see is indistinguishable from an event that never arrived,
2669        // and telling those two apart is most of debugging this stack.
2670        for outcome in &tick_result.command_outcomes {
2671            if let Err(error) = &outcome.result {
2672                shared.rejected_commands_total.fetch_add(1, Ordering::Relaxed);
2673                tracing::warn!(
2674                    command_id = outcome.command_id.0,
2675                    cause = %error,
2676                    "control command rejected"
2677                );
2678                shared.record_rejected_command(
2679                    outcome.command_id,
2680                    error.to_string(),
2681                    error.is_unsupported_transport(),
2682                );
2683            }
2684        }
2685        let mut events_drained: u64 = 0;
2686        while let Ok(event) = events.try_recv() {
2687            events_drained += 1;
2688            let clean_exit = matches!(
2689                event.event,
2690                ControlEventKind::Exited { exit_code: Some(0), forced: false },
2691            );
2692            let instance_id = event.instance_id;
2693            let generation = event.generation;
2694            // Resolve and spawn before publish_control: publish_control's own
2695            // managed-record bookkeeping (reconcile_managed_record) downgrades
2696            // this exact record from Live to Dormant and clears
2697            // active_session for every Exited event, inside the same
2698            // synchronous call. Capturing (record_id, session) here — before
2699            // that runs — is the only way to observe them still Live and
2700            // still populated; the export itself must still run on its own
2701            // detached task (never awaited inline on this loop) because it
2702            // needs NativeRuntime::tick() to keep running to ever settle, and
2703            // tick() is only ever driven by this same loop. See
2704            // reconcile_context_pack_export_for_record's doc comment.
2705            if clean_exit {
2706                if let Some((record_id, session)) =
2707                    shared.managed_record_export_target_for_instance(instance_id, generation)
2708                {
2709                    let export_shared = Arc::clone(&shared);
2710                    tokio::spawn(async move {
2711                        export_shared
2712                            .reconcile_context_pack_export_for_record(&record_id, session)
2713                            .await;
2714                    });
2715                }
2716            }
2717            shared.publish_control(event);
2718        }
2719        let event_drain_elapsed = event_drain_start.elapsed();
2720
2721        let publish_terminal_frames_start = Instant::now();
2722        shared.publish_terminal_frames();
2723        let publish_terminal_frames_elapsed = publish_terminal_frames_start.elapsed();
2724
2725        // Physical PTY count and the runtime's own per-phase tick timings
2726        // both live inside `NativeRuntime`, which `GET /metrics` (reading
2727        // only `NodeShared`) cannot reach directly -- this loop is the one
2728        // place holding `&mut NativeRuntime`, so it is the only place that
2729        // can copy either out.
2730        shared
2731            .native_session_gauge
2732            .store(runtime.active_native_sessions(), Ordering::Relaxed);
2733        *shared
2734            .runtime_tick_profile
2735            .lock()
2736            .unwrap_or_else(|poisoned| poisoned.into_inner()) = runtime.tick_profile_snapshot();
2737
2738        let remainder_start = Instant::now();
2739        if shared.shutdown.load(Ordering::Acquire) {
2740            let started = *shutdown_started.get_or_insert_with(Instant::now);
2741            let snapshot = shared.handle.snapshot();
2742            let sessions_settled = snapshot.sessions.iter().all(|session| {
2743                !matches!(
2744                    session.status,
2745                    gate4agent_types::SessionStatus::Starting
2746                        | gate4agent_types::SessionStatus::Running
2747                        | gate4agent_types::SessionStatus::Stopping
2748                )
2749            });
2750            if runtime.active_native_sessions() == 0 && sessions_settled {
2751                return Ok(());
2752            }
2753            if started.elapsed() >= shutdown_timeout {
2754                return Err(NodeServerError::ShutdownTimedOut {
2755                    active_native_sessions: runtime.active_native_sessions(),
2756                });
2757            }
2758        }
2759        let remainder_elapsed = remainder_start.elapsed();
2760
2761        {
2762            let mut profile = shared
2763                .drive_loop_profile
2764                .lock()
2765                .unwrap_or_else(|poisoned| poisoned.into_inner());
2766            profile.record_runtime_tick(runtime_tick_elapsed);
2767            profile.record_event_drain(event_drain_elapsed);
2768            profile.record_publish_terminal_frames(publish_terminal_frames_elapsed);
2769            profile.record_remainder(remainder_elapsed);
2770            profile.note_iteration(Instant::now());
2771        }
2772
2773        // "Produced nothing" means this iteration's `NativeRuntime::tick`
2774        // applied zero observations (`tick_result.observations_applied`,
2775        // which already folds in any terminal frames collected -- see
2776        // `NativeEffectDispatcher::drain_observations`) AND the
2777        // control-event subscription drained above forwarded zero events.
2778        // Command ingress and dispatched effects are deliberately NOT part
2779        // of this definition: an idle node still runs `step_control_plane`
2780        // and every provider supervisor's `tick` regardless, every 10ms --
2781        // so "nothing" here means nothing OBSERVABLE left this iteration,
2782        // matching what a reader would assume from `drive_loop_iterations`'s
2783        // own field name, not that the tick itself was free of work.
2784        shared.drive_loop_iterations_total.fetch_add(1, Ordering::Relaxed);
2785        if tick_result.observations_applied == 0 && events_drained == 0 {
2786            shared.drive_loop_iterations_idle.fetch_add(1, Ordering::Relaxed);
2787        }
2788
2789        // A sweep cadence, not a policy value -- see
2790        // `SESSION_RECORD_RETENTION_SWEEP_PERIOD`'s own doc comment. The
2791        // policy itself (whether this does anything at all) lives entirely
2792        // in `NodeShared::session_record_retention`.
2793        if last_retention_sweep.elapsed() >= SESSION_RECORD_RETENTION_SWEEP_PERIOD {
2794            shared.retire_unavailable_session_records().await;
2795            last_retention_sweep = Instant::now();
2796        }
2797        // Provisional Spawn grace: kill PTYs that never got an HQ attach.
2798        if last_provisional_reap.elapsed() >= Duration::from_secs(2) {
2799            for address in shared.take_due_provisional_spawns() {
2800                shared.reap_provisional_spawn(address).await;
2801            }
2802            last_provisional_reap = Instant::now();
2803        }
2804
2805        // Fast-poll for 300ms after PTY input so its echo is read and published
2806        // within ~1ms; otherwise nap 10ms but wake at once on new input.
2807        let hot = shared
2808            .pty_input_marks
2809            .lock()
2810            .unwrap_or_else(|poisoned| poisoned.into_inner())
2811            .values()
2812            .any(|(at, _)| at.elapsed() < Duration::from_millis(300));
2813        if hot {
2814            sleep(Duration::from_millis(1)).await;
2815        } else {
2816            tokio::select! {
2817                _ = sleep(Duration::from_millis(10)) => {}
2818                _ = shared.pty_input_wake.notified() => {}
2819            }
2820        }
2821    }
2822}
2823
2824#[derive(Clone)]
2825pub struct NodeShutdownHandle {
2826    shared: Arc<NodeShared>,
2827}
2828
2829impl NodeShutdownHandle {
2830    pub async fn request_shutdown(&self) -> Result<(), NodeServerError> {
2831        self.shared
2832            .begin_shutdown()
2833            .await
2834            .map_err(NodeServerError::ShutdownDispatch)
2835    }
2836}
2837
2838async fn wait_for_ctrl_signal() -> Result<(), NodeServerError> {
2839    #[cfg(windows)]
2840    {
2841    let mut ctrl_c = tokio::signal::windows::ctrl_c()?;
2842    let mut ctrl_break = tokio::signal::windows::ctrl_break()?;
2843    return tokio::select! {
2844        signal = ctrl_c.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2845        signal = ctrl_break.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2846    };
2847    }
2848    #[cfg(unix)]
2849    {
2850        let mut interrupt = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())?;
2851        let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
2852        tokio::select! {
2853            signal = interrupt.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2854            signal = terminate.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2855        }
2856    }
2857}
2858
2859/// The providers this node actually runs.
2860///
2861/// `builtin_registry()` is a reference catalog of everything the project has
2862/// ever described; the fleet is this list, and it is deliberately short. A
2863/// provider only belongs here if it can be exercised for real -- there is a
2864/// subscription behind it and a failure in it can be reproduced and
2865/// debugged. Carrying one that cannot is carrying code nobody can verify,
2866/// which is worse than not supporting it: it looks supported.
2867///
2868/// A provider dropped from the fleet by this rule is removed outright, not
2869/// carried dark: no catalog entry, no adapter, no dead code claiming support
2870/// for something nobody can verify runs.
2871/// Host interactive shell for HQ terminal attach (no provider CLI, no network).
2872fn shell_host_spec() -> AgentSpec {
2873    AgentSpec {
2874        id: AgentId::new("shell").expect("shell agent id"),
2875        revision: "hatchery:shell-pty-1".to_owned(),
2876        display_name: "Host shell".to_owned(),
2877        detection: DetectionSpec {
2878            command: "bash".to_owned(),
2879            aliases: vec!["sh".to_owned(), "shell".to_owned()],
2880            required_commands: Vec::new(),
2881            unsupported_platforms: Vec::new(),
2882        },
2883        launch: LaunchSpec {
2884            // Absolute path so admission does not depend on PATH.
2885            program: "/bin/bash".to_owned(),
2886            fixed_args: vec!["-i".to_owned()],
2887        },
2888        expected_processes: vec![ProcessMatcher::Exact {
2889            name: "bash".to_owned(),
2890        }],
2891        prompt: PromptSpec {
2892            initial: InitialPromptMode::None,
2893            native_draft: None,
2894        },
2895        readiness: AgentReadinessSpec::default(),
2896        capabilities: AgentCapabilities {
2897            agent_commands: None,
2898            transports: AgentTransportCapabilities {
2899                pty: true,
2900                pty_adapter: None,
2901                pipe: None,
2902                acp: None,
2903            },
2904            adapters: AgentAdapterCapabilities::default(),
2905        },
2906        verification: SpecVerification::Gate4AgentVerified,
2907    }
2908}
2909
2910fn active_registry() -> Result<AgentRegistry, NodeServerError> {
2911    let mut specs = vec![shell_host_spec()];
2912    specs.extend(
2913        builtin_registry()
2914            .iter()
2915            .filter(|spec| matches!(spec.id.as_str(), "claude" | "codex" | "grok" | "kimi"))
2916            .cloned(),
2917    );
2918    AgentRegistry::new(specs).map_err(|error| NodeServerError::Registry(error.to_string()))
2919}
2920
2921fn provider_contract_manifest(
2922    catalog: &AgentRegistry,
2923) -> Result<
2924    (
2925        Vec<ProviderContractSupport>,
2926        Vec<ProviderAdapterContractSupport>,
2927    ),
2928    NodeServerError,
2929> {
2930    let mut provider_contracts = Vec::new();
2931    let mut provider_adapter_contracts = Vec::new();
2932    for spec in catalog.iter() {
2933        let provider = spec.id.clone();
2934        let revision = ProviderContractRevision::new(spec.revision.clone())
2935            .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2936        provider_contracts.push(ProviderContractSupport {
2937            provider: provider.clone(),
2938            revision,
2939        });
2940        for (family, binding) in declared_adapter_bindings(spec)? {
2941            let revision = AdapterContractRevision::new(binding.revision.clone())
2942                .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2943            provider_adapter_contracts.push(ProviderAdapterContractSupport {
2944                provider: provider.clone(),
2945                family,
2946                adapter_id: binding.id.clone(),
2947                revision,
2948            });
2949        }
2950    }
2951    validate_provider_contract_manifest(&provider_contracts, &provider_adapter_contracts)
2952        .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2953    Ok((provider_contracts, provider_adapter_contracts))
2954}
2955
2956fn declared_adapter_bindings(
2957    spec: &AgentSpec,
2958) -> Result<Vec<(AdapterFamily, &AdapterBinding)>, NodeServerError> {
2959    let mut bindings = Vec::new();
2960    push_declared_binding(
2961        &mut bindings,
2962        AdapterFamily::PtySemantic,
2963        spec.capabilities.transports.pty_adapter.as_ref(),
2964    )?;
2965    push_declared_binding(
2966        &mut bindings,
2967        AdapterFamily::Pipe,
2968        spec.capabilities.adapters.pty_sidecar.as_ref(),
2969    )?;
2970    let pipe_transport = spec.capabilities.transports.pipe.as_ref();
2971    let pipe_family = pipe_transport.map(|transport| match transport.protocol {
2972        gate4agent_types::PipeProtocol::SemanticNdjson
2973        | gate4agent_types::PipeProtocol::StructuredJsonl => AdapterFamily::Pipe,
2974        gate4agent_types::PipeProtocol::OneShotText => AdapterFamily::OneShot,
2975    });
2976    push_declared_binding(
2977        &mut bindings,
2978        AdapterFamily::Pipe,
2979        pipe_transport
2980            .filter(|_| pipe_family == Some(AdapterFamily::Pipe))
2981            .map(|transport| &transport.adapter),
2982    )?;
2983    push_declared_binding(
2984        &mut bindings,
2985        AdapterFamily::Acp,
2986        spec.capabilities
2987            .transports
2988            .acp
2989            .as_ref()
2990            .map(|transport| &transport.adapter),
2991    )?;
2992    push_declared_binding(
2993        &mut bindings,
2994        AdapterFamily::Hook,
2995        spec.capabilities.adapters.hook.as_ref(),
2996    )?;
2997    push_declared_binding(
2998        &mut bindings,
2999        AdapterFamily::ManagedHook,
3000        spec.capabilities.adapters.managed_hook.as_ref(),
3001    )?;
3002    push_declared_binding(
3003        &mut bindings,
3004        AdapterFamily::OneShot,
3005        pipe_transport
3006            .filter(|_| pipe_family == Some(AdapterFamily::OneShot))
3007            .map(|transport| &transport.adapter),
3008    )?;
3009    push_declared_binding(
3010        &mut bindings,
3011        AdapterFamily::OneShot,
3012        spec.capabilities.adapters.one_shot.as_ref(),
3013    )?;
3014    push_declared_binding(
3015        &mut bindings,
3016        AdapterFamily::History,
3017        spec.capabilities.adapters.history.as_ref(),
3018    )?;
3019    push_declared_binding(
3020        &mut bindings,
3021        AdapterFamily::Resume,
3022        spec.capabilities.adapters.resume.as_ref(),
3023    )?;
3024    push_declared_binding(
3025        &mut bindings,
3026        AdapterFamily::SessionOptions,
3027        spec.capabilities.adapters.session_options.as_ref(),
3028    )?;
3029    push_declared_binding(
3030        &mut bindings,
3031        AdapterFamily::CapabilityProbe,
3032        spec.capabilities.adapters.capability_probe.as_ref(),
3033    )?;
3034    Ok(bindings)
3035}
3036
3037fn push_declared_binding<'a>(
3038    bindings: &mut Vec<(AdapterFamily, &'a AdapterBinding)>,
3039    family: AdapterFamily,
3040    binding: Option<&'a AdapterBinding>,
3041) -> Result<(), NodeServerError> {
3042    let Some(binding) = binding else {
3043        return Ok(());
3044    };
3045    if let Some((_, existing)) = bindings
3046        .iter()
3047        .find(|(existing_family, _)| *existing_family == family)
3048    {
3049        if *existing == binding {
3050            return Ok(());
3051        }
3052        return Err(NodeServerError::ProviderContractManifest(format!(
3053            "provider declares conflicting {family:?} adapter bindings"
3054        )));
3055    }
3056    bindings.push((family, binding));
3057    Ok(())
3058}
3059
3060fn merge_durable_state(
3061    configured: &[WorkspaceConfig],
3062    mut loaded: LoadedNodeState,
3063) -> Result<(Vec<WorkspaceConfig>, Vec<ManagedSessionRecord>, Option<String>), NodeServerError> {
3064    let mut workspaces = configured
3065        .iter()
3066        .cloned()
3067        .map(|workspace| (workspace.workspace_id.clone(), workspace))
3068        .collect::<BTreeMap<_, _>>();
3069    let mut roots = workspaces
3070        .values()
3071        .map(|workspace| {
3072            (
3073                platform::root_identity(&workspace.canonical_root),
3074                workspace.workspace_id.clone(),
3075            )
3076        })
3077        .collect::<BTreeMap<_, _>>();
3078    let mut warning = loaded
3079        .warning
3080        .take()
3081        .map(|message| session_registry::sanitized_persistence_summary(&message));
3082
3083    for (workspace_id, canonical_root) in loaded.workspaces {
3084        if let Some(existing) = workspaces.get(&workspace_id) {
3085            if !platform::roots_equal(&existing.canonical_root, &canonical_root) {
3086                return Err(NodeServerError::DurableState(io::Error::new(
3087                    io::ErrorKind::InvalidData,
3088                    DURABLE_STATE_CONFLICT_ERROR,
3089                )));
3090            }
3091            continue;
3092        }
3093        if roots.get(&platform::root_identity(&canonical_root)).is_some() {
3094            return Err(NodeServerError::DurableState(io::Error::new(
3095                io::ErrorKind::InvalidData,
3096                DURABLE_STATE_CONFLICT_ERROR,
3097            )));
3098        }
3099        if !Path::new(&canonical_root).is_dir() {
3100            warning.get_or_insert_with(|| {
3101                session_registry::DURABLE_STATE_WORKSPACE_WARNING.to_owned()
3102            });
3103            continue;
3104        }
3105        let workspace = WorkspaceConfig::new(workspace_id.clone(), &canonical_root).map_err(|_| {
3106            NodeServerError::DurableState(io::Error::new(
3107                io::ErrorKind::InvalidData,
3108                DURABLE_STATE_CONFLICT_ERROR,
3109            ))
3110        })?;
3111        roots.insert(
3112            platform::root_identity(&workspace.canonical_root),
3113            workspace.workspace_id.clone(),
3114        );
3115        workspaces.insert(workspace_id, workspace);
3116    }
3117
3118    for record in &mut loaded.records {
3119        match workspaces.get(&record.workspace_id) {
3120            Some(workspace)
3121                if platform::roots_equal(
3122                    &workspace.canonical_root,
3123                    windows_path_text(&record.canonical_root),
3124                ) => {}
3125            Some(_) => {
3126                return Err(NodeServerError::DurableState(io::Error::new(
3127                    io::ErrorKind::InvalidData,
3128                    DURABLE_STATE_CONFLICT_ERROR,
3129                )));
3130            }
3131            None => {
3132                record.state = ManagedSessionState::Unavailable;
3133                record.active_session = None;
3134                record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
3135            }
3136        }
3137    }
3138    Ok((workspaces.into_values().collect(), loaded.records, warning))
3139}
3140
3141/// One entry of `NodeShared::rejected_command_reasons` -- see that field's
3142/// own doc for the mechanism this exists to close.
3143struct RejectedCommandReason {
3144    message: String,
3145    /// True when the kernel refused this command specifically because the
3146    /// agent does not declare the requested transport (`KernelCommandError::
3147    /// UnsupportedTransport`) -- lets the reader choose the named
3148    /// `NodeFailureCode::UnsupportedTransport` over the generic
3149    /// `BackendOperationFailed` bucket, so this one well-known rejection
3150    /// keeps its identity all the way to the operator wire.
3151    unsupported_transport: bool,
3152    expires_at: Instant,
3153}
3154
3155struct NodeShared {
3156    handle: Gate4AgentHandle,
3157    access_token: String,
3158    node_id: NodeId,
3159    incarnation_id: NodeIncarnationId,
3160    started_at_unix_ms: u64,
3161    workspaces: RwLock<BTreeMap<WorkspaceId, String>>,
3162    worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode>,
3163    managed_worktree_profiles:
3164        BTreeMap<WorkspaceId, BTreeMap<WorktreeProfileId, ManagedWorktreeProfile>>,
3165    managed_worktrees: Mutex<ManagedWorktreeRegistry>,
3166    enabled_providers: Vec<AgentId>,
3167    provider_runtime_statuses: ProviderRuntimeStatuses,
3168    provider_runtime_status_updates:
3169        RwLock<BTreeMap<AgentId, crate::protocol::ProviderRuntimeStatus>>,
3170    provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
3171    provider_contracts: Vec<ProviderContractSupport>,
3172    provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
3173    spawn_profiles: SpawnProfileRegistry,
3174    environment_profiles:
3175        RwLock<BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>>,
3176    environment_materialization_profiles:
3177        RwLock<BTreeMap<SpawnEnvironmentProfileId, NodeSessionMaterializationProfile>>,
3178    /// Station **network** allowlist catalog (opaque policy ids only).
3179    /// Empty by default — any `network_allowlist` on spawn must be registered
3180    /// here or resolve refuses with `UnknownNetworkAllowlist`. Optional
3181    /// startup load via `NodeServerConfig::with_network_allowlist_catalog(_file)`
3182    /// / env `GATE4AGENT_NODE_NETWORK_ALLOWLIST_CATALOG`. Plan
3183    /// `station-network-and-browser-profile-knobs-2026-10-02.md` §2.1 / §4.
3184    /// Never holds cookies / OAuth / proxy credentials.
3185    network_allowlist_catalog: RwLock<crate::network_allowlist_catalog::NetworkAllowlistCatalog>,
3186    /// Node-local exclusive dig2browser station profile leases (feature
3187    /// `dig2-station-probe`). Opaque `browser_profile_id` ↔ session holder;
3188    /// cleared on session end. Never ImportSession / cookies on C2.
3189    #[cfg(feature = "dig2-station-probe")]
3190    browser_station_leases: crate::dig2_station_lease::BrowserStationLeaseTable,
3191    bundle_catalog: RwLock<BundleCatalog>,
3192    delivery_store: Mutex<Option<DeliveryStore>>,
3193    context_pack_store: Mutex<Option<ContextPackStore>>,
3194    context_catalog: RwLock<ContextPackCatalog>,
3195    native_launch_profile_control: Option<NativeLaunchProfileControl>,
3196    harness_mcp_registry: Option<HarnessMcpProxyRegistry>,
3197    native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
3198    session_environment_materializer: Option<SessionEnvironmentMaterializer>,
3199    /// Per-session m4a accounts assigned at spawn (see [`crate::m4a_accounts`]).
3200    m4a_accounts: Option<crate::m4a_accounts::M4aSessionAccounts>,
3201    /// nick -> the session that holds that m4a account (for letter wakes).
3202    m4a_live: Mutex<BTreeMap<String, (AgentInstanceId, AgentId)>>,
3203    /// m4a signer proxy (option A): when set, PTY sessions get a process-bound handle
3204    /// instead of a bearer capability.
3205    #[cfg(unix)]
3206    signer_proxy: std::sync::OnceLock<Arc<crate::keystore_proxy::SignerProxy>>,
3207    materializations: Mutex<BTreeMap<MaterializationId, MaterializationOwnershipRecord>>,
3208    spawn_idempotency: Mutex<SpawnIdempotencyCache>,
3209    managed_spawn_replays:
3210        Mutex<BTreeMap<SpawnIdempotencyKey, ManagedWorktreeSpawnReplayRecordV10>>,
3211    controller: Mutex<Option<ControllerLease>>,
3212    /// Fresh spawns waiting for their first successful HQ attach. Removed on
3213    /// Resize/TerminalBytes (sessionize) or when the grace reaper stops them.
3214    provisional_spawns: Mutex<BTreeMap<AgentInstanceId, (SessionAddress, Instant)>>,
3215    /// ACP launch options waiting for the agent to advertise its config options.
3216    pending_acp_options: Mutex<BTreeMap<AgentInstanceId, (Option<String>, Option<String>)>>,
3217    history: Mutex<NodeEventHistory>,
3218    event_tx: broadcast::Sender<NodeEventEnvelope>,
3219    terminal_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3220    /// The outbound `NodeEvent::AgentStream` push channel -- its own
3221    /// dedicated, capability-gated broadcast, mirroring `terminal_event_tx`
3222    /// exactly rather than riding the durable `event_tx` history (see
3223    /// `publish`'s own assertion): a subscriber who never negotiated
3224    /// `NODE_AGENT_STREAM_EVENTS_CAPABILITY` must never receive one of
3225    /// these, the same guarantee `include_terminal_frame_events` already
3226    /// gives terminal frames.
3227    agent_stream_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3228    /// The outbound `NodeEvent::HarnessMcpReadCall` push channel -- mirrors
3229    /// `agent_stream_event_tx` exactly, its own dedicated, capability-gated
3230    /// broadcast rather than a ride on the durable `event_tx` history. A
3231    /// `HarnessMcpReadCall` is a request/response call a live proxy call is
3232    /// waiting on (`harness_mcp_proxy.rs`'s `begin_call`); the shared bus's
3233    /// finite capacity and its willingness to silently evict a lagging
3234    /// subscriber's backlog is exactly how the call vanished with neither
3235    /// side ever logging it (see `publish_transient`'s own doc).
3236    harness_mcp_proxy_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3237    terminal_frame_watermarks:
3238        Mutex<BTreeMap<AgentInstanceId, (SessionAddress, u64)>>,
3239    /// Wakes the drive loop as soon as PTY input is queued (no 10ms nap).
3240    pty_input_wake: tokio::sync::Notify,
3241    /// Per session: when the last PTY input was dispatched (latency probe +
3242    /// a short fast-poll window so the echo is published within ~1ms).
3243    pty_input_marks: Mutex<BTreeMap<AgentInstanceId, (Instant, bool)>>,
3244    next_connection_id: AtomicU64,
3245    next_instance_id: AtomicU64,
3246    next_command_id: AtomicU64,
3247    shutdown: AtomicBool,
3248    shutdown_notify: Notify,
3249    preauth_slots: Arc<Semaphore>,
3250    authenticated_slots: Arc<Semaphore>,
3251    inspection_slots: Arc<Semaphore>,
3252    mutation_gate: AsyncMutex<()>,
3253    session_bindings: Mutex<BTreeMap<AgentInstanceId, SessionBinding>>,
3254    session_records: Mutex<SessionRecords>,
3255    state_path: Option<PathBuf>,
3256    persistence_error: RwLock<Option<String>>,
3257    state_transaction: Mutex<()>,
3258    input_settle_timeout_ms: u64,
3259    /// Drive-loop phase/rate timings -- see `drive_loop_profile`'s own doc
3260    /// comment. Written once per iteration by
3261    /// `drive_runtime_until_shutdown`, read by `GET /metrics`.
3262    drive_loop_profile: Mutex<DriveLoopProfiler>,
3263    /// Latest copy of `NativeRuntime::tick_profile_snapshot()` -- `Copy`,
3264    /// so an overwrite each iteration is cheap. The runtime itself is not
3265    /// reachable from here (it is owned locally by
3266    /// `drive_runtime_until_shutdown`), so this is how its own per-phase
3267    /// tick timings reach `GET /metrics`.
3268    runtime_tick_profile: Mutex<TickProfileSnapshot>,
3269    /// The live shell-efficiency profile, shared with the per-instance
3270    /// worker loops that fold into it.
3271    ///
3272    /// Deliberately NOT the per-iteration snapshot copy `runtime_tick_
3273    /// profile` above keeps: producing a snapshot sorts every ring behind
3274    /// it, and that sort belongs on the read. A `OnceLock` rather than a
3275    /// field set at construction because `NodeShared` is built before the
3276    /// `NativeRuntime` that owns the profile exists -- the drive loop
3277    /// publishes it once, before its first iteration. Unset means the
3278    /// runtime has not started driving yet, which `GET /metrics` reports as
3279    /// empty series rather than as zeroes it never measured.
3280    shell_efficiency_profile: OnceLock<Arc<Mutex<ShellEfficiencyProfile>>>,
3281    /// Physical PTY sessions the native runtime currently owns
3282    /// (`NativeRuntime::active_native_sessions`), refreshed once per drive
3283    /// loop iteration -- deliberately a separate count from
3284    /// `handle.snapshot().sessions.len()` (the control-plane kernel's own
3285    /// session bookkeeping), since the two can disagree and the gap is
3286    /// itself a diagnostic signal `GET /metrics` exposes.
3287    native_session_gauge: AtomicUsize,
3288    /// Lifetime totals for the per-connection serve loop, summed across
3289    /// every live connection.
3290    ///
3291    /// The drive loop's own phase timings account for about ten
3292    /// microseconds an iteration on an idle node, yet the process burns a
3293    /// quarter of a core — and burns it ONLY while a c2 is attached (0.5%
3294    /// with none, 23% with two, measured). That gap is somewhere in
3295    /// serving a connection, and there was no way to see it: iterations
3296    /// says whether the loop is spinning rather than sleeping, and events
3297    /// says whether it is spinning over real work or over nothing.
3298    connection_loop_iterations: AtomicU64,
3299    connection_events_sent: AtomicU64,
3300    /// Lifetime totals for `drive_runtime_until_shutdown`'s own iterations
3301    /// -- `drive_loop_iterations_idle` counts iterations that applied zero
3302    /// `NativeRuntime::tick` observations AND forwarded zero control
3303    /// events; see the exact "produced nothing" definition at the site in
3304    /// `drive_runtime_until_shutdown` that increments these. Read twice
3305    /// and divide by the interval, same rule as `connections` above --
3306    /// `idle / total` is backlog item 5's "wake on work" number.
3307    /// Lifetime count of control commands the kernel refused.
3308    rejected_commands_total: AtomicU64,
3309    /// The named reason behind a recent kernel rejection, by the
3310    /// `CommandId` `dispatch_bounded` handed back when it enqueued that
3311    /// command -- populated by `drive_runtime_until_shutdown` for every
3312    /// `Err` in a tick's `command_outcomes`, and drained by whichever
3313    /// spawn-dispatch waiter recognizes its own `Register`/`Start`
3314    /// `CommandId` among them.
3315    ///
3316    /// A `ControlCommand` only ever fails synchronously (a `NodeFailure`
3317    /// out of `dispatch`/`dispatch_bounded` itself) for a port-level ingress
3318    /// problem -- full or disconnected -- never for the kernel refusing the
3319    /// command's content (an unsupported transport, an unknown agent, ...);
3320    /// that verdict exists only once `NativeRuntime::tick` has processed the
3321    /// command, one drive-loop iteration later, in a different task than
3322    /// whichever caller is awaiting the spawn. Without this map, a spawn
3323    /// wait loop that polls `self.handle.snapshot()` for a session that
3324    /// will now never appear has no way to learn why -- it can only spin
3325    /// until its own commit deadline and report a generic "did not commit"
3326    /// timeout, discarding a reason the kernel already named precisely (see
3327    /// `KernelCommandError`'s variants, e.g. `UnsupportedTransport`).
3328    rejected_command_reasons: Mutex<BTreeMap<CommandId, RejectedCommandReason>>,
3329    drive_loop_iterations_total: AtomicU64,
3330    drive_loop_iterations_idle: AtomicU64,
3331    /// The operator-chosen age/keep-N policy the retention sweep applies to
3332    /// `ManagedSessionState::Unavailable` records. Both `0` by default --
3333    /// see `SessionRecordRetentionConfig`'s own doc comment.
3334    session_record_retention: SessionRecordRetentionConfig,
3335    /// Lifetime count of managed session records the retention sweep has
3336    /// retired. Same tier as `rejected_commands_total` above: a live,
3337    /// process-lifetime instrument, never persisted and never reloaded from
3338    /// `state-v1.json` -- a restart legitimately resets it, matching every
3339    /// other `*_total` counter here.
3340    retired_records_total: AtomicU64,
3341    #[cfg(feature = "fixture")]
3342    fixture_semantic_hook_policy: bool,
3343    #[cfg(feature = "fixture")]
3344    fixture_spawn_managed_worktree_v2_failure_probe: SpawnManagedWorktreeV2FailureProbe,
3345}
3346
3347impl Drop for NodeShared {
3348    fn drop(&mut self) {
3349        SessionLifecycleHook::on_shutdown(self);
3350    }
3351}
3352
3353#[derive(Clone, Debug, Eq, PartialEq)]
3354struct SessionBinding {
3355    workspace_id: WorkspaceId,
3356    generation: SessionGeneration,
3357    runtime_policy: ProviderRuntimePolicy,
3358    pending_resume: Option<(SessionGeneration, CommandId, ProviderRuntimePolicy)>,
3359    record_id: Option<SessionRecordId>,
3360    managed_worktree_lease_id: Option<ManagedWorktreeLeaseId>,
3361    environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
3362    bundle: Option<ResolvedBundleReceipt>,
3363    context: Option<ResolvedContextPackReceipt>,
3364    materialization_id: Option<MaterializationId>,
3365}
3366
3367#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3368enum SpawnRecordPolicy {
3369    ProviderIdentityOnly,
3370    Always,
3371}
3372
3373struct NativeEnvironmentSelectionGuard {
3374    control: Option<NativeLaunchProfileControl>,
3375    instance_id: AgentInstanceId,
3376}
3377
3378struct NativeInstanceOverlayGuard {
3379    control: Option<NativeLaunchProfileControl>,
3380    instance_id: AgentInstanceId,
3381}
3382
3383struct NativeHarnessMcpOverlayGuard {
3384    control: NativeLaunchProfileControl,
3385    instance_id: AgentInstanceId,
3386}
3387
3388impl NativeHarnessMcpOverlayGuard {
3389    fn retain(self) { std::mem::forget(self); }
3390}
3391
3392impl Drop for NativeHarnessMcpOverlayGuard {
3393    fn drop(&mut self) {
3394        self.control
3395            .clear_native_mcp_server_launch_overlay(self.instance_id);
3396    }
3397}
3398
3399impl NativeInstanceOverlayGuard {
3400    fn retain(mut self) {
3401        self.control = None;
3402    }
3403}
3404
3405impl Drop for NativeInstanceOverlayGuard {
3406    fn drop(&mut self) {
3407        if let Some(control) = self.control.as_ref() {
3408            control.clear_native_instance_launch_overlay(self.instance_id);
3409        }
3410    }
3411}
3412
3413enum PreparedNativeLaunchOverlay {
3414    Environment(NativeLaunchEnvironmentOverlay),
3415    Instance(NativeInstanceLaunchOverlay),
3416}
3417
3418impl NativeEnvironmentSelectionGuard {
3419    fn retain(mut self) {
3420        self.control = None;
3421    }
3422}
3423
3424impl Drop for NativeEnvironmentSelectionGuard {
3425    fn drop(&mut self) {
3426        if let Some(control) = self.control.as_ref() {
3427            control.clear_native_launch_profile_selection(self.instance_id);
3428        }
3429    }
3430}
3431
3432struct SessionMaterializationGuard<'a> {
3433    shared: &'a NodeShared,
3434    id: Option<MaterializationId>,
3435    /// Absolute ProviderHome (`home/`) under the session materialization root.
3436    /// Used for Claude station network `--settings` sidecar writes.
3437    provider_home: Option<PathBuf>,
3438}
3439
3440impl SessionMaterializationGuard<'_> {
3441    fn id(&self) -> Option<&MaterializationId> {
3442        self.id.as_ref()
3443    }
3444
3445    fn provider_home(&self) -> Option<&Path> {
3446        self.provider_home.as_deref()
3447    }
3448
3449    fn retain(mut self) {
3450        self.id = None;
3451    }
3452}
3453
3454impl Drop for SessionMaterializationGuard<'_> {
3455    fn drop(&mut self) {
3456        if let Some(id) = self.id.take() {
3457            let _ = self.shared.cleanup_materialization(&id);
3458        }
3459    }
3460}
3461
3462
3463/// Port: product-side reactions to session lifecycle transitions
3464/// (station lease, harness MCP overlay/registry, environment launch overlays).
3465trait SessionLifecycleHook {
3466    /// A binding was removed (unbind or spawn rollback).
3467    fn on_binding_removed(&self, address: &SessionAddress, removed: Option<&SessionBinding>);
3468    /// A session reached a terminal control event that revokes its harness MCP door.
3469    fn on_session_ended(&self, address: &SessionAddress);
3470    /// Node shutdown (or drop): revoke every harness MCP reservation.
3471    fn on_shutdown(&self);
3472    /// A harness MCP reservation was aborted before activation.
3473    fn on_harness_reservation_aborted(&self, instance_id: AgentInstanceId);
3474    /// Release session-owned materialization for a removed binding.
3475    fn cleanup_session_materialization(
3476        &self,
3477        address: &SessionAddress,
3478        binding: &SessionBinding,
3479    ) -> Result<(), NodeFailure>;
3480}
3481
3482impl SessionLifecycleHook for NodeShared {
3483    fn on_session_ended(&self, address: &SessionAddress) {
3484        if let Some(registry) = self.harness_mcp_registry.as_ref() {
3485            registry.revoke_session(address);
3486        }
3487        if let Some(control) = self.native_launch_profile_control.as_ref() {
3488            control.clear_native_mcp_server_launch_overlay(address.session.instance_id);
3489        }
3490    }
3491
3492    fn on_shutdown(&self) {
3493        if let Some(registry) = self.harness_mcp_registry.as_ref() {
3494            let instances = registry.shutdown();
3495            if let Some(control) = self.native_launch_profile_control.as_ref() {
3496                for instance_id in instances {
3497                    control.clear_native_mcp_server_launch_overlay(instance_id);
3498                }
3499            }
3500        }
3501    }
3502
3503    fn on_harness_reservation_aborted(&self, instance_id: AgentInstanceId) {
3504        if let Some(control) = self.native_launch_profile_control.as_ref() {
3505            control.clear_native_mcp_server_launch_overlay(instance_id);
3506        }
3507    }
3508
3509    fn cleanup_session_materialization(
3510        &self,
3511        address: &SessionAddress,
3512        binding: &SessionBinding,
3513    ) -> Result<(), NodeFailure> {
3514        self.cleanup_session_owned_materialization(address, binding)
3515    }
3516
3517    fn on_binding_removed(&self, address: &SessionAddress, removed: Option<&SessionBinding>) {
3518        // Exclusive dig2browser station profile lease is node-local; clear
3519        // on every session unbind / rollback. Never ImportSession/cookies.
3520        #[cfg(feature = "dig2-station-probe")]
3521        {
3522            crate::dig2_station_lease::release_lease_for_holder(
3523                &self.browser_station_leases,
3524                address.session.instance_id,
3525            );
3526        }
3527        if let Some(control) = self.native_launch_profile_control.as_ref() {
3528            control.clear_native_mcp_server_launch_overlay(
3529                address.session.instance_id,
3530            );
3531        }
3532        if let Some(registry) = self.harness_mcp_registry.as_ref() {
3533            registry.revoke_session(address);
3534        }
3535        if removed
3536            .is_some_and(|binding| {
3537                binding.environment_profile.is_some()
3538                    || binding.bundle.is_some()
3539                    || binding.context.is_some()
3540            })
3541        {
3542            if let Some(control) = self.native_launch_profile_control.as_ref() {
3543                control.clear_native_launch_profile_selection(
3544                    address.session.instance_id,
3545                );
3546                control.clear_native_instance_launch_overlay(
3547                    address.session.instance_id,
3548                );
3549            }
3550        }
3551    }
3552}
3553
3554struct SpawnPrepareRequest<'r> {
3555    instance_id: AgentInstanceId,
3556    address: &'r SessionAddress,
3557    provider: &'r AgentId,
3558    mode: SessionMode,
3559    environment_profile: Option<&'r ResolvedEnvironmentProfileReceipt>,
3560    bundle: Option<&'r ResolvedBundleReceipt>,
3561    context: Option<&'r ResolvedContextPackReceipt>,
3562    managed_authority: Option<&'r ManagedWorktreeLeaseId>,
3563    harness_mcp: Option<&'r PreparedHarnessMcpSpawn>,
3564    approval_level: ApprovalLevel,
3565    network_allowlist: Option<&'r crate::protocol::SpawnNetworkAllowlistId>,
3566    browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
3567}
3568
3569/// Guards held between "session identity minted" and "launch committed".
3570/// Field order is the drop order and matches the pre-refactor local order
3571/// (instance overlay, environment selection, materialization, harness, station lease).
3572struct PreparedSpawn<'a> {
3573    instance_overlay: Option<NativeInstanceOverlayGuard>,
3574    environment_selection: Option<NativeEnvironmentSelectionGuard>,
3575    materialization_guard: Option<SessionMaterializationGuard<'a>>,
3576    harness_mcp_overlay: Option<NativeHarnessMcpOverlayGuard>,
3577    #[cfg(feature = "dig2-station-probe")]
3578    browser_station_lease_guard: Option<crate::dig2_station_lease::BrowserStationLeaseGuard>,
3579    materialization_id: Option<MaterializationId>,
3580}
3581
3582impl PreparedSpawn<'_> {
3583    /// Session-owned launch state now belongs to the binding.
3584    fn retain_session_guards(&mut self) {
3585        if let Some(selection) = self.environment_selection.take() {
3586            selection.retain();
3587        }
3588        if let Some(overlay) = self.instance_overlay.take() {
3589            overlay.retain();
3590        }
3591        if let Some(guard) = self.materialization_guard.take() {
3592            guard.retain();
3593        }
3594    }
3595
3596    /// Launch committed: keep harness overlay and station lease for the session lifetime.
3597    fn retain_launch_guards(&mut self) {
3598        if let Some(overlay) = self.harness_mcp_overlay.take() {
3599            overlay.retain();
3600        }
3601        #[cfg(feature = "dig2-station-probe")]
3602        if let Some(guard) = self.browser_station_lease_guard.take() {
3603            guard.retain();
3604        }
3605    }
3606}
3607
3608/// Port: everything product-side that must be prepared before a session is
3609/// bound and started (station lease, harness MCP overlay, materialization,
3610/// environment selection, network overlays, launch overlay).
3611trait SpawnPreparer {
3612    fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure>;
3613    /// Select the native launch profile for an environment-profile session.
3614    fn select_environment(
3615        &self,
3616        instance_id: AgentInstanceId,
3617        provider: &AgentId,
3618        mode: SessionMode,
3619        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3620    ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure>;
3621    /// Install a prepared launch overlay for the instance.
3622    fn install_launch_overlay(
3623        &self,
3624        instance_id: AgentInstanceId,
3625        overlay: PreparedNativeLaunchOverlay,
3626    ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure>;
3627}
3628
3629impl SpawnPreparer for NodeShared {
3630    fn select_environment(
3631        &self,
3632        instance_id: AgentInstanceId,
3633        provider: &AgentId,
3634        mode: SessionMode,
3635        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3636    ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
3637        self.select_environment_profile(instance_id, provider, mode, environment_profile)
3638    }
3639
3640    fn install_launch_overlay(
3641        &self,
3642        instance_id: AgentInstanceId,
3643        overlay: PreparedNativeLaunchOverlay,
3644    ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
3645        self.install_prepared_launch_overlay(instance_id, overlay)
3646    }
3647
3648    fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure> {
3649        let SpawnPrepareRequest {
3650            instance_id,
3651            address,
3652            provider,
3653            mode,
3654            environment_profile,
3655            bundle,
3656            context,
3657            managed_authority,
3658            harness_mcp,
3659            approval_level,
3660            network_allowlist,
3661            browser_profile_id,
3662        } = request;
3663        // Track A follow-on: exclusive node-local BrowserStationLease after
3664        // dig2 probe (feature dig2-station-probe). Opaque profile id only —
3665        // never ImportSession / cookies on C2. Linux/feature-off: probe
3666        // already refused or stub; this block is cfg'd out when feature off.
3667        #[cfg(feature = "dig2-station-probe")]
3668        let mut browser_station_lease_guard = if let Some(profile_id) = browser_profile_id {
3669            use crate::dig2_station_lease::{acquire_lease, BrowserStationLeaseError};
3670            match acquire_lease(
3671                &self.browser_station_leases,
3672                profile_id,
3673                instance_id,
3674                unix_time_ms(),
3675            ) {
3676                Ok(guard) => Some(guard),
3677                Err(BrowserStationLeaseError::ProfileBusy { .. }) => {
3678                    return Err(failure(
3679                        NodeFailureCode::BrowserStationProfileBusy,
3680                        "dig2browser station profile is already leased by another session",
3681                    ));
3682                }
3683                Err(BrowserStationLeaseError::CapacityExceeded) => {
3684                    return Err(failure(
3685                        NodeFailureCode::BrowserStationProfileBusy,
3686                        "dig2browser station profile lease capacity exhausted",
3687                    ));
3688                }
3689                Err(BrowserStationLeaseError::HolderAlreadyLeased) => {
3690                    return Err(failure(
3691                        NodeFailureCode::BrowserStationProfileBusy,
3692                        "session already holds a dig2browser station profile lease",
3693                    ));
3694                }
3695            }
3696        } else {
3697            None
3698        };
3699        #[cfg(not(feature = "dig2-station-probe"))]
3700        let _ = browser_profile_id;
3701        let mut harness_mcp_overlay = if let Some(prepared) = harness_mcp {
3702            // Slice A(ii) of gate4agent-arc-mailbox-and-task-layer: the door
3703            // exists for PTY (env vars) and ACP (`session/new.mcpServers`,
3704            // `gate4agent-shell-native`'s `mcp_server_acp_entry`) alike --
3705            // both read the identical overlay resolved below, one as child
3706            // environment, the other translated into one stdio MCP server
3707            // entry. `Inline`/`Pipe` stays refused: this arc's proof is ACP
3708            // only, and nothing downstream reads a harness-MCP overlay for
3709            // that transport yet.
3710            if !matches!(mode, SessionMode::Pty | SessionMode::Acp) || prepared.provider != *provider
3711            {
3712                return Err(failure(
3713                    NodeFailureCode::BindingMismatch,
3714                    "harness MCP reservation does not match the exact PTY/ACP provider",
3715                ));
3716            }
3717            prepared.verify_helper_program().map_err(harness_mcp_failure)?;
3718            let control = self.native_launch_profile_control.clone().ok_or_else(|| {
3719                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP launch control is unavailable")
3720            })?;
3721            // Every name below is the caller's, carried in the reservation's
3722            // launch description: which environment variables receive the
3723            // endpoint, token and helper program, what the MCP server is called,
3724            // which argv it gets and which stale variables are erased.
3725            let launch = prepared.launch();
3726            let mut env_entries = vec![
3727                (
3728                    OsString::from(launch.endpoint_env.as_str()),
3729                    prepared.endpoint().as_os_str().to_os_string(),
3730                ),
3731                (
3732                    OsString::from(launch.token_env.as_str()),
3733                    prepared.token().expose().into(),
3734                ),
3735                (
3736                    OsString::from(launch.program_env.as_str()),
3737                    prepared.helper_program().as_os_str().to_os_string(),
3738                ),
3739            ];
3740            if let Some(trace_entry) = harness_mcp_trace_env_entry(launch) {
3741                env_entries.push(trace_entry);
3742            }
3743            if let Some(door) = crate::door::door_listen_addr() {
3744                env_entries.push((
3745                    OsString::from("HATCHERY_NODE_DOOR"),
3746                    OsString::from(door.to_string()),
3747                ));
3748                // Same key shape as HQ browser/draft ids:
3749                // `pty-{incarnation}-{workspace}-{instance}` — never reuse a
3750                // slot across node boots when the instance counter resets.
3751                let session_key = format!(
3752                    "pty-{}-{}-{}",
3753                    self.incarnation_id,
3754                    address.workspace_id.as_str(),
3755                    address.session.instance_id.0
3756                );
3757                env_entries.push((
3758                    OsString::from("HATCHERY_LOCAL_SERVER_SESSION"),
3759                    OsString::from(session_key),
3760                ));
3761            }
3762            let overlay = NativeMcpServerLaunchOverlay::new(
3763                provider.clone(),
3764                launch.server_name.as_str(),
3765                prepared.helper_program().as_os_str().to_os_string(),
3766                launch.args.iter().map(OsString::from).collect(),
3767                env_entries,
3768                launch.scrub_env.iter().map(OsString::from).collect(),
3769            ).map_err(|_| failure(
3770                NodeFailureCode::HarnessMcpUnavailable,
3771                "harness MCP launch overlay is unavailable",
3772            ))?;
3773            control
3774                .install_native_mcp_server_launch_overlay(instance_id, overlay)
3775                .map_err(|_| failure(
3776                    NodeFailureCode::HarnessMcpUnavailable,
3777                    "harness MCP launch overlay could not be installed",
3778                ))?;
3779            // State-change line: the overlay is installed and will be read
3780            // back by the PTY child's environment or, for ACP,
3781            // `gate4agent-shell-native`'s `mcp_server_acp_entry` at spawn
3782            // time -- no secrets here, only which instance/provider/mode
3783            // now carries a harness-MCP door.
3784            tracing::info!(
3785                node_id = %self.node_id,
3786                instance_id = ?instance_id,
3787                provider = %provider,
3788                mode = ?mode,
3789                "harness MCP overlay installed for spawn",
3790            );
3791            Some(NativeHarnessMcpOverlayGuard { control, instance_id })
3792        } else {
3793            None
3794        };
3795        let prepared_materialization = self.prepare_session_materialization(
3796            &address,
3797            &provider,
3798            mode,
3799            environment_profile,
3800            bundle,
3801            context,
3802            managed_authority.cloned(),
3803        )?;
3804        let (environment_overlay, mut materialization_guard) = match prepared_materialization {
3805            Some((overlay, guard)) => (Some(overlay), Some(guard)),
3806            None => (None, None),
3807        };
3808        let materialization_id = materialization_guard
3809            .as_ref()
3810            .and_then(SessionMaterializationGuard::id)
3811            .cloned();
3812        let mut environment_selection = SpawnPreparer::select_environment(self, 
3813            instance_id,
3814            &provider,
3815            mode,
3816            environment_profile,
3817        )?;
3818        // Station network overlays: Codex `-c network_access` and/or Claude
3819        // ProviderHome `--settings` sidecar (permits → allowedDomains).
3820        let provider_home = materialization_guard
3821            .as_ref()
3822            .and_then(SessionMaterializationGuard::provider_home);
3823        let network_overlay_args = self.resolve_network_allowlist_overlay_args(
3824            &provider,
3825            mode,
3826            approval_level,
3827            network_allowlist,
3828            provider_home,
3829        )?;
3830        let environment_overlay = match (environment_overlay.flatten(), network_overlay_args) {
3831            (prepared, args) if args.is_empty() => prepared,
3832            (Some(PreparedNativeLaunchOverlay::Instance(mut overlay)), args) => {
3833                overlay.append_extra_args(args).map_err(|_| {
3834                    failure(
3835                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3836                        "network allowlist overlay could not merge with instance launch args",
3837                    )
3838                })?;
3839                Some(PreparedNativeLaunchOverlay::Instance(overlay))
3840            }
3841            (Some(PreparedNativeLaunchOverlay::Environment(overlay)), args) => {
3842                Some(PreparedNativeLaunchOverlay::Instance(
3843                    overlay
3844                        .into_instance_with_extra_args(args)
3845                        .map_err(|_| {
3846                            failure(
3847                                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3848                                "network allowlist overlay could not merge with environment overlay",
3849                            )
3850                        })?,
3851                ))
3852            }
3853            (None, args) => Some(PreparedNativeLaunchOverlay::Instance(
3854                NativeInstanceLaunchOverlay::new(
3855                    provider.clone(),
3856                    match mode {
3857                        SessionMode::Pty => TransportKind::Pty,
3858                        SessionMode::Acp => TransportKind::Acp,
3859                        SessionMode::Inline => TransportKind::Pipe,
3860                    },
3861                    Vec::new(),
3862                    args,
3863                )
3864                .map_err(|_| {
3865                    failure(
3866                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3867                        "network allowlist overlay is invalid for this transport",
3868                    )
3869                })?,
3870            )),
3871        };
3872        // Per-session m4a account: own nick + capability for this PTY child only, on its own
3873        // overlay or appended to the overlay the spawn already carries.
3874        #[cfg(unix)]
3875        // `HATCHERY_M4A_SIGNER_SHELL=1` also gives operator shells an identity (live canaries).
3876        let signer_provider = crate::m4a_accounts::m4a_provider(provider.as_str()).or_else(|| {
3877            (provider.as_str() == "shell" && std::env::var_os("HATCHERY_M4A_SIGNER_SHELL").is_some_and(|v| v == "1")).then_some("shell")
3878        });
3879        let signer_identity = match (mode, self.signer_proxy.get(), signer_provider) {
3880            (SessionMode::Pty, Some(proxy), Some(m4a_provider)) => {
3881                let session_key = format!("{}-{}", self.incarnation_id, instance_id.0);
3882                let uid = unsafe { libc_geteuid() };
3883                let handle = proxy.reserve(&session_key, m4a_provider, uid);
3884                let sock = std::env::var_os(crate::keystore_proxy::SOCKET_ENV).unwrap_or_default();
3885                tracing::info!(provider = %provider, instance_id = ?instance_id, "m4a signer handle reserved at spawn");
3886                Some(vec![
3887                    gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from(crate::keystore_proxy::HANDLE_ENV), value: Some(std::ffi::OsString::from(handle)) },
3888                    gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from(crate::keystore_proxy::SOCKET_ENV), value: Some(sock) },
3889                    gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from("M4A_SESSION_ID"), value: Some(std::ffi::OsString::from(session_key)) },
3890                ])
3891            }
3892            _ => None,
3893        };
3894        #[cfg(not(unix))]
3895        let signer_identity: Option<Vec<gate4agent_catalog::EnvMutation>> = None;
3896        let environment_overlay = match (mode, self.m4a_accounts.as_ref(), crate::m4a_accounts::m4a_provider(provider.as_str()), signer_identity) {
3897            (SessionMode::Pty, _, _, Some(identity)) => {
3898                let merged = match environment_overlay {
3899                    None => NativeInstanceLaunchOverlay::new_session_identity(provider.clone(), identity).map(PreparedNativeLaunchOverlay::Instance),
3900                    Some(PreparedNativeLaunchOverlay::Instance(mut overlay)) => {
3901                        overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
3902                    }
3903                    Some(PreparedNativeLaunchOverlay::Environment(mut overlay)) => {
3904                        overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Environment(overlay))
3905                    }
3906                }
3907                .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a signer handle overlay is invalid"))?;
3908                Some(merged)
3909            }
3910            (SessionMode::Pty, Some(accounts), Some(m4a_provider), None) => {
3911                let session_key = format!("{}-{}", self.incarnation_id, instance_id.0);
3912                match accounts.provision(m4a_provider, &session_key) {
3913                    Ok(account) => {
3914                        let identity = account.env_mutations();
3915                        let merged = match environment_overlay {
3916                            None => NativeInstanceLaunchOverlay::new_session_identity(provider.clone(), identity)
3917                                .map(PreparedNativeLaunchOverlay::Instance),
3918                            Some(PreparedNativeLaunchOverlay::Instance(mut overlay)) => {
3919                                overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
3920                            }
3921                            Some(PreparedNativeLaunchOverlay::Environment(mut overlay)) => {
3922                                overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Environment(overlay))
3923                            }
3924                        }
3925                        .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a session account overlay is invalid"))?;
3926                        tracing::info!(provider = %provider, instance_id = ?instance_id, nick = %account.nick, "m4a session account assigned at spawn");
3927                        self.m4a_live
3928                            .lock()
3929                            .unwrap_or_else(|p| p.into_inner())
3930                            .insert(account.nick.clone(), (instance_id, provider.clone()));
3931                        Some(merged)
3932                    }
3933                    Err(error) => {
3934                        tracing::warn!(provider = %provider, instance_id = ?instance_id, %error, "m4a session account not assigned");
3935                        environment_overlay
3936                    }
3937                }
3938            }
3939            (SessionMode::Pty, Some(_), None, _) => {
3940                tracing::warn!(provider = %provider, instance_id = ?instance_id, "m4a session account not assigned: provider has no m4a mapping");
3941                environment_overlay
3942            }
3943            _ => environment_overlay,
3944        };
3945        let mut instance_overlay = environment_overlay
3946            .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
3947            .transpose()?
3948            .flatten();
3949        Ok(PreparedSpawn {
3950            instance_overlay,
3951            environment_selection,
3952            materialization_guard,
3953            harness_mcp_overlay,
3954            #[cfg(feature = "dig2-station-probe")]
3955            browser_station_lease_guard,
3956            materialization_id,
3957        })
3958    }
3959}
3960
3961struct SessionRecords {
3962    records: BTreeMap<SessionRecordId, ManagedSessionRecord>,
3963}
3964
3965struct SpawnIdempotencyCache {
3966    entries: BTreeMap<SpawnIdempotencyKey, SpawnIdempotencyEntry>,
3967}
3968
3969struct SpawnIdempotencyEntry {
3970    value: SpawnIdempotencyValue,
3971    expires_at: Instant,
3972}
3973
3974enum SpawnIdempotencyValue {
3975    Standard {
3976        spec: SpawnSpec,
3977        result: Result<ResolvedSpawnReceipt, NodeFailure>,
3978    },
3979    Managed {
3980        request: ManagedWorktreeSpawnRequest,
3981        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
3982    },
3983    HarnessMcp,
3984}
3985
3986enum DurableManagedSpawnReplayDecision {
3987    Reserved,
3988    PendingLinked,
3989    Committed(ManagedWorktreeSpawnReceipt),
3990}
3991
3992impl SpawnIdempotencyValue {
3993    fn references_context(&self, context_id: &SpawnContextId) -> bool {
3994        match self {
3995            Self::Standard { result, .. } => result
3996                .as_ref()
3997                .ok()
3998                .and_then(|receipt| receipt.context.as_ref()),
3999            Self::Managed { result, .. } => result
4000                .as_ref()
4001                .ok()
4002                .and_then(|receipt| receipt.spawn.context.as_ref()),
4003            Self::HarnessMcp => None,
4004        }
4005        .is_some_and(|receipt| &receipt.id == context_id)
4006    }
4007}
4008
4009#[derive(Clone, Copy)]
4010struct ControllerLease {
4011    connection_id: u64,
4012    expires_at: Instant,
4013}
4014
4015struct NodeEventHistory {
4016    last_sequence: u64,
4017    replay_floor_sequence: u64,
4018    events: VecDeque<NodeEventEnvelope>,
4019    record_providers: BTreeMap<SessionRecordId, AgentId>,
4020    removed_record_providers: BTreeMap<u64, AgentId>,
4021}
4022
4023impl NodeEventHistory {
4024    fn new(record_providers: BTreeMap<SessionRecordId, AgentId>) -> Self {
4025        Self {
4026            last_sequence: 0,
4027            replay_floor_sequence: 1,
4028            events: VecDeque::with_capacity(NODE_EVENT_HISTORY_MAX),
4029            record_providers,
4030            removed_record_providers: BTreeMap::new(),
4031        }
4032    }
4033}
4034
4035impl NodeShared {
4036    fn new_with_incarnation(
4037        handle: Gate4AgentHandle,
4038        access_token: String,
4039        node_id: NodeId,
4040        incarnation_id: NodeIncarnationId,
4041        workspaces: Vec<WorkspaceConfig>,
4042        enabled_providers: Vec<AgentId>,
4043        provider_runtime_statuses: ProviderRuntimeStatuses,
4044        provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
4045        provider_contracts: Vec<ProviderContractSupport>,
4046        provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
4047        spawn_profiles: SpawnProfileRegistry,
4048        native_launch_profile_control: Option<NativeLaunchProfileControl>,
4049        native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
4050        session_environment_materializer: Option<SessionEnvironmentMaterializer>,
4051        state_path: Option<PathBuf>,
4052        records: Vec<ManagedSessionRecord>,
4053        managed_worktrees: Vec<ManagedWorktreeLeaseRecord>,
4054        managed_worktree_tombstones: Vec<ManagedWorktreeLeaseRecord>,
4055        materializations: Vec<MaterializationOwnershipRecord>,
4056        persistence_warning: Option<String>,
4057        input_settle_timeout_ms: u64,
4058    ) -> Self {
4059        let (event_tx, _) = broadcast::channel(NODE_BROADCAST_CAPACITY);
4060        let (terminal_event_tx, _) = broadcast::channel(NODE_TERMINAL_BROADCAST_CAPACITY);
4061        let (agent_stream_event_tx, _) = broadcast::channel(NODE_AGENT_STREAM_BROADCAST_CAPACITY);
4062        let (harness_mcp_proxy_event_tx, _) =
4063            broadcast::channel(NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY);
4064        let record_providers = records
4065            .iter()
4066            .map(|record| (record.record_id.clone(), record.provider.clone()))
4067            .collect();
4068        let mut worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode> = workspaces
4069            .iter()
4070            .map(|workspace| {
4071                (workspace.workspace_id.clone(), workspace.worktree_service_mode)
4072            })
4073            .collect();
4074        for lease in &managed_worktrees {
4075            worktree_service_modes.insert(lease.workspace_id.clone(), WorktreeServiceMode::Off);
4076        }
4077        let managed_worktree_profiles = workspaces.iter()
4078            .map(|workspace| (
4079                workspace.workspace_id.clone(),
4080                workspace.managed_worktree_profiles.clone(),
4081            ))
4082            .collect();
4083        let mut managed_worktrees = ManagedWorktreeRegistry::from_records(
4084            managed_worktrees,
4085            managed_worktree_tombstones,
4086        ).expect("durable managed worktree registry was validated while loading");
4087        managed_worktrees.clear_stale_session_holders(incarnation_id, unix_time_ms());
4088        managed_worktrees.reattach_record_holders(
4089            &managed_worktree_record_holders(&records),
4090            unix_time_ms(),
4091        );
4092        Self {
4093            handle,
4094            access_token,
4095            node_id,
4096            incarnation_id,
4097            started_at_unix_ms: std::time::SystemTime::now()
4098                .duration_since(std::time::UNIX_EPOCH)
4099                .unwrap_or_default()
4100                .as_millis()
4101                .min(u64::MAX as u128) as u64,
4102            workspaces: RwLock::new(
4103                workspaces
4104                    .into_iter()
4105                    .map(|workspace| (workspace.workspace_id, workspace.canonical_root))
4106                    .collect(),
4107            ),
4108            worktree_service_modes,
4109            managed_worktree_profiles,
4110            managed_worktrees: Mutex::new(managed_worktrees),
4111            enabled_providers,
4112            provider_runtime_statuses,
4113            provider_runtime_status_updates: RwLock::new(BTreeMap::new()),
4114            provider_runtime_monitor,
4115            provider_contracts,
4116            provider_adapter_contracts,
4117            spawn_profiles,
4118            environment_profiles: RwLock::new(BTreeMap::new()),
4119            environment_materialization_profiles: RwLock::new(BTreeMap::new()),
4120            network_allowlist_catalog: RwLock::new(crate::network_allowlist_catalog::NetworkAllowlistCatalog::new()),
4121            #[cfg(feature = "dig2-station-probe")]
4122            browser_station_leases: crate::dig2_station_lease::new_lease_table(),
4123            bundle_catalog: RwLock::new(BundleCatalog::default()),
4124            delivery_store: Mutex::new(None),
4125            context_pack_store: Mutex::new(None),
4126            context_catalog: RwLock::new(ContextPackCatalog::default()),
4127            native_launch_profile_control,
4128            harness_mcp_registry: None,
4129            native_session_catalog,
4130            session_environment_materializer,
4131            m4a_accounts: crate::m4a_accounts::M4aSessionAccounts::from_env(),
4132            m4a_live: Mutex::new(BTreeMap::new()),
4133            #[cfg(unix)]
4134            signer_proxy: std::sync::OnceLock::new(),
4135            materializations: Mutex::new(
4136                materializations
4137                    .into_iter()
4138                    .map(|record| (record.id().clone(), record))
4139                    .collect(),
4140            ),
4141            spawn_idempotency: Mutex::new(SpawnIdempotencyCache {
4142                entries: BTreeMap::new(),
4143            }),
4144            managed_spawn_replays: Mutex::new(BTreeMap::new()),
4145            controller: Mutex::new(None),
4146            provisional_spawns: Mutex::new(BTreeMap::new()),
4147            pending_acp_options: Mutex::new(BTreeMap::new()),
4148            history: Mutex::new(NodeEventHistory::new(record_providers)),
4149            event_tx,
4150            terminal_event_tx,
4151            agent_stream_event_tx,
4152            harness_mcp_proxy_event_tx,
4153            terminal_frame_watermarks: Mutex::new(BTreeMap::new()),
4154            pty_input_wake: tokio::sync::Notify::new(),
4155            pty_input_marks: Mutex::new(BTreeMap::new()),
4156            next_connection_id: AtomicU64::new(1),
4157            next_instance_id: AtomicU64::new(1),
4158            next_command_id: AtomicU64::new(1),
4159            shutdown: AtomicBool::new(false),
4160            shutdown_notify: Notify::new(),
4161            preauth_slots: Arc::new(Semaphore::new(MAX_PREAUTH_CONNECTIONS)),
4162            authenticated_slots: Arc::new(Semaphore::new(MAX_AUTHENTICATED_CONNECTIONS)),
4163            inspection_slots: Arc::new(Semaphore::new(WORKSPACE_INSPECTION_MAX_CONCURRENCY)),
4164            mutation_gate: AsyncMutex::new(()),
4165            session_bindings: Mutex::new(BTreeMap::new()),
4166            session_records: Mutex::new(SessionRecords {
4167                records: records
4168                    .into_iter()
4169                    .map(|record| (record.record_id.clone(), record))
4170                    .collect(),
4171            }),
4172            state_path,
4173            persistence_error: RwLock::new(
4174                persistence_warning
4175                    .map(|message| session_registry::sanitized_persistence_summary(&message)),
4176            ),
4177            state_transaction: Mutex::new(()),
4178            input_settle_timeout_ms,
4179            drive_loop_profile: Mutex::new(DriveLoopProfiler::default()),
4180            runtime_tick_profile: Mutex::new(TickProfileSnapshot::default()),
4181            shell_efficiency_profile: OnceLock::new(),
4182            native_session_gauge: AtomicUsize::new(0),
4183            connection_loop_iterations: AtomicU64::new(0),
4184            connection_events_sent: AtomicU64::new(0),
4185            rejected_commands_total: AtomicU64::new(0),
4186            rejected_command_reasons: Mutex::new(BTreeMap::new()),
4187            drive_loop_iterations_total: AtomicU64::new(0),
4188            drive_loop_iterations_idle: AtomicU64::new(0),
4189            session_record_retention: SessionRecordRetentionConfig::default(),
4190            retired_records_total: AtomicU64::new(0),
4191            #[cfg(feature = "fixture")]
4192            fixture_semantic_hook_policy: false,
4193            #[cfg(feature = "fixture")]
4194            fixture_spawn_managed_worktree_v2_failure_probe:
4195                SpawnManagedWorktreeV2FailureProbe::default(),
4196        }
4197    }
4198
4199    #[cfg(test)]
4200    fn new(
4201        handle: Gate4AgentHandle,
4202        access_token: String,
4203        node_id: NodeId,
4204        workspaces: Vec<WorkspaceConfig>,
4205        enabled_providers: Vec<AgentId>,
4206    ) -> Self {
4207        Self::new_with_incarnation(
4208            handle,
4209            access_token,
4210            node_id,
4211            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
4212            workspaces,
4213            enabled_providers,
4214            ProviderRuntimeStatuses::default(),
4215            None,
4216            Vec::new(),
4217            Vec::new(),
4218            SpawnProfileRegistry::default(),
4219            None,
4220            None,
4221            None,
4222            None,
4223            Vec::new(),
4224            Vec::new(),
4225            Vec::new(),
4226            Vec::new(),
4227            None,
4228            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
4229        )
4230    }
4231
4232    fn persistence_error(&self) -> Option<String> {
4233        self.persistence_error
4234            .read()
4235            .unwrap_or_else(|poisoned| poisoned.into_inner())
4236            .clone()
4237    }
4238
4239    async fn admit_provider_runtime(
4240        &self,
4241        provider: &AgentId,
4242        requirement: ProviderRuntimeRequirement,
4243    ) -> Result<ProviderRuntimePolicy, NodeFailure> {
4244        let admission = if let Some(monitor) = self.provider_runtime_monitor.clone() {
4245            let pipe_transport = monitor.supports_pipe_transport(provider);
4246            let acp_transport = monitor.supports_acp_transport(provider);
4247            let provider = provider.clone();
4248            let refresh = tokio::task::spawn_blocking(move || monitor.evaluate(&provider));
4249            let (status, admission) = timeout(
4250                Duration::from_millis(PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS),
4251                refresh,
4252            )
4253            .await
4254            .map_err(|_| failure(
4255                NodeFailureCode::BackendBusy,
4256                "provider runtime probe exceeded its bounded deadline",
4257            ))?
4258            .map_err(|_| failure(
4259                NodeFailureCode::BackendOperationFailed,
4260                "provider runtime probe task failed",
4261            ))?;
4262            if let Some(status) = status {
4263                self
4264                    .provider_runtime_status_updates
4265                    .write()
4266                    .unwrap_or_else(|poisoned| poisoned.into_inner())
4267                    .insert(status.provider().clone(), status);
4268            }
4269            admission.and_then(|policy| {
4270                require_policy(policy, pipe_transport, acp_transport, requirement).map(|()| policy)
4271            })
4272        } else {
4273            // No live monitor means no catalog to consult -- there is
4274            // nothing here to confirm this provider declares a Pipe or ACP
4275            // transport, so Inline/Acp are rejected the same conservative
4276            // way an undeclared provider always was, rather than admitted
4277            // on trust.
4278            crate::provider_runtime::admit_status(
4279                &self.provider_runtime_statuses,
4280                provider,
4281                false,
4282                false,
4283                requirement,
4284            )
4285        };
4286        let admission = admission.map(|policy| self.admit_declared_hook_semantics(provider, policy));
4287        admission.map_err(|error| match error {
4288            ProviderRuntimeAdmissionError::LauncherUnavailable => failure(
4289                NodeFailureCode::BackendOperationFailed,
4290                "provider launcher is unavailable",
4291            ),
4292            ProviderRuntimeAdmissionError::SemanticCapabilityUnverified => failure(
4293                NodeFailureCode::UnsupportedCapability,
4294                "provider semantic capability is not verified",
4295            ),
4296            ProviderRuntimeAdmissionError::ProbeBusy => failure(
4297                NodeFailureCode::BackendBusy,
4298                "provider runtime probe is already in progress",
4299            ),
4300        })
4301    }
4302
4303    /// Whether the catalog declares a Pipe transport for `provider` --
4304    /// the fact `require_policy` needs to admit or reject
4305    /// `ProviderRuntimeRequirement::Inline`. With no live monitor there is no
4306    /// catalog to consult, so this stays conservative and rejects, matching
4307    /// `admit_provider_runtime`'s own no-monitor branch.
4308    fn provider_supports_pipe_transport(&self, provider: &AgentId) -> bool {
4309        match self.provider_runtime_monitor.as_deref() {
4310            Some(monitor) => monitor.supports_pipe_transport(provider),
4311            None => false,
4312        }
4313    }
4314
4315    /// Whether the catalog declares an ACP transport for `provider` -- the
4316    /// fact `require_policy` needs to admit or reject
4317    /// `ProviderRuntimeRequirement::Acp`. Mirrors
4318    /// `provider_supports_pipe_transport` exactly, including the
4319    /// conservative no-monitor default.
4320    fn provider_supports_acp_transport(&self, provider: &AgentId) -> bool {
4321        match self.provider_runtime_monitor.as_deref() {
4322            Some(monitor) => monitor.supports_acp_transport(provider),
4323            None => false,
4324        }
4325    }
4326
4327    /// Grants hook ingestion to a provider whose catalog entry declares a
4328    /// Hook adapter, whatever runtime mode its launcher probe landed in.
4329    ///
4330    /// `admit_status`'s `RawPassthrough` arm hands back
4331    /// `ProviderRuntimePolicy::raw_pty()`, which grants nothing semantic --
4332    /// correct for everything it was written to answer, and wrong for hooks
4333    /// specifically. A hook event is not inferred from terminal behaviour,
4334    /// so it has nothing to do with whether a launcher probe verified this
4335    /// CLI version's PTY contract; the authority is the catalog declaring
4336    /// the adapter plus the ingress authenticating the route. Without this
4337    /// the node installs a provider's hooks, the CLI faithfully calls them,
4338    /// and every event is refused -- which is exactly the state grok was in.
4339    ///
4340    /// Applied to both admission paths so the monitor's own derivation and
4341    /// this coarse fallback cannot disagree about the same provider.
4342    fn admit_declared_hook_semantics(
4343        &self,
4344        provider: &AgentId,
4345        policy: ProviderRuntimePolicy,
4346    ) -> ProviderRuntimePolicy {
4347        if policy.hook_semantics || !policy.raw_pty_lifecycle {
4348            return policy;
4349        }
4350        let declares_hook = self.provider_adapter_contracts.iter().any(|contract| {
4351            contract.provider == *provider && contract.family == AdapterFamily::Hook
4352        });
4353        if !declares_hook {
4354            return policy;
4355        }
4356        ProviderRuntimePolicy::new(
4357            policy.raw_pty_lifecycle,
4358            policy.semantic_readiness,
4359            policy.structured_prompt,
4360            policy.provider_session_identity,
4361            policy.semantic_resume,
4362            true,
4363        )
4364        .expect("granting hook semantics over a raw-PTY policy is internally valid")
4365    }
4366
4367    /// Station network allowlist → provider launch overlay argv.
4368    ///
4369    /// - **Codex**: first-slice `-c sandbox_workspace_write.network_access` from
4370    ///   `provider_native` under Moderate (PTY or ACP).
4371    /// - **Claude**: catalog `permits` → ProviderHome sidecar settings JSON +
4372    ///   documented `--settings <path>` on **PTY** only (Linux/macOS/WSL2).
4373    ///   ACP refuses (no measured ACP settings-path surface). Native Windows
4374    ///   refuses (no vendor Bash sandbox). Membership-only (empty permits) =
4375    ///   no overlay.
4376    /// - **Kimi / Grok**: no first-party settings/argv network surface here —
4377    ///   empty overlay when no `provider_native`; invented native keys already
4378    ///   refuse at load / resolve.
4379    ///
4380    /// Empty when no allowlist / nothing to map. Never cookies / secrets.
4381    fn resolve_network_allowlist_overlay_args(
4382        &self,
4383        provider: &AgentId,
4384        mode: SessionMode,
4385        approval_level: ApprovalLevel,
4386        network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4387        provider_home: Option<&Path>,
4388    ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4389        let Some(allowlist_id) = network_allowlist else {
4390            return Ok(Vec::new());
4391        };
4392        let catalog = self
4393            .network_allowlist_catalog
4394            .read()
4395            .unwrap_or_else(|poisoned| poisoned.into_inner());
4396        let Some(entry) = catalog.get(allowlist_id) else {
4397            return Err(failure(
4398                NodeFailureCode::UnknownNetworkAllowlist,
4399                "spawn network allowlist is unavailable on this node",
4400            ));
4401        };
4402
4403        // Claude: settings-shaped overlay from permits (not provider_native).
4404        if provider.as_str() == "claude" && !entry.permits.is_empty() {
4405            crate::network_allowlist_catalog::claude_bash_sandbox_network_os_supported().map_err(
4406                |message| {
4407                    failure(
4408                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4409                        &message,
4410                    )
4411                },
4412            )?;
4413            if !matches!(mode, SessionMode::Pty) {
4414                return Err(failure(
4415                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4416                    "Claude sandbox.network --settings overlay requires PTY transport; ACP has no measured settings-path surface (refuse rather than invent RPC)",
4417                ));
4418            }
4419            let Some(home) = provider_home else {
4420                return Err(failure(
4421                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4422                    "Claude sandbox.network --settings overlay requires ProviderHome materialization (CLAUDE_CONFIG_DIR)",
4423                ));
4424            };
4425            let domains =
4426                crate::network_allowlist_catalog::claude_allowed_domains_from_permits(&entry.permits);
4427            if domains.is_empty() {
4428                return Err(failure(
4429                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4430                    "Claude sandbox.network overlay could not map catalog permits to allowedDomains",
4431                ));
4432            }
4433            let settings_path = home.join(
4434                crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
4435            );
4436            let json =
4437                crate::network_allowlist_catalog::claude_station_network_settings_json(&domains);
4438            crate::session_environment::secure_create_file(&settings_path, json.as_bytes()).map_err(
4439                |_| {
4440                    failure(
4441                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4442                        "Claude station network settings sidecar could not be written under ProviderHome",
4443                    )
4444                },
4445            )?;
4446            let overlay =
4447                crate::network_allowlist_catalog::claude_settings_network_overlay_args(&settings_path);
4448            return Ok(overlay.into_iter().map(std::ffi::OsString::from).collect());
4449        }
4450
4451        let Some(native) = entry.provider_native.as_ref() else {
4452            return Ok(Vec::new());
4453        };
4454        let overlay = crate::network_allowlist_catalog::resolve_provider_native_launch_overlay(
4455            provider.as_str(),
4456            approval_level,
4457            native,
4458        )
4459        .map_err(|message| {
4460            failure(
4461                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4462                &message,
4463            )
4464        })?;
4465        if overlay.is_empty() {
4466            return Ok(Vec::new());
4467        }
4468        if !matches!(mode, SessionMode::Pty | SessionMode::Acp) {
4469            return Err(failure(
4470                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4471                "Codex network_access -c overlay requires PTY or ACP transport",
4472            ));
4473        }
4474        Ok(overlay.into_iter().map(std::ffi::OsString::from).collect())
4475    }
4476
4477    /// Backward-compatible alias used by Codex-focused unit tests.
4478    fn resolve_codex_network_access_overlay_args(
4479        &self,
4480        provider: &AgentId,
4481        mode: SessionMode,
4482        approval_level: ApprovalLevel,
4483        network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4484    ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4485        self.resolve_network_allowlist_overlay_args(
4486            provider,
4487            mode,
4488            approval_level,
4489            network_allowlist,
4490            None,
4491        )
4492    }
4493
4494    fn resolve_environment_profile(
4495        &self,
4496        resolved: &ResolvedSpawnSpec,
4497    ) -> Result<Option<ResolvedEnvironmentProfileReceipt>, NodeFailure> {
4498        // Station network allowlist catalog enforce (plan
4499        // station-network-and-browser-profile-knobs §2.1 / §4): empty-default
4500        // catalog — unknown ids refuse rather than silent ambient. Runs even
4501        // when no environment_profile_id is present so a bare allowlist still
4502        // cannot bypass the catalog. Never cookies / OAuth / proxy credentials.
4503        if let Some(allowlist_id) = resolved.network_allowlist.as_ref() {
4504            let catalog = self
4505                .network_allowlist_catalog
4506                .read()
4507                .unwrap_or_else(|poisoned| poisoned.into_inner());
4508            let Some(entry) = catalog.get(allowlist_id) else {
4509                return Err(failure(
4510                    NodeFailureCode::UnknownNetworkAllowlist,
4511                    "spawn network allowlist is unavailable on this node",
4512                ));
4513            };
4514            // Track B: refuse provider-native mappings this provider cannot
4515            // honor (e.g. codex_network_access on Claude). Never silent ambient.
4516            if let Some(native) = entry.provider_native.as_ref() {
4517                if let Err(message) = crate::network_allowlist_catalog::provider_native_mapping_supported(
4518                    resolved.provider.as_str(),
4519                    resolved.approval_level,
4520                    native,
4521                ) {
4522                    return Err(failure(
4523                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4524                        &message,
4525                    ));
4526                }
4527            }
4528        }
4529        // Station knobs Track A (dig2browser-station-probe-and-network-permit-set):
4530        // when feature `dig2-station-probe` is on and browser_profile_id is set,
4531        // cheap named-pipe path reachability must succeed (Windows
4532        // `\\.\pipe\{suffix}`); platform-unsupported or unreachable → clear
4533        // NodeFailureCode. Runs even without environment_profile_id (same
4534        // placement spirit as allowlist catalog). Feature off keeps stub echo.
4535        // Never cookies / OAuth / proxy credentials. Exclusive lease is
4536        // acquired later in spawn_session_with_deadline (after instance id).
4537        #[cfg(feature = "dig2-station-probe")]
4538        if resolved.browser_profile_id.is_some() {
4539            use crate::dig2_station_probe::{probe_station_reachable, StationProbeError};
4540            match probe_station_reachable() {
4541                Ok(()) => {}
4542                Err(StationProbeError::UnsupportedPlatform)
4543                | Err(StationProbeError::InvalidPipeSuffix) => {
4544                    return Err(failure(
4545                        NodeFailureCode::BrowserStationProbeUnavailable,
4546                        "dig2browser station probe unavailable on this platform",
4547                    ));
4548                }
4549                Err(StationProbeError::Unreachable) => {
4550                    return Err(failure(
4551                        NodeFailureCode::BrowserStationUnreachable,
4552                        "dig2browser station named-pipe is unreachable",
4553                    ));
4554                }
4555            }
4556        }
4557        let Some(profile_id) = resolved.environment_profile_id.as_ref() else {
4558            return Ok(None);
4559        };
4560        let profiles = self
4561            .environment_profiles
4562            .read()
4563            .unwrap_or_else(|poisoned| poisoned.into_inner());
4564        let binding = profiles.get(profile_id).ok_or_else(|| {
4565            failure(
4566                NodeFailureCode::UnknownEnvironmentProfile,
4567                "spawn environment profile is unavailable on this node",
4568            )
4569        })?;
4570        if binding.provider != resolved.provider
4571            || binding.native_profile_id(resolved.mode).is_none()
4572        {
4573            return Err(failure(
4574                NodeFailureCode::EnvironmentProfileBindingMismatch,
4575                "spawn environment profile does not match the provider and mode",
4576            ));
4577        }
4578        // Echo opaque network_allowlist / browser_profile_id onto the receipt
4579        // after catalog + optional dig2 probe above. Empty/whitespace ids
4580        // already refuse at Spawn*Id construction.
4581        Ok(Some(ResolvedEnvironmentProfileReceipt {
4582            profile_id: binding.id.clone(),
4583            profile_revision: binding.revision.clone(),
4584            network_allowlist: resolved.network_allowlist.clone(),
4585            browser_profile_id: resolved.browser_profile_id.clone(),
4586        }))
4587    }
4588
4589    fn resolve_bundle(
4590        &self,
4591        resolved: &ResolvedSpawnSpec,
4592        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4593    ) -> Result<Option<ResolvedBundleReceipt>, NodeFailure> {
4594        let Some(bundle_id) = resolved.bundle_id.as_ref() else {
4595            return Ok(None);
4596        };
4597        let catalog = self.bundle_catalog
4598            .read()
4599            .unwrap_or_else(|poisoned| poisoned.into_inner());
4600        let bundle = catalog.get(bundle_id).ok_or_else(|| {
4601                failure(
4602                    NodeFailureCode::UnknownBundle,
4603                    "spawn bundle is unavailable on this node",
4604                )
4605            })?;
4606        self.bundle_layout(
4607            &resolved.provider,
4608            resolved.mode,
4609            environment_profile,
4610            bundle,
4611        )?;
4612        Ok(Some(bundle.receipt()))
4613    }
4614
4615    fn begin_delivery_stage(
4616        &self,
4617        manifest: DeliveryBundleManifestV2,
4618    ) -> Result<NodeResponse, NodeFailure> {
4619        let mut store = self
4620            .delivery_store
4621            .lock()
4622            .unwrap_or_else(|poisoned| poisoned.into_inner());
4623        let store = store.as_mut().ok_or_else(|| {
4624            failure(
4625                NodeFailureCode::DeliveryStageStorageFailed,
4626                "node delivery store is unavailable",
4627            )
4628        })?;
4629        let begun = store.begin(manifest).map_err(delivery_failure)?;
4630        Ok(NodeResponse::DeliveryStageBegun {
4631            stage_id: begun.stage_id,
4632            manifest_digest: begun.manifest_digest,
4633            missing_blobs: begun.missing_blobs,
4634        })
4635    }
4636
4637    fn put_delivery_blob_chunk(
4638        &self,
4639        stage_id: DeliveryStageId,
4640        blob_digest: DeliveryBlobDigestV1,
4641        offset: u64,
4642        chunk_hex: DeliveryBlobChunkHexV1,
4643    ) -> Result<NodeResponse, NodeFailure> {
4644        let mut store = self
4645            .delivery_store
4646            .lock()
4647            .unwrap_or_else(|poisoned| poisoned.into_inner());
4648        let store = store.as_mut().ok_or_else(|| {
4649            failure(
4650                NodeFailureCode::DeliveryStageStorageFailed,
4651                "node delivery store is unavailable",
4652            )
4653        })?;
4654        let accepted = store
4655            .put_chunk(&stage_id, &blob_digest, offset, &chunk_hex.decode())
4656            .map_err(delivery_failure)?;
4657        Ok(NodeResponse::DeliveryBlobChunkAccepted {
4658            stage_id: accepted.stage_id,
4659            blob_digest: accepted.blob_digest,
4660            next_offset: accepted.next_offset,
4661        })
4662    }
4663
4664    fn commit_delivery_stage(
4665        &self,
4666        stage_id: DeliveryStageId,
4667    ) -> Result<DeliveryCommitReceiptV1, NodeFailure> {
4668        let mut store = self
4669            .delivery_store
4670            .lock()
4671            .unwrap_or_else(|poisoned| poisoned.into_inner());
4672        let store = store.as_mut().ok_or_else(|| {
4673            failure(
4674                NodeFailureCode::DeliveryStageStorageFailed,
4675                "node delivery store is unavailable",
4676            )
4677        })?;
4678        let prepared = store.prepare_commit(&stage_id).map_err(delivery_failure)?;
4679        let mut catalog = self
4680            .bundle_catalog
4681            .write()
4682            .unwrap_or_else(|poisoned| poisoned.into_inner());
4683        let mut next_catalog = catalog.clone();
4684        next_catalog
4685            .insert_idempotent(prepared.bundle().clone())
4686            .map_err(|_| {
4687                failure(
4688                    NodeFailureCode::DeliveryStageConflict,
4689                    "delivery bundle identity conflicts with installed content",
4690                )
4691            })?;
4692        let receipt = store.publish_commit(prepared).map_err(delivery_failure)?;
4693        *catalog = next_catalog;
4694        Ok(receipt)
4695    }
4696
4697    fn abort_delivery_stage(
4698        &self,
4699        stage_id: &DeliveryStageId,
4700    ) -> Result<(), NodeFailure> {
4701        let mut store = self
4702            .delivery_store
4703            .lock()
4704            .unwrap_or_else(|poisoned| poisoned.into_inner());
4705        let store = store.as_mut().ok_or_else(|| {
4706            failure(
4707                NodeFailureCode::DeliveryStageStorageFailed,
4708                "node delivery store is unavailable",
4709            )
4710        })?;
4711        store.abort(stage_id).map_err(delivery_failure)
4712    }
4713
4714    fn resolve_context(
4715        &self,
4716        resolved: &ResolvedSpawnSpec,
4717    ) -> Result<Option<ResolvedContextPackReceipt>, NodeFailure> {
4718        let Some(context_id) = resolved.context_id.as_ref() else {
4719            return Ok(None);
4720        };
4721        self.context_catalog
4722            .read()
4723            .unwrap_or_else(|poisoned| poisoned.into_inner())
4724            .get(context_id)
4725            .map(|context| context.receipt().clone())
4726            .ok_or_else(|| {
4727                failure(
4728                    NodeFailureCode::UnknownContextPack,
4729                    "spawn context pack is unavailable on this node",
4730                )
4731            })
4732            .map(Some)
4733    }
4734
4735    fn bundle_layout(
4736        &self,
4737        provider: &AgentId,
4738        mode: SessionMode,
4739        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4740        bundle: &NodeBundle,
4741    ) -> Result<BundleProviderLayout, NodeFailure> {
4742        let layout = validate_bundle_binding(provider, mode, bundle).map_err(|_| {
4743            failure(
4744                NodeFailureCode::BundleBindingMismatch,
4745                "spawn bundle does not match the provider and mode",
4746            )
4747        })?;
4748        if layout != BundleProviderLayout::Codex {
4749            return Ok(layout);
4750        }
4751        let profile = environment_profile.and_then(|receipt| {
4752            let profiles = self
4753                .environment_profiles
4754                .read()
4755                .unwrap_or_else(|poisoned| poisoned.into_inner());
4756            let binding = profiles.get(&receipt.profile_id)?;
4757            if binding.revision != receipt.profile_revision
4758                || &binding.provider != provider
4759                || binding.native_profile_id(mode).is_none()
4760            {
4761                return None;
4762            }
4763            self.environment_materialization_profiles
4764                .read()
4765                .unwrap_or_else(|poisoned| poisoned.into_inner())
4766                .get(&receipt.profile_id)
4767                .cloned()
4768        });
4769        if !profile.is_some_and(|profile| profile.supports_bundle_layout(layout)) {
4770            return Err(failure(
4771                NodeFailureCode::BundleBindingMismatch,
4772                "Codex bundle requires an exact isolated CODEX_HOME profile",
4773            ));
4774        }
4775        Ok(layout)
4776    }
4777
4778    fn select_environment_profile(
4779        &self,
4780        instance_id: AgentInstanceId,
4781        provider: &AgentId,
4782        mode: SessionMode,
4783        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4784    ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
4785        let Some(environment_profile) = environment_profile else {
4786            return Ok(None);
4787        };
4788        let native_profile_id = {
4789            let profiles = self
4790                .environment_profiles
4791                .read()
4792                .unwrap_or_else(|poisoned| poisoned.into_inner());
4793            let binding = profiles
4794                .get(&environment_profile.profile_id)
4795                .ok_or_else(|| {
4796                    failure(
4797                        NodeFailureCode::UnknownEnvironmentProfile,
4798                        "session environment profile is unavailable on this node",
4799                    )
4800                })?;
4801            if &binding.revision != &environment_profile.profile_revision
4802                || &binding.provider != provider
4803            {
4804                return Err(failure(
4805                    NodeFailureCode::EnvironmentProfileBindingMismatch,
4806                    "session environment profile revision or provider changed",
4807                ));
4808            }
4809            binding.native_profile_id(mode).cloned().ok_or_else(|| {
4810                failure(
4811                    NodeFailureCode::EnvironmentProfileBindingMismatch,
4812                    "session environment profile does not support this mode",
4813                )
4814            })?
4815        };
4816        let control = self.native_launch_profile_control.clone().ok_or_else(|| {
4817            failure(
4818                NodeFailureCode::BackendOperationFailed,
4819                "native environment profile controller is unavailable",
4820            )
4821        })?;
4822        control
4823            .select_native_launch_profile(instance_id, native_profile_id)
4824            .map_err(|_| {
4825                failure(
4826                    NodeFailureCode::BackendOperationFailed,
4827                    "native environment profile selection failed",
4828                )
4829            })?;
4830        Ok(Some(NativeEnvironmentSelectionGuard {
4831            control: Some(control),
4832            instance_id,
4833        }))
4834    }
4835
4836    fn install_prepared_launch_overlay(
4837        &self,
4838        instance_id: AgentInstanceId,
4839        overlay: PreparedNativeLaunchOverlay,
4840    ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
4841        let control = self.native_launch_profile_control.clone().ok_or_else(|| {
4842            failure(
4843                NodeFailureCode::BackendOperationFailed,
4844                "native launch profile controller is unavailable",
4845            )
4846        })?;
4847        match overlay {
4848            PreparedNativeLaunchOverlay::Environment(overlay) => {
4849                control
4850                    .install_native_launch_environment_overlay(instance_id, overlay)
4851                    .map_err(|_| failure(
4852                        NodeFailureCode::BackendOperationFailed,
4853                        "session environment overlay installation failed",
4854                    ))?;
4855                Ok(None)
4856            }
4857            PreparedNativeLaunchOverlay::Instance(overlay) => {
4858                control
4859                    .install_native_instance_launch_overlay(instance_id, overlay)
4860                    .map_err(|_| failure(
4861                        NodeFailureCode::BundleBindingMismatch,
4862                        "session bundle launch overlay installation failed",
4863                    ))?;
4864                Ok(Some(NativeInstanceOverlayGuard {
4865                    control: Some(control),
4866                    instance_id,
4867                }))
4868            }
4869        }
4870    }
4871
4872    fn environment_materialization_profile(
4873        &self,
4874        environment_profile: &ResolvedEnvironmentProfileReceipt,
4875    ) -> Option<NodeSessionMaterializationProfile> {
4876        self.environment_materialization_profiles
4877            .read()
4878            .unwrap_or_else(|poisoned| poisoned.into_inner())
4879            .get(&environment_profile.profile_id)
4880            .cloned()
4881    }
4882
4883    fn materialization_profile(
4884        &self,
4885        provider: &AgentId,
4886        mode: SessionMode,
4887        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4888        bundle: Option<&ResolvedBundleReceipt>,
4889        context: Option<&ResolvedContextPackReceipt>,
4890    ) -> Result<Option<(
4891        NodeSessionMaterializationProfile,
4892        bool,
4893        Option<BundleProviderLayout>,
4894    )>, NodeFailure> {
4895        let environment = environment_profile
4896            .and_then(|receipt| self.environment_materialization_profile(receipt));
4897        let (bundle, bundle_layout) = match bundle {
4898            Some(receipt) => {
4899                let catalog = self.bundle_catalog.read()
4900                    .unwrap_or_else(|poisoned| poisoned.into_inner());
4901                let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
4902                    NodeFailureCode::UnknownBundle,
4903                    "session bundle is unavailable on this node",
4904                ))?;
4905                if installed.receipt() != *receipt {
4906                    return Err(failure(
4907                        NodeFailureCode::BundleBindingMismatch,
4908                        "session bundle revision or digest changed",
4909                    ));
4910                }
4911                let layout = self.bundle_layout(
4912                    provider,
4913                    mode,
4914                    environment_profile,
4915                    installed,
4916                )?;
4917                (Some(installed.clone()), Some(layout))
4918            }
4919            None => (None, None),
4920        };
4921        let has_environment_overlay = environment.is_some() || context.is_some();
4922        let profile = match (environment, bundle.as_ref(), bundle_layout) {
4923            (None, None, None) => None,
4924            (Some(profile), None, None) => Some(Ok(profile)),
4925            (None, Some(bundle), Some(layout)) => {
4926                Some(NodeSessionMaterializationProfile::from_bundle(bundle, layout))
4927            }
4928            (Some(profile), Some(bundle), Some(layout)) => {
4929                Some(profile.with_bundle(bundle, layout))
4930            }
4931            _ => {
4932                return Err(failure(
4933                    NodeFailureCode::BundleBindingMismatch,
4934                    "session bundle provider layout is unavailable",
4935                ));
4936            }
4937        };
4938        let profile = profile
4939            .transpose()
4940            .map_err(|_| failure(
4941                NodeFailureCode::BundleMaterializationFailed,
4942                "session bundle materialization profile is invalid",
4943            ))?;
4944        let profile = match (profile, context) {
4945            (Some(profile), Some(receipt)) => {
4946                let catalog = self.context_catalog
4947                    .read()
4948                    .unwrap_or_else(|poisoned| poisoned.into_inner());
4949                let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
4950                    NodeFailureCode::UnknownContextPack,
4951                    "session context pack is unavailable on this node",
4952                ))?;
4953                if pack.receipt() != receipt {
4954                    return Err(failure(
4955                        NodeFailureCode::ContextPackMaterializationFailed,
4956                        "session context pack identity changed",
4957                    ));
4958                }
4959                profile.with_context(pack)
4960            }
4961            (None, Some(receipt)) => {
4962                let catalog = self.context_catalog
4963                    .read()
4964                    .unwrap_or_else(|poisoned| poisoned.into_inner());
4965                let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
4966                    NodeFailureCode::UnknownContextPack,
4967                    "session context pack is unavailable on this node",
4968                ))?;
4969                if pack.receipt() != receipt {
4970                    return Err(failure(
4971                        NodeFailureCode::ContextPackMaterializationFailed,
4972                        "session context pack identity changed",
4973                    ));
4974                }
4975                NodeSessionMaterializationProfile::from_context(pack)
4976            }
4977            (Some(profile), None) => Ok(profile),
4978            (None, None) => return Ok(None),
4979        }
4980        .map_err(|_| failure(
4981            NodeFailureCode::ContextPackMaterializationFailed,
4982            "session context pack materialization profile is invalid",
4983        ))?;
4984        Ok(Some((profile, has_environment_overlay, bundle_layout)))
4985    }
4986
4987    fn prepare_session_materialization<'a>(
4988        &'a self,
4989        address: &SessionAddress,
4990        provider: &AgentId,
4991        mode: SessionMode,
4992        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4993        bundle: Option<&ResolvedBundleReceipt>,
4994        context: Option<&ResolvedContextPackReceipt>,
4995        managed_lease_id: Option<ManagedWorktreeLeaseId>,
4996    ) -> Result<Option<(Option<PreparedNativeLaunchOverlay>, SessionMaterializationGuard<'a>)>, NodeFailure> {
4997        let Some((profile, has_environment_overlay, bundle_layout)) =
4998            self.materialization_profile(provider, mode, environment_profile, bundle, context)?
4999        else {
5000            return Ok(None);
5001        };
5002        let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5003            failure(
5004                NodeFailureCode::BackendOperationFailed,
5005                "session environment materializer is unavailable",
5006            )
5007        })?;
5008        let materialization_failure_code = if context.is_some() {
5009            NodeFailureCode::ContextPackMaterializationFailed
5010        } else if bundle.is_some() {
5011            NodeFailureCode::BundleMaterializationFailed
5012        } else {
5013            NodeFailureCode::BackendOperationFailed
5014        };
5015        let id = MaterializationId::new(format!(
5016            "mat-{}-{}-{}",
5017            self.incarnation_id,
5018            address.session.instance_id.0,
5019            address.session.generation.0,
5020        ))
5021        .map_err(|_| {
5022            failure(
5023                NodeFailureCode::BackendOperationFailed,
5024                "session environment materialization identity failed",
5025            )
5026        })?;
5027        let owner = MaterializationOwner::Session {
5028            incarnation_id: self.incarnation_id,
5029            instance_id: address.session.instance_id,
5030            generation: address.session.generation,
5031        };
5032        let mut ownership = materializer
5033            .begin(
5034                id.clone(),
5035                environment_profile.cloned(),
5036                bundle.cloned(),
5037                context.cloned(),
5038                owner,
5039                managed_lease_id,
5040                &profile,
5041                unix_time_ms(),
5042            )
5043            .map_err(|_| {
5044                failure(
5045                    materialization_failure_code,
5046                    "session materialization preparation failed",
5047                )
5048            })?;
5049        {
5050            let _transaction = self
5051                .state_transaction
5052                .lock()
5053                .unwrap_or_else(|poisoned| poisoned.into_inner());
5054            let duplicate = self
5055                .materializations
5056                .lock()
5057                .unwrap_or_else(|poisoned| poisoned.into_inner())
5058                .insert(id.clone(), ownership.clone());
5059            if duplicate.is_some() {
5060                return Err(failure(
5061                    NodeFailureCode::BackendOperationFailed,
5062                    "session environment materialization identity conflict",
5063                ));
5064            }
5065            if let Err(error) = self.persist_state_locked() {
5066                self.materializations
5067                    .lock()
5068                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5069                    .remove(&id);
5070                return Err(persistence_failure(error));
5071            }
5072        }
5073        let environment = match materializer.materialize(&mut ownership, &profile, unix_time_ms()) {
5074            Ok(environment) => environment,
5075            Err(_) => {
5076                let transaction = self
5077                    .state_transaction
5078                    .lock()
5079                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5080                self.materializations
5081                    .lock()
5082                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5083                    .insert(id.clone(), ownership.clone());
5084                if self.persist_state_locked().is_err() {
5085                    self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5086                }
5087                drop(transaction);
5088                if ownership.state() == MaterializationState::CleanupRequired {
5089                    let _ = self.cleanup_materialization(&id);
5090                }
5091                return Err(failure(
5092                    materialization_failure_code,
5093                    "session materialization failed",
5094                ));
5095            }
5096        };
5097        {
5098            let _transaction = self
5099                .state_transaction
5100                .lock()
5101                .unwrap_or_else(|poisoned| poisoned.into_inner());
5102            self.materializations
5103                .lock()
5104                .unwrap_or_else(|poisoned| poisoned.into_inner())
5105                .insert(id.clone(), ownership.clone());
5106            if let Err(error) = self.persist_state_locked() {
5107                drop(_transaction);
5108                let _ = self.cleanup_materialization(&id);
5109                return Err(persistence_failure(error));
5110            }
5111        }
5112        let guard = SessionMaterializationGuard {
5113            shared: self,
5114            id: Some(id),
5115            provider_home: Some(ownership.provider_home().to_path_buf()),
5116        };
5117        let bundle_arguments = match bundle {
5118            Some(receipt) => {
5119                let catalog = self.bundle_catalog.read()
5120                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5121                let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
5122                    NodeFailureCode::UnknownBundle,
5123                    "session bundle is unavailable on this node",
5124                ))?;
5125                if installed.receipt() != *receipt {
5126                    return Err(failure(
5127                        NodeFailureCode::BundleBindingMismatch,
5128                        "session bundle revision or digest changed",
5129                    ));
5130                }
5131                let layout = bundle_layout.ok_or_else(|| failure(
5132                    NodeFailureCode::BundleBindingMismatch,
5133                    "materialized bundle provider layout is unavailable",
5134                ))?;
5135                materializer.revalidate_bundle(&ownership, installed, layout).map_err(|_| {
5136                    failure(
5137                        NodeFailureCode::BundleMaterializationFailed,
5138                        "session bundle byte revalidation failed",
5139                    )
5140                })?;
5141                Some(bundle_launch_arguments(
5142                    layout,
5143                    ownership.bundle_root(),
5144                    ownership.provider_home(),
5145                ).map_err(|_| failure(
5146                    NodeFailureCode::BundleBindingMismatch,
5147                    "session bundle launch binding is invalid",
5148                ))?)
5149            }
5150            None => None,
5151        };
5152        if let Some(receipt) = context {
5153            materializer
5154                .revalidate_context(&ownership, receipt)
5155                .map_err(|_| failure(
5156                    NodeFailureCode::ContextPackMaterializationFailed,
5157                    "session context pack byte revalidation failed",
5158                ))?;
5159        }
5160        let transport = match mode {
5161            SessionMode::Pty => TransportKind::Pty,
5162            SessionMode::Inline => TransportKind::Pipe,
5163            SessionMode::Acp => TransportKind::Acp,
5164        };
5165        let overlay = if let Some(extra_args) = bundle_arguments {
5166            Some(PreparedNativeLaunchOverlay::Instance(
5167                NativeInstanceLaunchOverlay::new(
5168                    provider.clone(),
5169                    transport,
5170                    if has_environment_overlay {
5171                        environment
5172                    } else {
5173                        Vec::new()
5174                    },
5175                    extra_args,
5176                )
5177                .map_err(|_| {
5178                    failure(
5179                        NodeFailureCode::BundleBindingMismatch,
5180                        "session bundle launch overlay is invalid",
5181                    )
5182                })?,
5183            ))
5184        } else if has_environment_overlay {
5185            let context_only = environment_profile.is_none()
5186                && bundle.is_none()
5187                && context.is_some();
5188            let overlay = if context_only {
5189                NativeLaunchEnvironmentOverlay::new_context_root(
5190                    provider.clone(),
5191                    transport,
5192                    environment,
5193                )
5194                .map_err(|_| {
5195                    failure(
5196                        NodeFailureCode::ContextPackMaterializationFailed,
5197                        "context-only launch overlay is invalid",
5198                    )
5199                })?
5200            } else {
5201                NativeLaunchEnvironmentOverlay::new(
5202                    provider.clone(),
5203                    transport,
5204                    environment,
5205                )
5206                .map_err(|_| {
5207                    failure(
5208                        NodeFailureCode::BackendOperationFailed,
5209                        "session environment overlay is invalid",
5210                    )
5211                })?
5212            };
5213            Some(PreparedNativeLaunchOverlay::Environment(overlay))
5214        } else {
5215            None
5216        };
5217        Ok(Some((overlay, guard)))
5218    }
5219
5220    fn cleanup_materialization(&self, id: &MaterializationId) -> Result<(), NodeFailure> {
5221        let Some(mut ownership) = self
5222            .materializations
5223            .lock()
5224            .unwrap_or_else(|poisoned| poisoned.into_inner())
5225            .get(id)
5226            .cloned()
5227        else {
5228            return Ok(());
5229        };
5230        if ownership.state() != MaterializationState::CleanupRequired {
5231            ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5232                failure(
5233                    NodeFailureCode::BackendOperationFailed,
5234                    "session environment cleanup state is invalid",
5235                )
5236            })?;
5237        }
5238        {
5239            let _transaction = self
5240                .state_transaction
5241                .lock()
5242                .unwrap_or_else(|poisoned| poisoned.into_inner());
5243            let previous = self
5244                .materializations
5245                .lock()
5246                .unwrap_or_else(|poisoned| poisoned.into_inner())
5247                .insert(id.clone(), ownership.clone());
5248            if let Err(error) = self.persist_state_locked() {
5249                let mut materializations = self
5250                    .materializations
5251                    .lock()
5252                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5253                if let Some(previous) = previous {
5254                    materializations.insert(id.clone(), previous);
5255                }
5256                return Err(persistence_failure(error));
5257            }
5258        }
5259        let cleanup = self
5260            .session_environment_materializer
5261            .as_ref()
5262            .ok_or(())
5263            .and_then(|materializer| materializer.cleanup(&ownership).map_err(|_| ()));
5264        if cleanup.is_err() {
5265            let _ = ownership.mark_recovery_required(unix_time_ms());
5266            self.materializations
5267                .lock()
5268                .unwrap_or_else(|poisoned| poisoned.into_inner())
5269                .insert(id.clone(), ownership);
5270            if self.persist_state().is_err() {
5271                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5272            }
5273            return Err(failure(
5274                NodeFailureCode::BackendOperationFailed,
5275                "session environment cleanup requires recovery",
5276            ));
5277        }
5278        let _transaction = self
5279            .state_transaction
5280            .lock()
5281            .unwrap_or_else(|poisoned| poisoned.into_inner());
5282        self.materializations
5283            .lock()
5284            .unwrap_or_else(|poisoned| poisoned.into_inner())
5285            .remove(id);
5286        if let Err(error) = self.persist_state_locked() {
5287            self.materializations
5288                .lock()
5289                .unwrap_or_else(|poisoned| poisoned.into_inner())
5290                .insert(id.clone(), ownership);
5291            return Err(persistence_failure(error));
5292        }
5293        Ok(())
5294    }
5295
5296    fn resolve_record_materialization(
5297        &self,
5298        record_id: &SessionRecordId,
5299        provider: &AgentId,
5300        mode: SessionMode,
5301        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5302        bundle: Option<&ResolvedBundleReceipt>,
5303        context: Option<&ResolvedContextPackReceipt>,
5304    ) -> Result<Option<(MaterializationId, Option<PreparedNativeLaunchOverlay>)>, NodeFailure> {
5305        let ownership = {
5306            let materializations = self
5307                .materializations
5308                .lock()
5309                .unwrap_or_else(|poisoned| poisoned.into_inner());
5310            let mut matches = materializations.values().filter(|record| {
5311                record.owner()
5312                    == &MaterializationOwner::Record {
5313                        record_id: record_id.clone(),
5314                    }
5315            });
5316            let ownership = matches.next().cloned();
5317            if matches.next().is_some() {
5318                return Err(failure(
5319                    NodeFailureCode::BackendOperationFailed,
5320                    "managed session has conflicting environment ownership",
5321                ));
5322            }
5323            ownership
5324        };
5325        if let (Some(ownership), Some(receipt)) = (ownership.as_ref(), context) {
5326            let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5327                failure(
5328                    NodeFailureCode::ContextPackMaterializationFailed,
5329                    "session context materializer is unavailable",
5330                )
5331            })?;
5332            let pack = materializer
5333                .revalidate_context(ownership, receipt)
5334                .map_err(|_| {
5335                    self.mark_materialization_recovery_required(ownership.id());
5336                    failure(
5337                        NodeFailureCode::ContextPackMaterializationFailed,
5338                        "managed session context pack revalidation failed",
5339                    )
5340                })?;
5341            self.context_catalog
5342                .write()
5343                .unwrap_or_else(|poisoned| poisoned.into_inner())
5344                .insert(pack)
5345                .map_err(|_| failure(
5346                    NodeFailureCode::ContextPackMaterializationFailed,
5347                    "managed session context pack catalog restore failed",
5348                ))?;
5349        }
5350        let materialization = self.materialization_profile(
5351            provider,
5352            mode,
5353            environment_profile,
5354            bundle,
5355            context,
5356        )?;
5357        match (ownership, materialization) {
5358            (None, None) => Ok(None),
5359            (None, Some(_)) | (Some(_), None) => Err(failure(
5360                NodeFailureCode::BackendOperationFailed,
5361                "managed session materialization is unavailable",
5362            )),
5363            (Some(ownership), Some((profile, has_environment_materialization, bundle_layout))) => {
5364                if ownership.environment_profile() != environment_profile
5365                    || ownership.bundle() != bundle
5366                    || ownership.context() != context
5367                    || ownership.state() != MaterializationState::Ready
5368                {
5369                    self.mark_materialization_recovery_required(ownership.id());
5370                    return Err(failure(
5371                        NodeFailureCode::BackendOperationFailed,
5372                        "managed session environment ownership requires recovery",
5373                    ));
5374                }
5375                let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5376                    failure(
5377                        NodeFailureCode::BackendOperationFailed,
5378                        "session environment materializer is unavailable",
5379                    )
5380                })?;
5381                if materializer.revalidate(&ownership).is_err() {
5382                    self.mark_materialization_recovery_required(ownership.id());
5383                    return Err(failure(
5384                        NodeFailureCode::BackendOperationFailed,
5385                        "managed session environment revalidation failed",
5386                    ));
5387                }
5388                let bundle_arguments = if let Some(bundle_receipt) = bundle {
5389                    let catalog = self.bundle_catalog.read()
5390                        .unwrap_or_else(|poisoned| poisoned.into_inner());
5391                    let Some(installed) = catalog.get(&bundle_receipt.id) else {
5392                        return Err(failure(
5393                            NodeFailureCode::UnknownBundle,
5394                            "managed session bundle is unavailable",
5395                        ));
5396                    };
5397                    let layout = bundle_layout.ok_or_else(|| failure(
5398                        NodeFailureCode::BundleBindingMismatch,
5399                        "record bundle provider layout is unavailable",
5400                    ))?;
5401                    if materializer.revalidate_bundle(&ownership, installed, layout).is_err() {
5402                        drop(catalog);
5403                        self.mark_materialization_recovery_required(ownership.id());
5404                        return Err(failure(
5405                            NodeFailureCode::BundleMaterializationFailed,
5406                            "managed session bundle revalidation failed",
5407                        ));
5408                    }
5409                    Some(bundle_launch_arguments(
5410                        layout,
5411                        ownership.bundle_root(),
5412                        ownership.provider_home(),
5413                    ).map_err(|_| failure(
5414                        NodeFailureCode::BundleBindingMismatch,
5415                        "managed session bundle launch binding is invalid",
5416                    ))?)
5417                } else {
5418                    None
5419                };
5420                if !has_environment_materialization {
5421                    let overlay = bundle_arguments.map(|extra_args| {
5422                        NativeInstanceLaunchOverlay::new(
5423                            provider.clone(),
5424                            TransportKind::Pty,
5425                            Vec::new(),
5426                            extra_args,
5427                        )
5428                        .map(PreparedNativeLaunchOverlay::Instance)
5429                        .map_err(|_| failure(
5430                            NodeFailureCode::BundleBindingMismatch,
5431                            "managed session bundle launch overlay is invalid",
5432                        ))
5433                    }).transpose()?;
5434                    return Ok(Some((ownership.id().clone(), overlay)));
5435                }
5436                let environment = match materializer.resolve_environment(&ownership, &profile) {
5437                    Ok(environment) => environment,
5438                    Err(
5439                        SessionEnvironmentMaterializeError::SecretUnavailable
5440                        | SessionEnvironmentMaterializeError::SecretDenied
5441                        | SessionEnvironmentMaterializeError::InvalidSecretValue,
5442                    ) => {
5443                        return Err(failure(
5444                            NodeFailureCode::BackendOperationFailed,
5445                            "managed session environment resolution failed",
5446                        ));
5447                    }
5448                    Err(
5449                        SessionEnvironmentMaterializeError::InvalidRoot
5450                        | SessionEnvironmentMaterializeError::OwnershipMismatch
5451                        | SessionEnvironmentMaterializeError::Filesystem(_)
5452                        | SessionEnvironmentMaterializeError::Record(_),
5453                    ) => {
5454                        self.mark_materialization_recovery_required(ownership.id());
5455                        return Err(failure(
5456                            NodeFailureCode::BackendOperationFailed,
5457                            "managed session environment resolution failed",
5458                        ));
5459                    }
5460                };
5461                let transport = match mode {
5462                    SessionMode::Pty => TransportKind::Pty,
5463                    SessionMode::Inline => TransportKind::Pipe,
5464                    SessionMode::Acp => TransportKind::Acp,
5465                };
5466                let overlay = match bundle_arguments {
5467                    Some(extra_args) => PreparedNativeLaunchOverlay::Instance(
5468                        NativeInstanceLaunchOverlay::new(
5469                            provider.clone(),
5470                            transport,
5471                            environment,
5472                            extra_args,
5473                        )
5474                        .map_err(|_| failure(
5475                            NodeFailureCode::BundleBindingMismatch,
5476                            "managed session bundle launch overlay is invalid",
5477                        ))?,
5478                    ),
5479                    None => PreparedNativeLaunchOverlay::Environment(
5480                        NativeLaunchEnvironmentOverlay::new(
5481                            provider.clone(),
5482                            transport,
5483                            environment,
5484                        )
5485                        .map_err(|_| {
5486                            failure(
5487                                NodeFailureCode::BackendOperationFailed,
5488                                "managed session environment overlay is invalid",
5489                            )
5490                        })?,
5491                    ),
5492                };
5493                Ok(Some((ownership.id().clone(), Some(overlay))))
5494            }
5495        }
5496    }
5497
5498    fn destroy_record_materialization(
5499        &self,
5500        record_id: &SessionRecordId,
5501    ) -> Result<bool, NodeFailure> {
5502        let ids = self
5503            .materializations
5504            .lock()
5505            .unwrap_or_else(|poisoned| poisoned.into_inner())
5506            .values()
5507            .filter_map(|record| {
5508                (record.owner()
5509                    == &MaterializationOwner::Record {
5510                        record_id: record_id.clone(),
5511                    })
5512                .then(|| record.id().clone())
5513            })
5514            .collect::<Vec<_>>();
5515        if ids.len() > 1 {
5516            return Err(failure(
5517                NodeFailureCode::BackendOperationFailed,
5518                "managed session has conflicting environment ownership",
5519            ));
5520        }
5521        let Some(id) = ids.first().cloned() else {
5522            return Ok(false);
5523        };
5524        let (terminal_record, mut cleanup_ownership) = {
5525            let _transaction = self
5526                .state_transaction
5527                .lock()
5528                .unwrap_or_else(|poisoned| poisoned.into_inner());
5529            let original_record = self.record(record_id)?;
5530            let mut terminal_record = original_record.clone();
5531            terminal_record.provider_session = None;
5532            terminal_record.active_session = None;
5533            terminal_record.state = ManagedSessionState::Unavailable;
5534            terminal_record.last_error = Some("environment-profile-unavailable".to_owned());
5535            terminal_record.updated_at_unix_ms = unix_time_ms();
5536            let original_ownership = self
5537                .materializations
5538                .lock()
5539                .unwrap_or_else(|poisoned| poisoned.into_inner())
5540                .get(&id)
5541                .cloned()
5542                .ok_or_else(|| {
5543                    failure(
5544                        NodeFailureCode::BackendOperationFailed,
5545                        "managed session environment ownership disappeared during cleanup",
5546                    )
5547                })?;
5548            let mut cleanup_ownership = original_ownership.clone();
5549            if cleanup_ownership.state() != MaterializationState::CleanupRequired {
5550                cleanup_ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5551                    failure(
5552                        NodeFailureCode::BackendOperationFailed,
5553                        "managed session environment cleanup state is invalid",
5554                    )
5555                })?;
5556            }
5557            self.session_records
5558                .lock()
5559                .unwrap_or_else(|poisoned| poisoned.into_inner())
5560                .records
5561                .insert(record_id.clone(), terminal_record.clone());
5562            self.materializations
5563                .lock()
5564                .unwrap_or_else(|poisoned| poisoned.into_inner())
5565                .insert(id.clone(), cleanup_ownership.clone());
5566            if let Err(error) = self.persist_state_locked() {
5567                self.session_records
5568                    .lock()
5569                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5570                    .records
5571                    .insert(record_id.clone(), original_record);
5572                self.materializations
5573                    .lock()
5574                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5575                    .insert(id.clone(), original_ownership);
5576                return Err(persistence_failure(error));
5577            }
5578            (terminal_record, cleanup_ownership)
5579        };
5580        let cleanup = self
5581            .session_environment_materializer
5582            .as_ref()
5583            .ok_or(())
5584            .and_then(|materializer| materializer.cleanup(&cleanup_ownership).map_err(|_| ()));
5585        if cleanup.is_err() {
5586            let _ = cleanup_ownership.mark_recovery_required(unix_time_ms());
5587            self.materializations
5588                .lock()
5589                .unwrap_or_else(|poisoned| poisoned.into_inner())
5590                .insert(id.clone(), cleanup_ownership);
5591            if self.persist_state().is_err() {
5592                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5593            }
5594            return Err(failure(
5595                NodeFailureCode::BackendOperationFailed,
5596                "managed session environment cleanup requires recovery",
5597            ));
5598        }
5599        let _transaction = self
5600            .state_transaction
5601            .lock()
5602            .unwrap_or_else(|poisoned| poisoned.into_inner());
5603        self.remove_record_memory(record_id);
5604        self.materializations
5605            .lock()
5606            .unwrap_or_else(|poisoned| poisoned.into_inner())
5607            .remove(&id);
5608        if let Err(error) = self.persist_state_locked() {
5609            self.session_records
5610                .lock()
5611                .unwrap_or_else(|poisoned| poisoned.into_inner())
5612                .records
5613                .insert(record_id.clone(), terminal_record);
5614            self.materializations
5615                .lock()
5616                .unwrap_or_else(|poisoned| poisoned.into_inner())
5617                .insert(id, cleanup_ownership);
5618            return Err(persistence_failure(error));
5619        }
5620        Ok(true)
5621    }
5622
5623    fn mark_materialization_recovery_required(&self, id: &MaterializationId) {
5624        let changed = {
5625            let mut materializations = self
5626                .materializations
5627                .lock()
5628                .unwrap_or_else(|poisoned| poisoned.into_inner());
5629            let Some(ownership) = materializations.get_mut(id) else {
5630                return;
5631            };
5632            ownership.mark_recovery_required(unix_time_ms()).is_ok()
5633        };
5634        if changed && self.persist_state().is_err() {
5635            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5636        }
5637    }
5638
5639    fn reconcile_materializations(&self) {
5640        let records = self
5641            .materializations
5642            .lock()
5643            .unwrap_or_else(|poisoned| poisoned.into_inner())
5644            .values()
5645            .cloned()
5646            .collect::<Vec<_>>();
5647        for mut ownership in records {
5648            let result = match (ownership.state(), ownership.owner()) {
5649                (MaterializationState::Preparing, _)
5650                | (MaterializationState::CleanupRequired, _) => {
5651                    self.cleanup_materialization(ownership.id())
5652                }
5653                (MaterializationState::Ready, MaterializationOwner::Session { .. }) => {
5654                    if ownership.mark_recovery_required(unix_time_ms()).is_ok() {
5655                        self.materializations
5656                            .lock()
5657                            .unwrap_or_else(|poisoned| poisoned.into_inner())
5658                            .insert(ownership.id().clone(), ownership.clone());
5659                        self.persist_state().map_err(persistence_failure)
5660                    } else {
5661                        Err(failure(
5662                            NodeFailureCode::BackendOperationFailed,
5663                            "session environment reconciliation failed",
5664                        ))
5665                    }
5666                }
5667                (MaterializationState::Ready, MaterializationOwner::Record { record_id }) => self
5668                    .session_environment_materializer
5669                    .as_ref()
5670                    .ok_or_else(|| {
5671                        failure(
5672                            NodeFailureCode::BackendOperationFailed,
5673                            "session environment materializer is unavailable",
5674                        )
5675                    })
5676                    .and_then(|materializer| {
5677                        materializer.revalidate(&ownership).map_err(|_| failure(
5678                            NodeFailureCode::BackendOperationFailed,
5679                            "session materialization revalidation failed",
5680                        ))?;
5681                        let record = self.record(record_id)?;
5682                        if record.environment_profile.as_ref() != ownership.environment_profile()
5683                            || record.bundle.as_ref() != ownership.bundle()
5684                            || record.context.as_ref() != ownership.context()
5685                        {
5686                            return Err(failure(
5687                                NodeFailureCode::BackendOperationFailed,
5688                                "session materialization ownership changed after restart",
5689                            ));
5690                        }
5691                        if let Some(receipt) = ownership.bundle() {
5692                            let catalog = self.bundle_catalog.read()
5693                                .unwrap_or_else(|poisoned| poisoned.into_inner());
5694                            let bundle = catalog.get(&receipt.id).ok_or_else(|| failure(
5695                                NodeFailureCode::UnknownBundle,
5696                                "session bundle is unavailable after restart",
5697                            ))?;
5698                            if bundle.receipt() != *receipt {
5699                                return Err(failure(
5700                                    NodeFailureCode::BundleBindingMismatch,
5701                                    "session bundle changed after restart",
5702                                ));
5703                            }
5704                            let layout = self.bundle_layout(
5705                                &record.provider,
5706                                record.mode,
5707                                record.environment_profile.as_ref(),
5708                                bundle,
5709                            )?;
5710                            materializer
5711                                .revalidate_bundle(&ownership, bundle, layout)
5712                                .map_err(|_| failure(
5713                                    NodeFailureCode::BundleMaterializationFailed,
5714                                    "session bundle revalidation failed",
5715                                ))?;
5716                        }
5717                        if let Some(receipt) = ownership.context() {
5718                            let pack = materializer
5719                                .revalidate_context(&ownership, receipt)
5720                                .map_err(|_| {
5721                                    failure(
5722                                        NodeFailureCode::ContextPackMaterializationFailed,
5723                                        "session context revalidation failed",
5724                                    )
5725                                })?;
5726                            self.context_catalog
5727                                .write()
5728                                .unwrap_or_else(|poisoned| poisoned.into_inner())
5729                                .insert(pack)
5730                                .map_err(|_| {
5731                                    failure(
5732                                        NodeFailureCode::ContextPackMaterializationFailed,
5733                                        "session context catalog restore failed",
5734                                    )
5735                                })?;
5736                        }
5737                        Ok(())
5738                    }),
5739                (MaterializationState::RecoveryRequired, _) => Ok(()),
5740            };
5741            if result.is_err() && ownership.state() != MaterializationState::RecoveryRequired {
5742                let _ = ownership.mark_recovery_required(unix_time_ms());
5743                self.materializations
5744                    .lock()
5745                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5746                    .insert(ownership.id().clone(), ownership);
5747                if self.persist_state().is_err() {
5748                    self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5749                }
5750            }
5751        }
5752        let materialized_profiles = self
5753            .environment_materialization_profiles
5754            .read()
5755            .unwrap_or_else(|poisoned| poisoned.into_inner())
5756            .keys()
5757            .cloned()
5758            .collect::<Vec<_>>();
5759        let ownership = self
5760            .materializations
5761            .lock()
5762            .unwrap_or_else(|poisoned| poisoned.into_inner())
5763            .values()
5764            .cloned()
5765            .collect::<Vec<_>>();
5766        let changed = {
5767            let mut session_records = self
5768                .session_records
5769                .lock()
5770                .unwrap_or_else(|poisoned| poisoned.into_inner());
5771            let mut changed = false;
5772            for record in session_records.records.values_mut() {
5773                let environment_profile = record.environment_profile.as_ref();
5774                if environment_profile.is_none()
5775                    && record.bundle.is_none()
5776                    && record.context.is_none()
5777                {
5778                    continue;
5779                }
5780                if record.bundle.is_none()
5781                    && environment_profile.is_some_and(|profile| {
5782                        !materialized_profiles.contains(&profile.profile_id)
5783                    })
5784                {
5785                    continue;
5786                }
5787                let has_exact_ownership = ownership.iter().any(|candidate| {
5788                    candidate.state() == MaterializationState::Ready
5789                        && candidate.environment_profile() == environment_profile
5790                        && candidate.bundle() == record.bundle.as_ref()
5791                        && candidate.context() == record.context.as_ref()
5792                        && candidate.owner()
5793                            == &MaterializationOwner::Record {
5794                                record_id: record.record_id.clone(),
5795                            }
5796                });
5797                if !has_exact_ownership {
5798                    record.provider_session = None;
5799                    record.active_session = None;
5800                    record.state = ManagedSessionState::Unavailable;
5801                    record.last_error = Some(if record.bundle.is_some() {
5802                        "bundle-unavailable".to_owned()
5803                    } else if record.context.is_some() {
5804                        "context-unavailable".to_owned()
5805                    } else {
5806                        "environment-profile-unavailable".to_owned()
5807                    });
5808                    record.updated_at_unix_ms = unix_time_ms();
5809                    changed = true;
5810                }
5811            }
5812            changed
5813        };
5814        if changed && self.persist_state().is_err() {
5815            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5816        }
5817    }
5818
5819    fn replay_spawn_spec(
5820        &self,
5821        spec: &SpawnSpec,
5822        now: Instant,
5823    ) -> Result<Option<Result<ResolvedSpawnReceipt, NodeFailure>>, NodeFailure> {
5824        let mut cache = self
5825            .spawn_idempotency
5826            .lock()
5827            .unwrap_or_else(|poisoned| poisoned.into_inner());
5828        cache.entries.retain(|_, entry| entry.expires_at > now);
5829        if let Some(entry) = cache.entries.get(&spec.idempotency_key) {
5830            let SpawnIdempotencyValue::Standard { spec: existing, result } = &entry.value else {
5831                return Err(failure(
5832                    NodeFailureCode::SpawnIdempotencyConflict,
5833                    "spawn idempotency key was reused across spawn kinds",
5834                ));
5835            };
5836            if existing != spec {
5837                return Err(failure(
5838                    NodeFailureCode::SpawnIdempotencyConflict,
5839                    "spawn idempotency key was reused with a different specification",
5840                ));
5841            }
5842            return Ok(Some(result.clone()));
5843        }
5844        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5845            return Err(failure(
5846                NodeFailureCode::SpawnIdempotencyCapacity,
5847                "spawn idempotency cache reached its bounded capacity",
5848            ));
5849        }
5850        Ok(None)
5851    }
5852
5853    fn remember_spawn_spec(
5854        &self,
5855        spec: SpawnSpec,
5856        result: Result<ResolvedSpawnReceipt, NodeFailure>,
5857        accepted_at: Instant,
5858    ) {
5859        self
5860            .spawn_idempotency
5861            .lock()
5862            .unwrap_or_else(|poisoned| poisoned.into_inner())
5863            .entries
5864            .insert(
5865                spec.idempotency_key.clone(),
5866                SpawnIdempotencyEntry {
5867                    value: SpawnIdempotencyValue::Standard { spec, result },
5868                    expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5869                },
5870            );
5871    }
5872
5873    fn claim_harness_mcp_spawn(
5874        &self,
5875        _reservation_id: &HarnessMcpReservationId,
5876        _activation_digest: &HarnessMcpActivationDigest,
5877        spec: &SpawnSpec,
5878        now: Instant,
5879    ) -> Result<(), NodeFailure> {
5880        let mut cache = self.spawn_idempotency.lock()
5881            .unwrap_or_else(|poisoned| poisoned.into_inner());
5882        cache.entries.retain(|_, entry| entry.expires_at > now);
5883        if cache.entries.contains_key(&spec.idempotency_key) {
5884            return Err(failure(
5885                NodeFailureCode::SpawnIdempotencyConflict,
5886                "spawn idempotency key was reused across spawn kinds",
5887            ));
5888        }
5889        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5890            return Err(failure(
5891                NodeFailureCode::SpawnIdempotencyCapacity,
5892                "spawn idempotency cache reached its bounded capacity",
5893            ));
5894        }
5895        cache.entries.insert(spec.idempotency_key.clone(), SpawnIdempotencyEntry {
5896            value: SpawnIdempotencyValue::HarnessMcp,
5897            expires_at: now + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5898        });
5899        Ok(())
5900    }
5901
5902    fn replay_managed_spawn(
5903        &self,
5904        request: &ManagedWorktreeSpawnRequest,
5905        now: Instant,
5906    ) -> Result<Option<Result<ManagedWorktreeSpawnReceipt, NodeFailure>>, NodeFailure> {
5907        let mut cache = self.spawn_idempotency.lock()
5908            .unwrap_or_else(|poisoned| poisoned.into_inner());
5909        cache.entries.retain(|_, entry| entry.expires_at > now);
5910        if let Some(entry) = cache.entries.get(&request.spawn_spec.idempotency_key) {
5911            let SpawnIdempotencyValue::Managed { request: existing, result } = &entry.value else {
5912                return Err(failure(
5913                    NodeFailureCode::SpawnIdempotencyConflict,
5914                    "spawn idempotency key was reused across spawn kinds",
5915                ));
5916            };
5917            if existing != request {
5918                return Err(failure(
5919                    NodeFailureCode::SpawnIdempotencyConflict,
5920                    "spawn idempotency key was reused with a different managed specification",
5921                ));
5922            }
5923            return Ok(Some(result.clone()));
5924        }
5925        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5926            return Err(failure(
5927                NodeFailureCode::SpawnIdempotencyCapacity,
5928                "spawn idempotency cache reached its bounded capacity",
5929            ));
5930        }
5931        Ok(None)
5932    }
5933
5934    fn remember_managed_spawn(
5935        &self,
5936        request: ManagedWorktreeSpawnRequest,
5937        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
5938        accepted_at: Instant,
5939    ) {
5940        self.spawn_idempotency.lock()
5941            .unwrap_or_else(|poisoned| poisoned.into_inner())
5942            .entries
5943            .insert(
5944                request.spawn_spec.idempotency_key.clone(),
5945                SpawnIdempotencyEntry {
5946                    value: SpawnIdempotencyValue::Managed { request, result },
5947                    expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5948                },
5949            );
5950    }
5951
5952    fn remember_managed_spawn_attempt(
5953        &self,
5954        durable_v2_key: Option<&SpawnIdempotencyKey>,
5955        request: ManagedWorktreeSpawnRequest,
5956        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
5957        accepted_at: Instant,
5958    ) {
5959        if durable_v2_key.is_none() {
5960            self.remember_managed_spawn(request, result, accepted_at);
5961        }
5962    }
5963
5964    fn acquire_durable_managed_spawn_v2(
5965        &self,
5966        request: &ManagedWorktreeSpawnRequestV2,
5967        request_digest: String,
5968    ) -> Result<DurableManagedSpawnReplayDecision, NodeFailure> {
5969        if self.state_path.is_none() {
5970            return Err(failure(
5971                NodeFailureCode::BackendOperationFailed,
5972                "managed worktree V2 requires durable node state",
5973            ));
5974        }
5975        let _transaction = self.state_transaction
5976            .lock()
5977            .unwrap_or_else(|poisoned| poisoned.into_inner());
5978        let key = request.spawn_spec.idempotency_key.clone();
5979        let mut previous = None;
5980        {
5981            let mut replays = self.managed_spawn_replays
5982                .lock()
5983                .unwrap_or_else(|poisoned| poisoned.into_inner());
5984            if let Some(record) = replays.get_mut(&key) {
5985                if record.request_digest != request_digest {
5986                    return Err(failure(
5987                        NodeFailureCode::SpawnIdempotencyConflict,
5988                        "managed worktree V2 idempotency key was reused with a different request",
5989                    ));
5990                }
5991                match &record.state {
5992                    ManagedWorktreeSpawnReplayStateV10::Pending {
5993                        owner_incarnation_id,
5994                        lease_id: None,
5995                    } if owner_incarnation_id != &self.incarnation_id => {
5996                        previous = Some(record.clone());
5997                        record.state = ManagedWorktreeSpawnReplayStateV10::Pending {
5998                            owner_incarnation_id: self.incarnation_id,
5999                            lease_id: None,
6000                        };
6001                        record.updated_at_unix_ms = unix_time_ms();
6002                    }
6003                    ManagedWorktreeSpawnReplayStateV10::Pending {
6004                        lease_id: None,
6005                        ..
6006                    } => {
6007                        return Err(failure(
6008                            NodeFailureCode::BackendBusy,
6009                            "managed worktree V2 reservation is already active",
6010                        ));
6011                    }
6012                    ManagedWorktreeSpawnReplayStateV10::Pending {
6013                        lease_id: Some(_),
6014                        ..
6015                    } => {
6016                        return Ok(DurableManagedSpawnReplayDecision::PendingLinked);
6017                    }
6018                    ManagedWorktreeSpawnReplayStateV10::Committed { receipt } => {
6019                        return Ok(DurableManagedSpawnReplayDecision::Committed(
6020                            receipt.clone(),
6021                        ));
6022                    }
6023                }
6024            } else if replays.len() >= MAX_MANAGED_WORKTREE_SPAWN_REPLAYS {
6025                return Err(failure(
6026                    NodeFailureCode::SpawnIdempotencyCapacity,
6027                    "durable managed worktree replay ledger reached its bounded capacity",
6028                ));
6029            } else {
6030                let now = unix_time_ms();
6031                replays.insert(key.clone(), ManagedWorktreeSpawnReplayRecordV10 {
6032                    idempotency_key: key.clone(),
6033                    request_digest,
6034                    source_workspace_id: request.spawn_spec.target.workspace_id.clone(),
6035                    profile_id: request.worktree_profile_id.clone(),
6036                    expected_profile_revision: request.expected_profile_revision.clone(),
6037                    state: ManagedWorktreeSpawnReplayStateV10::Pending {
6038                        owner_incarnation_id: self.incarnation_id,
6039                        lease_id: None,
6040                    },
6041                    created_at_unix_ms: now,
6042                    updated_at_unix_ms: now,
6043                });
6044            }
6045        }
6046        if let Err(error) = self.persist_state_locked() {
6047            let mut replays = self.managed_spawn_replays
6048                .lock()
6049                .unwrap_or_else(|poisoned| poisoned.into_inner());
6050            match previous {
6051                Some(previous) => {
6052                    replays.insert(key, previous);
6053                }
6054                None => {
6055                    replays.remove(&key);
6056                }
6057            }
6058            return Err(persistence_failure(error));
6059        }
6060        Ok(DurableManagedSpawnReplayDecision::Reserved)
6061    }
6062
6063    fn link_durable_managed_spawn_v2_lease_locked(
6064        &self,
6065        key: &SpawnIdempotencyKey,
6066        lease_id: &ManagedWorktreeLeaseId,
6067    ) -> Result<ManagedWorktreeSpawnReplayRecordV10, NodeFailure> {
6068        let mut replays = self.managed_spawn_replays
6069            .lock()
6070            .unwrap_or_else(|poisoned| poisoned.into_inner());
6071        let record = replays.get_mut(key).ok_or_else(|| {
6072            failure(
6073                NodeFailureCode::ManagedWorktreeRecoveryRequired,
6074                "managed worktree V2 reservation disappeared before allocation",
6075            )
6076        })?;
6077        let previous = record.clone();
6078        match &mut record.state {
6079            ManagedWorktreeSpawnReplayStateV10::Pending {
6080                lease_id: current,
6081                ..
6082            }
6083                if current.is_none() =>
6084            {
6085                *current = Some(lease_id.clone());
6086                record.updated_at_unix_ms = unix_time_ms();
6087                Ok(previous)
6088            }
6089            _ => Err(failure(
6090                NodeFailureCode::ManagedWorktreeRecoveryRequired,
6091                "managed worktree V2 reservation is not allocation-ready",
6092            )),
6093        }
6094    }
6095
6096    fn restore_durable_managed_spawn_v2_locked(
6097        &self,
6098        key: &SpawnIdempotencyKey,
6099        previous: ManagedWorktreeSpawnReplayRecordV10,
6100    ) {
6101        self.managed_spawn_replays
6102            .lock()
6103            .unwrap_or_else(|poisoned| poisoned.into_inner())
6104            .insert(key.clone(), previous);
6105    }
6106
6107    fn commit_durable_managed_spawn_v2(
6108        &self,
6109        request: &ManagedWorktreeSpawnRequestV2,
6110        receipt: ManagedWorktreeSpawnReceipt,
6111    ) -> Result<(), NodeFailure> {
6112        let _transaction = self.state_transaction
6113            .lock()
6114            .unwrap_or_else(|poisoned| poisoned.into_inner());
6115        let key = &request.spawn_spec.idempotency_key;
6116        let previous = {
6117            let mut replays = self.managed_spawn_replays
6118                .lock()
6119                .unwrap_or_else(|poisoned| poisoned.into_inner());
6120            let record = replays.get_mut(key).ok_or_else(|| {
6121                failure(
6122                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
6123                    "managed worktree V2 reservation disappeared before commit",
6124                )
6125            })?;
6126            let previous = record.clone();
6127            match &record.state {
6128                ManagedWorktreeSpawnReplayStateV10::Pending {
6129                    lease_id: Some(lease_id),
6130                    ..
6131                } if lease_id == &receipt.lease.lease_id => {}
6132                _ => {
6133                    return Err(failure(
6134                        NodeFailureCode::ManagedWorktreeRecoveryRequired,
6135                        "managed worktree V2 reservation is not linked to the spawned lease",
6136                    ));
6137                }
6138            }
6139            record.state = ManagedWorktreeSpawnReplayStateV10::Committed { receipt };
6140            record.updated_at_unix_ms = unix_time_ms();
6141            previous
6142        };
6143        if let Err(error) = self.persist_state_locked() {
6144            self.restore_durable_managed_spawn_v2_locked(key, previous);
6145            return Err(persistence_failure(error));
6146        }
6147        Ok(())
6148    }
6149
6150    fn remove_unallocated_durable_managed_spawn_v2(
6151        &self,
6152        request: &ManagedWorktreeSpawnRequestV2,
6153    ) -> Result<(), NodeFailure> {
6154        let _transaction = self.state_transaction
6155            .lock()
6156            .unwrap_or_else(|poisoned| poisoned.into_inner());
6157        let key = &request.spawn_spec.idempotency_key;
6158        let removed = {
6159            let mut replays = self.managed_spawn_replays
6160                .lock()
6161                .unwrap_or_else(|poisoned| poisoned.into_inner());
6162            match replays.get(key).map(|record| &record.state) {
6163                Some(ManagedWorktreeSpawnReplayStateV10::Pending {
6164                    lease_id: None,
6165                    ..
6166                }) => {
6167                    replays.remove(key)
6168                }
6169                _ => None,
6170            }
6171        };
6172        let Some(removed) = removed else {
6173            return Ok(());
6174        };
6175        if let Err(error) = self.persist_state_locked() {
6176            self.managed_spawn_replays
6177                .lock()
6178                .unwrap_or_else(|poisoned| poisoned.into_inner())
6179                .insert(key.clone(), removed);
6180            return Err(persistence_failure(error));
6181        }
6182        Ok(())
6183    }
6184
6185    fn resolve_spawn_spec(&self, spec: &SpawnSpec) -> Result<ResolvedSpawnSpec, NodeFailure> {
6186        if spec.target.node_id != self.node_id {
6187            return Err(failure(
6188                NodeFailureCode::SpawnTargetMismatch,
6189                "spawn target does not match this node",
6190            ));
6191        }
6192        let defaults = self.spawn_profiles.get(&spec.profile_id).ok_or_else(|| {
6193            failure(
6194                NodeFailureCode::UnknownSpawnProfile,
6195                "spawn profile is unavailable on this node",
6196            )
6197        })?;
6198        let resolved = spec.resolve(defaults).map_err(|error| match error {
6199            SpawnSpecResolveError::ProfileRevisionMismatch { .. } => failure(
6200                NodeFailureCode::SpawnProfileRevisionMismatch,
6201                "spawn profile revision does not match the loaded profile",
6202            ),
6203            _ => failure(
6204                NodeFailureCode::InvalidRequest,
6205                "spawn specification could not be resolved",
6206            ),
6207        })?;
6208        if resolved.target.worktree_id.is_some() {
6209            self.require_worktree_service(&resolved.target.workspace_id)?;
6210        }
6211        let environment_profile = self.resolve_environment_profile(&resolved)?;
6212        self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6213        self.resolve_context(&resolved)?;
6214        Ok(resolved)
6215    }
6216
6217    async fn resolve_spawn_workspace(
6218        &self,
6219        resolved: &ResolvedSpawnSpec,
6220        deadline: Instant,
6221    ) -> Result<WorkspaceId, NodeFailure> {
6222        let Some(worktree_id) = resolved.target.worktree_id.as_ref() else {
6223            return Ok(resolved.target.workspace_id.clone());
6224        };
6225        if self.managed_worktrees.lock()
6226            .unwrap_or_else(|poisoned| poisoned.into_inner())
6227            .lease_for_workspace(worktree_id)
6228            .is_some()
6229        {
6230            return Err(failure(
6231                NodeFailureCode::ManagedWorktreeOwnershipConflict,
6232                "managed worktree targets may only be spawned through their lease request",
6233            ));
6234        }
6235        self.require_worktree_service(&resolved.target.workspace_id)?;
6236        let source_root = self.workspace_root(&resolved.target.workspace_id)?;
6237        let selected_root = self.workspace_root(worktree_id)?;
6238        let remaining = spawn_deadline_remaining(deadline)?;
6239        let worktrees = timeout(remaining, list_git_worktrees(&source_root))
6240            .await
6241            .map_err(|_| {
6242                failure(
6243                    NodeFailureCode::SpawnDeadlineExceeded,
6244                    "Git worktree selection exceeded the spawn deadline",
6245                )
6246            })?
6247            .map_err(git_worktree_failure)?;
6248        validate_selected_worktree(&source_root, &selected_root, &worktrees)?;
6249        Ok(worktree_id.clone())
6250    }
6251
6252    async fn spawn_from_spec(
6253        &self,
6254        spec: SpawnSpec,
6255    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6256        self.spawn_from_spec_with_proxy(spec, None).await
6257    }
6258
6259    async fn spawn_from_spec_with_proxy(
6260        &self,
6261        spec: SpawnSpec,
6262        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
6263    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6264        let accepted_at = Instant::now();
6265        if harness_mcp.is_none() {
6266            if let Some(replayed) = self.replay_spawn_spec(&spec, accepted_at)? {
6267                return replayed;
6268            }
6269        }
6270        let resolved = self.resolve_spawn_spec(&spec)?;
6271        let environment_profile = self.resolve_environment_profile(&resolved)?;
6272        let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6273        let context = self.resolve_context(&resolved)?;
6274        let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6275        let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6276        let spawn_workspace_id = self.resolve_spawn_workspace(&resolved, deadline).await;
6277        let spawn_workspace_id = match spawn_workspace_id {
6278            Ok(workspace_id) => workspace_id,
6279            Err(error) => {
6280                let result = Err(error);
6281                if harness_mcp.is_none() {
6282                    self.remember_spawn_spec(spec, result.clone(), accepted_at);
6283                }
6284                return result;
6285            }
6286        };
6287        if self.managed_worktrees.lock()
6288            .unwrap_or_else(|poisoned| poisoned.into_inner())
6289            .lease_for_workspace(&spawn_workspace_id)
6290            .is_some()
6291        {
6292            let result = Err(failure(
6293                NodeFailureCode::ManagedWorktreeOwnershipConflict,
6294                "managed worktree targets may only be spawned through their lease request",
6295            ));
6296            if harness_mcp.is_none() {
6297                self.remember_spawn_spec(spec, result.clone(), accepted_at);
6298            }
6299            return result;
6300        }
6301        let result = self
6302            .spawn_session_with_deadline(
6303                spawn_workspace_id,
6304                resolved.provider.clone(),
6305                resolved.mode,
6306                resolved.terminal_size,
6307                resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
6308                environment_profile.clone(),
6309                bundle.clone(),
6310                context.clone(),
6311                None,
6312                None,
6313                SpawnRecordPolicy::Always,
6314                Some(deadline),
6315                &required_capabilities,
6316                harness_mcp,
6317                resolved.approval_level,
6318                resolved.network_allowlist.clone(),
6319                resolved.browser_profile_id.clone(),
6320            )
6321            .await
6322            .map(|(session, _runtime_policy)| {
6323                resolved.receipt_with_materialization(
6324                    self.incarnation_id,
6325                    session,
6326                    environment_profile,
6327                    bundle,
6328                    context,
6329                )
6330            });
6331        if harness_mcp.is_none() {
6332            self.remember_spawn_spec(spec, result.clone(), accepted_at);
6333        }
6334        result
6335    }
6336
6337    async fn spawn_from_spec_with_harness_mcp(
6338        &self,
6339        reservation_id: HarnessMcpReservationId,
6340        activation_digest: HarnessMcpActivationDigest,
6341        spec: SpawnSpec,
6342        deadline_unix_ms: u64,
6343    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6344        let registry = self.harness_mcp_registry.as_ref().ok_or_else(|| {
6345            failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
6346        })?;
6347        if let Some(receipt) = registry
6348            .replay_spawn(&reservation_id, &activation_digest, &spec)
6349            .map_err(harness_mcp_failure)?
6350        {
6351            return Ok(receipt);
6352        }
6353        let resolved = self.resolve_spawn_spec(&spec)?;
6354        let prepared = registry
6355            .prepare_spawn(
6356                &reservation_id,
6357                &activation_digest,
6358                &spec,
6359                deadline_unix_ms,
6360                resolved.provider.clone(),
6361            )
6362            .map_err(harness_mcp_failure)?;
6363        self.claim_harness_mcp_spawn(
6364            &reservation_id,
6365            &activation_digest,
6366            &spec,
6367            Instant::now(),
6368        )?;
6369        let spawn = self.spawn_from_spec_with_proxy(spec, Some(&prepared)).await;
6370        let mut receipt = match spawn {
6371            Ok(receipt) => receipt,
6372            Err(error) => {
6373                let _ = registry.abort(&reservation_id, &activation_digest);
6374                return Err(error);
6375            }
6376        };
6377        let record_id = self
6378            .session_bindings
6379            .lock()
6380            .unwrap_or_else(|poisoned| poisoned.into_inner())
6381            .get(&receipt.session.session.instance_id)
6382            .and_then(|binding| binding.record_id.clone());
6383        let record_id = match record_id {
6384            Some(record_id) => record_id,
6385            None => {
6386                let rollback = self.remove_session(&receipt.session).await;
6387                let _ = registry.abort(&reservation_id, &activation_digest);
6388                if let Err(rollback_error) = rollback {
6389                    return Err(rollback_error);
6390                }
6391                return Err(failure(
6392                    NodeFailureCode::BindingMismatch,
6393                    "harness MCP spawn did not retain its exact managed record",
6394                ));
6395            }
6396        };
6397        receipt.harness_mcp_proxy = Some(ResolvedHarnessMcpProxyReceiptV1 {
6398            reservation_id: reservation_id.clone(),
6399            activation_digest: activation_digest.clone(),
6400        });
6401        if let Err(error) = registry.mark_spawned(
6402            &prepared,
6403            receipt.session.clone(),
6404            record_id,
6405            receipt.clone(),
6406        ) {
6407            let rollback = self.remove_session(&receipt.session).await;
6408            let _ = registry.abort(&reservation_id, &activation_digest);
6409            if let Err(rollback_error) = rollback {
6410                return Err(rollback_error);
6411            }
6412            return Err(harness_mcp_failure(error));
6413        }
6414        Ok(receipt)
6415    }
6416
6417    async fn spawn_managed_worktree(
6418        &self,
6419        request: ManagedWorktreeSpawnRequest,
6420    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6421        self.spawn_managed_worktree_inner(request, None).await
6422    }
6423
6424    async fn spawn_managed_worktree_inner(
6425        &self,
6426        request: ManagedWorktreeSpawnRequest,
6427        durable_v2_key: Option<&SpawnIdempotencyKey>,
6428    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6429        let accepted_at = Instant::now();
6430        if durable_v2_key.is_none() {
6431            if let Some(replayed) = self.replay_managed_spawn(&request, accepted_at)? {
6432                return replayed;
6433            }
6434        }
6435        let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6436        if request.spawn_spec.target.worktree_id.is_some() {
6437            let result = Err(failure(
6438                NodeFailureCode::InvalidRequest,
6439                "managed worktree spawn must not select a caller-provided worktree",
6440            ));
6441            self.remember_managed_spawn_attempt(
6442                durable_v2_key,
6443                request,
6444                result.clone(),
6445                accepted_at,
6446            );
6447            return result;
6448        }
6449        let environment_profile = self.resolve_environment_profile(&resolved)?;
6450        let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6451        let context = self.resolve_context(&resolved)?;
6452        let source_workspace_id = resolved.target.workspace_id.clone();
6453        let profile = self.managed_profile(&source_workspace_id, &request.worktree_profile_id)?;
6454        let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6455        let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6456        let runtime_requirement = match (resolved.mode, resolved.prompt.is_some()) {
6457            (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
6458            (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
6459            (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
6460            (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
6461        };
6462        let runtime_policy = timeout(
6463            spawn_deadline_remaining(deadline)?,
6464            self.admit_provider_runtime(&resolved.provider, runtime_requirement),
6465        ).await.map_err(|_| failure(
6466            NodeFailureCode::SpawnDeadlineExceeded,
6467            "provider admission exceeded the managed spawn deadline",
6468        ))??;
6469        if required_capabilities.iter().any(|capability| !runtime_policy.admits(*capability)) {
6470            return Err(failure(
6471                NodeFailureCode::UnsupportedSpawnCapability,
6472                "provider runtime does not admit a required spawn capability",
6473            ));
6474        }
6475        self.ensure_binding_capacity()?;
6476        let source_root = self.workspace_root(&source_workspace_id)?;
6477        profile.validate_for_workspace(&source_root)
6478            .map_err(|message| failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message))?;
6479        let base_timeout_ms = u64::try_from(
6480            spawn_deadline_remaining(deadline)?.as_millis(),
6481        ).unwrap_or(u64::MAX).max(1);
6482        let base_commit = resolve_base_commit_with_timeout(
6483            &source_root,
6484            profile.base(),
6485            base_timeout_ms,
6486        ).await.map_err(|error| {
6487            if Instant::now() >= deadline || error.message.contains("timed out") {
6488                failure(
6489                    NodeFailureCode::SpawnDeadlineExceeded,
6490                    "managed worktree base resolution exceeded the spawn deadline",
6491                )
6492            } else {
6493                managed_git_worktree_failure(error)
6494            }
6495        })?;
6496
6497        let lease = {
6498            let _transaction = self.state_transaction.lock()
6499                .unwrap_or_else(|poisoned| poisoned.into_inner());
6500            let previous = self.managed_worktrees.lock()
6501                .unwrap_or_else(|poisoned| poisoned.into_inner()).clone();
6502            let lease = self.managed_worktrees.lock()
6503                .unwrap_or_else(|poisoned| poisoned.into_inner())
6504                .allocate(source_workspace_id.clone(), &profile, base_commit.clone(), unix_time_ms())
6505                .map_err(|message| failure(NodeFailureCode::BackendBusy, &message))?;
6506            let previous_replay = match durable_v2_key
6507                .map(|key| self.link_durable_managed_spawn_v2_lease_locked(key, &lease.lease_id))
6508                .transpose()
6509            {
6510                Ok(previous) => previous,
6511                Err(error) => {
6512                    *self.managed_worktrees.lock()
6513                        .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6514                    return Err(error);
6515                }
6516            };
6517            if let Err(error) = self.persist_state_locked() {
6518                *self.managed_worktrees.lock()
6519                    .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6520                if let (Some(key), Some(previous_replay)) =
6521                    (durable_v2_key, previous_replay)
6522                {
6523                    self.restore_durable_managed_spawn_v2_locked(key, previous_replay);
6524                }
6525                return Err(persistence_failure(error));
6526            }
6527            lease
6528        };
6529        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() });
6530        if let Err(message) = profile.validate_target_authority(&lease.lease_id, &lease.target_root) {
6531            self.managed_worktrees.lock()
6532                .unwrap_or_else(|poisoned| poisoned.into_inner())
6533                .remove_unmutated(&lease.lease_id);
6534            if self.persist_state().is_err() {
6535                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6536            }
6537            let result = Err(failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message));
6538            self.remember_managed_spawn_attempt(
6539                durable_v2_key,
6540                request,
6541                result.clone(),
6542                accepted_at,
6543            );
6544            return result;
6545        }
6546        let mutation_timeout_ms = match spawn_deadline_remaining(deadline) {
6547            Ok(remaining) => u64::try_from(remaining.as_millis()).unwrap_or(u64::MAX).max(1),
6548            Err(error) => {
6549                self.reconcile_failed_allocation(&lease, &source_root).await;
6550                let result = Err(error);
6551                self.remember_managed_spawn_attempt(
6552                    durable_v2_key,
6553                    request,
6554                    result.clone(),
6555                    accepted_at,
6556                );
6557                return result;
6558            }
6559        };
6560        let created = match create_git_worktree_with_timeout(
6561                &source_root,
6562                &lease.target_root,
6563                &lease.branch,
6564                Some(&base_commit),
6565                mutation_timeout_ms,
6566            ).await {
6567            Ok(created) => created,
6568            Err(error) => {
6569                self.reconcile_failed_allocation(&lease, &source_root).await;
6570                let result = Err(if Instant::now() >= deadline
6571                    || error.message.contains("timed out")
6572                    || error.message.contains("deadline")
6573                {
6574                    failure(
6575                        NodeFailureCode::SpawnDeadlineExceeded,
6576                        "managed worktree creation exceeded the spawn deadline",
6577                    )
6578                } else {
6579                    managed_git_worktree_failure(error)
6580                });
6581                self.remember_managed_spawn_attempt(
6582                    durable_v2_key,
6583                    request,
6584                    result.clone(),
6585                    accepted_at,
6586                );
6587                return result;
6588            }
6589        };
6590        if !exact_created_worktree(&lease, &created.path, created.branch.as_deref(), &created.head) {
6591            self.mark_managed_recovery_required(&lease.lease_id, ManagedWorktreeCleanupFailure::OwnershipConflict);
6592            let result = Err(failure(
6593                NodeFailureCode::ManagedWorktreeRecoveryRequired,
6594                "created worktree identity did not match its durable lease",
6595            ));
6596            self.remember_managed_spawn_attempt(
6597                durable_v2_key,
6598                request,
6599                result.clone(),
6600                accepted_at,
6601            );
6602            return result;
6603        }
6604        {
6605            let mut registry = self.managed_worktrees.lock()
6606                .unwrap_or_else(|poisoned| poisoned.into_inner());
6607            let current = registry.get_mut(&lease.lease_id)
6608                .expect("allocated managed worktree lease remains present");
6609            current.expected_head = Some(created.head.clone());
6610            current.state = ManagedWorktreeLeaseState::Ready;
6611            current.updated_at_unix_ms = unix_time_ms();
6612        }
6613        if let Err(error) = self.persist_state() {
6614            self.mark_managed_recovery_required(
6615                &lease.lease_id,
6616                ManagedWorktreeCleanupFailure::Backend,
6617            );
6618            let result = Err(persistence_failure(error));
6619            self.remember_managed_spawn_attempt(
6620                durable_v2_key,
6621                request,
6622                result.clone(),
6623                accepted_at,
6624            );
6625            return result;
6626        }
6627        let ready = self.managed_worktrees.lock()
6628            .unwrap_or_else(|poisoned| poisoned.into_inner())
6629            .get(&lease.lease_id)
6630            .expect("ready managed worktree lease remains present")
6631            .snapshot();
6632        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: ready });
6633        if let Err(error) = self.register_workspace(lease.workspace_id.clone(), created.path.clone()).await {
6634            let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
6635            let result = Err(error);
6636            self.remember_managed_spawn_attempt(
6637                durable_v2_key,
6638                request,
6639                result.clone(),
6640                accepted_at,
6641            );
6642            return result;
6643        }
6644        let spawn = self.spawn_session_with_deadline(
6645            lease.workspace_id.clone(),
6646            resolved.provider.clone(),
6647            resolved.mode,
6648            resolved.terminal_size,
6649            resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
6650            environment_profile.clone(),
6651            bundle.clone(),
6652            context.clone(),
6653            Some(lease.lease_id.clone()),
6654            Some(runtime_policy),
6655            SpawnRecordPolicy::Always,
6656            Some(deadline),
6657            &required_capabilities,
6658            None,
6659            resolved.approval_level,
6660            resolved.network_allowlist.clone(),
6661            resolved.browser_profile_id.clone(),
6662        ).await;
6663        let (session, effective_runtime_policy) = match spawn {
6664            Ok(spawned) => spawned,
6665            Err(error) => {
6666                if self
6667                    .cleanup_managed_worktree(&lease.lease_id, true)
6668                    .await
6669                    .is_err()
6670                {
6671                    self.mark_managed_recovery_required(
6672                        &lease.lease_id,
6673                        ManagedWorktreeCleanupFailure::Backend,
6674                    );
6675                }
6676                let result = Err(error);
6677                self.remember_managed_spawn_attempt(
6678                    durable_v2_key,
6679                    request,
6680                    result.clone(),
6681                    accepted_at,
6682                );
6683                return result;
6684            }
6685        };
6686        let (record_id, raw_inventory_record_id) = {
6687            let mut bindings = self.session_bindings.lock()
6688                .unwrap_or_else(|poisoned| poisoned.into_inner());
6689            let binding = bindings.get_mut(&session.session.instance_id)
6690                .expect("successful spawn retains its session binding");
6691            binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
6692            managed_spawn_record_ownership(binding, effective_runtime_policy)
6693        };
6694        let bound = self.managed_worktrees.lock()
6695            .unwrap_or_else(|poisoned| poisoned.into_inner())
6696            .bind_session(
6697                &lease.lease_id,
6698                ManagedWorktreeSessionHolder {
6699                    incarnation_id: self.incarnation_id,
6700                    instance_id: session.session.instance_id,
6701                    generation: session.session.generation,
6702                },
6703                record_id,
6704                unix_time_ms(),
6705            )
6706            .map_err(|message| failure(NodeFailureCode::ManagedWorktreeRecoveryRequired, &message));
6707        let snapshot = match bound {
6708            Ok(snapshot) => snapshot,
6709            Err(error) => {
6710                if raw_inventory_record_id
6711                    .as_ref()
6712                    .is_some_and(|record_id| self.discard_record(record_id).is_err())
6713                {
6714                    self.mark_managed_recovery_required(
6715                        &lease.lease_id,
6716                        ManagedWorktreeCleanupFailure::Backend,
6717                    );
6718                }
6719                let _ = self.remove_session(&session).await;
6720                self.mark_managed_recovery_required(
6721                    &lease.lease_id,
6722                    ManagedWorktreeCleanupFailure::Backend,
6723                );
6724                let result = Err(error);
6725                self.remember_managed_spawn_attempt(
6726                    durable_v2_key,
6727                    request,
6728                    result.clone(),
6729                    accepted_at,
6730                );
6731                return result;
6732            }
6733        };
6734        if let Err(error) = self.persist_state() {
6735            if raw_inventory_record_id
6736                .as_ref()
6737                .is_some_and(|record_id| self.discard_record(record_id).is_err())
6738            {
6739                self.mark_managed_recovery_required(
6740                    &lease.lease_id,
6741                    ManagedWorktreeCleanupFailure::Backend,
6742                );
6743            }
6744            let _ = self.remove_session(&session).await;
6745            if self
6746                .cleanup_managed_worktree(&lease.lease_id, true)
6747                .await
6748                .is_err()
6749            {
6750                self.mark_managed_recovery_required(
6751                    &lease.lease_id,
6752                    ManagedWorktreeCleanupFailure::Backend,
6753                );
6754            }
6755            let result = Err(persistence_failure(error));
6756            self.remember_managed_spawn_attempt(
6757                durable_v2_key,
6758                request,
6759                result.clone(),
6760                accepted_at,
6761            );
6762            return result;
6763        }
6764        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
6765        let receipt = managed_spawn_receipt(
6766            &resolved,
6767            self.incarnation_id,
6768            session,
6769            snapshot,
6770            environment_profile,
6771            bundle,
6772            context,
6773        );
6774        let result = Ok(receipt);
6775        self.remember_managed_spawn_attempt(
6776            durable_v2_key,
6777            request,
6778            result.clone(),
6779            accepted_at,
6780        );
6781        result
6782    }
6783
6784    async fn spawn_managed_worktree_v2(
6785        &self,
6786        request: ManagedWorktreeSpawnRequestV2,
6787    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6788        if request.spawn_spec.target.node_id != self.node_id {
6789            return Err(failure(
6790                NodeFailureCode::SpawnTargetMismatch,
6791                "spawn target does not match this node",
6792            ));
6793        }
6794        if request.spawn_spec.target.worktree_id.is_some() {
6795            return Err(failure(
6796                NodeFailureCode::InvalidRequest,
6797                "managed worktree spawn must not select a caller-provided worktree",
6798            ));
6799        }
6800        let request_digest = managed_spawn_request_digest_v2(&request)?;
6801        let replay = self.acquire_durable_managed_spawn_v2(&request, request_digest)?;
6802        match replay {
6803            DurableManagedSpawnReplayDecision::Committed(receipt) => return Ok(receipt),
6804            DurableManagedSpawnReplayDecision::PendingLinked => {
6805                return Err(failure(
6806                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
6807                    "managed worktree V2 reservation has an incomplete durable allocation",
6808                ));
6809            }
6810            DurableManagedSpawnReplayDecision::Reserved => {}
6811        }
6812        let validated = (|| {
6813            let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6814            let profile = self.managed_profile(
6815                &resolved.target.workspace_id,
6816                &request.worktree_profile_id,
6817            )?;
6818            if profile.revision() != &request.expected_profile_revision {
6819                return Err(failure(
6820                    NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
6821                    "managed worktree profile revision does not match the loaded profile",
6822                ));
6823            }
6824            Ok(())
6825        })();
6826        match validated {
6827            Ok(()) => {}
6828            Err(error) => {
6829                self.remove_unallocated_durable_managed_spawn_v2(&request)?;
6830                return Err(error);
6831            }
6832        }
6833        let legacy = ManagedWorktreeSpawnRequest {
6834            spawn_spec: request.spawn_spec.clone(),
6835            worktree_profile_id: request.worktree_profile_id.clone(),
6836        };
6837        match self
6838            .spawn_managed_worktree_inner(
6839                legacy,
6840                Some(&request.spawn_spec.idempotency_key),
6841            )
6842            .await
6843        {
6844            Ok(receipt) => {
6845                self.commit_durable_managed_spawn_v2(&request, receipt.clone())?;
6846                Ok(receipt)
6847            }
6848            Err(error) => {
6849                self.remove_unallocated_durable_managed_spawn_v2(&request)?;
6850                Err(error)
6851            }
6852        }
6853    }
6854
6855    fn managed_profile(
6856        &self,
6857        workspace_id: &WorkspaceId,
6858        profile_id: &WorktreeProfileId,
6859    ) -> Result<ManagedWorktreeProfile, NodeFailure> {
6860        if self.worktree_service_modes.get(workspace_id).copied()
6861            != Some(WorktreeServiceMode::Managed)
6862        {
6863            return Err(failure(
6864                NodeFailureCode::UnsupportedSpawnCapability,
6865                "workspace does not enable managed worktrees",
6866            ));
6867        }
6868        self.managed_worktree_profiles.get(workspace_id)
6869            .and_then(|profiles| profiles.get(profile_id))
6870            .cloned()
6871            .ok_or_else(|| failure(
6872                NodeFailureCode::InvalidRequest,
6873                "managed worktree profile is unavailable for this workspace",
6874            ))
6875    }
6876
6877    async fn reconcile_failed_allocation(
6878        &self,
6879        lease: &ManagedWorktreeLeaseRecord,
6880        source_root: &str,
6881    ) {
6882        match list_git_worktrees(source_root).await {
6883            Ok(worktrees) => {
6884                match worktrees.iter().find(|item| worktree_paths_equal(&item.path, &lease.target_root)) {
6885                    None => {
6886                        match std::fs::symlink_metadata(&lease.target_root) {
6887                            Err(error) if error.kind() == io::ErrorKind::NotFound => {
6888                                self.managed_worktrees.lock()
6889                                    .unwrap_or_else(|poisoned| poisoned.into_inner())
6890                                    .remove_unmutated(&lease.lease_id);
6891                                let _ = self.persist_state();
6892                            }
6893                            _ => self.mark_managed_recovery_required(
6894                                &lease.lease_id,
6895                                ManagedWorktreeCleanupFailure::OwnershipConflict,
6896                            ),
6897                        }
6898                    }
6899                    Some(item) if exact_owned_worktree(
6900                        lease,
6901                        &item.path,
6902                        item.branch.as_deref(),
6903                        &item.head,
6904                    ) => {
6905                        let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
6906                    }
6907                    Some(_) => self.mark_managed_recovery_required(
6908                        &lease.lease_id,
6909                        ManagedWorktreeCleanupFailure::OwnershipConflict,
6910                    ),
6911                }
6912            }
6913            Err(_) => self.mark_managed_recovery_required(
6914                &lease.lease_id,
6915                ManagedWorktreeCleanupFailure::Backend,
6916            ),
6917        }
6918    }
6919
6920    async fn reconcile_managed_worktrees(&self) {
6921        let leases = self.managed_worktrees.lock()
6922            .unwrap_or_else(|poisoned| poisoned.into_inner())
6923            .active_records()
6924            .cloned()
6925            .collect::<Vec<_>>();
6926        for lease in leases {
6927            let profile = match self.managed_profile(&lease.source_workspace_id, &lease.profile_id) {
6928                Ok(profile) if profile.revision() == &lease.profile_revision => profile,
6929                _ => {
6930                    self.mark_managed_recovery_required(
6931                        &lease.lease_id,
6932                        ManagedWorktreeCleanupFailure::OwnershipConflict,
6933                    );
6934                    continue;
6935                }
6936            };
6937            if profile.validate_target_authority(&lease.lease_id, &lease.target_root).is_err() {
6938                self.mark_managed_recovery_required(
6939                    &lease.lease_id,
6940                    ManagedWorktreeCleanupFailure::OwnershipConflict,
6941                );
6942                continue;
6943            }
6944            let source_root = match self.workspace_root(&lease.source_workspace_id) {
6945                Ok(root) => root,
6946                Err(_) => {
6947                    self.mark_managed_recovery_required(
6948                        &lease.lease_id,
6949                        ManagedWorktreeCleanupFailure::OwnershipConflict,
6950                    );
6951                    continue;
6952                }
6953            };
6954            let worktrees = match list_git_worktrees(&source_root).await {
6955                Ok(worktrees) => worktrees,
6956                Err(_) => {
6957                    self.mark_managed_recovery_required(
6958                        &lease.lease_id,
6959                        ManagedWorktreeCleanupFailure::Backend,
6960                    );
6961                    continue;
6962                }
6963            };
6964            let listed = worktrees.iter()
6965                .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
6966            let Some(listed) = listed else {
6967                match std::fs::symlink_metadata(&lease.target_root) {
6968                    Err(error) if error.kind() == io::ErrorKind::NotFound
6969                        && lease.state == ManagedWorktreeLeaseState::Allocating
6970                        && !lease.has_holders() => {
6971                            let removed = self.managed_worktrees.lock()
6972                                .unwrap_or_else(|poisoned| poisoned.into_inner())
6973                                .tombstone(&lease.lease_id, unix_time_ms());
6974                            if self.persist_state().is_ok() && removed.is_some() {
6975                                self.publish(NodeEvent::ManagedWorktreeRemoved {
6976                                    lease_id: lease.lease_id.clone(),
6977                                });
6978                            }
6979                        }
6980                    _ => self.mark_managed_recovery_required(
6981                        &lease.lease_id,
6982                        ManagedWorktreeCleanupFailure::OwnershipConflict,
6983                    ),
6984                }
6985                continue;
6986            };
6987            if listed.is_main || listed.is_bare || !exact_owned_worktree(
6988                &lease,
6989                &listed.path,
6990                listed.branch.as_deref(),
6991                &listed.head,
6992            ) {
6993                self.mark_managed_recovery_required(
6994                    &lease.lease_id,
6995                    ManagedWorktreeCleanupFailure::OwnershipConflict,
6996                );
6997                continue;
6998            }
6999            if !self.workspaces.read()
7000                .unwrap_or_else(|poisoned| poisoned.into_inner())
7001                .contains_key(&lease.workspace_id)
7002            {
7003                if self.register_workspace(lease.workspace_id.clone(), listed.path.clone()).await.is_err() {
7004                    self.mark_managed_recovery_required(
7005                        &lease.lease_id,
7006                        ManagedWorktreeCleanupFailure::Backend,
7007                    );
7008                    continue;
7009                }
7010            }
7011            let snapshot = {
7012                let mut registry = self.managed_worktrees.lock()
7013                    .unwrap_or_else(|poisoned| poisoned.into_inner());
7014                let current = registry.get_mut(&lease.lease_id)
7015                    .expect("reconciled managed lease remains present");
7016                current.expected_head = Some(listed.head.clone());
7017                current.cleanup_failure = None;
7018                current.state = if current.has_holders() {
7019                    ManagedWorktreeLeaseState::InUse
7020                } else if current.retention == ManagedWorktreeRetention::Retain {
7021                    ManagedWorktreeLeaseState::Retained
7022                } else {
7023                    ManagedWorktreeLeaseState::Ready
7024                };
7025                current.updated_at_unix_ms = unix_time_ms();
7026                current.snapshot()
7027            };
7028            if self.persist_state().is_err() {
7029                self.mark_managed_recovery_required(
7030                    &lease.lease_id,
7031                    ManagedWorktreeCleanupFailure::Backend,
7032                );
7033                continue;
7034            }
7035            self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
7036            if snapshot.state == ManagedWorktreeLeaseState::Ready {
7037                let _ = self.cleanup_managed_worktree(&lease.lease_id, false).await;
7038            }
7039        }
7040    }
7041
7042    /// Crash-recovery startup sweep: durably capture the pack for every
7043    /// `Live` managed record whose most recent session has not yet produced
7044    /// one (recovers a session whose `Exited{0}` event was observed but the
7045    /// Node crashed before the export finished). The reactive fast path for
7046    /// a single just-exited record does not go through this — see
7047    /// `drive_runtime_until_shutdown`, which resolves and spawns directly so
7048    /// it can capture the exact `SessionAddress` before `publish_control`
7049    /// clears it, rather than re-deriving it from (by then likely already
7050    /// `Dormant`) record state the way a rescan naturally would.
7051    ///
7052    /// Returns as soon as every eligible record's export has been *spawned*,
7053    /// not completed — each one runs on its own detached task (see
7054    /// `reconcile_context_pack_export_for_record`'s own doc comment for why:
7055    /// `NativeRuntime::tick()` has not even started its own loop yet at the
7056    /// point this is called from `NodeServer::run()`, so nothing could ever
7057    /// drain the history round trip if this awaited it inline here either).
7058    /// Best-effort throughout: never panics, no unwrap/expect; every failure
7059    /// is a silent skip for THIS record — an unusable source or a racing
7060    /// record is not surfaced to any caller.
7061    async fn reconcile_context_pack_exports(self: &Arc<Self>) {
7062        let candidates: Vec<(SessionRecordId, SessionAddress)> = self
7063            .session_records
7064            .lock()
7065            .unwrap_or_else(|poisoned| poisoned.into_inner())
7066            .records
7067            .values()
7068            .filter(|record| {
7069                record.state == ManagedSessionState::Live
7070                    && record.exported_context.is_none()
7071                    && record.provider_session.is_some()
7072            })
7073            .filter_map(|record| {
7074                record
7075                    .active_session
7076                    .clone()
7077                    .map(|session| (record.record_id.clone(), session))
7078            })
7079            .collect();
7080        for (record_id, session) in candidates {
7081            let shared = Arc::clone(self);
7082            tokio::spawn(async move {
7083                shared
7084                    .reconcile_context_pack_export_for_record(&record_id, session)
7085                    .await;
7086            });
7087        }
7088    }
7089
7090    /// Runs on its own detached task (`tokio::spawn`, see both call sites),
7091    /// never inline on `drive_runtime_until_shutdown`'s own task: the history
7092    /// round trip this performs (`discover_history`/`load_history`, via
7093    /// `export_context_pack_for_session_record`) can only ever settle once
7094    /// `NativeRuntime::tick()` runs again to drain the authority worker's
7095    /// completion — and `tick()` is exclusively owned by, and only ever
7096    /// called from, that same drive-loop task. Awaiting this inline there
7097    /// would starve `tick()` and this call could never observe its own
7098    /// command complete.
7099    ///
7100    /// Because this now genuinely races the drive loop's own
7101    /// `publish_control` (which downgrades this exact record from `Live` to
7102    /// `Dormant`, clearing `active_session`, for the very `Exited` event that
7103    /// triggered this reconcile), the record legitimately may already be
7104    /// `Dormant` by the time this reads it, or may transition mid-flight —
7105    /// `allow_clean_detachment: true` on the downstream export call is what
7106    /// tolerates exactly that transition (and only that transition; see
7107    /// `session_record_export_target_matches`).
7108    async fn reconcile_context_pack_export_for_record(
7109        &self,
7110        record_id: &SessionRecordId,
7111        session: SessionAddress,
7112    ) {
7113        let Ok(record) = self.record(record_id) else {
7114            return;
7115        };
7116        if !matches!(
7117            record.state,
7118            ManagedSessionState::Live | ManagedSessionState::Dormant
7119        ) || record.exported_context.is_some()
7120            || record.provider_session.is_none()
7121        {
7122            return;
7123        }
7124        // Pack bytes are already durably committed inside this call, via
7125        // commit_context_pack_for_session_record's own durable
7126        // write-through (context_pack_store) — no separate step here.
7127        // Unclean exits (non-zero/forced/no loadable history) are already
7128        // rejected by context_pack_source_status_is_usable inside
7129        // materialize_context_pack: this simply returns Err and is skipped.
7130        let Ok(receipt) = self
7131            .export_context_pack_for_session_record(&record.record_id, &session, true)
7132            .await
7133        else {
7134            return;
7135        };
7136        self.set_exported_context(&record.record_id, receipt);
7137    }
7138
7139    /// Records the record's most recent clean-exit pack. No-op (not an
7140    /// error) if the record disappeared, or if it already carries an
7141    /// `exported_context` — first-write-wins, matching the harness-side
7142    /// idempotent reconciliation that consumes this field.
7143    fn set_exported_context(&self, record_id: &SessionRecordId, receipt: ResolvedContextPackReceipt) {
7144        let transaction = self
7145            .state_transaction
7146            .lock()
7147            .unwrap_or_else(|poisoned| poisoned.into_inner());
7148        let (previous, updated) = {
7149            let mut records = self
7150                .session_records
7151                .lock()
7152                .unwrap_or_else(|poisoned| poisoned.into_inner());
7153            let Some(record) = records.records.get_mut(record_id) else {
7154                return;
7155            };
7156            if record.exported_context.is_some() {
7157                return;
7158            }
7159            let previous = record.clone();
7160            record.exported_context = Some(receipt);
7161            record.updated_at_unix_ms = unix_time_ms().max(record.updated_at_unix_ms);
7162            (previous, record.clone())
7163        };
7164        if self.persist_state_locked().is_err() {
7165            self.session_records
7166                .lock()
7167                .unwrap_or_else(|poisoned| poisoned.into_inner())
7168                .records
7169                .insert(record_id.clone(), previous);
7170            return;
7171        }
7172        drop(transaction);
7173        self.publish_record(updated);
7174    }
7175
7176    /// Resolves the managed record and its exact `SessionAddress` currently
7177    /// bound to `instance_id` at exactly `generation`, used by the reactive
7178    /// `Exited{0}` hook in `drive_runtime_until_shutdown` to target a single
7179    /// record without a full rescan. Mirrors the lookup already inlined in
7180    /// `publish_control`. Captured synchronously, before `publish_control`
7181    /// for the same event clears the record's own `active_session` — the
7182    /// caller passes the resolved address through to a detached
7183    /// reconciliation task rather than re-deriving it from the record later,
7184    /// after that race has necessarily already happened.
7185    fn managed_record_export_target_for_instance(
7186        &self,
7187        instance_id: AgentInstanceId,
7188        generation: SessionGeneration,
7189    ) -> Option<(SessionRecordId, SessionAddress)> {
7190        let bindings = self
7191            .session_bindings
7192            .lock()
7193            .unwrap_or_else(|poisoned| poisoned.into_inner());
7194        let binding = bindings.get(&instance_id).filter(|binding| binding.generation == generation)?;
7195        let record_id = binding.record_id.clone()?;
7196        Some((
7197            record_id,
7198            SessionAddress {
7199                workspace_id: binding.workspace_id.clone(),
7200                session: SessionKey { instance_id, generation },
7201            },
7202        ))
7203    }
7204
7205    fn mark_managed_recovery_required(
7206        &self,
7207        lease_id: &ManagedWorktreeLeaseId,
7208        failure_kind: ManagedWorktreeCleanupFailure,
7209    ) {
7210        let snapshot = {
7211            let mut registry = self.managed_worktrees.lock()
7212                .unwrap_or_else(|poisoned| poisoned.into_inner());
7213            let Some(lease) = registry.get_mut(lease_id) else { return };
7214            lease.state = ManagedWorktreeLeaseState::RecoveryRequired;
7215            lease.cleanup_failure = Some(failure_kind);
7216            lease.updated_at_unix_ms = unix_time_ms();
7217            lease.snapshot()
7218        };
7219        let _ = self.persist_state();
7220        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
7221    }
7222
7223    fn set_persistence_error(&self, error: Option<String>) {
7224        *self
7225            .persistence_error
7226            .write()
7227            .unwrap_or_else(|poisoned| poisoned.into_inner()) = error
7228            .map(|message| session_registry::sanitized_persistence_summary(&message));
7229    }
7230
7231    fn persist_state(&self) -> io::Result<()> {
7232        let _transaction = self
7233            .state_transaction
7234            .lock()
7235            .unwrap_or_else(|poisoned| poisoned.into_inner());
7236        self.persist_state_locked()
7237    }
7238
7239    fn persist_state_locked(&self) -> io::Result<()> {
7240        let workspaces = self
7241            .workspaces
7242            .read()
7243            .unwrap_or_else(|poisoned| poisoned.into_inner())
7244            .clone();
7245        let records = self
7246            .session_records
7247            .lock()
7248            .unwrap_or_else(|poisoned| poisoned.into_inner())
7249            .records
7250            .values()
7251            .cloned()
7252            .collect::<Vec<_>>();
7253        let mut managed = self.managed_worktrees
7254            .lock()
7255            .unwrap_or_else(|poisoned| poisoned.into_inner());
7256        managed.reattach_record_holders(
7257            &managed_worktree_record_holders(&records),
7258            unix_time_ms(),
7259        );
7260        let materializations = self
7261            .materializations
7262            .lock()
7263            .unwrap_or_else(|poisoned| poisoned.into_inner())
7264            .values()
7265            .cloned()
7266            .collect::<Vec<_>>();
7267        let managed_spawn_replays = self.managed_spawn_replays
7268            .lock()
7269            .unwrap_or_else(|poisoned| poisoned.into_inner())
7270            .values()
7271            .cloned()
7272            .collect::<Vec<_>>();
7273        let result = session_registry::save_v10(
7274            self.state_path.as_deref(),
7275            &self.node_id,
7276            &workspaces,
7277            &records,
7278            &managed.records(),
7279            &managed.tombstones(),
7280            &materializations,
7281            &managed_spawn_replays,
7282        );
7283        match &result {
7284            Ok(warning) => self.set_persistence_error(warning.clone()),
7285            Err(_) => self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned())),
7286        }
7287        result.map(|_| ())
7288    }
7289
7290    async fn begin_shutdown(&self) -> Result<(), NodeFailure> {
7291        let _mutation_guard = self.mutation_gate.lock().await;
7292        self.begin_shutdown_locked().await
7293    }
7294
7295    async fn begin_shutdown_locked(&self) -> Result<(), NodeFailure> {
7296        if self.shutdown.swap(true, Ordering::AcqRel) {
7297            return Ok(());
7298        }
7299        SessionLifecycleHook::on_shutdown(self);
7300        let deadline = Instant::now() + Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
7301        let mut first_error = None;
7302        for session in &self.handle.snapshot().sessions {
7303            let remaining = deadline.saturating_duration_since(Instant::now());
7304            if remaining.is_zero() {
7305                first_error.get_or_insert_with(|| {
7306                    failure(NodeFailureCode::BackendBusy, "stop-all dispatch exceeded its bounded deadline")
7307                });
7308                break;
7309            }
7310            if let Err(error) = self
7311                .dispatch_bounded(
7312                    ControlCommand::Stop {
7313                        instance_id: session.instance_id,
7314                        force: true,
7315                    },
7316                    remaining,
7317                )
7318                .await
7319            {
7320                first_error.get_or_insert(error);
7321            }
7322        }
7323        self.shutdown_notify.notify_waiters();
7324        match first_error {
7325            Some(error) => Err(error),
7326            None => Ok(()),
7327        }
7328    }
7329
7330    fn ensure_binding_capacity(&self) -> Result<(), NodeFailure> {
7331        let bindings = self
7332            .session_bindings
7333            .lock()
7334            .unwrap_or_else(|poisoned| poisoned.into_inner());
7335        if bindings.len() >= CONTROL_SESSIONS_MAX {
7336            Err(failure(
7337                NodeFailureCode::BackendBusy,
7338                "node session-binding capacity is full; remove an existing session first",
7339            ))
7340        } else {
7341            Ok(())
7342        }
7343    }
7344
7345    /// One-owner workspace/env collision guard: a live session on
7346    /// `workspace_id` with a different `environment_profile` (option
7347    /// equality on the resolved receipt, including Some vs None) blocks
7348    /// another spawn on that same cwd. Same profile may still share the
7349    /// workspace; distinct profiles need distinct workspace ids (managed
7350    /// worktree).
7351    fn ensure_workspace_environment_profile_compatible(
7352        &self,
7353        workspace_id: &WorkspaceId,
7354        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
7355    ) -> Result<(), NodeFailure> {
7356        let bindings = self
7357            .session_bindings
7358            .lock()
7359            .unwrap_or_else(|poisoned| poisoned.into_inner());
7360        for binding in bindings.values() {
7361            if &binding.workspace_id != workspace_id {
7362                continue;
7363            }
7364            if binding.environment_profile.as_ref() != environment_profile {
7365                return Err(failure(
7366                    NodeFailureCode::WorkspaceBusy,
7367                    "workspace already has a live session with a different environment profile; use a managed worktree for concurrent distinct ProviderHome/env bindings on one repo",
7368                ));
7369            }
7370        }
7371        Ok(())
7372    }
7373
7374    #[cfg(test)]
7375    fn bind_session(
7376        &self,
7377        instance_id: AgentInstanceId,
7378        workspace_id: WorkspaceId,
7379        generation: SessionGeneration,
7380    ) {
7381        self.bind_session_with_policy(
7382            &SessionAddress {
7383                workspace_id,
7384                session: SessionKey { instance_id, generation },
7385            },
7386            ProviderRuntimePolicy::raw_pty(),
7387            None,
7388        );
7389    }
7390
7391    #[cfg(test)]
7392    fn bind_session_with_policy(
7393        &self,
7394        address: &SessionAddress,
7395        runtime_policy: ProviderRuntimePolicy,
7396        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7397    ) {
7398        self.bind_session_with_materialization(
7399            address,
7400            runtime_policy,
7401            environment_profile,
7402            None,
7403            None,
7404            None,
7405        );
7406    }
7407
7408    fn bind_session_with_materialization(
7409        &self,
7410        address: &SessionAddress,
7411        runtime_policy: ProviderRuntimePolicy,
7412        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7413        bundle: Option<ResolvedBundleReceipt>,
7414        context: Option<ResolvedContextPackReceipt>,
7415        materialization_id: Option<MaterializationId>,
7416    ) {
7417        let mut bindings = self
7418            .session_bindings
7419            .lock()
7420            .unwrap_or_else(|poisoned| poisoned.into_inner());
7421        debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7422        debug_assert!(!bindings.contains_key(&address.session.instance_id));
7423        bindings.insert(address.session.instance_id, SessionBinding {
7424            workspace_id: address.workspace_id.clone(),
7425            generation: address.session.generation,
7426            runtime_policy,
7427            pending_resume: None,
7428            record_id: None,
7429            managed_worktree_lease_id: None,
7430            environment_profile,
7431            bundle,
7432            context,
7433            materialization_id,
7434        });
7435    }
7436
7437    #[cfg(test)]
7438    fn bind_managed_session(
7439        &self,
7440        address: &SessionAddress,
7441        record_id: SessionRecordId,
7442        runtime_policy: ProviderRuntimePolicy,
7443        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7444    ) {
7445        self.bind_managed_session_with_materialization(
7446            address,
7447            record_id,
7448            runtime_policy,
7449            environment_profile,
7450            None,
7451            None,
7452            None,
7453        );
7454    }
7455
7456    fn bind_managed_session_with_materialization(
7457        &self,
7458        address: &SessionAddress,
7459        record_id: SessionRecordId,
7460        runtime_policy: ProviderRuntimePolicy,
7461        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7462        bundle: Option<ResolvedBundleReceipt>,
7463        context: Option<ResolvedContextPackReceipt>,
7464        materialization_id: Option<MaterializationId>,
7465    ) {
7466        let mut bindings = self
7467            .session_bindings
7468            .lock()
7469            .unwrap_or_else(|poisoned| poisoned.into_inner());
7470        debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7471        debug_assert!(!bindings.contains_key(&address.session.instance_id));
7472        bindings.insert(
7473            address.session.instance_id,
7474            SessionBinding {
7475                workspace_id: address.workspace_id.clone(),
7476                generation: address.session.generation,
7477                runtime_policy,
7478                pending_resume: None,
7479                record_id: Some(record_id),
7480                managed_worktree_lease_id: None,
7481                environment_profile,
7482                bundle,
7483                context,
7484                materialization_id,
7485            },
7486        );
7487    }
7488
7489    #[cfg(test)]
7490    fn bind_spawn_session(
7491        &self,
7492        address: &SessionAddress,
7493        provider: AgentId,
7494        mode: SessionMode,
7495        runtime_policy: ProviderRuntimePolicy,
7496        record_policy: SpawnRecordPolicy,
7497        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7498    ) -> Result<Option<SessionRecordId>, NodeFailure> {
7499        self.bind_spawn_session_with_materialization(
7500            address,
7501            provider,
7502            mode,
7503            runtime_policy,
7504            record_policy,
7505            environment_profile,
7506            None,
7507            None,
7508            None,
7509        )
7510    }
7511
7512    fn bind_spawn_session_with_materialization(
7513        &self,
7514        address: &SessionAddress,
7515        provider: AgentId,
7516        mode: SessionMode,
7517        runtime_policy: ProviderRuntimePolicy,
7518        record_policy: SpawnRecordPolicy,
7519        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7520        bundle: Option<ResolvedBundleReceipt>,
7521        context: Option<ResolvedContextPackReceipt>,
7522        materialization_id: Option<MaterializationId>,
7523    ) -> Result<Option<SessionRecordId>, NodeFailure> {
7524        if record_policy == SpawnRecordPolicy::ProviderIdentityOnly
7525            && !runtime_policy.provider_session_identity
7526        {
7527            self.bind_session_with_materialization(
7528                address,
7529                runtime_policy,
7530                environment_profile,
7531                bundle,
7532                context,
7533                materialization_id,
7534            );
7535            return Ok(None);
7536        }
7537        let record_owns_lifecycle = runtime_policy.provider_session_identity;
7538        let record = self.new_record(
7539            address,
7540            provider,
7541            mode,
7542            active_record_state(runtime_policy.provider_session_identity, false),
7543            None,
7544            record_owns_lifecycle.then(|| environment_profile.clone()).flatten(),
7545            record_owns_lifecycle.then(|| bundle.clone()).flatten(),
7546            record_owns_lifecycle.then(|| context.clone()).flatten(),
7547        )?;
7548        let record_id = record.record_id.clone();
7549        let transaction = self
7550            .state_transaction
7551            .lock()
7552            .unwrap_or_else(|poisoned| poisoned.into_inner());
7553        self.insert_record(record.clone())?;
7554        let previous_materialization = if record_owns_lifecycle {
7555            if let Some(materialization_id) = materialization_id.as_ref() {
7556                let mut materializations = self
7557                    .materializations
7558                    .lock()
7559                    .unwrap_or_else(|poisoned| poisoned.into_inner());
7560                let Some(ownership) = materializations.get_mut(materialization_id) else {
7561                    drop(materializations);
7562                    self.remove_record_memory(&record_id);
7563                    return Err(failure(
7564                        NodeFailureCode::BackendOperationFailed,
7565                        "session environment ownership disappeared before binding",
7566                    ));
7567                };
7568                let previous = ownership.clone();
7569                if ownership
7570                    .transfer_to_record(record_id.clone(), unix_time_ms())
7571                    .is_err()
7572                {
7573                    drop(materializations);
7574                    self.remove_record_memory(&record_id);
7575                    return Err(failure(
7576                        NodeFailureCode::BackendOperationFailed,
7577                        "session environment ownership transfer failed",
7578                    ));
7579                }
7580                Some(previous)
7581            } else {
7582                None
7583            }
7584        } else {
7585            None
7586        };
7587        self.bind_managed_session_with_materialization(
7588            address,
7589            record_id.clone(),
7590            runtime_policy,
7591            environment_profile,
7592            bundle,
7593            context,
7594            materialization_id.clone(),
7595        );
7596        if let Err(error) = self.persist_state_locked() {
7597            self.remove_binding(address);
7598            self.remove_record_memory(&record_id);
7599            if let (Some(materialization_id), Some(previous)) =
7600                (materialization_id.as_ref(), previous_materialization)
7601            {
7602                self.materializations
7603                    .lock()
7604                    .unwrap_or_else(|poisoned| poisoned.into_inner())
7605                    .insert(materialization_id.clone(), previous);
7606            }
7607            return Err(persistence_failure(error));
7608        }
7609        drop(transaction);
7610        self.publish_record(record);
7611        Ok(Some(record_id))
7612    }
7613
7614    fn allocate_record_id(&self) -> Result<SessionRecordId, NodeFailure> {
7615        for _ in 0..8 {
7616            let bytes = random_nonce().map_err(|error| {
7617                failure(
7618                    NodeFailureCode::BackendOperationFailed,
7619                    &format!("session record identity generation failed: {error}"),
7620                )
7621            })?;
7622            let mut value = String::with_capacity(27);
7623            value.push_str("sr-");
7624            for byte in &bytes[..12] {
7625                use std::fmt::Write as _;
7626                let _ = write!(&mut value, "{byte:02x}");
7627            }
7628            let record_id = SessionRecordId::new(value).map_err(|error| {
7629                failure(NodeFailureCode::BackendOperationFailed, &error.to_string())
7630            })?;
7631            let records = self
7632                .session_records
7633                .lock()
7634                .unwrap_or_else(|poisoned| poisoned.into_inner());
7635            if !records.records.contains_key(&record_id) {
7636                return Ok(record_id);
7637            }
7638        }
7639        Err(failure(
7640            NodeFailureCode::BackendBusy,
7641            "could not allocate a unique session record ID",
7642        ))
7643    }
7644
7645    fn allocate_task_id(&self) -> Result<TaskId, NodeFailure> {
7646        for _ in 0..8 {
7647            let bytes = random_nonce().map_err(|error| {
7648                failure(
7649                    NodeFailureCode::BackendOperationFailed,
7650                    &format!("task identity generation failed: {error}"),
7651                )
7652            })?;
7653            let mut nonce = [0_u8; 12];
7654            nonce.copy_from_slice(&bytes[..12]);
7655            let task_id = TaskId::from_nonce(nonce);
7656            let records = self
7657                .session_records
7658                .lock()
7659                .unwrap_or_else(|poisoned| poisoned.into_inner());
7660            if !records.records.values().any(|record| {
7661                record.task_binding.as_ref().and_then(|binding| binding.task_id.as_ref())
7662                    == Some(&task_id)
7663            }) {
7664                return Ok(task_id);
7665            }
7666        }
7667        Err(failure(
7668            NodeFailureCode::BackendBusy,
7669            "could not allocate a unique task ID",
7670        ))
7671    }
7672
7673    fn default_record_name(&self, provider: &AgentId) -> String {
7674        let records = self
7675            .session_records
7676            .lock()
7677            .unwrap_or_else(|poisoned| poisoned.into_inner());
7678        let ordinal = records
7679            .records
7680            .values()
7681            .filter(|record| &record.provider == provider)
7682            .count()
7683            .saturating_add(1);
7684        format!("{} #{ordinal}", provider.as_str())
7685    }
7686
7687    fn new_record(
7688        &self,
7689        address: &SessionAddress,
7690        provider: AgentId,
7691        mode: SessionMode,
7692        state: ManagedSessionState,
7693        provider_session: Option<gate4agent_types::ProviderSessionIdentity>,
7694        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7695        bundle: Option<ResolvedBundleReceipt>,
7696        context: Option<ResolvedContextPackReceipt>,
7697    ) -> Result<ManagedSessionRecord, NodeFailure> {
7698        let canonical_root = self.workspace_root(&address.workspace_id)?;
7699        let now = unix_time_ms();
7700        Ok(ManagedSessionRecord {
7701            record_id: self.allocate_record_id()?,
7702            display_name: self.default_record_name(&provider),
7703            provider,
7704            mode,
7705            state,
7706            workspace_id: address.workspace_id.clone(),
7707            canonical_root: opaque_windows_path(canonical_root),
7708            provider_session,
7709            active_session: Some(address.clone()),
7710            environment_profile,
7711            bundle,
7712            context_id: context.as_ref().map(|receipt| receipt.id.clone()),
7713            context,
7714            exported_context: None,
7715            task_binding: None,
7716            created_at_unix_ms: now,
7717            updated_at_unix_ms: now,
7718            last_error: None,
7719        })
7720    }
7721
7722    fn insert_record(&self, record: ManagedSessionRecord) -> Result<(), NodeFailure> {
7723        let mut records = self
7724            .session_records
7725            .lock()
7726            .unwrap_or_else(|poisoned| poisoned.into_inner());
7727        if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
7728            return Err(failure(
7729                NodeFailureCode::BackendBusy,
7730                "managed session record capacity is full; forget an inactive record first",
7731            ));
7732        }
7733        if let Some(identity) = record.provider_session.as_ref() {
7734            if records.records.values().any(|candidate| {
7735                candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
7736                    session_registry::same_provider_session(
7737                        &candidate.provider,
7738                        candidate_identity,
7739                        &record.provider,
7740                        identity,
7741                    )
7742                })
7743            }) {
7744                return Err(failure(
7745                    NodeFailureCode::SessionRecordConflict,
7746                    "provider session is already represented by another managed record",
7747                ));
7748            }
7749        }
7750        match records.records.entry(record.record_id.clone()) {
7751            std::collections::btree_map::Entry::Vacant(entry) => {
7752                entry.insert(record);
7753            }
7754            std::collections::btree_map::Entry::Occupied(_) => {
7755                return Err(failure(
7756                    NodeFailureCode::SessionRecordConflict,
7757                    "session record ID collision",
7758                ));
7759            }
7760        }
7761        Ok(())
7762    }
7763
7764    fn record(&self, record_id: &SessionRecordId) -> Result<ManagedSessionRecord, NodeFailure> {
7765        self.session_records
7766            .lock()
7767            .unwrap_or_else(|poisoned| poisoned.into_inner())
7768            .records
7769            .get(record_id)
7770            .cloned()
7771            .ok_or_else(|| {
7772                failure(
7773                    NodeFailureCode::UnknownSessionRecord,
7774                    "managed session record does not exist",
7775                )
7776            })
7777    }
7778
7779    fn revalidate_session_record_preview(
7780        &self,
7781        expected: &ManagedSessionRecord,
7782        identity: &ProviderSessionIdentity,
7783    ) -> Result<(), NodeFailure> {
7784        let current = self.record(&expected.record_id)?;
7785        let current_root = self.workspace_root(&current.workspace_id)?;
7786        if current.provider != expected.provider
7787            || current.workspace_id != expected.workspace_id
7788            || current.canonical_root != expected.canonical_root
7789            || current.provider_session.as_ref() != Some(identity)
7790            || !platform::roots_equal(
7791                &current_root,
7792                windows_path_text(&current.canonical_root),
7793            )
7794        {
7795            return Err(failure(
7796                NodeFailureCode::SessionRecordConflict,
7797                "managed session changed while its preview was loading",
7798            ));
7799        }
7800        Ok(())
7801    }
7802
7803    fn revalidate_session_record_context_export(
7804        &self,
7805        expected: &ManagedSessionRecord,
7806        identity: Option<&ProviderSessionIdentity>,
7807        session: &SessionAddress,
7808        allow_clean_detachment: bool,
7809    ) -> Result<(), NodeFailure> {
7810        let current = self.record(&expected.record_id)?;
7811        let current_root = self.workspace_root(&current.workspace_id)?;
7812        let snapshot = self.internal_session_snapshot(session)?;
7813        let binding = self
7814            .session_bindings
7815            .lock()
7816            .unwrap_or_else(|poisoned| poisoned.into_inner())
7817            .get(&session.session.instance_id)
7818            .cloned()
7819            .ok_or_else(|| {
7820                failure(
7821                    NodeFailureCode::SessionRecordConflict,
7822                    "managed session runtime binding disappeared during context export",
7823                )
7824            })?;
7825        if !session_record_context_export_binding_is_exact(
7826            expected,
7827            &current,
7828            identity,
7829            session,
7830            &binding,
7831            &snapshot.agent_id,
7832            &current_root,
7833            allow_clean_detachment,
7834        ) || !session_record_context_export_source_is_usable(
7835            &current,
7836            &snapshot.status,
7837            allow_clean_detachment,
7838        ) {
7839            return Err(failure(
7840                NodeFailureCode::SessionRecordConflict,
7841                "managed session binding changed during context export",
7842            ));
7843        }
7844        Ok(())
7845    }
7846
7847    fn revalidate_loaded_history(
7848        &self,
7849        session: &SessionAddress,
7850        candidate_id: &str,
7851        loaded: &HistorySessionRecord,
7852    ) -> Result<(), NodeFailure> {
7853        let snapshot = self.internal_session_snapshot(session)?;
7854        if snapshot.history.pending.is_some()
7855            || snapshot.history.loaded_candidate_id.as_deref() != Some(candidate_id)
7856            || snapshot.history.loaded.as_ref() != Some(loaded)
7857        {
7858            return Err(failure(
7859                NodeFailureCode::SessionRecordConflict,
7860                "managed session history changed during context export",
7861            ));
7862        }
7863        Ok(())
7864    }
7865
7866    fn remove_record_memory(&self, record_id: &SessionRecordId) -> Option<ManagedSessionRecord> {
7867        self.session_records
7868            .lock()
7869            .unwrap_or_else(|poisoned| poisoned.into_inner())
7870            .records
7871            .remove(record_id)
7872    }
7873
7874    fn discard_record(&self, record_id: &SessionRecordId) -> Result<(), NodeFailure> {
7875        if self.destroy_record_materialization(record_id)? {
7876            self.publish(NodeEvent::SessionRecordRemoved {
7877                record_id: record_id.clone(),
7878            });
7879            return Ok(());
7880        }
7881        let transaction = self
7882            .state_transaction
7883            .lock()
7884            .unwrap_or_else(|poisoned| poisoned.into_inner());
7885        let Some(removed) = self.remove_record_memory(record_id) else {
7886            return Ok(());
7887        };
7888        if let Err(error) = self.persist_state_locked() {
7889            let _ = self.insert_record(removed);
7890            return Err(persistence_failure(error));
7891        }
7892        drop(transaction);
7893        self.publish(NodeEvent::SessionRecordRemoved {
7894            record_id: record_id.clone(),
7895        });
7896        Ok(())
7897    }
7898
7899    fn publish_record(&self, record: ManagedSessionRecord) {
7900        self.publish(NodeEvent::SessionRecordUpserted { record });
7901    }
7902
7903    fn mark_record_error(
7904        &self,
7905        record_id: &SessionRecordId,
7906        message: &str,
7907    ) -> Result<(), NodeFailure> {
7908        let transaction = self
7909            .state_transaction
7910            .lock()
7911            .unwrap_or_else(|poisoned| poisoned.into_inner());
7912        let previous = {
7913            let mut records = self
7914                .session_records
7915                .lock()
7916                .unwrap_or_else(|poisoned| poisoned.into_inner());
7917            let Some(record) = records.records.get_mut(record_id) else {
7918                return Ok(());
7919            };
7920            let previous = record.clone();
7921            record.state = ManagedSessionState::Unavailable;
7922            record.last_error = Some(session_registry::sanitized_record_error_summary(message));
7923            record.updated_at_unix_ms = unix_time_ms();
7924            previous
7925        };
7926        if let Err(error) = self.persist_state_locked() {
7927            self.session_records
7928                .lock()
7929                .unwrap_or_else(|poisoned| poisoned.into_inner())
7930                .records
7931                .insert(record_id.clone(), previous);
7932            return Err(persistence_failure(error));
7933        }
7934        let record = self.record(record_id)?;
7935        drop(transaction);
7936        self.publish_record(record);
7937        Ok(())
7938    }
7939
7940    fn rename_session_record(
7941        &self,
7942        record_id: &SessionRecordId,
7943        display_name: String,
7944    ) -> Result<ManagedSessionRecord, NodeFailure> {
7945        validate_display_name(&display_name)
7946            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
7947        let _transaction = self
7948            .state_transaction
7949            .lock()
7950            .unwrap_or_else(|poisoned| poisoned.into_inner());
7951        let previous = {
7952            let mut records = self
7953                .session_records
7954                .lock()
7955                .unwrap_or_else(|poisoned| poisoned.into_inner());
7956            let record = records.records.get_mut(record_id).ok_or_else(|| {
7957                failure(
7958                    NodeFailureCode::UnknownSessionRecord,
7959                    "managed session record does not exist",
7960                )
7961            })?;
7962            let previous = record.clone();
7963            record.display_name = display_name;
7964            record.updated_at_unix_ms = unix_time_ms();
7965            previous
7966        };
7967        if let Err(error) = self.persist_state_locked() {
7968            self.session_records
7969                .lock()
7970                .unwrap_or_else(|poisoned| poisoned.into_inner())
7971                .records
7972                .insert(record_id.clone(), previous);
7973            return Err(persistence_failure(error));
7974        }
7975        let updated = self.record(record_id)?;
7976        self.publish_record(updated.clone());
7977        Ok(updated)
7978    }
7979
7980    fn set_session_task(
7981        &self,
7982        record_id: &SessionRecordId,
7983        expected_revision: u64,
7984        target: SessionTaskTargetV1,
7985    ) -> Result<ManagedSessionRecord, NodeFailure> {
7986        let transaction = self
7987            .state_transaction
7988            .lock()
7989            .unwrap_or_else(|poisoned| poisoned.into_inner());
7990        let current = self.record(record_id)?;
7991        let current_task_id = current
7992            .task_binding
7993            .as_ref()
7994            .and_then(|binding| binding.task_id.as_ref());
7995        let current_revision = current
7996            .task_binding
7997            .as_ref()
7998            .map_or(0, |binding| binding.revision);
7999        if expected_revision != current_revision {
8000            return Err(failure(
8001                NodeFailureCode::SessionRecordConflict,
8002                "managed session task binding revision changed",
8003            ));
8004        }
8005        let exact = match &target {
8006            SessionTaskTargetV1::Existing { task_id } => current_task_id == Some(task_id),
8007            SessionTaskTargetV1::Clear => current_task_id.is_none(),
8008            SessionTaskTargetV1::New => false,
8009        };
8010        if exact {
8011            return Ok(current);
8012        }
8013        let revision = current_revision.checked_add(1).ok_or_else(|| {
8014            failure(
8015                NodeFailureCode::SessionRecordConflict,
8016                "managed session task binding revision is exhausted",
8017            )
8018        })?;
8019        let task_id = match target {
8020            SessionTaskTargetV1::New => Some(self.allocate_task_id()?),
8021            SessionTaskTargetV1::Existing { task_id } => Some(task_id),
8022            SessionTaskTargetV1::Clear => None,
8023        };
8024        let changed_at_unix_ms = unix_time_ms()
8025            .max(current.updated_at_unix_ms.checked_add(1).ok_or_else(|| {
8026                failure(
8027                    NodeFailureCode::SessionRecordConflict,
8028                    "managed session record timestamp is exhausted",
8029                )
8030            })?);
8031        let updated = {
8032            let mut records = self
8033                .session_records
8034                .lock()
8035                .unwrap_or_else(|poisoned| poisoned.into_inner());
8036            let record = records.records.get_mut(record_id).ok_or_else(|| {
8037                failure(
8038                    NodeFailureCode::UnknownSessionRecord,
8039                    "managed session record does not exist",
8040                )
8041            })?;
8042            record.task_binding = Some(SessionTaskBindingV1 {
8043                revision,
8044                task_id,
8045                changed_at_unix_ms,
8046            });
8047            record.updated_at_unix_ms = changed_at_unix_ms;
8048            record.clone()
8049        };
8050        if let Err(error) = self.persist_state_locked() {
8051            self.session_records
8052                .lock()
8053                .unwrap_or_else(|poisoned| poisoned.into_inner())
8054                .records
8055                .insert(record_id.clone(), current);
8056            return Err(persistence_failure(error));
8057        }
8058        drop(transaction);
8059        self.publish_record(updated.clone());
8060        Ok(updated)
8061    }
8062
8063    fn index_provider_session(
8064        &self,
8065        workspace_id: WorkspaceId,
8066        provider: AgentId,
8067        identity: ProviderSessionIdentity,
8068        display_name: String,
8069    ) -> Result<ManagedSessionRecord, NodeFailure> {
8070        self.index_provider_session_with_policy(
8071            workspace_id,
8072            provider,
8073            identity,
8074            display_name,
8075            false,
8076        )
8077    }
8078
8079    fn index_provider_session_with_policy(
8080        &self,
8081        workspace_id: WorkspaceId,
8082        provider: AgentId,
8083        identity: ProviderSessionIdentity,
8084        display_name: String,
8085        allow_validated_transcript_path: bool,
8086    ) -> Result<ManagedSessionRecord, NodeFailure> {
8087        validate_display_name(&display_name)
8088            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8089        identity
8090            .validate()
8091            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8092        if identity.transcript_path.is_some() && !allow_validated_transcript_path {
8093            return Err(failure(
8094                NodeFailureCode::InvalidRequest,
8095                "provider session reference index v1 does not accept transcript paths",
8096            ));
8097        }
8098        let canonical_root = self.workspace_root(&workspace_id)?;
8099        if !self.enabled_providers.contains(&provider) {
8100            return Err(failure(
8101                NodeFailureCode::InvalidRequest,
8102                "provider is not enabled on this node",
8103            ));
8104        }
8105
8106        let transaction = self
8107            .state_transaction
8108            .lock()
8109            .unwrap_or_else(|poisoned| poisoned.into_inner());
8110        {
8111            let records = self
8112                .session_records
8113                .lock()
8114                .unwrap_or_else(|poisoned| poisoned.into_inner());
8115            if let Some(existing) = records.records.values().find(|candidate| {
8116                candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8117                    candidate_identity.key == identity.key && candidate_identity.id == identity.id
8118                })
8119            }) {
8120                if existing.provider == provider && existing.workspace_id == workspace_id {
8121                    return Ok(existing.clone());
8122                }
8123                return Err(failure(
8124                    NodeFailureCode::SessionRecordConflict,
8125                    "provider session reference conflicts with another provider or workspace",
8126                ));
8127            }
8128            if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
8129                return Err(failure(
8130                    NodeFailureCode::BackendBusy,
8131                    "managed session record capacity is full; forget an inactive record first",
8132                ));
8133            }
8134        }
8135
8136        let now = unix_time_ms();
8137        let record = ManagedSessionRecord {
8138            record_id: self.allocate_record_id()?,
8139            display_name,
8140            provider,
8141            mode: SessionMode::Pty,
8142            state: ManagedSessionState::Dormant,
8143            workspace_id,
8144            canonical_root: opaque_windows_path(canonical_root),
8145            provider_session: Some(identity),
8146            active_session: None,
8147            environment_profile: None,
8148            bundle: None,
8149            context_id: None,
8150            context: None,
8151            exported_context: None,
8152            task_binding: None,
8153            created_at_unix_ms: now,
8154            updated_at_unix_ms: now,
8155            last_error: None,
8156        };
8157        self.insert_record(record.clone())?;
8158        if let Err(error) = self.persist_state_locked() {
8159            self.remove_record_memory(&record.record_id);
8160            return Err(persistence_failure(error));
8161        }
8162        drop(transaction);
8163        self.publish_record(record.clone());
8164        Ok(record)
8165    }
8166
8167    fn bound_address_for_record(&self, record_id: &SessionRecordId) -> Option<SessionAddress> {
8168        self.session_bindings
8169            .lock()
8170            .unwrap_or_else(|poisoned| poisoned.into_inner())
8171            .iter()
8172            .find_map(|(instance_id, binding)| {
8173                (binding.record_id.as_ref() == Some(record_id)).then(|| SessionAddress {
8174                    workspace_id: binding.workspace_id.clone(),
8175                    session: SessionKey {
8176                        instance_id: *instance_id,
8177                        generation: binding.generation,
8178                    },
8179                })
8180            })
8181    }
8182
8183    async fn forget_session_record(
8184        &self,
8185        record_id: &SessionRecordId,
8186    ) -> Result<(), NodeFailure> {
8187        let record = self.record(record_id)?;
8188        if record.active_session.is_some()
8189            || matches!(
8190                record.state,
8191                ManagedSessionState::Live | ManagedSessionState::IdentityPending
8192            )
8193        {
8194            return Err(failure(
8195                NodeFailureCode::SessionRecordBusy,
8196                "stop the managed session before forgetting it",
8197            ));
8198        }
8199        if let Some(address) = self.bound_address_for_record(record_id) {
8200            self.remove_session(&address).await?;
8201        }
8202        self.remove_managed_session_record(record_id, &record.workspace_id).await
8203    }
8204
8205    /// Removes one managed session record from durable state and notifies
8206    /// every listener -- the shared transaction body behind both
8207    /// `forget_session_record`'s operator-driven path (busy-checked above
8208    /// it, and clears any still-live session binding before calling in
8209    /// here) and the retention sweep (`retire_unavailable_session_records`,
8210    /// which only ever selects `ManagedSessionState::Unavailable` records
8211    /// that are already known never-busy and never bound, so it calls
8212    /// straight into this with neither check). Cleans a managed-worktree
8213    /// lease if the record held one, removes the record's environment
8214    /// materialization (or, absent one, removes it from memory directly),
8215    /// persists under `state_transaction` with rollback on persist failure,
8216    /// then publishes `NodeEvent::SessionRecordRemoved` so c2's own
8217    /// legacy-provider bookkeeping (`record_is_legacy`) stays in sync
8218    /// exactly as it does for an operator-driven forget.
8219    async fn remove_managed_session_record(
8220        &self,
8221        record_id: &SessionRecordId,
8222        workspace_id: &WorkspaceId,
8223    ) -> Result<(), NodeFailure> {
8224        let managed_lease_id = self.managed_worktrees.lock()
8225            .unwrap_or_else(|poisoned| poisoned.into_inner())
8226            .lease_for_workspace(workspace_id);
8227        if !self.destroy_record_materialization(record_id)? {
8228            let transaction = self
8229                .state_transaction
8230                .lock()
8231                .unwrap_or_else(|poisoned| poisoned.into_inner());
8232            let removed = self.remove_record_memory(record_id).ok_or_else(|| {
8233                failure(
8234                    NodeFailureCode::UnknownSessionRecord,
8235                    "managed session record does not exist",
8236                )
8237            })?;
8238            if let Err(error) = self.persist_state_locked() {
8239                let _ = self.insert_record(removed);
8240                return Err(persistence_failure(error));
8241            }
8242            drop(transaction);
8243        }
8244        self.publish(NodeEvent::SessionRecordRemoved {
8245            record_id: record_id.clone(),
8246        });
8247        if let Some(lease_id) = managed_lease_id {
8248            if let Some(snapshot) = self.managed_worktrees.lock()
8249                .unwrap_or_else(|poisoned| poisoned.into_inner())
8250                .get(&lease_id)
8251                .map(ManagedWorktreeLeaseRecord::snapshot)
8252            {
8253                self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
8254            }
8255            let _ = self.cleanup_managed_worktree(&lease_id, false).await;
8256        }
8257        Ok(())
8258    }
8259
8260    /// Retires `ManagedSessionState::Unavailable` records against the
8261    /// node's configured `session_record_retention` age/keep-N policy.
8262    /// Never touches `Live`, `IdentityPending`, or `Dormant` records --
8263    /// `Dormant` is resumable and folding it into this sweep would delete
8264    /// records a caller can still act on. Both policy fields default to
8265    /// `0` (disabled); when both are `0` this is a no-op. When both are
8266    /// set, a record is retired only if it fails BOTH tests (older than
8267    /// `age_ms` AND beyond the newest `keep_per_workspace` in its
8268    /// workspace); when only one is set, that one alone decides. Called
8269    /// once at startup (alongside `reconcile_managed_worktrees`) and then
8270    /// on a `SESSION_RECORD_RETENTION_SWEEP_PERIOD` cadence from the drive
8271    /// loop -- see both call sites.
8272    async fn retire_unavailable_session_records(&self) {
8273        let policy = self.session_record_retention;
8274        let age_enabled = policy.age_ms > 0;
8275        let keep_enabled = policy.keep_per_workspace > 0;
8276        if !age_enabled && !keep_enabled {
8277            return;
8278        }
8279        let now = unix_time_ms();
8280        let candidates: Vec<(SessionRecordId, WorkspaceId, AgentId, u64)> = {
8281            self.session_records
8282                .lock()
8283                .unwrap_or_else(|poisoned| poisoned.into_inner())
8284                .records
8285                .values()
8286                .filter(|record| record.state == ManagedSessionState::Unavailable)
8287                .map(|record| {
8288                    (
8289                        record.record_id.clone(),
8290                        record.workspace_id.clone(),
8291                        record.provider.clone(),
8292                        record.updated_at_unix_ms,
8293                    )
8294                })
8295                .collect()
8296        };
8297        if candidates.is_empty() {
8298            return;
8299        }
8300        let mut by_workspace: BTreeMap<WorkspaceId, Vec<(SessionRecordId, AgentId, u64)>> =
8301            BTreeMap::new();
8302        for (record_id, workspace_id, provider, updated_at_unix_ms) in candidates {
8303            by_workspace
8304                .entry(workspace_id)
8305                .or_default()
8306                .push((record_id, provider, updated_at_unix_ms));
8307        }
8308        let reason = if age_enabled && keep_enabled {
8309            "age+keep-n"
8310        } else if age_enabled {
8311            "age"
8312        } else {
8313            "keep-n"
8314        };
8315        let mut retired: u64 = 0;
8316        for (workspace_id, mut records) in by_workspace {
8317            // Newest `updated_at_unix_ms` first, so index `>=
8318            // keep_per_workspace` names exactly "beyond the newest N".
8319            records.sort_by(|left, right| right.2.cmp(&left.2));
8320            for (index, (record_id, provider, updated_at_unix_ms)) in
8321                records.into_iter().enumerate()
8322            {
8323                let record_age_ms = now.saturating_sub(updated_at_unix_ms);
8324                let age_hit = age_enabled && record_age_ms >= policy.age_ms;
8325                let keep_hit = keep_enabled && index >= policy.keep_per_workspace as usize;
8326                let should_retire = if age_enabled && keep_enabled {
8327                    age_hit && keep_hit
8328                } else {
8329                    age_hit || keep_hit
8330                };
8331                if !should_retire {
8332                    continue;
8333                }
8334                match self.remove_managed_session_record(&record_id, &workspace_id).await {
8335                    Ok(()) => {
8336                        self.retired_records_total.fetch_add(1, Ordering::Relaxed);
8337                        retired += 1;
8338                        tracing::info!(
8339                            node_id = %self.node_id,
8340                            record_id = %record_id,
8341                            workspace_id = %workspace_id,
8342                            provider = %provider,
8343                            age_ms = record_age_ms,
8344                            reason,
8345                            "managed session record retired"
8346                        );
8347                    }
8348                    Err(error) => tracing::warn!(
8349                        node_id = %self.node_id,
8350                        record_id = %record_id,
8351                        workspace_id = %workspace_id,
8352                        code = ?error.code,
8353                        cause = %error.message,
8354                        "session record retention sweep failed to retire a record"
8355                    ),
8356                }
8357            }
8358        }
8359        if retired > 0 {
8360            let remaining_unavailable = self
8361                .session_records
8362                .lock()
8363                .unwrap_or_else(|poisoned| poisoned.into_inner())
8364                .records
8365                .values()
8366                .filter(|record| record.state == ManagedSessionState::Unavailable)
8367                .count();
8368            tracing::info!(
8369                node_id = %self.node_id,
8370                retired,
8371                remaining_unavailable,
8372                "session record retention sweep completed"
8373            );
8374        }
8375    }
8376
8377    async fn resume_session_record(
8378        &self,
8379        record_id: &SessionRecordId,
8380        terminal_size: gate4agent_types::TerminalSize,
8381        initial_prompt: Option<String>,
8382    ) -> Result<(ManagedSessionRecord, SessionAddress), NodeFailure> {
8383        let record = self.record(record_id)?;
8384        if record.active_session.is_some()
8385            || matches!(
8386                record.state,
8387                ManagedSessionState::Live | ManagedSessionState::IdentityPending
8388            )
8389        {
8390            return Err(failure(
8391                NodeFailureCode::SessionRecordBusy,
8392                "managed session already has a live runtime binding",
8393            ));
8394        }
8395        let identity = record.provider_session.clone().ok_or_else(|| {
8396            failure(
8397                NodeFailureCode::SessionRecordNotResumable,
8398                "managed session has no verified provider session identity",
8399            )
8400        })?;
8401        let working_directory = self.workspace_root(&record.workspace_id)?;
8402        if !platform::roots_equal(
8403            &working_directory,
8404            windows_path_text(&record.canonical_root),
8405        ) {
8406            return Err(failure(
8407                NodeFailureCode::SessionRecordConflict,
8408                "managed session workspace root changed; refusing to resume in another directory",
8409            ));
8410        }
8411        {
8412            let records = self
8413                .session_records
8414                .lock()
8415                .unwrap_or_else(|poisoned| poisoned.into_inner());
8416            if records.records.values().any(|candidate| {
8417                candidate.record_id != *record_id
8418                    && candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8419                        session_registry::same_provider_session(
8420                            &candidate.provider,
8421                            candidate_identity,
8422                            &record.provider,
8423                            &identity,
8424                        )
8425                    })
8426                    && (candidate.active_session.is_some()
8427                        || matches!(
8428                            candidate.state,
8429                            ManagedSessionState::Live | ManagedSessionState::IdentityPending
8430                        ))
8431            }) {
8432                return Err(failure(
8433                    NodeFailureCode::SessionRecordConflict,
8434                    "provider session is already active through another managed record",
8435                ));
8436            }
8437        }
8438        let runtime_requirement = if initial_prompt.is_some() {
8439            ProviderRuntimeRequirement::ResumeWithPrompt
8440        } else {
8441            ProviderRuntimeRequirement::Resume
8442        };
8443        let runtime_policy = self.admit_provider_runtime(
8444            &record.provider,
8445            runtime_requirement,
8446        )
8447        .await?;
8448        let request = ResumeLaunchRequest {
8449            working_directory,
8450            terminal_size,
8451            initial_prompt,
8452        };
8453        request
8454            .validate()
8455            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8456        self.ensure_binding_capacity()?;
8457        let resolved_materialization = match self.resolve_record_materialization(
8458            record_id,
8459            &record.provider,
8460            record.mode,
8461            record.environment_profile.as_ref(),
8462            record.bundle.as_ref(),
8463            record.context.as_ref(),
8464        ) {
8465            Ok(materialization) => materialization,
8466            Err(error) => {
8467                let label = match error.code {
8468                    NodeFailureCode::UnknownBundle => "bundle-unavailable",
8469                    NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
8470                    NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
8471                    _ => "environment-profile-unavailable",
8472                };
8473                self.mark_record_error(record_id, label)?;
8474                return Err(error);
8475            }
8476        };
8477        let (materialization_id, environment_overlay) = match resolved_materialization {
8478            Some((id, overlay)) => (Some(id), overlay),
8479            None => (None, None),
8480        };
8481        let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
8482        let mut environment_selection = match SpawnPreparer::select_environment(self, 
8483            instance_id,
8484            &record.provider,
8485            record.mode,
8486            record.environment_profile.as_ref(),
8487        ) {
8488            Ok(selection) => selection,
8489            Err(error) => {
8490                self.mark_record_error(record_id, "environment-profile-unavailable")?;
8491                return Err(error);
8492            }
8493        };
8494        let mut instance_overlay = environment_overlay
8495            .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
8496            .transpose()?
8497            .flatten();
8498        if let Some(stale_address) = self.bound_address_for_record(record_id) {
8499            self.remove_session(&stale_address).await?;
8500        }
8501        let address = SessionAddress {
8502            workspace_id: record.workspace_id.clone(),
8503            session: SessionKey {
8504                instance_id,
8505                generation: SessionGeneration(1),
8506            },
8507        };
8508        let transaction = self
8509            .state_transaction
8510            .lock()
8511            .unwrap_or_else(|poisoned| poisoned.into_inner());
8512        let current = self.record(record_id)?;
8513        if current.active_session.is_some()
8514            || matches!(
8515                current.state,
8516                ManagedSessionState::Live | ManagedSessionState::IdentityPending
8517            )
8518        {
8519            return Err(failure(
8520                NodeFailureCode::SessionRecordBusy,
8521                "managed session became active while resume was being prepared",
8522            ));
8523        }
8524        self.bind_managed_session_with_materialization(
8525            &address,
8526            record_id.clone(),
8527            runtime_policy,
8528            record.environment_profile.clone(),
8529            record.bundle.clone(),
8530            record.context.clone(),
8531            materialization_id,
8532        );
8533        if let Some(selection) = environment_selection.take() {
8534            selection.retain();
8535        }
8536        if let Some(overlay) = instance_overlay.take() {
8537            overlay.retain();
8538        }
8539        drop(transaction);
8540
8541        let transport = match record.mode {
8542            SessionMode::Pty => TransportKind::Pty,
8543            SessionMode::Inline => TransportKind::Pipe,
8544            SessionMode::Acp => TransportKind::Acp,
8545        };
8546        let agent_id = record.provider.clone();
8547        let dispatch_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
8548        if let Err(error) = self
8549            .dispatch_bounded(
8550                ControlCommand::Register {
8551                    instance_id,
8552                    agent_id,
8553                    transport,
8554                },
8555                dispatch_timeout,
8556            )
8557            .await
8558        {
8559            if let Some(binding) = self.remove_binding(&address) {
8560                SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
8561            }
8562            return Err(error);
8563        }
8564        let command = self.prepare_command(ControlCommand::Resume {
8565            instance_id,
8566            target: ResumeTarget::ProviderSession { identity },
8567            runtime_policy,
8568            request,
8569        });
8570        let started_after = self.current_sequence();
8571        if let Err(error) = self.arm_resume(&address, command.id, runtime_policy) {
8572            self.rollback_spawn(&address, dispatch_timeout).await?;
8573            return Err(error);
8574        }
8575        if let Err(error) = self.dispatch_envelope(command) {
8576            self.clear_armed_resume(&address);
8577            self.rollback_spawn(&address, dispatch_timeout).await?;
8578            return Err(error);
8579        }
8580        match self
8581            .wait_for_record_resume(&address, started_after)
8582            .await
8583        {
8584            Ok(settled_address) => {
8585                let managed_lease_id = {
8586                    self.managed_worktrees.lock()
8587                        .unwrap_or_else(|poisoned| poisoned.into_inner())
8588                        .lease_for_workspace(&record.workspace_id)
8589                };
8590                if let Some(lease_id) = managed_lease_id {
8591                    {
8592                        let mut bindings = self.session_bindings.lock()
8593                            .unwrap_or_else(|poisoned| poisoned.into_inner());
8594                        let binding = bindings.get_mut(&settled_address.session.instance_id)
8595                            .ok_or_else(|| failure(
8596                                NodeFailureCode::ManagedWorktreeRecoveryRequired,
8597                                "resumed managed session lost its exact binding",
8598                            ))?;
8599                        if binding.generation != settled_address.session.generation {
8600                            return Err(failure(
8601                                NodeFailureCode::ManagedWorktreeRecoveryRequired,
8602                                "resumed managed session generation diverged",
8603                            ));
8604                        }
8605                        binding.managed_worktree_lease_id = Some(lease_id.clone());
8606                    }
8607                    let snapshot = self.managed_worktrees.lock()
8608                        .unwrap_or_else(|poisoned| poisoned.into_inner())
8609                        .bind_session(
8610                            &lease_id,
8611                            ManagedWorktreeSessionHolder {
8612                                incarnation_id: self.incarnation_id,
8613                                instance_id: settled_address.session.instance_id,
8614                                generation: settled_address.session.generation,
8615                            },
8616                            Some(record_id.clone()),
8617                            unix_time_ms(),
8618                        ).map_err(|message| failure(
8619                            NodeFailureCode::ManagedWorktreeRecoveryRequired,
8620                            &message,
8621                        ))?;
8622                    if let Err(error) = self.persist_state() {
8623                        let _ = self.remove_session(&settled_address).await;
8624                        return Err(persistence_failure(error));
8625                    }
8626                    self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
8627                }
8628                Ok((self.record(record_id)?, settled_address))
8629            }
8630            Err(error) => {
8631                if matches!(
8632                    error.code,
8633                    NodeFailureCode::SessionRecordNotResumable
8634                        | NodeFailureCode::BackendOperationFailed
8635                ) {
8636                    if let Some(bound) = self.bound_address_for_record(record_id) {
8637                        let _ = self.remove_session(&bound).await;
8638                    }
8639                }
8640                Err(error)
8641            }
8642        }
8643    }
8644
8645    async fn wait_for_record_resume(
8646        &self,
8647        initial_address: &SessionAddress,
8648        after_sequence: u64,
8649    ) -> Result<SessionAddress, NodeFailure> {
8650        let deadline = Instant::now()
8651            + Duration::from_millis(MANAGED_RESUME_SETTLE_TIMEOUT_MS);
8652        let mut scan_after = after_sequence;
8653        loop {
8654            let events = self
8655                .history
8656                .lock()
8657                .unwrap_or_else(|poisoned| poisoned.into_inner())
8658                .events
8659                .iter()
8660                .filter(|event| event.sequence > scan_after)
8661                .cloned()
8662                .collect::<Vec<_>>();
8663            for envelope in events {
8664                scan_after = scan_after.max(envelope.sequence);
8665                let NodeEvent::Control { address, event } = envelope.event else {
8666                    continue;
8667                };
8668                if address.session.instance_id != initial_address.session.instance_id {
8669                    continue;
8670                }
8671                match event.event {
8672                    ControlEventKind::Resumed { .. } => return Ok(address),
8673                    ControlEventKind::ResumeDenied { reason } => {
8674                        return Err(failure(
8675                            NodeFailureCode::SessionRecordNotResumable,
8676                            &reason,
8677                        ));
8678                    }
8679                    ControlEventKind::ResumeFailed { message }
8680                    | ControlEventKind::CommandRejected { message } => {
8681                        return Err(failure(
8682                            NodeFailureCode::BackendOperationFailed,
8683                            &message,
8684                        ));
8685                    }
8686                    _ => {}
8687                }
8688            }
8689            if Instant::now() >= deadline {
8690                return Err(failure(
8691                    NodeFailureCode::BackendBusy,
8692                    "managed resume did not settle before the bounded deadline",
8693                ));
8694            }
8695            sleep(Duration::from_millis(5)).await;
8696        }
8697    }
8698
8699    fn workspace_root(&self, workspace_id: &WorkspaceId) -> Result<String, NodeFailure> {
8700        self.workspaces
8701            .read()
8702            .unwrap_or_else(|poisoned| poisoned.into_inner())
8703            .get(workspace_id)
8704            .cloned()
8705            .ok_or_else(|| failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"))
8706    }
8707
8708    fn workspace_roots(&self) -> Vec<PathBuf> {
8709        self.workspaces
8710            .read()
8711            .unwrap_or_else(|poisoned| poisoned.into_inner())
8712            .values()
8713            .map(PathBuf::from)
8714            .collect()
8715    }
8716
8717    async fn inspect_workspace(
8718        &self,
8719        workspace_id: WorkspaceId,
8720    ) -> Result<WorkspaceInspection, NodeFailure> {
8721        let _permit = self
8722            .inspection_slots
8723            .clone()
8724            .try_acquire_owned()
8725            .map_err(|_| {
8726                tracing::warn!(
8727                    workspace_id = %workspace_id,
8728                    "workspace inspection rejected: inspection capacity is busy",
8729                );
8730                failure(
8731                    NodeFailureCode::BackendBusy,
8732                    "workspace inspection capacity is busy",
8733                )
8734            })?;
8735        let canonical_root = self.workspace_root(&workspace_id).map_err(|error| {
8736            tracing::warn!(
8737                workspace_id = %workspace_id,
8738                code = ?error.code,
8739                "workspace inspection rejected: workspace is not registered",
8740            );
8741            error
8742        })?;
8743        let tree_root = canonical_root.clone();
8744        let started_at = Instant::now();
8745        let time_budget_ms = workspace_inspection_time_budget_ms();
8746        let entry_cap = workspace_inspection_entry_cap();
8747        let deadline = started_at + Duration::from_millis(time_budget_ms);
8748        let (entries, tree_truncated, walk_budget) = tokio::task::spawn_blocking(move || {
8749            collect_workspace_entries(Path::new(&tree_root), deadline, entry_cap)
8750        })
8751        .await
8752        .map_err(|error| {
8753            tracing::warn!(
8754                workspace_id = %workspace_id,
8755                error = %error,
8756                "workspace inspection rejected: walk task failed",
8757            );
8758            failure(
8759                NodeFailureCode::BackendOperationFailed,
8760                &format!("workspace inspection task failed: {error}"),
8761            )
8762        })?;
8763        let (mut git, git_time_budget_exceeded) =
8764            inspect_git_workspace(&canonical_root, deadline).await;
8765        if !git.worktrees.is_empty() {
8766            let registered = self
8767                .workspaces
8768                .read()
8769                .unwrap_or_else(|poisoned| poisoned.into_inner())
8770                .iter()
8771                .map(|(workspace_id, root)| (workspace_id.clone(), root.clone()))
8772                .collect::<Vec<_>>();
8773            for worktree in &mut git.worktrees {
8774                worktree.workspace_id = registered
8775                    .iter()
8776                    .find(|(_, root)| {
8777                        worktree_paths_equal(root, windows_path_text(&worktree.path))
8778                    })
8779                    .map(|(workspace_id, _)| workspace_id.clone());
8780            }
8781        }
8782        if git.is_repository {
8783            git.managed_worktree = self
8784                .managed_worktrees
8785                .lock()
8786                .unwrap_or_else(|poisoned| poisoned.into_inner())
8787                .git_scope(&workspace_id, git.branch.as_deref());
8788        }
8789        let truncation = workspace_inspection_truncation(
8790            &walk_budget,
8791            git_time_budget_exceeded,
8792            started_at.elapsed(),
8793        );
8794        if let Some(truncation) = &truncation {
8795            tracing::info!(
8796                workspace_id = %workspace_id,
8797                walk_time_budget_exceeded = truncation.walk_time_budget_exceeded,
8798                walk_entry_cap_exceeded = truncation.walk_entry_cap_exceeded,
8799                git_time_budget_exceeded = truncation.git_time_budget_exceeded,
8800                entries_visited = truncation.entries_visited,
8801                elapsed_ms = truncation.elapsed_ms,
8802                "workspace inspection truncated by its inner budget",
8803            );
8804        }
8805        Ok(WorkspaceInspection {
8806            workspace_id,
8807            entries,
8808            tree_truncated,
8809            git,
8810            truncation,
8811        })
8812    }
8813
8814    async fn browse_host_directories(
8815        &self,
8816        directory: Option<OpaqueHostPath>,
8817        after: Option<OpaqueHostPath>,
8818    ) -> Result<HostDirectoryListing, NodeFailure> {
8819        let permit = self
8820            .inspection_slots
8821            .clone()
8822            .try_acquire_owned()
8823            .map_err(|_| {
8824                failure(
8825                    NodeFailureCode::BackendBusy,
8826                    "host directory browse capacity is busy",
8827                )
8828            })?;
8829        let task = tokio::task::spawn_blocking(move || {
8830            let _permit = permit;
8831            browse_host_directories(directory, after)
8832        });
8833        timeout(Duration::from_millis(HOST_DIRECTORY_BROWSE_TIMEOUT_MS), task)
8834            .await
8835            .map_err(|_| {
8836                failure(
8837                    NodeFailureCode::HostDirectoryReadTimedOut,
8838                    "host directory browse exceeded its bounded deadline",
8839                )
8840            })?
8841            .map_err(|_| {
8842                failure(
8843                    NodeFailureCode::HostDirectoryReadFailed,
8844                    "host directory browse task failed",
8845                )
8846            })?
8847            .map_err(host_directory_failure)
8848    }
8849
8850    async fn read_workspace_file(
8851        &self,
8852        workspace_id: WorkspaceId,
8853        path: RepositoryPath,
8854    ) -> Result<WorkspaceFileRead, NodeFailure> {
8855        let canonical_root = self.workspace_root(&workspace_id)?;
8856        let permit = self
8857            .inspection_slots
8858            .clone()
8859            .try_acquire_owned()
8860            .map_err(|_| {
8861                failure(
8862                    NodeFailureCode::BackendBusy,
8863                    "workspace file read capacity is busy",
8864                )
8865            })?;
8866        let path_to_read = path.clone();
8867        let task = tokio::task::spawn_blocking(move || {
8868            let _permit = permit;
8869            read_workspace_file_from_disk(Path::new(&canonical_root), &path_to_read)
8870        });
8871        let content = timeout(
8872            Duration::from_millis(WORKSPACE_FILE_READ_TIMEOUT_MS),
8873            task,
8874        )
8875        .await
8876        .map_err(|_| {
8877            failure(
8878                NodeFailureCode::RepositoryFileReadTimedOut,
8879                "repository file read exceeded its bounded deadline",
8880            )
8881        })?
8882        .map_err(|_| {
8883            failure(
8884                NodeFailureCode::RepositoryFileReadFailed,
8885                "repository file read task failed",
8886            )
8887        })?
8888        .map_err(workspace_file_failure)?;
8889        let content = match content {
8890            WorkspaceFileBytes::Utf8(text) => {
8891                let revision = Some(workspace_file_revision(text.as_bytes()));
8892                return Ok(WorkspaceFileRead {
8893                    workspace_id,
8894                    path,
8895                    content: WorkspaceFileContent::Utf8 {
8896                        byte_len: u32::try_from(text.len())
8897                            .expect("bounded workspace text length must fit u32"),
8898                        text,
8899                    },
8900                    revision,
8901                });
8902            }
8903            WorkspaceFileBytes::NonUtf8 { byte_length } => {
8904                WorkspaceFileContent::NonUtf8 {
8905                    byte_len: u32::try_from(byte_length)
8906                        .expect("bounded workspace file length must fit u32"),
8907                }
8908            }
8909            WorkspaceFileBytes::TooLarge => WorkspaceFileContent::TooLarge {
8910                limit_bytes: MAX_WORKSPACE_FILE_BYTES as u32,
8911            },
8912        };
8913        Ok(WorkspaceFileRead {
8914            workspace_id,
8915            path,
8916            content,
8917            revision: None,
8918        })
8919    }
8920
8921    async fn write_workspace_file(
8922        &self,
8923        workspace_id: WorkspaceId,
8924        path: RepositoryPath,
8925        expected_revision: WorkspaceFileRevision,
8926        text: String,
8927    ) -> Result<WorkspaceFileRead, NodeFailure> {
8928        let canonical_root = self.workspace_root(&workspace_id)?;
8929        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8930            failure(NodeFailureCode::BackendBusy, "workspace file write capacity is busy")
8931        })?;
8932        let path_to_write = path.clone();
8933        let expected = expected_revision.as_str().to_owned();
8934        let text_to_write = text.clone();
8935        let task = tokio::task::spawn_blocking(move || {
8936            let _permit = permit;
8937            write_workspace_file_to_disk(
8938                Path::new(&canonical_root),
8939                &path_to_write,
8940                &expected,
8941                &text_to_write,
8942            )
8943        });
8944        let revision = timeout(Duration::from_millis(WORKSPACE_FILE_WRITE_TIMEOUT_MS), task)
8945            .await
8946            .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteTimedOut, "repository file write exceeded its bounded deadline"))?
8947            .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteFailed, "repository file write task failed"))?
8948            .map_err(workspace_file_write_failure)?;
8949        Ok(WorkspaceFileRead {
8950            workspace_id,
8951            path,
8952            content: WorkspaceFileContent::Utf8 {
8953                byte_len: u32::try_from(text.len()).expect("bounded workspace text length must fit u32"),
8954                text,
8955            },
8956            revision: Some(WorkspaceFileRevision::new(revision)
8957                .expect("workspace writer returns a valid SHA-256 revision")),
8958        })
8959    }
8960
8961    async fn create_workspace_file(
8962        &self,
8963        workspace_id: WorkspaceId,
8964        path: RepositoryPath,
8965    ) -> Result<WorkspaceFileRead, NodeFailure> {
8966        let canonical_root = self.workspace_root(&workspace_id)?;
8967        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8968            failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
8969        })?;
8970        let path_to_create = path.clone();
8971        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
8972        let task_commit_state = Arc::clone(&commit_state);
8973        let task = tokio::task::spawn_blocking(move || {
8974            let _permit = permit;
8975            create_workspace_file_on_disk(
8976                Path::new(&canonical_root),
8977                &path_to_create,
8978                &task_commit_state,
8979            )
8980        });
8981        let revision = settle_workspace_entry_create(
8982            task,
8983            commit_state,
8984            Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
8985            "repository file create exceeded its bounded deadline",
8986            "repository file create task failed",
8987        )
8988        .await?;
8989        Ok(WorkspaceFileRead {
8990            workspace_id,
8991            path,
8992            content: WorkspaceFileContent::Utf8 {
8993                text: String::new(),
8994                byte_len: 0,
8995            },
8996            revision: Some(WorkspaceFileRevision::new(revision)
8997                .expect("workspace creator returns a valid SHA-256 revision")),
8998        })
8999    }
9000
9001    async fn create_workspace_directory(
9002        &self,
9003        workspace_id: WorkspaceId,
9004        path: RepositoryPath,
9005    ) -> Result<WorkspaceEntry, NodeFailure> {
9006        let canonical_root = self.workspace_root(&workspace_id)?;
9007        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9008            failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
9009        })?;
9010        let path_to_create = path.clone();
9011        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
9012        let task_commit_state = Arc::clone(&commit_state);
9013        let task = tokio::task::spawn_blocking(move || {
9014            let _permit = permit;
9015            create_workspace_directory_on_disk(
9016                Path::new(&canonical_root),
9017                &path_to_create,
9018                &task_commit_state,
9019            )
9020        });
9021        settle_workspace_entry_create(
9022            task,
9023            commit_state,
9024            Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
9025            "repository directory create exceeded its bounded deadline",
9026            "repository directory create task failed",
9027        )
9028        .await?;
9029        Ok(WorkspaceEntry {
9030            relative_path: path,
9031            kind: WorkspaceEntryKind::Directory,
9032        })
9033    }
9034
9035    async fn read_git_history(
9036        &self,
9037        workspace_id: WorkspaceId,
9038        path: Option<RepositoryPath>,
9039        before: Option<GitObjectId>,
9040        limit: u16,
9041    ) -> Result<GitHistoryPage, NodeFailure> {
9042        let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9043            failure(NodeFailureCode::BackendBusy, "git history capacity is busy")
9044        })?;
9045        let root = self.workspace_root(&workspace_id)?;
9046        read_git_history_bounded(&root, path.as_ref(), before.as_ref(), limit).await
9047    }
9048
9049    async fn read_git_diff(
9050        &self,
9051        workspace_id: WorkspaceId,
9052        request: GitDiffRequest,
9053    ) -> Result<GitDiff, NodeFailure> {
9054        let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9055            failure(NodeFailureCode::BackendBusy, "git diff capacity is busy")
9056        })?;
9057        let root = self.workspace_root(&workspace_id)?;
9058        read_git_diff_bounded(&root, request).await
9059    }
9060
9061    fn native_session_route_root(
9062        &self,
9063        route: &NativeSessionCatalogRoute,
9064    ) -> Result<Option<String>, NodeFailure> {
9065        route
9066            .validate()
9067            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9068        match route.scope {
9069            gate4agent_types::NativeSessionCatalogScope::Workspace => route
9070                .workspace_id
9071                .as_ref()
9072                .map(|workspace_id| self.workspace_root(workspace_id))
9073                .transpose(),
9074            gate4agent_types::NativeSessionCatalogScope::Unregistered => Ok(None),
9075        }
9076    }
9077
9078    fn project_native_session_entries(
9079        &self,
9080        route: &NativeSessionCatalogRoute,
9081        entries: Vec<ScopedNativeSessionCatalogEntry>,
9082    ) -> Vec<crate::protocol::NativeSessionCatalogEntry> {
9083        let current_root = route
9084            .workspace_id
9085            .as_ref()
9086            .and_then(|workspace_id| self.workspace_root(workspace_id).ok());
9087        let records = self
9088            .session_records
9089            .lock()
9090            .unwrap_or_else(|poisoned| poisoned.into_inner());
9091        let mut seen_records = std::collections::HashSet::new();
9092        entries
9093            .into_iter()
9094            .map(|entry| {
9095                let record_id = route.workspace_id.as_ref().and_then(|workspace_id| {
9096                    let matches = records
9097                        .records
9098                        .values()
9099                        .filter(|record| {
9100                            record.provider == route.provider
9101                                && record.workspace_id == *workspace_id
9102                                && current_root.as_deref().is_some_and(|root| {
9103                                    platform::roots_equal(
9104                                        root,
9105                                        windows_path_text(&record.canonical_root),
9106                                    )
9107                                })
9108                                && record.provider_session.as_ref().is_some_and(|identity| {
9109                                    session_registry::same_provider_session(
9110                                        &record.provider,
9111                                        identity,
9112                                        &route.provider,
9113                                        &entry.provider_session,
9114                                    )
9115                                })
9116                        })
9117                        .map(|record| record.record_id.clone())
9118                        .collect::<Vec<_>>();
9119                    (matches.len() == 1
9120                        && seen_records.insert(matches[0].clone()))
9121                        .then(|| matches[0].clone())
9122                });
9123                let metadata = entry.metadata;
9124                crate::protocol::NativeSessionCatalogEntry {
9125                    selection_id: metadata.selection_id,
9126                    title: metadata.title,
9127                    modified_at_unix_ms: metadata.modified_at_unix_ms,
9128                    model: metadata.model,
9129                    message_count: metadata.message_count,
9130                    completed_turn_count: metadata.completed_turn_count,
9131                    external_group: entry.external_group,
9132                    record_id,
9133                }
9134            })
9135            .collect()
9136    }
9137
9138    async fn catalog_native_sessions(
9139        &self,
9140        route: NativeSessionCatalogRoute,
9141        limit: u16,
9142    ) -> Result<(
9143        Vec<crate::protocol::NativeSessionCatalogEntry>,
9144        crate::protocol::NativeSessionCatalogSummary,
9145    ), NodeFailure> {
9146        let canonical_root = self.native_session_route_root(&route)?;
9147        let registered_workspace_roots = self.workspace_roots();
9148        let provider = route.provider.clone();
9149        let scope = route.scope;
9150        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9151            failure(
9152                NodeFailureCode::InvalidRequest,
9153                "native session catalog is not configured",
9154            )
9155        })?;
9156        let _permit = Arc::clone(&self.inspection_slots)
9157            .try_acquire_owned()
9158            .map_err(|_| {
9159                failure(
9160                    NodeFailureCode::BackendBusy,
9161                    "native session catalog capacity is busy",
9162                )
9163            })?;
9164        let page = run_native_session_catalog_operation(
9165            catalog,
9166            "native session catalog",
9167            move |catalog| {
9168                catalog.catalog_initial_for_scope(
9169                    &provider,
9170                    scope,
9171                    canonical_root.as_deref().map(Path::new),
9172                    &registered_workspace_roots,
9173                    limit,
9174                )
9175            },
9176        )
9177        .await?;
9178        let summary = crate::protocol::NativeSessionCatalogSummary {
9179            catalog_revision: page.revision,
9180            recent_cutoff_unix_ms: page.cutoff_unix_ms,
9181            recent_total_count: page.recent_total_count,
9182            older_total_count: page.older_total_count,
9183            recent_next_after_selection_id: page.next_after_selection_id,
9184            recent_has_more: page.remaining_count > 0,
9185        };
9186        Ok((self.project_native_session_entries(&route, page.entries), summary))
9187    }
9188
9189    async fn page_native_sessions(
9190        &self,
9191        route: NativeSessionCatalogRoute,
9192        window: crate::protocol::NativeSessionCatalogWindow,
9193        catalog_revision: u64,
9194        recent_cutoff_unix_ms: u64,
9195        after_selection_id: Option<String>,
9196        limit: u16,
9197    ) -> Result<crate::protocol::NativeSessionCatalogPage, NodeFailure> {
9198        let canonical_root = self.native_session_route_root(&route)?;
9199        let registered_workspace_roots = self.workspace_roots();
9200        let provider = route.provider.clone();
9201        let scope = route.scope;
9202        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9203            failure(
9204                NodeFailureCode::InvalidRequest,
9205                "native session catalog is not configured",
9206            )
9207        })?;
9208        let _permit = Arc::clone(&self.inspection_slots)
9209            .try_acquire_owned()
9210            .map_err(|_| {
9211                failure(
9212                    NodeFailureCode::BackendBusy,
9213                    "native session catalog capacity is busy",
9214                )
9215            })?;
9216        let page = run_native_session_catalog_operation(
9217            catalog,
9218            "native session catalog page",
9219            move |catalog| {
9220                Ok::<_, std::convert::Infallible>(catalog.catalog_page_for_scope(
9221                    &provider,
9222                    scope,
9223                    canonical_root.as_deref().map(Path::new),
9224                    &registered_workspace_roots,
9225                    window,
9226                    catalog_revision,
9227                    recent_cutoff_unix_ms,
9228                    after_selection_id.as_deref(),
9229                    limit,
9230                ))
9231            },
9232        )
9233        .await?;
9234        let page = page.map_err(|error| match error {
9235            NativeSessionCatalogError::StaleCatalog => failure(
9236                NodeFailureCode::StaleNativeSessionCatalog,
9237                "native session catalog revision or cursor is stale",
9238            ),
9239            _ => failure(
9240                NodeFailureCode::BackendOperationFailed,
9241                "native session catalog page failed",
9242            ),
9243        })?;
9244        let entries = self.project_native_session_entries(&route, page.entries);
9245        Ok(crate::protocol::NativeSessionCatalogPage {
9246            window: page.window,
9247            revision: page.revision,
9248            entries,
9249            next_after_selection_id: page.next_after_selection_id,
9250            remaining_count: page.remaining_count,
9251            has_more: page.remaining_count > 0,
9252        })
9253    }
9254
9255    async fn preview_native_session(
9256        &self,
9257        selection: NativeSessionSelection,
9258        message_limit: u16,
9259    ) -> Result<crate::protocol::NativeSessionPreview, NodeFailure> {
9260        selection
9261            .validate()
9262            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9263        let canonical_root = self.native_session_route_root(&selection.route)?;
9264        let registered_workspace_roots = self.workspace_roots();
9265        let provider = selection.route.provider.clone();
9266        let scope = selection.route.scope;
9267        let catalog_revision = selection.catalog_revision;
9268        let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9269        let selection_id = selection.selection_id.clone();
9270        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9271            failure(
9272                NodeFailureCode::InvalidRequest,
9273                "native session preview is not configured",
9274            )
9275        })?;
9276        let _permit = Arc::clone(&self.inspection_slots)
9277            .try_acquire_owned()
9278            .map_err(|_| {
9279                failure(
9280                    NodeFailureCode::BackendBusy,
9281                    "native session preview capacity is busy",
9282                )
9283            })?;
9284        let preview = run_native_session_catalog_operation(
9285            catalog,
9286            "native session preview",
9287            move |catalog| {
9288                Ok::<_, std::convert::Infallible>(catalog.preview_for_scope(
9289                    &provider,
9290                    scope,
9291                    canonical_root.as_deref().map(Path::new),
9292                    &registered_workspace_roots,
9293                    catalog_revision,
9294                    recent_cutoff_unix_ms,
9295                    &selection_id,
9296                    message_limit,
9297                ))
9298            },
9299        )
9300        .await?;
9301        preview
9302            .map(Into::into)
9303            .map_err(native_session_preview_failure)
9304    }
9305
9306    async fn index_native_session(
9307        &self,
9308        selection: NativeSessionSelection,
9309        display_name: String,
9310    ) -> Result<ManagedSessionRecord, NodeFailure> {
9311        selection
9312            .validate()
9313            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9314        if selection.route.scope
9315            != gate4agent_types::NativeSessionCatalogScope::Workspace
9316        {
9317            return Err(failure(
9318                NodeFailureCode::WorkspaceRegistrationRequired,
9319                "register the external project as a workspace before indexing or resuming it",
9320            ));
9321        }
9322        let workspace_id = selection
9323            .route
9324            .workspace_id
9325            .clone()
9326            .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9327        let canonical_root = self
9328            .native_session_route_root(&selection.route)?
9329            .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9330        let registered_workspace_roots = self.workspace_roots();
9331        let provider = selection.route.provider.clone();
9332        let catalog_revision = selection.catalog_revision;
9333        let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9334        let selection_id = selection.selection_id.clone();
9335        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9336            failure(
9337                NodeFailureCode::InvalidRequest,
9338                "native session index is not configured",
9339            )
9340        })?;
9341        let _permit = Arc::clone(&self.inspection_slots)
9342            .try_acquire_owned()
9343            .map_err(|_| {
9344                failure(
9345                    NodeFailureCode::BackendBusy,
9346                    "native session index capacity is busy",
9347                )
9348            })?;
9349        let resolved = run_native_session_catalog_operation(
9350            catalog,
9351            "native session index resolution",
9352            move |catalog| {
9353                Ok::<_, std::convert::Infallible>(catalog.resolve_selection_for_scope(
9354                    &provider,
9355                    gate4agent_types::NativeSessionCatalogScope::Workspace,
9356                    Some(Path::new(&canonical_root)),
9357                    &registered_workspace_roots,
9358                    catalog_revision,
9359                    recent_cutoff_unix_ms,
9360                    &selection_id,
9361                ))
9362            },
9363        )
9364        .await?
9365        .map_err(native_session_preview_failure)?;
9366        self.index_provider_session_with_policy(
9367            workspace_id,
9368            selection.route.provider,
9369            resolved.identity,
9370            display_name,
9371            true,
9372        )
9373    }
9374
9375    async fn preview_native_session_by_session_id(
9376        &self,
9377        workspace_id: WorkspaceId,
9378        provider: AgentId,
9379        session_id: String,
9380        message_limit: u16,
9381    ) -> Result<gate4agent_types::NativeSessionPreview, NodeFailure> {
9382        let canonical_root = self.workspace_root(&workspace_id)?;
9383        let registered_workspace_roots = self.workspace_roots();
9384        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9385            failure(
9386                NodeFailureCode::InvalidRequest,
9387                "native session preview is not configured",
9388            )
9389        })?;
9390        let _permit = Arc::clone(&self.inspection_slots)
9391            .try_acquire_owned()
9392            .map_err(|_| {
9393                failure(
9394                    NodeFailureCode::BackendBusy,
9395                    "native session preview capacity is busy",
9396                )
9397            })?;
9398        run_native_session_catalog_operation(
9399            catalog,
9400            "native session preview",
9401            move |catalog| {
9402                catalog.preview_session_id_for_workspace(
9403                    &provider,
9404                    Path::new(&canonical_root),
9405                    &registered_workspace_roots,
9406                    &session_id,
9407                    message_limit,
9408                )
9409            },
9410        )
9411        .await
9412    }
9413
9414    async fn register_workspace(
9415        &self,
9416        workspace_id: WorkspaceId,
9417        root: String,
9418    ) -> Result<WorkspaceSnapshot, NodeFailure> {
9419        validate_workspace_request_root(&root)?;
9420        let canonicalize_workspace_id = workspace_id.clone();
9421        let workspace = tokio::task::spawn_blocking(move || {
9422            WorkspaceConfig::new(canonicalize_workspace_id, root)
9423        })
9424        .await
9425        .map_err(|error| {
9426            failure(
9427                NodeFailureCode::BackendOperationFailed,
9428                &format!("workspace canonicalization task failed: {error}"),
9429            )
9430        })?
9431        .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
9432        let transaction = self
9433            .state_transaction
9434            .lock()
9435            .unwrap_or_else(|poisoned| poisoned.into_inner());
9436        let mut workspaces = self
9437            .workspaces
9438            .write()
9439            .unwrap_or_else(|poisoned| poisoned.into_inner());
9440        if workspaces.contains_key(&workspace_id) {
9441            return Err(failure(
9442                NodeFailureCode::DuplicateWorkspaceId,
9443                "workspace ID is already registered",
9444            ));
9445        }
9446        if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
9447            platform::roots_equal(existing_root, workspace.canonical_root())
9448        }) {
9449            return Err(failure(
9450                NodeFailureCode::DuplicateWorkspaceRoot,
9451                &format!("workspace root is already registered as '{existing_id}'"),
9452            ));
9453        }
9454        {
9455            let records = self
9456                .session_records
9457                .lock()
9458                .unwrap_or_else(|poisoned| poisoned.into_inner());
9459            if let Some(record) = records.records.values().find(|record| {
9460                record.workspace_id == workspace_id
9461                    && !record
9462                        .canonical_root
9463                        .as_utf8()
9464                        .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9465            }) {
9466                return Err(failure(
9467                    NodeFailureCode::SessionRecordConflict,
9468                    &format!(
9469                        "workspace ID is retained by session record '{}' at another root",
9470                        record.record_id,
9471                    ),
9472                ));
9473            }
9474        }
9475        let snapshot = WorkspaceSnapshot {
9476            workspace_id: workspace_id.clone(),
9477            canonical_root: opaque_windows_path(workspace.canonical_root().to_owned()),
9478            sessions: Vec::new(),
9479            worktree_service_mode: Some(WorktreeServiceMode::Manual),
9480            managed_worktree_profiles: Some(WorktreeProfileInventory {
9481                profiles: Vec::new(),
9482            }),
9483        };
9484        workspaces.insert(workspace_id.clone(), workspace.canonical_root().to_owned());
9485        drop(workspaces);
9486        let previous_records = {
9487            let mut records = self
9488                .session_records
9489                .lock()
9490                .unwrap_or_else(|poisoned| poisoned.into_inner());
9491            let previous = records.records.clone();
9492            for record in records.records.values_mut().filter(|record| {
9493                record.workspace_id == workspace_id
9494                    && record
9495                        .canonical_root
9496                        .as_utf8()
9497                        .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9498            }) {
9499                if record.active_session.is_none() {
9500                    record.state = detached_record_state(record);
9501                }
9502                if record.last_error.as_deref() == Some(WORKSPACE_UNAVAILABLE_ERROR) {
9503                    record.last_error = None;
9504                }
9505                record.updated_at_unix_ms = unix_time_ms();
9506            }
9507            previous
9508        };
9509        if let Err(error) = self.persist_state_locked() {
9510            self.workspaces
9511                .write()
9512                .unwrap_or_else(|poisoned| poisoned.into_inner())
9513                .remove(&workspace_id);
9514            self.session_records
9515                .lock()
9516                .unwrap_or_else(|poisoned| poisoned.into_inner())
9517                .records = previous_records;
9518            return Err(persistence_failure(error));
9519        }
9520        let restored = self
9521            .session_records
9522            .lock()
9523            .unwrap_or_else(|poisoned| poisoned.into_inner())
9524            .records
9525            .values()
9526            .filter(|record| record.workspace_id == workspace_id)
9527            .cloned()
9528            .collect::<Vec<_>>();
9529        drop(transaction);
9530        self.publish(NodeEvent::WorkspaceAdded {
9531            workspace: snapshot.clone(),
9532        });
9533        for record in restored {
9534            self.publish_record(record);
9535        }
9536        Ok(snapshot)
9537    }
9538
9539    async fn create_standalone_workspace(
9540        &self,
9541        workspace_id: WorkspaceId,
9542        root: String,
9543        initial_branch: Option<String>,
9544    ) -> Result<WorkspaceSnapshot, NodeFailure> {
9545        validate_workspace_request_root(&root)?;
9546        validate_git_revision("initial branch", initial_branch.as_deref())?;
9547        let preflight_workspace_id = workspace_id.clone();
9548        let preflight_root = root.clone();
9549        let canonical_candidate = tokio::task::spawn_blocking(move || {
9550            canonical_standalone_workspace_candidate(&preflight_workspace_id, &preflight_root)
9551        })
9552        .await
9553        .map_err(|_| failure(
9554            NodeFailureCode::BackendOperationFailed,
9555            "standalone workspace root preflight task failed",
9556        ))?
9557        .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
9558        {
9559            let workspaces = self
9560                .workspaces
9561                .read()
9562                .unwrap_or_else(|poisoned| poisoned.into_inner());
9563            if workspaces.contains_key(&workspace_id) {
9564                return Err(failure(
9565                    NodeFailureCode::DuplicateWorkspaceId,
9566                    "workspace ID is already registered",
9567                ));
9568            }
9569            if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
9570                platform::roots_equal(existing_root, &canonical_candidate)
9571            }) {
9572                return Err(failure(
9573                    NodeFailureCode::DuplicateWorkspaceRoot,
9574                    &format!("workspace root is already registered as '{existing_id}'"),
9575                ));
9576            }
9577        }
9578        let prepared = prepare_standalone_workspace(root, initial_branch.as_deref())
9579            .await
9580            .map_err(standalone_workspace_failure)?;
9581        prepared
9582            .verify_for_registration()
9583            .await
9584            .map_err(standalone_workspace_failure)?;
9585        match self
9586            .register_workspace(workspace_id, prepared.root().to_owned())
9587            .await
9588        {
9589            Ok(workspace) => Ok(workspace),
9590            Err(registration_error) => {
9591                if prepared.compensate_registration_failure().await {
9592                    Err(registration_error)
9593                } else {
9594                    Err(failure(
9595                        NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
9596                        "standalone workspace initialization succeeded but registration failed; recovery is required",
9597                    ))
9598                }
9599            }
9600        }
9601    }
9602
9603    fn unregister_workspace(&self, workspace_id: &WorkspaceId) -> Result<(), NodeFailure> {
9604        let bindings = self
9605            .session_bindings
9606            .lock()
9607            .unwrap_or_else(|poisoned| poisoned.into_inner());
9608        if bindings.values().any(|binding| &binding.workspace_id == workspace_id) {
9609            return Err(failure(
9610                NodeFailureCode::WorkspaceBusy,
9611                "workspace retains a bound session; remove every session before unregistering it",
9612            ));
9613        }
9614        drop(bindings);
9615        let transaction = self
9616            .state_transaction
9617            .lock()
9618            .unwrap_or_else(|poisoned| poisoned.into_inner());
9619        let mut workspaces = self
9620            .workspaces
9621            .write()
9622            .unwrap_or_else(|poisoned| poisoned.into_inner());
9623        if !workspaces.contains_key(workspace_id) {
9624            return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
9625        }
9626        if workspaces.len() == 1 {
9627            return Err(failure(
9628                NodeFailureCode::LastWorkspace,
9629                "node must retain at least one workspace",
9630            ));
9631        }
9632        let removed_root = workspaces
9633            .remove(workspace_id)
9634            .expect("workspace presence was validated");
9635        drop(workspaces);
9636        let previous_records = {
9637            let mut records = self
9638                .session_records
9639                .lock()
9640                .unwrap_or_else(|poisoned| poisoned.into_inner());
9641            let previous = records.records.clone();
9642            for record in records
9643                .records
9644                .values_mut()
9645                .filter(|record| &record.workspace_id == workspace_id)
9646            {
9647                record.active_session = None;
9648                record.state = ManagedSessionState::Unavailable;
9649                record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
9650                record.updated_at_unix_ms = unix_time_ms();
9651            }
9652            previous
9653        };
9654        if let Err(error) = self.persist_state_locked() {
9655            self.workspaces
9656                .write()
9657                .unwrap_or_else(|poisoned| poisoned.into_inner())
9658                .insert(workspace_id.clone(), removed_root);
9659            self.session_records
9660                .lock()
9661                .unwrap_or_else(|poisoned| poisoned.into_inner())
9662                .records = previous_records;
9663            return Err(persistence_failure(error));
9664        }
9665        let unavailable = self
9666            .session_records
9667            .lock()
9668            .unwrap_or_else(|poisoned| poisoned.into_inner())
9669            .records
9670            .values()
9671            .filter(|record| &record.workspace_id == workspace_id)
9672            .cloned()
9673            .collect::<Vec<_>>();
9674        drop(transaction);
9675        self.publish(NodeEvent::WorkspaceRemoved {
9676            workspace_id: workspace_id.clone(),
9677        });
9678        for record in unavailable {
9679            self.publish_record(record);
9680        }
9681        Ok(())
9682    }
9683
9684    async fn create_worktree(
9685        &self,
9686        source_workspace_id: WorkspaceId,
9687        workspace_id: WorkspaceId,
9688        target_root: String,
9689        branch: String,
9690        base: Option<String>,
9691    ) -> Result<(GitWorktreeSnapshot, WorkspaceSnapshot), NodeFailure> {
9692        self.require_worktree_service(&source_workspace_id)?;
9693        validate_workspace_request_root(&target_root)?;
9694        validate_git_revision("worktree base", base.as_deref())?;
9695        let source_root = self.workspace_root(&source_workspace_id)?;
9696        {
9697            let workspaces = self
9698                .workspaces
9699                .read()
9700                .unwrap_or_else(|poisoned| poisoned.into_inner());
9701            if workspaces.contains_key(&workspace_id) {
9702                return Err(failure(
9703                    NodeFailureCode::DuplicateWorkspaceId,
9704                    "workspace ID is already registered",
9705                ));
9706            }
9707            if let Some((existing_id, _)) = workspaces
9708                .iter()
9709                .find(|(_, root)| worktree_paths_equal(root, &target_root))
9710            {
9711                return Err(failure(
9712                    NodeFailureCode::DuplicateWorkspaceRoot,
9713                    &format!("workspace root is already registered as '{existing_id}'"),
9714                ));
9715            }
9716        }
9717        let mut worktree = create_git_worktree(
9718            &source_root,
9719            &target_root,
9720            &branch,
9721            base.as_deref(),
9722        )
9723        .await
9724        .map_err(git_worktree_failure)?;
9725        let workspace = match self
9726            .register_workspace(workspace_id, worktree.path.clone())
9727            .await
9728        {
9729            Ok(workspace) => workspace,
9730            Err(registration_error) => {
9731                if remove_git_worktree(&source_root, &worktree.path).await.is_err() {
9732                    return Err(failure(
9733                        NodeFailureCode::ManagedWorktreeRecoveryRequired,
9734                        "worktree registration failed and compensation requires recovery",
9735                    ));
9736                }
9737                return Err(failure(
9738                    registration_error.code,
9739                    &registration_error.message,
9740                ));
9741            }
9742        };
9743        worktree.workspace_id = Some(workspace.workspace_id.clone());
9744        Ok((protocol_worktree(worktree), workspace))
9745    }
9746
9747    async fn remove_worktree(
9748        &self,
9749        source_workspace_id: WorkspaceId,
9750        target_root: String,
9751    ) -> Result<Option<WorkspaceId>, NodeFailure> {
9752        self.require_worktree_service(&source_workspace_id)?;
9753        validate_workspace_request_root(&target_root)?;
9754        let source_root = self.workspace_root(&source_workspace_id)?;
9755        let target_root = normalize_worktree_removal_target(&target_root)
9756            .map_err(git_worktree_failure)?;
9757        let target = list_git_worktrees(&source_root)
9758            .await
9759            .map_err(git_worktree_failure)?
9760            .into_iter()
9761            .find(|worktree| worktree_paths_equal(&worktree.path, &target_root))
9762            .ok_or_else(|| {
9763                failure(
9764                    NodeFailureCode::WorktreeProtected,
9765                    "refusing to remove a path that is not in Git's worktree listing",
9766                )
9767            })?;
9768        let registered_workspace_id = {
9769            let workspaces = self
9770                .workspaces
9771                .read()
9772                .unwrap_or_else(|poisoned| poisoned.into_inner());
9773            let matched = workspaces
9774                .iter()
9775                .find(|(_, root)| worktree_paths_equal(root, &target.path))
9776                .map(|(workspace_id, _)| workspace_id.clone());
9777            if matched.is_some() && workspaces.len() == 1 {
9778                return Err(failure(
9779                    NodeFailureCode::LastWorkspace,
9780                    "node must retain at least one workspace",
9781                ));
9782            }
9783            matched
9784        };
9785        if let Some(workspace_id) = registered_workspace_id.as_ref() {
9786            let bindings = self
9787                .session_bindings
9788                .lock()
9789                .unwrap_or_else(|poisoned| poisoned.into_inner());
9790            if bindings
9791                .values()
9792                .any(|binding| &binding.workspace_id == workspace_id)
9793            {
9794                return Err(failure(
9795                    NodeFailureCode::WorkspaceBusy,
9796                    "worktree workspace retains a bound session; remove every session first",
9797                ));
9798            }
9799        }
9800        remove_git_worktree(&source_root, &target.path)
9801            .await
9802            .map_err(git_worktree_failure)?;
9803        if let Some(workspace_id) = registered_workspace_id.as_ref() {
9804            self.unregister_workspace(workspace_id)
9805                .map_err(|error| failure(error.code, &error.message))?;
9806        }
9807        Ok(registered_workspace_id)
9808    }
9809
9810    async fn cleanup_managed_worktree(
9811        &self,
9812        lease_id: &ManagedWorktreeLeaseId,
9813        explicit: bool,
9814    ) -> Result<ManagedWorktreeLeaseSnapshot, NodeFailure> {
9815        let lease = self.managed_worktrees.lock()
9816            .unwrap_or_else(|poisoned| poisoned.into_inner())
9817            .get(lease_id)
9818            .cloned()
9819            .ok_or_else(|| failure(
9820                NodeFailureCode::UnknownManagedWorktreeLease,
9821                "managed worktree lease does not exist",
9822            ))?;
9823        if lease.state == ManagedWorktreeLeaseState::Removed {
9824            return Ok(lease.snapshot());
9825        }
9826        if lease.has_holders() {
9827            return Err(failure(
9828                NodeFailureCode::ManagedWorktreeBusy,
9829                "managed worktree lease still has a session or durable-record holder",
9830            ));
9831        }
9832        if self
9833            .materializations
9834            .lock()
9835            .unwrap_or_else(|poisoned| poisoned.into_inner())
9836            .values()
9837            .any(|record| record.managed_lease_id() == Some(lease_id))
9838        {
9839            return Err(failure(
9840                NodeFailureCode::ManagedWorktreeRecoveryRequired,
9841                "managed worktree cleanup is blocked by session environment ownership",
9842            ));
9843        }
9844        if !explicit && lease.retention == ManagedWorktreeRetention::Retain {
9845            let snapshot = {
9846                let mut registry = self.managed_worktrees.lock()
9847                    .unwrap_or_else(|poisoned| poisoned.into_inner());
9848                let current = registry.get_mut(lease_id)
9849                    .expect("managed worktree lease remains present");
9850                current.state = ManagedWorktreeLeaseState::Retained;
9851                current.cleanup_failure = None;
9852                current.updated_at_unix_ms = unix_time_ms();
9853                current.snapshot()
9854            };
9855            if self.persist_state().is_err() {
9856                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
9857            }
9858            self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
9859            return Ok(snapshot);
9860        }
9861        let profile = self.managed_profile(&lease.source_workspace_id, &lease.profile_id)?;
9862        if profile.revision() != &lease.profile_revision {
9863            self.mark_managed_recovery_required(
9864                lease_id,
9865                ManagedWorktreeCleanupFailure::OwnershipConflict,
9866            );
9867            return Err(failure(
9868                NodeFailureCode::ManagedWorktreeRecoveryRequired,
9869                "managed worktree profile revision changed",
9870            ));
9871        }
9872        profile.validate_target_authority(&lease.lease_id, &lease.target_root).map_err(|message| {
9873            self.mark_managed_recovery_required(
9874                lease_id,
9875                ManagedWorktreeCleanupFailure::OwnershipConflict,
9876            );
9877            failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message)
9878        })?;
9879        let source_root = self.workspace_root(&lease.source_workspace_id)?;
9880        let listed = list_git_worktrees(&source_root).await.map_err(|error| {
9881            self.mark_managed_cleanup_blocked(lease_id, ManagedWorktreeCleanupFailure::Backend);
9882            managed_git_worktree_failure(error)
9883        })?;
9884        let target = listed.iter()
9885            .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
9886        if let Some(target) = target {
9887            if target.is_main || target.is_bare || !exact_owned_worktree(
9888                &lease,
9889                &target.path,
9890                target.branch.as_deref(),
9891                &target.head,
9892            ) {
9893                self.mark_managed_recovery_required(
9894                    lease_id,
9895                    ManagedWorktreeCleanupFailure::OwnershipConflict,
9896                );
9897                return Err(failure(
9898                    NodeFailureCode::ManagedWorktreeOwnershipConflict,
9899                    "Git worktree identity no longer matches the managed lease",
9900                ));
9901            }
9902            if target.prunable {
9903                self.mark_managed_cleanup_blocked(
9904                    lease_id,
9905                    ManagedWorktreeCleanupFailure::Prunable,
9906                );
9907                return Err(failure(
9908                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
9909                    "managed worktree is prunable and requires recovery",
9910                ));
9911            }
9912            if let Err(error) = remove_git_worktree(&source_root, &target.path).await {
9913                let failure_kind = match error.kind {
9914                    GitWorktreeErrorKind::Dirty => ManagedWorktreeCleanupFailure::Dirty,
9915                    GitWorktreeErrorKind::Locked => ManagedWorktreeCleanupFailure::Locked,
9916                    GitWorktreeErrorKind::Protected => ManagedWorktreeCleanupFailure::OwnershipConflict,
9917                    GitWorktreeErrorKind::Conflict => ManagedWorktreeCleanupFailure::Busy,
9918                    GitWorktreeErrorKind::Invalid | GitWorktreeErrorKind::NotRepository
9919                    | GitWorktreeErrorKind::Failed => ManagedWorktreeCleanupFailure::Backend,
9920                };
9921                if failure_kind == ManagedWorktreeCleanupFailure::OwnershipConflict {
9922                    self.mark_managed_recovery_required(lease_id, failure_kind);
9923                } else {
9924                    self.mark_managed_cleanup_blocked(lease_id, failure_kind);
9925                }
9926                return Err(managed_git_worktree_failure(error));
9927            }
9928        } else {
9929            match std::fs::symlink_metadata(&lease.target_root) {
9930                Err(error) if error.kind() == io::ErrorKind::NotFound => {}
9931                Ok(_) => {
9932                    self.mark_managed_recovery_required(
9933                        lease_id,
9934                        ManagedWorktreeCleanupFailure::OwnershipConflict,
9935                    );
9936                    return Err(failure(
9937                        NodeFailureCode::ManagedWorktreeOwnershipConflict,
9938                        "managed target exists but is not owned by Git",
9939                    ));
9940                }
9941                Err(_) => {
9942                    self.mark_managed_cleanup_blocked(
9943                        lease_id,
9944                        ManagedWorktreeCleanupFailure::Backend,
9945                    );
9946                    return Err(failure(
9947                        NodeFailureCode::BackendOperationFailed,
9948                        "managed target identity could not be inspected",
9949                    ));
9950                }
9951            }
9952        }
9953        let registered = self.workspaces.read()
9954            .unwrap_or_else(|poisoned| poisoned.into_inner())
9955            .contains_key(&lease.workspace_id);
9956        if registered {
9957            self.unregister_workspace(&lease.workspace_id)?;
9958        }
9959        let removed = {
9960            self.managed_worktrees.lock()
9961                .unwrap_or_else(|poisoned| poisoned.into_inner())
9962                .tombstone(lease_id, unix_time_ms())
9963                .expect("managed worktree lease remains present during cleanup")
9964        };
9965        self.persist_state().map_err(persistence_failure)?;
9966        self.publish(NodeEvent::ManagedWorktreeRemoved { lease_id: lease_id.clone() });
9967        Ok(removed.snapshot())
9968    }
9969
9970    fn mark_managed_cleanup_blocked(
9971        &self,
9972        lease_id: &ManagedWorktreeLeaseId,
9973        failure_kind: ManagedWorktreeCleanupFailure,
9974    ) {
9975        let snapshot = {
9976            let mut registry = self.managed_worktrees.lock()
9977                .unwrap_or_else(|poisoned| poisoned.into_inner());
9978            let Some(lease) = registry.get_mut(lease_id) else { return };
9979            lease.state = ManagedWorktreeLeaseState::CleanupBlocked;
9980            lease.cleanup_failure = Some(failure_kind);
9981            lease.updated_at_unix_ms = unix_time_ms();
9982            lease.snapshot()
9983        };
9984        let _ = self.persist_state();
9985        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
9986    }
9987
9988    fn require_worktree_service(
9989        &self,
9990        source_workspace_id: &WorkspaceId,
9991    ) -> Result<(), NodeFailure> {
9992        if self.managed_worktrees.lock()
9993            .unwrap_or_else(|poisoned| poisoned.into_inner())
9994            .lease_for_workspace(source_workspace_id)
9995            .is_some()
9996        {
9997            return Err(failure(
9998                NodeFailureCode::ManagedWorktreeOwnershipConflict,
9999                "managed worktree targets are internal-only workspaces",
10000            ));
10001        }
10002        match self
10003            .worktree_service_modes
10004            .get(source_workspace_id)
10005            .copied()
10006            .unwrap_or(WorktreeServiceMode::Manual)
10007        {
10008            WorktreeServiceMode::Manual => Ok(()),
10009            WorktreeServiceMode::Managed | WorktreeServiceMode::Off => Err(failure(
10010                NodeFailureCode::UnsupportedSpawnCapability,
10011                "Git worktree service mode is unavailable in this node build",
10012            )),
10013        }
10014    }
10015
10016    fn managed_reservation_conflict(
10017        &self,
10018        workspace_id: Option<&WorkspaceId>,
10019        root: Option<&str>,
10020    ) -> bool {
10021        self.managed_worktrees.lock()
10022            .unwrap_or_else(|poisoned| poisoned.into_inner())
10023            .active_records()
10024            .any(|lease| {
10025                workspace_id.is_some_and(|workspace_id| {
10026                    workspace_id == &lease.source_workspace_id
10027                        || workspace_id == &lease.workspace_id
10028                }) || root.is_some_and(|root| worktree_paths_equal(root, &lease.target_root))
10029            })
10030    }
10031
10032    fn reject_managed_reservation(
10033        &self,
10034        workspace_id: Option<&WorkspaceId>,
10035        root: Option<&str>,
10036    ) -> Result<(), NodeFailure> {
10037        if self.managed_reservation_conflict(workspace_id, root) {
10038            Err(failure(
10039                NodeFailureCode::ManagedWorktreeOwnershipConflict,
10040                "managed worktree ownership may only be mutated through its lease cleanup",
10041            ))
10042        } else {
10043            Ok(())
10044        }
10045    }
10046
10047    fn bound_workspace_root(&self, address: &SessionAddress) -> Result<String, NodeFailure> {
10048        self.validate_address(address)?;
10049        self.workspace_root(&address.workspace_id)
10050    }
10051
10052    /// `Resume`/`ResumeWithPrompt` are always a raw-PTY relaunch (see
10053    /// `require_policy`'s own doc on those two requirements), so they keep
10054    /// gating on `binding.runtime_policy`'s PTY-terminal-text-inference
10055    /// flags exactly as before; `pipe_transport`/`acp_transport` stay
10056    /// `false` on the `require_policy` call below for them because a PTY
10057    /// resume never needs those facts, not because this method cannot look
10058    /// them up.
10059    ///
10060    /// `SemanticPrompt` -- the requirement `Prompt`/`Paste` share -- reads
10061    /// the SAME `binding.runtime_policy` every other requirement reads here
10062    /// (one computed policy, read by every gate). That policy is no longer
10063    /// PTY-terminal-verification-shaped for an ACP session --
10064    /// `spawn_session_with_deadline` computes it BY TRANSPORT now, through
10065    /// `provider_runtime::policy_for_transport` -- so consulting it for
10066    /// `Prompt`/`Paste` on ACP is sound where it used to gate the wrong fact
10067    /// (this is what made an ACP `Prompt` come back `UnsupportedCapability`
10068    /// even though a prompt is an ACP session's only interface). Pipe keeps
10069    /// reading the same PTY-probe-derived value it always has --
10070    /// `policy_for_transport` passes Pipe through unchanged this pass (see
10071    /// its own doc for why).
10072    ///
10073    /// What this method still adds on top, for ACP/Pipe only, is a
10074    /// turn-in-flight PRECONDITION ahead of that policy check: a session
10075    /// takes one turn at a time, so a `Prompt`/`Paste` is refused by name
10076    /// (`NodeFailureCode::TurnInFlight`) while a turn is already running,
10077    /// read off the same live `SessionSnapshot` state `require_acp_session`
10078    /// already reads for the ACP control verbs -- `TurnStarted` observed
10079    /// with no matching `TurnCompleted` yet is exactly `ProviderSnapshot::
10080    /// lead_activity != Idle`. Sending a second prompt mid-turn would put
10081    /// its fate in vendor-specific behaviour this node cannot observe (one
10082    /// agent may queue it, another drop it, another interleave it), so this
10083    /// refuses it outright rather than gambling on that -- a rule with no
10084    /// PTY analogue, which is why PTY does not take this extra precondition.
10085    fn require_session_runtime_policy(
10086        &self,
10087        address: &SessionAddress,
10088        requirement: ProviderRuntimeRequirement,
10089    ) -> Result<ProviderRuntimePolicy, NodeFailure> {
10090        let runtime_policy = {
10091            let bindings = self
10092                .session_bindings
10093                .lock()
10094                .unwrap_or_else(|poisoned| poisoned.into_inner());
10095            let binding = bindings
10096                .get(&address.session.instance_id)
10097                .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10098            if binding.workspace_id != address.workspace_id {
10099                return Err(failure(
10100                    NodeFailureCode::SessionWorkspaceMismatch,
10101                    "session belongs to another workspace",
10102                ));
10103            }
10104            if binding.generation != address.session.generation {
10105                return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10106            }
10107            binding.runtime_policy
10108        };
10109        if requirement == ProviderRuntimeRequirement::SemanticPrompt {
10110            let snapshot = self.internal_session_snapshot(address)?;
10111            if matches!(snapshot.transport, TransportKind::Acp | TransportKind::Pipe)
10112                && snapshot.provider.lead_activity != ProviderActivity::Idle
10113            {
10114                return Err(failure(
10115                    NodeFailureCode::TurnInFlight,
10116                    "session already has a turn in flight; wait for it to complete before sending another prompt",
10117                ));
10118            }
10119        }
10120        require_policy(runtime_policy, false, false, requirement)
10121            .map(|()| runtime_policy)
10122            .map_err(|_| failure(
10123                NodeFailureCode::UnsupportedCapability,
10124                "session runtime policy does not admit this semantic operation",
10125            ))
10126    }
10127
10128    fn remove_binding(&self, address: &SessionAddress) -> Option<SessionBinding> {
10129        let mut bindings = self
10130            .session_bindings
10131            .lock()
10132            .unwrap_or_else(|poisoned| poisoned.into_inner());
10133        if bindings.get(&address.session.instance_id).is_some_and(|binding| {
10134            binding.workspace_id == address.workspace_id
10135                && binding.generation == address.session.generation
10136        }) {
10137            let removed = bindings.remove(&address.session.instance_id);
10138            self.clear_terminal_frame_watermark(address);
10139            SessionLifecycleHook::on_binding_removed(self, address, removed.as_ref());
10140            return removed;
10141        }
10142        None
10143    }
10144
10145    fn cleanup_session_owned_materialization(
10146        &self,
10147        address: &SessionAddress,
10148        binding: &SessionBinding,
10149    ) -> Result<(), NodeFailure> {
10150        let Some(id) = binding.materialization_id.as_ref() else {
10151            return Ok(());
10152        };
10153        let owner = self
10154            .materializations
10155            .lock()
10156            .unwrap_or_else(|poisoned| poisoned.into_inner())
10157            .get(id)
10158            .map(|record| record.owner().clone());
10159        match owner {
10160            None | Some(MaterializationOwner::Record { .. }) => Ok(()),
10161            Some(MaterializationOwner::Session {
10162                incarnation_id,
10163                instance_id,
10164                generation,
10165            }) if incarnation_id == self.incarnation_id
10166                && instance_id == address.session.instance_id
10167                && generation == address.session.generation =>
10168            {
10169                self.cleanup_materialization(id)
10170            }
10171            Some(MaterializationOwner::Session { .. }) => Err(failure(
10172                NodeFailureCode::BackendOperationFailed,
10173                "session environment ownership requires recovery",
10174            )),
10175        }
10176    }
10177
10178    fn clear_terminal_frame_watermark(&self, address: &SessionAddress) {
10179        let mut watermarks = self
10180            .terminal_frame_watermarks
10181            .lock()
10182            .unwrap_or_else(|poisoned| poisoned.into_inner());
10183        if watermarks
10184            .get(&address.session.instance_id)
10185            .is_some_and(|(current, _)| current == address)
10186        {
10187            watermarks.remove(&address.session.instance_id);
10188        }
10189    }
10190
10191    #[cfg(test)]
10192    fn address_for(
10193        &self,
10194        instance_id: AgentInstanceId,
10195        generation: SessionGeneration,
10196    ) -> Option<SessionAddress> {
10197        let binding = self.session_bindings
10198            .lock()
10199            .unwrap_or_else(|poisoned| poisoned.into_inner())
10200            .get(&instance_id)
10201            .cloned()?;
10202        (binding.generation == generation).then_some(SessionAddress {
10203            workspace_id: binding.workspace_id,
10204            session: SessionKey { instance_id, generation },
10205        })
10206    }
10207
10208    fn arm_resume(
10209        &self,
10210        address: &SessionAddress,
10211        command_id: CommandId,
10212        runtime_policy: ProviderRuntimePolicy,
10213    ) -> Result<(), NodeFailure> {
10214        let mut bindings = self
10215            .session_bindings
10216            .lock()
10217            .unwrap_or_else(|poisoned| poisoned.into_inner());
10218        let binding = bindings
10219            .get_mut(&address.session.instance_id)
10220            .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10221        if binding.workspace_id != address.workspace_id {
10222            return Err(failure(
10223                NodeFailureCode::SessionWorkspaceMismatch,
10224                "session belongs to another workspace",
10225            ));
10226        }
10227        if binding.generation != address.session.generation {
10228            return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10229        }
10230        binding.pending_resume = Some((binding.generation, command_id, runtime_policy));
10231        Ok(())
10232    }
10233
10234    fn clear_armed_resume(&self, address: &SessionAddress) {
10235        let mut bindings = self
10236            .session_bindings
10237            .lock()
10238            .unwrap_or_else(|poisoned| poisoned.into_inner());
10239        if let Some(binding) = bindings.get_mut(&address.session.instance_id) {
10240            if binding.workspace_id == address.workspace_id
10241                && binding.generation == address.session.generation
10242            {
10243                binding.pending_resume = None;
10244            }
10245        }
10246    }
10247
10248    fn publish_control(&self, event: ControlEvent) {
10249        let revokes_harness_mcp = matches!(
10250            &event.event,
10251            ControlEventKind::Exited { .. }
10252                | ControlEventKind::Failed { .. }
10253                | ControlEventKind::Removed
10254        );
10255        let (address, previous_address, record_id, identity_admitted, resume_command_rejected) = {
10256            let mut bindings = self
10257                .session_bindings
10258                .lock()
10259                .unwrap_or_else(|poisoned| poisoned.into_inner());
10260            let Some(binding) = bindings.get_mut(&event.instance_id) else {
10261                return;
10262            };
10263            let previous_address = SessionAddress {
10264                workspace_id: binding.workspace_id.clone(),
10265                session: SessionKey {
10266                    instance_id: event.instance_id,
10267                    generation: binding.generation,
10268                },
10269            };
10270            let mut resume_command_rejected = false;
10271            if binding.generation == event.generation {
10272                if let Some((pending_generation, pending_command_id, _)) = binding.pending_resume {
10273                    let rejected_resume = matches!(
10274                        &event.event,
10275                        ControlEventKind::CommandRejected { .. }
10276                    ) && event.command_id == Some(pending_command_id);
10277                    let resume_failed = matches!(
10278                        &event.event,
10279                        ControlEventKind::ResumeDenied { .. }
10280                            | ControlEventKind::ResumeFailed { .. }
10281                    );
10282                    if pending_generation == binding.generation
10283                        && (rejected_resume || resume_failed)
10284                    {
10285                        resume_command_rejected = rejected_resume;
10286                        binding.pending_resume = None;
10287                    }
10288                }
10289            } else {
10290                let expected = binding.generation.0.checked_add(1);
10291                let authorized = matches!(&event.event, ControlEventKind::ResumeAuthorized { .. });
10292                if !binding.pending_resume.is_some_and(|(generation, _, _)| {
10293                    generation == binding.generation
10294                })
10295                    || expected != Some(event.generation.0)
10296                    || !authorized
10297                {
10298                    return;
10299                }
10300                let (_, _, runtime_policy) = binding
10301                    .pending_resume
10302                    .expect("authorized resume retains its pending runtime policy");
10303                binding.generation = event.generation;
10304                binding.runtime_policy = runtime_policy;
10305                binding.pending_resume = None;
10306            }
10307            (
10308                SessionAddress {
10309                    workspace_id: binding.workspace_id.clone(),
10310                    session: SessionKey {
10311                        instance_id: event.instance_id,
10312                        generation: event.generation,
10313                    },
10314                },
10315                (previous_address.session.generation != event.generation)
10316                    .then_some(previous_address),
10317                binding.record_id.clone(),
10318                binding.runtime_policy.provider_session_identity,
10319                resume_command_rejected,
10320            )
10321        };
10322        if let Some(previous_address) = previous_address.as_ref() {
10323            self.clear_terminal_frame_watermark(previous_address);
10324        }
10325        if let Some(record_id) = record_id {
10326            self.reconcile_managed_record(
10327                &record_id,
10328                &address,
10329                &event,
10330                identity_admitted,
10331                resume_command_rejected,
10332            );
10333        }
10334        // Every state change a session's process lifecycle can reach gets a
10335        // line here: this is the single chokepoint every `ControlEventKind`
10336        // passes through regardless of transport (PTY/pipe/ACP) or whether it
10337        // came from a fresh spawn or a resume. Per-message provider chatter
10338        // (`ProviderEvent`/`ProviderGap`/terminal frames) is deliberately not
10339        // matched here -- that is exactly the per-frame noise this crate
10340        // family's level discipline forbids at INFO.
10341        match &event.event {
10342            ControlEventKind::Running { process_id } => {
10343                #[cfg(unix)]
10344                if let (Some(proxy), Some(pid)) = (self.signer_proxy.get(), process_id) {
10345                    proxy.attach_pid(&format!("{}-{}", self.incarnation_id, address.session.instance_id.0), *pid);
10346                }
10347                tracing::info!(
10348                    node_id = %self.node_id,
10349                    workspace_id = %address.workspace_id,
10350                    session = ?address.session,
10351                    pid = ?process_id,
10352                    "session process is running",
10353                );
10354            }
10355            ControlEventKind::Exited { exit_code, forced } => {
10356                #[cfg(unix)]
10357                if let Some(proxy) = self.signer_proxy.get().cloned() {
10358                    let key = format!("{}-{}", self.incarnation_id, address.session.instance_id.0);
10359                    if proxy.has_session(&key) {
10360                        // Revoke at core off the event path (blocking uplink call).
10361                        std::thread::spawn(move || proxy.release(&key));
10362                    }
10363                }
10364                tracing::info!(
10365                    node_id = %self.node_id,
10366                    workspace_id = %address.workspace_id,
10367                    session = ?address.session,
10368                    exit_code = ?exit_code,
10369                    forced = forced,
10370                    "session process exited",
10371                );
10372            }
10373            ControlEventKind::Failed { message } => {
10374                tracing::warn!(
10375                    node_id = %self.node_id,
10376                    workspace_id = %address.workspace_id,
10377                    session = ?address.session,
10378                    cause = %message,
10379                    "session failed",
10380                );
10381            }
10382            ControlEventKind::CommandRejected { message } => {
10383                tracing::warn!(
10384                    node_id = %self.node_id,
10385                    workspace_id = %address.workspace_id,
10386                    session = ?address.session,
10387                    cause = %message,
10388                    "session command rejected",
10389                );
10390            }
10391            ControlEventKind::Removed => {
10392                tracing::info!(
10393                    node_id = %self.node_id,
10394                    workspace_id = %address.workspace_id,
10395                    session = ?address.session,
10396                    "session removed",
10397                );
10398            }
10399            ControlEventKind::ResumeAuthorized { session: provider_session } => {
10400                tracing::info!(
10401                    node_id = %self.node_id,
10402                    workspace_id = %address.workspace_id,
10403                    session = ?address.session,
10404                    provider_session = ?provider_session,
10405                    "session resume authorized",
10406                );
10407            }
10408            ControlEventKind::Resumed { session: provider_session, process_id } => {
10409                tracing::info!(
10410                    node_id = %self.node_id,
10411                    workspace_id = %address.workspace_id,
10412                    session = ?address.session,
10413                    provider_session = ?provider_session,
10414                    pid = ?process_id,
10415                    "session resumed",
10416                );
10417            }
10418            ControlEventKind::ResumeDenied { reason } => {
10419                tracing::warn!(
10420                    node_id = %self.node_id,
10421                    workspace_id = %address.workspace_id,
10422                    session = ?address.session,
10423                    cause = %reason,
10424                    "session resume denied",
10425                );
10426            }
10427            ControlEventKind::ResumeFailed { message } => {
10428                tracing::warn!(
10429                    node_id = %self.node_id,
10430                    workspace_id = %address.workspace_id,
10431                    session = ?address.session,
10432                    cause = %message,
10433                    "session resume failed",
10434                );
10435            }
10436            _ => {}
10437        }
10438        self.apply_pending_acp_options(&address, &event);
10439        let stream_chunk = agent_stream_chunk(&event);
10440        self.publish(NodeEvent::Control {
10441            address: address.clone(),
10442            event,
10443        });
10444        if let Some(chunk) = stream_chunk {
10445            self.publish_agent_stream_chunk(address.clone(), chunk);
10446        }
10447        if revokes_harness_mcp {
10448            SessionLifecycleHook::on_session_ended(self, &address);
10449        }
10450    }
10451
10452    fn reconcile_managed_record(
10453        &self,
10454        record_id: &SessionRecordId,
10455        address: &SessionAddress,
10456        event: &ControlEvent,
10457        identity_admitted: bool,
10458        resume_command_rejected: bool,
10459    ) {
10460        let _transaction = self
10461            .state_transaction
10462            .lock()
10463            .unwrap_or_else(|poisoned| poisoned.into_inner());
10464        let observed_identity = match &event.event {
10465            ControlEventKind::ProviderEvent {
10466                event: gate4agent_types::ProviderEvent::SessionIdentityObserved { identity },
10467                ..
10468            } if identity_admitted => Some(identity.clone()),
10469            _ => None,
10470        };
10471        let identity_was_observed = observed_identity.is_some();
10472        let replacement_id = observed_identity
10473            .as_ref()
10474            .and_then(|_| self.allocate_record_id().ok());
10475        let mut upserts = Vec::new();
10476        let mut removals = Vec::new();
10477        let mut rebind = None;
10478        let original_records;
10479        let original_materializations = self
10480            .materializations
10481            .lock()
10482            .unwrap_or_else(|poisoned| poisoned.into_inner())
10483            .clone();
10484        {
10485            let mut records = self
10486                .session_records
10487                .lock()
10488                .unwrap_or_else(|poisoned| poisoned.into_inner());
10489            original_records = records.records.clone();
10490            let Some(current_snapshot) = records.records.get(record_id).cloned() else {
10491                return;
10492            };
10493            if let Some(identity) = observed_identity {
10494                let existing_id = records
10495                    .records
10496                    .values()
10497                    .find(|record| {
10498                        record.record_id != *record_id
10499                            && record.provider_session.as_ref().is_some_and(|record_identity| {
10500                                session_registry::same_provider_session(
10501                                    &record.provider,
10502                                    record_identity,
10503                                    &current_snapshot.provider,
10504                                    &identity,
10505                                )
10506                            })
10507                    })
10508                    .map(|record| record.record_id.clone());
10509                if let Some(existing_id) = existing_id {
10510                    let scope_matches = records.records.get(&existing_id).is_some_and(|record| {
10511                        record.mode == current_snapshot.mode
10512                            && record.workspace_id == current_snapshot.workspace_id
10513                            && record.environment_profile == current_snapshot.environment_profile
10514                            && record.bundle == current_snapshot.bundle
10515                            && record.context_id == current_snapshot.context_id
10516                            && record.context == current_snapshot.context
10517                            && record
10518                                .canonical_root
10519                                .as_utf8()
10520                                .zip(current_snapshot.canonical_root.as_utf8())
10521                                .is_some_and(|(left, right)| platform::roots_equal(left, right))
10522                    });
10523                    if !scope_matches {
10524                        if let Some(current) = records.records.get_mut(record_id) {
10525                            current.state = ManagedSessionState::Unavailable;
10526                            current.last_error =
10527                                Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR.to_owned());
10528                            current.updated_at_unix_ms = unix_time_ms();
10529                            upserts.push(current.clone());
10530                        }
10531                    } else {
10532                        let existing_is_active = records
10533                            .records
10534                            .get(&existing_id)
10535                            .is_some_and(|record| record.active_session.is_some());
10536                        if existing_is_active {
10537                            if let Some(current) = records.records.get_mut(record_id) {
10538                                current.state = ManagedSessionState::Unavailable;
10539                                current.last_error =
10540                                    Some(PROVIDER_SESSION_LIVE_CONFLICT_ERROR.to_owned());
10541                                current.updated_at_unix_ms = unix_time_ms();
10542                                upserts.push(current.clone());
10543                            }
10544                        } else {
10545                            if current_snapshot.provider_session.is_some() {
10546                                if let Some(current) = records.records.get_mut(record_id) {
10547                                    current.active_session = None;
10548                                    current.state = ManagedSessionState::Dormant;
10549                                    current.updated_at_unix_ms = unix_time_ms();
10550                                    upserts.push(current.clone());
10551                                }
10552                            } else {
10553                                records.records.remove(record_id);
10554                                removals.push(record_id.clone());
10555                            }
10556                            if let Some(existing) = records.records.get_mut(&existing_id) {
10557                                existing.provider_session = Some(identity.clone());
10558                                existing.active_session = Some(address.clone());
10559                                existing.state = ManagedSessionState::Live;
10560                                existing.updated_at_unix_ms = unix_time_ms();
10561                                existing.last_error = None;
10562                                upserts.push(existing.clone());
10563                            }
10564                            rebind = Some(existing_id);
10565                        }
10566                    }
10567                } else if current_snapshot
10568                    .provider_session
10569                    .as_ref()
10570                    .is_some_and(|current| {
10571                        !session_registry::same_provider_session(
10572                            &current_snapshot.provider,
10573                            current,
10574                            &current_snapshot.provider,
10575                            &identity,
10576                        )
10577                    })
10578                {
10579                    if let Some(current) = records.records.get_mut(record_id) {
10580                        current.active_session = None;
10581                        if current.bundle.is_some() || current.context.is_some() {
10582                            let had_bundle = current.bundle.is_some();
10583                            current.provider_session = None;
10584                            current.bundle = None;
10585                            current.context_id = None;
10586                            current.context = None;
10587                            current.state = ManagedSessionState::Unavailable;
10588                            current.last_error = Some(if had_bundle {
10589                                "bundle-unavailable".to_owned()
10590                            } else {
10591                                "context-unavailable".to_owned()
10592                            });
10593                        } else {
10594                            current.state = ManagedSessionState::Dormant;
10595                        }
10596                        current.updated_at_unix_ms = unix_time_ms();
10597                        upserts.push(current.clone());
10598                    }
10599                    if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
10600                        if let Some(current) = records.records.get_mut(record_id) {
10601                            current.state = ManagedSessionState::Unavailable;
10602                            current.last_error =
10603                                Some(MANAGED_SESSION_CAPACITY_ERROR.to_owned());
10604                            current.updated_at_unix_ms = unix_time_ms();
10605                            upserts.push(current.clone());
10606                        }
10607                    } else if let Some(new_record_id) = replacement_id {
10608                        let now = unix_time_ms();
10609                        let replacement = ManagedSessionRecord {
10610                            record_id: new_record_id.clone(),
10611                            display_name: format!(
10612                                "{} #{}",
10613                                current_snapshot.provider.as_str(),
10614                                records
10615                                    .records
10616                                    .values()
10617                                    .filter(|record| {
10618                                        record.provider == current_snapshot.provider
10619                                    })
10620                                    .count()
10621                                    .saturating_add(1),
10622                            ),
10623                            provider: current_snapshot.provider.clone(),
10624                            mode: current_snapshot.mode,
10625                            state: ManagedSessionState::Live,
10626                            workspace_id: current_snapshot.workspace_id,
10627                            canonical_root: current_snapshot.canonical_root,
10628                            provider_session: Some(identity),
10629                            active_session: Some(address.clone()),
10630                            environment_profile: current_snapshot.environment_profile,
10631                            bundle: current_snapshot.bundle,
10632                            context_id: current_snapshot.context_id,
10633                            context: current_snapshot.context,
10634                            // This is a fresh record_id representing the session under its
10635                            // newly-observed identity; it has not yet produced any clean
10636                            // exit of its own, so it starts with no exported pack (the old
10637                            // record, still addressable under its own record_id, keeps
10638                            // whatever exported_context it already had).
10639                            exported_context: None,
10640                            task_binding: current_snapshot.task_binding,
10641                            created_at_unix_ms: now,
10642                            updated_at_unix_ms: now,
10643                            last_error: None,
10644                        };
10645                        records
10646                            .records
10647                            .insert(new_record_id.clone(), replacement.clone());
10648                        upserts.push(replacement);
10649                        rebind = Some(new_record_id);
10650                    } else if let Some(current) = records.records.get_mut(record_id) {
10651                        current.state = ManagedSessionState::Unavailable;
10652                        current.last_error =
10653                            Some(PROVIDER_IDENTITY_ALLOCATION_ERROR.to_owned());
10654                        upserts.push(current.clone());
10655                    }
10656                } else if let Some(current) = records.records.get_mut(record_id) {
10657                    current.provider_session = Some(identity);
10658                    current.active_session = Some(address.clone());
10659                    current.state = ManagedSessionState::Live;
10660                    current.updated_at_unix_ms = unix_time_ms();
10661                    current.last_error = None;
10662                    upserts.push(current.clone());
10663                }
10664            } else if let Some(current) = records.records.get_mut(record_id) {
10665                if resume_command_rejected {
10666                    current.active_session = None;
10667                    current.state = detached_record_state(current);
10668                    current.updated_at_unix_ms = unix_time_ms();
10669                    current.last_error =
10670                        Some(PROVIDER_RESUME_REJECTED_ERROR.to_owned());
10671                    upserts.push(current.clone());
10672                } else {
10673                    match &event.event {
10674                    ControlEventKind::Running { .. }
10675                    | ControlEventKind::ResumeAuthorized { .. }
10676                    | ControlEventKind::Resumed { .. } => {
10677                        current.active_session = Some(address.clone());
10678                        current.state = active_record_state(
10679                            identity_admitted,
10680                            current.provider_session.is_some(),
10681                        );
10682                        current.updated_at_unix_ms = unix_time_ms();
10683                        current.last_error = None;
10684                        upserts.push(current.clone());
10685                    }
10686                    ControlEventKind::Exited { .. } | ControlEventKind::Removed => {
10687                        current.active_session = None;
10688                        current.state = detached_record_state(current);
10689                        current.updated_at_unix_ms = unix_time_ms();
10690                        upserts.push(current.clone());
10691                    }
10692                    ControlEventKind::Failed { message }
10693                    | ControlEventKind::ResumeFailed { message } => {
10694                        current.active_session = None;
10695                        current.state = detached_record_state(current);
10696                        current.updated_at_unix_ms = unix_time_ms();
10697                        current.last_error = Some(
10698                            session_registry::sanitized_record_error_summary(message),
10699                        );
10700                        upserts.push(current.clone());
10701                    }
10702                    ControlEventKind::ResumeDenied { reason } => {
10703                        current.active_session = None;
10704                        current.state = detached_record_state(current);
10705                        current.updated_at_unix_ms = unix_time_ms();
10706                        current.last_error = Some(
10707                            session_registry::sanitized_record_error_summary(reason),
10708                        );
10709                        upserts.push(current.clone());
10710                    }
10711                        _ => {}
10712                    }
10713                }
10714            }
10715        }
10716        let materialization_owner_record = identity_was_observed.then(|| {
10717            rebind.clone().unwrap_or_else(|| record_id.clone())
10718        }).and_then(|candidate| {
10719            self.session_records
10720                .lock()
10721                .unwrap_or_else(|poisoned| poisoned.into_inner())
10722                .records
10723                .get(&candidate)
10724                .filter(|record| {
10725                    record.provider_session.is_some()
10726                        && record.active_session.as_ref() == Some(address)
10727                })
10728                .map(|_| candidate)
10729        });
10730        if let Some(owner_record_id) = materialization_owner_record {
10731            let materialization_id = self
10732                .session_bindings
10733                .lock()
10734                .unwrap_or_else(|poisoned| poisoned.into_inner())
10735                .get(&address.session.instance_id)
10736                .and_then(|binding| binding.materialization_id.clone());
10737            if let Some(materialization_id) = materialization_id {
10738                let mut transfer_failed = false;
10739                {
10740                    let mut materializations = self
10741                        .materializations
10742                        .lock()
10743                        .unwrap_or_else(|poisoned| poisoned.into_inner());
10744                    if let Some(ownership) = materializations.get_mut(&materialization_id) {
10745                        match ownership.owner() {
10746                            MaterializationOwner::Session {
10747                                incarnation_id,
10748                                instance_id,
10749                                generation,
10750                            } if *incarnation_id == self.incarnation_id
10751                                && *instance_id == address.session.instance_id
10752                                && *generation == address.session.generation =>
10753                            {
10754                                transfer_failed = ownership
10755                                    .transfer_to_record(owner_record_id.clone(), unix_time_ms())
10756                                    .is_err();
10757                            }
10758                            MaterializationOwner::Record { record_id }
10759                                if record_id == &owner_record_id => {}
10760                            MaterializationOwner::Record { record_id } => {
10761                                let expected = record_id.clone();
10762                                transfer_failed = ownership
10763                                    .transfer_record_owner(
10764                                        &expected,
10765                                        owner_record_id.clone(),
10766                                        unix_time_ms(),
10767                                    )
10768                                    .is_err();
10769                            }
10770                            _ => transfer_failed = true,
10771                        }
10772                        if transfer_failed {
10773                            let _ = ownership.mark_recovery_required(unix_time_ms());
10774                        }
10775                    } else {
10776                        transfer_failed = true;
10777                    }
10778                }
10779                if transfer_failed {
10780                    if let Some(record) = self
10781                        .session_records
10782                        .lock()
10783                        .unwrap_or_else(|poisoned| poisoned.into_inner())
10784                        .records
10785                        .get_mut(&owner_record_id)
10786                    {
10787                        record.state = ManagedSessionState::Unavailable;
10788                        record.last_error = Some("environment-profile-unavailable".to_owned());
10789                        record.updated_at_unix_ms = unix_time_ms();
10790                        upserts.push(record.clone());
10791                    }
10792                }
10793            }
10794        }
10795        if let Some(new_record_id) = rebind.clone() {
10796            if let Some(binding) = self
10797                .session_bindings
10798                .lock()
10799                .unwrap_or_else(|poisoned| poisoned.into_inner())
10800                .get_mut(&address.session.instance_id)
10801            {
10802                binding.record_id = Some(new_record_id);
10803            }
10804        }
10805        if upserts.is_empty() && removals.is_empty() {
10806            return;
10807        }
10808        if self.persist_state_locked().is_err() {
10809            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
10810            let mut records = self
10811                .session_records
10812                .lock()
10813                .unwrap_or_else(|poisoned| poisoned.into_inner());
10814            records.records = original_records;
10815            *self
10816                .materializations
10817                .lock()
10818                .unwrap_or_else(|poisoned| poisoned.into_inner()) = original_materializations;
10819            if let Some(current) = records.records.get_mut(record_id) {
10820                current.state = ManagedSessionState::Unavailable;
10821                current.last_error = Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned());
10822                current.updated_at_unix_ms = unix_time_ms();
10823                upserts = vec![current.clone()];
10824            } else {
10825                upserts.clear();
10826            }
10827            removals.clear();
10828            drop(records);
10829            if let Some(binding) = self
10830                .session_bindings
10831                .lock()
10832                .unwrap_or_else(|poisoned| poisoned.into_inner())
10833                .get_mut(&address.session.instance_id)
10834            {
10835                binding.record_id = Some(record_id.clone());
10836            }
10837            eprintln!("gate4agent-node: {DURABLE_STATE_COMMIT_FAILED_ERROR}");
10838        }
10839        for record in upserts {
10840            self.publish_record(record);
10841        }
10842        for record_id in removals {
10843            self.publish(NodeEvent::SessionRecordRemoved { record_id });
10844        }
10845    }
10846
10847    fn snapshot(&self) -> NodeSnapshot {
10848        let control = self.handle.snapshot();
10849        let bindings = self
10850            .session_bindings
10851            .lock()
10852            .unwrap_or_else(|poisoned| poisoned.into_inner())
10853            .clone();
10854        let workspace_roots = self
10855            .workspaces
10856            .read()
10857            .unwrap_or_else(|poisoned| poisoned.into_inner())
10858            .clone();
10859        let environment_profiles = self
10860            .environment_profiles
10861            .read()
10862            .unwrap_or_else(|poisoned| poisoned.into_inner());
10863        let mut workspaces = workspace_roots
10864            .iter()
10865            .map(|(workspace_id, canonical_root)| {
10866                (
10867                    workspace_id.clone(),
10868                    WorkspaceSnapshot {
10869                        workspace_id: workspace_id.clone(),
10870                        canonical_root: opaque_windows_path(canonical_root.clone()),
10871                        sessions: Vec::new(),
10872                        worktree_service_mode: Some(
10873                            self.worktree_service_modes
10874                                .get(workspace_id)
10875                                .copied()
10876                                .unwrap_or(WorktreeServiceMode::Manual),
10877                        ),
10878                        managed_worktree_profiles: Some(WorktreeProfileInventory {
10879                            profiles: self
10880                                .managed_worktree_profiles
10881                                .get(workspace_id)
10882                                .into_iter()
10883                                .flat_map(BTreeMap::values)
10884                                .map(|profile| ManagedWorktreeProfileSummary {
10885                                    id: profile.profile_id().clone(),
10886                                    revision: profile.revision().clone(),
10887                                    retention: profile.retention(),
10888                                })
10889                                .collect(),
10890                        }),
10891                    },
10892                )
10893            })
10894            .collect::<BTreeMap<_, _>>();
10895        let mut agent_progress = Vec::new();
10896        for session in &control.sessions {
10897            let Some(binding) = bindings.get(&session.instance_id) else {
10898                continue;
10899            };
10900            if binding.generation != session.generation {
10901                continue;
10902            }
10903            if let Some(workspace) = workspaces.get_mut(&binding.workspace_id) {
10904                let address = SessionAddress {
10905                    workspace_id: binding.workspace_id.clone(),
10906                    session: SessionKey {
10907                        instance_id: session.instance_id,
10908                        generation: session.generation,
10909                    },
10910                };
10911                if let Some(progress) =
10912                    agent_progress_from_provider_snapshot(address, &session.provider)
10913                {
10914                    agent_progress.push(progress);
10915                }
10916                workspace.sessions.push(session.clone());
10917            }
10918        }
10919        NodeSnapshot {
10920            node_id: self.node_id.clone(),
10921            enabled_providers: self.enabled_providers.clone(),
10922            provider_runtime_statuses: {
10923                let updates = self
10924                    .provider_runtime_status_updates
10925                    .read()
10926                    .unwrap_or_else(|poisoned| poisoned.into_inner());
10927                ProviderRuntimeStatuses::new(
10928                    self.provider_runtime_statuses
10929                        .iter()
10930                        .filter(|status| !updates.contains_key(status.provider()))
10931                        .cloned()
10932                        .chain(updates.values().cloned()),
10933                )
10934                .expect("refreshed provider runtime inventory remains bounded and unique")
10935            },
10936            workspaces: workspaces.into_values().collect(),
10937            session_records: self
10938                .session_records
10939                .lock()
10940                .unwrap_or_else(|poisoned| poisoned.into_inner())
10941                .records
10942                .values()
10943                .cloned()
10944                .collect(),
10945            managed_worktrees: self.managed_worktrees
10946                .lock()
10947                .unwrap_or_else(|poisoned| poisoned.into_inner())
10948                .snapshots(),
10949            launch_inventory: Some(LaunchInventory {
10950                spawn_profiles: Some(
10951                    self.spawn_profiles
10952                        .iter()
10953                        .filter_map(|profile| {
10954                            resolved_spawn_profile_summary(profile, &environment_profiles)
10955                        })
10956                        .collect(),
10957                ),
10958                bundles: Some(
10959                    self.bundle_catalog
10960                        .read()
10961                        .unwrap_or_else(|poisoned| poisoned.into_inner())
10962                        .iter()
10963                        .map(NodeBundle::receipt)
10964                        .collect(),
10965                ),
10966                // Opaque station allowlist ids from empty-default catalog /
10967                // optional startup loader. Never secrets. Dig2 probe stays stub.
10968                network_allowlists: {
10969                    let catalog = self
10970                        .network_allowlist_catalog
10971                        .read()
10972                        .unwrap_or_else(|poisoned| poisoned.into_inner());
10973                    if catalog.is_empty() {
10974                        None
10975                    } else {
10976                        Some(catalog.ids().cloned().collect())
10977                    }
10978                },
10979            }),
10980            agent_progress,
10981        }
10982    }
10983
10984    fn publish(&self, event: NodeEvent) -> NodeEventEnvelope {
10985        assert!(
10986            !matches!(
10987                &event,
10988                NodeEvent::TerminalFrame { .. } | NodeEvent::AgentStream { .. }
10989            ),
10990            "terminal frame and agent stream events must use their own dedicated live channel",
10991        );
10992        let mut history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
10993        let sequence = history
10994            .last_sequence
10995            .checked_add(1)
10996            .expect("node event sequence exhausted");
10997        if history.events.len() == NODE_EVENT_HISTORY_MAX {
10998            if let Some(evicted) = history.events.pop_front() {
10999                history.replay_floor_sequence = evicted
11000                    .sequence
11001                    .checked_add(1)
11002                    .expect("node replay floor sequence exhausted");
11003                history.removed_record_providers.remove(&evicted.sequence);
11004            }
11005        }
11006        match &event {
11007            NodeEvent::HarnessMcpReadCall { .. } => {
11008                panic!("transient harness MCP calls must not enter durable event history")
11009            }
11010            NodeEvent::SessionRecordUpserted { record } => {
11011                history
11012                    .record_providers
11013                    .insert(record.record_id.clone(), record.provider.clone());
11014            }
11015            NodeEvent::SessionRecordRemoved { record_id } => {
11016                if let Some(provider) = history.record_providers.remove(record_id) {
11017                    history.removed_record_providers.insert(sequence, provider);
11018                }
11019            }
11020            NodeEvent::ManagedWorktreeUpserted { .. }
11021            | NodeEvent::ManagedWorktreeRemoved { .. }
11022            | NodeEvent::Control { .. }
11023            | NodeEvent::ControllerChanged { .. }
11024            | NodeEvent::SessionRecordHistorySummarized { .. }
11025            | NodeEvent::WorkspaceAdded { .. }
11026            | NodeEvent::WorkspaceRemoved { .. }
11027            | NodeEvent::ResyncRequired { .. }
11028            | NodeEvent::AgentStream { .. }
11029            | NodeEvent::TerminalFrame { .. } => {}
11030        }
11031        let envelope = NodeEventEnvelope { sequence, event };
11032        history.events.push_back(envelope.clone());
11033        history.last_sequence = sequence;
11034        let _ = self.event_tx.send(envelope.clone());
11035        drop(history);
11036        envelope
11037    }
11038
11039    /// Publishes a transient (never durable) `NodeEvent` -- today only
11040    /// `NodeEvent::HarnessMcpReadCall` -- onto the dedicated
11041    /// `harness_mcp_proxy_event_tx` broadcast (see that field's own doc for
11042    /// why this must never ride the shared `event_tx` bus). Returns whether
11043    /// the send reached at least one subscriber: `broadcast::Sender::send`
11044    /// fails in exactly one way, no active receiver at that instant, and
11045    /// the caller (`HarnessMcpProxyRegistry::begin_call`) uses a `false`
11046    /// return to fail the pending call immediately rather than leave it to
11047    /// expire silently at `LOCAL_READ_TIMEOUT`/
11048    /// `MAX_HARNESS_MCP_CALL_DEADLINE_MS`.
11049    fn publish_transient(&self, event: NodeEvent) -> bool {
11050        assert!(event.requires_harness_mcp_proxy_capability());
11051        assert!(event.harness_mcp_contract_is_valid_at(unix_time_ms()));
11052        let envelope = NodeEventEnvelope { sequence: 0, event };
11053        match self.harness_mcp_proxy_event_tx.send(Arc::new(vec![envelope])) {
11054            Ok(_) => true,
11055            Err(broadcast::error::SendError(undelivered)) => {
11056                if let Some(NodeEventEnvelope {
11057                    event: NodeEvent::HarnessMcpReadCall { call_id, request, .. },
11058                    ..
11059                }) = undelivered.first() {
11060                    // `request.content_type` only, never a per-tool label --
11061                    // this crate does not decode `request.body` and does not
11062                    // hold `hatchery-harness-api` (Nested Control Plane
11063                    // doctrine, Law 3; see this crate's dependency on
11064                    // `gate4agent-node-protocol`'s own `Forbidden:` line).
11065                    tracing::warn!(
11066                        call_id = call_id.as_str(),
11067                        content_type = ?request.content_type,
11068                        cause = "no-subscribers",
11069                        "harness MCP proxy event could not be published",
11070                    );
11071                }
11072                false
11073            }
11074        }
11075    }
11076
11077    fn publish_terminal_frames(&self) {
11078        let snapshot = self.handle.snapshot();
11079        let bindings = self
11080            .session_bindings
11081            .lock()
11082            .unwrap_or_else(|poisoned| poisoned.into_inner());
11083        let candidates = snapshot
11084            .sessions
11085            .iter()
11086            .filter_map(|session| {
11087                let binding = bindings.get(&session.instance_id)?;
11088                if binding.generation != session.generation {
11089                    return None;
11090                }
11091                let frame = session.terminal_frame.clone()?;
11092                crate::session_signals::feed(
11093                    binding.workspace_id.as_str(),
11094                    session.instance_id.0,
11095                    session.agent_id.as_str(),
11096                    &frame.contents,
11097                );
11098                Some((
11099                    SessionAddress {
11100                        workspace_id: binding.workspace_id.clone(),
11101                        session: SessionKey {
11102                            instance_id: session.instance_id,
11103                            generation: session.generation,
11104                        },
11105                    },
11106                    frame,
11107                ))
11108            })
11109            .collect();
11110        drop(bindings);
11111        self.publish_terminal_frame_candidates(candidates);
11112    }
11113
11114    fn publish_terminal_frame_candidates(
11115        &self,
11116        candidates: Vec<(SessionAddress, TerminalFrame)>,
11117    ) {
11118        let mut watermarks = self
11119            .terminal_frame_watermarks
11120            .lock()
11121            .unwrap_or_else(|poisoned| poisoned.into_inner());
11122        let mut events = Vec::with_capacity(candidates.len());
11123        for (address, frame) in candidates {
11124            let is_first_frame = !watermarks.contains_key(&address.session.instance_id);
11125            let advanced = watermarks
11126                .get(&address.session.instance_id)
11127                .map_or(true, |(current_address, sequence)| {
11128                    current_address != &address || frame.sequence > *sequence
11129                });
11130            if !advanced {
11131                continue;
11132            }
11133            if is_first_frame {
11134                // Do NOT sessionize on first PTY output: a session is a
11135                // provider conversation id, not bytes on the wire. Drafts
11136                // leave the provisional map only when HQ attaches
11137                // (Resize / TerminalBytes). The attach-never-arrived timer
11138                // (PROVISIONAL_SPAWN_GRACE_MS) covers deploy lag; HQ's
11139                // disconnect grace starts only on a real client WS close.
11140                // This is the first PTY output this node has observed for
11141                // this session -- a one-time event per spawn, not per-frame
11142                // chatter (the watermark makes sure of that). A session that
11143                // reaches this line with no controller attached anywhere on
11144                // the node is running unobserved: exactly how processes from
11145                // repeated spawn attempts went orphaned for hours without a
11146                // single line about it.
11147                if self.controller_state().is_none() {
11148                    tracing::warn!(
11149                        node_id = %self.node_id,
11150                        workspace_id = %address.workspace_id,
11151                        session = ?address.session,
11152                        terminal_size = ?frame.size,
11153                        "session producing output with no controller attached",
11154                    );
11155                } else {
11156                    tracing::info!(
11157                        node_id = %self.node_id,
11158                        workspace_id = %address.workspace_id,
11159                        session = ?address.session,
11160                        terminal_size = ?frame.size,
11161                        "session produced its first output",
11162                    );
11163                }
11164            }
11165            watermarks.insert(
11166                address.session.instance_id,
11167                (address.clone(), frame.sequence),
11168            );
11169            if let Some((at, reported)) = self
11170                .pty_input_marks
11171                .lock()
11172                .unwrap_or_else(|poisoned| poisoned.into_inner())
11173                .get_mut(&address.session.instance_id)
11174            {
11175                // First frame after an input: PTY write -> output published.
11176                if !*reported && at.elapsed() < Duration::from_secs(5) {
11177                    *reported = true;
11178                    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");
11179                }
11180            }
11181            events.push(NodeEvent::TerminalFrame { address, frame });
11182        }
11183        drop(watermarks);
11184        if events.is_empty() {
11185            return;
11186        }
11187        let mut history = self
11188            .history
11189            .lock()
11190            .unwrap_or_else(|poisoned| poisoned.into_inner());
11191        let mut envelopes = Vec::with_capacity(events.len());
11192        for event in events {
11193            let sequence = history
11194                .last_sequence
11195                .checked_add(1)
11196                .expect("node event sequence exhausted");
11197            history.last_sequence = sequence;
11198            envelopes.push(NodeEventEnvelope { sequence, event });
11199        }
11200        let _ = self.terminal_event_tx.send(Arc::new(envelopes));
11201        drop(history);
11202    }
11203
11204    /// Publishes one `AgentStreamChunkV1` onto the dedicated agent-stream
11205    /// channel, mirroring `publish_terminal_frame_candidates` above: the
11206    /// envelope's `sequence` is drawn from the same `history.last_sequence`
11207    /// counter every other event kind shares, never a counter of its own --
11208    /// `queue_connection_event`/`queue_connection_event_batch` compare
11209    /// `envelope.sequence` against one `discard_events_through` watermark
11210    /// regardless of which broadcast channel it arrived on, so a
11211    /// per-channel counter would desynchronize that comparison.
11212    fn publish_agent_stream_chunk(&self, address: SessionAddress, chunk: AgentStreamChunkV1) {
11213        let mut history = self
11214            .history
11215            .lock()
11216            .unwrap_or_else(|poisoned| poisoned.into_inner());
11217        let sequence = history
11218            .last_sequence
11219            .checked_add(1)
11220            .expect("node event sequence exhausted");
11221        history.last_sequence = sequence;
11222        drop(history);
11223        // Debug, not info/warn -- this fires on every single chunk, but it
11224        // is exactly the counter the next drop-measurement pass needs: what
11225        // the node actually emitted (`source_sequence`, `kind`) versus what
11226        // a harness subscriber's own forwarder later logs having received
11227        // (`hatchery-harness-service::runtime`'s "forwarding agent stream
11228        // event"), so a gap between the two logs names itself instead of
11229        // needing a new counter added ad hoc.
11230        tracing::debug!(
11231            source_sequence = chunk.source_sequence,
11232            kind = agent_stream_chunk_kind_label(&chunk.kind),
11233            envelope_sequence = sequence,
11234            "publishing agent stream chunk",
11235        );
11236        let envelope = NodeEventEnvelope {
11237            sequence,
11238            event: NodeEvent::AgentStream { address, chunk },
11239        };
11240        let _ = self.agent_stream_event_tx.send(Arc::new(vec![envelope]));
11241    }
11242
11243    fn current_sequence(&self) -> u64 {
11244        self.history
11245            .lock()
11246            .unwrap_or_else(|poisoned| poisoned.into_inner())
11247            .last_sequence
11248    }
11249
11250    fn controller_state(&self) -> Option<ControllerState> {
11251        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11252        let lease = controller.as_ref().copied()?;
11253        let remaining = lease.expires_at.saturating_duration_since(Instant::now());
11254        if remaining.is_zero() {
11255            *controller = None;
11256            return None;
11257        }
11258        Some(ControllerState {
11259            connection_id: lease.connection_id,
11260            lease_remaining_ms: remaining.as_millis().min(u64::MAX as u128) as u64,
11261        })
11262    }
11263
11264    fn acquire_controller(&self, connection_id: u64, role: ClientRole, lease_ms: u64) -> Result<ControllerState, NodeFailure> {
11265        if role != ClientRole::Operator {
11266            tracing::warn!(
11267                node_id = %self.node_id,
11268                connection_id,
11269                "controller lease rejected: observer connections cannot acquire control",
11270            );
11271            return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections cannot acquire control"));
11272        }
11273        let lease_ms = if lease_ms == 0 {
11274            DEFAULT_CONTROLLER_LEASE_MS
11275        } else {
11276            lease_ms.clamp(MIN_CONTROLLER_LEASE_MS, MAX_CONTROLLER_LEASE_MS)
11277        };
11278        let now = Instant::now();
11279        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11280        if let Some(active) = controller.as_ref() {
11281            if active.connection_id != connection_id && active.expires_at > now {
11282                tracing::warn!(
11283                    node_id = %self.node_id,
11284                    connection_id,
11285                    holder_connection_id = active.connection_id,
11286                    "controller lease rejected: another operator holds it",
11287                );
11288                return Err(failure(NodeFailureCode::ControllerBusy, "another operator holds the controller lease"));
11289            }
11290        }
11291        // A reconnect (this connection already holds the lease, renewing
11292        // before expiry) happens on a periodic cadence; logging it at INFO
11293        // every renewal would be exactly the heartbeat chatter this crate
11294        // family's level discipline forbids. A genuine attach -- the
11295        // controller identity actually changing -- is a state change and
11296        // gets INFO; a same-connection renewal gets DEBUG.
11297        let is_new_attach = match controller.as_ref() {
11298            Some(active) => active.connection_id != connection_id,
11299            None => true,
11300        };
11301        *controller = Some(ControllerLease {
11302            connection_id,
11303            expires_at: now + Duration::from_millis(lease_ms),
11304        });
11305        drop(controller);
11306        let state = ControllerState { connection_id, lease_remaining_ms: lease_ms };
11307        if is_new_attach {
11308            tracing::info!(
11309                node_id = %self.node_id,
11310                connection_id,
11311                lease_ms,
11312                "controller lease acquired",
11313            );
11314        } else {
11315            tracing::debug!(
11316                node_id = %self.node_id,
11317                connection_id,
11318                lease_ms,
11319                "controller lease renewed",
11320            );
11321        }
11322        self.publish(NodeEvent::ControllerChanged { controller: Some(state.clone()) });
11323        Ok(state)
11324    }
11325
11326    fn release_controller(&self, connection_id: u64) -> bool {
11327        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11328        let released = controller.as_ref().is_some_and(|active| active.connection_id == connection_id);
11329        if released {
11330            *controller = None;
11331        }
11332        drop(controller);
11333        if released {
11334            tracing::info!(
11335                node_id = %self.node_id,
11336                connection_id,
11337                "controller lease released",
11338            );
11339            self.publish(NodeEvent::ControllerChanged { controller: None });
11340        }
11341        released
11342    }
11343
11344    /// Mark a fresh Spawn as provisional and arm the attach-never-arrived
11345    /// reaper (PROVISIONAL_SPAWN_GRACE_MS from spawn). Removed from this map
11346    /// only when HQ attaches (Resize / TerminalBytes) — that is *not* a
11347    /// provider session; it only cancels the orphan timer. Disconnect/reload
11348    /// draft kills are owned by HQ and arm only on a real client WS close.
11349    /// ACP path of launch options: once the agent advertises its config options, write the
11350    /// pending model / effort through session/set_config_option (session/set_model when the
11351    /// agent has no model option). Gaps are logged, never forced.
11352    fn apply_pending_acp_options(&self, address: &SessionAddress, event: &ControlEvent) {
11353        let ControlEventKind::ProviderEvent { event: ProviderEvent::ConfigOptionsUpdated { options }, .. } = &event.event else {
11354            return;
11355        };
11356        let id = address.session.instance_id;
11357        let Some((model, effort)) = self.pending_acp_options.lock().unwrap_or_else(|p| p.into_inner()).remove(&id) else {
11358            return;
11359        };
11360        let advertised: Vec<(String, Option<String>, Vec<String>)> = options
11361            .iter()
11362            .map(|o| (o.id.clone(), o.category.clone(), o.choices.iter().map(|c| c.value_json.clone()).collect()))
11363            .collect();
11364        let (writes, set_model, effort_gap) =
11365            backend_paths::acp_option_writes(&advertised, model.as_deref(), effort.as_deref());
11366        if set_model {
11367            if let Some(model_id) = model.clone() {
11368                if let Err(e) = self.dispatch(ControlCommand::SetSessionModel { instance_id: id, model_id }) {
11369                    tracing::warn!(instance_id = id.0, message = %e.message, "acp set_model failed");
11370                }
11371            }
11372        }
11373        for (option_id, value_json) in writes {
11374            if let Err(e) = self.dispatch(ControlCommand::SetSessionConfigOption { instance_id: id, option_id, value_json }) {
11375                tracing::warn!(instance_id = id.0, message = %e.message, "acp set_config_option failed");
11376            }
11377        }
11378        if effort_gap {
11379            tracing::warn!(instance_id = id.0, effort = ?effort, "acp agent advertises no matching thought_level option; effort not applied");
11380        }
11381        tracing::info!(instance_id = id.0, model = ?model, effort = ?effort, "launch options applied over acp");
11382    }
11383
11384    fn mark_spawn_provisional(&self, address: SessionAddress) {
11385        let id = address.session.instance_id;
11386        self.provisional_spawns
11387            .lock()
11388            .unwrap_or_else(|poisoned| poisoned.into_inner())
11389            .insert(id, (address, Instant::now()));
11390        tracing::info!(
11391            node_id = %self.node_id,
11392            instance_id = id.0,
11393            grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
11394            "draft spawn; waiting for HQ view",
11395        );
11396    }
11397
11398    fn sessionize_spawn(&self, instance_id: AgentInstanceId) {
11399        let removed = self
11400            .provisional_spawns
11401            .lock()
11402            .unwrap_or_else(|poisoned| poisoned.into_inner())
11403            .remove(&instance_id);
11404        if removed.is_some() {
11405            tracing::info!(
11406                node_id = %self.node_id,
11407                instance_id = instance_id.0,
11408                "draft spawn claimed by HQ view",
11409            );
11410        }
11411    }
11412
11413    fn take_due_provisional_spawns(&self) -> Vec<SessionAddress> {
11414        let grace = Duration::from_millis(PROVISIONAL_SPAWN_GRACE_MS);
11415        let mut map = self
11416            .provisional_spawns
11417            .lock()
11418            .unwrap_or_else(|poisoned| poisoned.into_inner());
11419        let due: Vec<AgentInstanceId> = map
11420            .iter()
11421            .filter(|(_, (_, at))| at.elapsed() >= grace)
11422            .map(|(id, _)| *id)
11423            .collect();
11424        due.into_iter()
11425            .filter_map(|id| map.remove(&id).map(|(addr, _)| addr))
11426            .collect()
11427    }
11428
11429    async fn reap_provisional_spawn(&self, address: SessionAddress) {
11430        let instance_id = address.session.instance_id;
11431        tracing::warn!(
11432            node_id = %self.node_id,
11433            instance_id = instance_id.0,
11434            workspace = %address.workspace_id,
11435            reason = "attach_never_arrived",
11436            grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
11437            "draft spawn never viewed; stopping PTY",
11438        );
11439        let _ = self.dispatch(ControlCommand::Stop {
11440            instance_id,
11441            force: true,
11442        });
11443        let _ = self.remove_session(&address).await;
11444    }
11445
11446    fn require_controller(&self, connection_id: u64, role: ClientRole) -> Result<(), NodeFailure> {
11447        if role != ClientRole::Operator {
11448            return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections are read-only"));
11449        }
11450        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11451        let now = Instant::now();
11452        match controller.as_ref().copied() {
11453            Some(active) if active.connection_id == connection_id && active.expires_at > now => Ok(()),
11454            Some(active) if active.expires_at <= now => {
11455                *controller = None;
11456                Err(failure(NodeFailureCode::ControllerRequired, "controller lease expired"))
11457            }
11458            _ => Err(failure(NodeFailureCode::ControllerRequired, "controller lease is required")),
11459        }
11460    }
11461
11462    fn validate_address(&self, address: &SessionAddress) -> Result<AgentId, NodeFailure> {
11463        if !self
11464            .workspaces
11465            .read()
11466            .unwrap_or_else(|poisoned| poisoned.into_inner())
11467            .contains_key(&address.workspace_id)
11468        {
11469            return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
11470        }
11471        let binding = self
11472            .session_bindings
11473            .lock()
11474            .unwrap_or_else(|poisoned| poisoned.into_inner())
11475            .get(&address.session.instance_id)
11476            .cloned()
11477            .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
11478        if binding.workspace_id != address.workspace_id {
11479            return Err(failure(
11480                NodeFailureCode::SessionWorkspaceMismatch,
11481                "session belongs to another workspace",
11482            ));
11483        }
11484        if binding.generation != address.session.generation {
11485            return Err(failure(
11486                NodeFailureCode::StaleGeneration,
11487                &format!(
11488                    "stale session generation: expected {}, received {}",
11489                    binding.generation.0,
11490                    address.session.generation.0,
11491                ),
11492            ));
11493        }
11494        let snapshot = self.handle.snapshot();
11495        let Some(current) = snapshot
11496            .sessions
11497            .iter()
11498            .find(|item| item.instance_id == address.session.instance_id)
11499        else {
11500            return Err(failure(NodeFailureCode::UnknownSession, "session instance does not exist"));
11501        };
11502        if current.generation != address.session.generation {
11503            return Err(failure(
11504                NodeFailureCode::StaleGeneration,
11505                &format!(
11506                    "stale session generation: expected {}, received {}",
11507                    current.generation.0,
11508                    address.session.generation.0,
11509                ),
11510            ));
11511        }
11512        Ok(current.agent_id.clone())
11513    }
11514
11515    fn dispatch(&self, command: ControlCommand) -> Result<CommandId, NodeFailure> {
11516        self.dispatch_envelope(self.prepare_command(command))
11517    }
11518
11519    fn prepare_command(&self, command: ControlCommand) -> CommandEnvelope {
11520        let id = self.next_command_id.fetch_add(1, Ordering::AcqRel);
11521        CommandEnvelope {
11522            id: CommandId(id),
11523            command,
11524        }
11525    }
11526
11527    fn dispatch_envelope(&self, envelope: CommandEnvelope) -> Result<CommandId, NodeFailure> {
11528        let command_id = envelope.id;
11529        self.handle
11530            .dispatch(envelope)
11531            .map_err(|error| match error {
11532                PortDispatchError::Full => failure(NodeFailureCode::BackendBusy, "backend command ingress is full"),
11533                PortDispatchError::Disconnected => failure(NodeFailureCode::BackendDisconnected, "backend command ingress is disconnected"),
11534            })?;
11535        Ok(command_id)
11536    }
11537
11538    /// Records the kernel's own reason for asynchronously refusing
11539    /// `command_id` -- see `rejected_command_reasons`'s doc for why a
11540    /// spawn-dispatch waiter otherwise has no way to learn it. Bounded by
11541    /// `REJECTED_COMMAND_REASON_MAX_ENTRIES`: expired entries are swept
11542    /// first, and if the map is still at capacity after that, the single
11543    /// oldest surviving entry is evicted -- `CommandId` is monotonically
11544    /// assigned (`next_command_id.fetch_add`), so the `BTreeMap`'s first key
11545    /// is always the oldest.
11546    fn record_rejected_command(
11547        &self,
11548        command_id: CommandId,
11549        message: String,
11550        unsupported_transport: bool,
11551    ) {
11552        let now = Instant::now();
11553        let mut reasons = self.rejected_command_reasons.lock()
11554            .unwrap_or_else(|poisoned| poisoned.into_inner());
11555        reasons.retain(|_, reason| reason.expires_at > now);
11556        if reasons.len() >= REJECTED_COMMAND_REASON_MAX_ENTRIES {
11557            if let Some(&oldest) = reasons.keys().next() {
11558                reasons.remove(&oldest);
11559            }
11560        }
11561        reasons.insert(
11562            command_id,
11563            RejectedCommandReason {
11564                message,
11565                unsupported_transport,
11566                expires_at: now + Duration::from_millis(REJECTED_COMMAND_REASON_TTL_MS),
11567            },
11568        );
11569    }
11570
11571    /// Removes and returns the kernel's reason for refusing `command_id`,
11572    /// if the drive loop has recorded one and it has not expired -- see
11573    /// `rejected_command_reasons`'s doc. Consumed at most once: a spawn
11574    /// wait loop calls this instead of blindly polling out its whole
11575    /// deadline when the command it is waiting on turns out to have been
11576    /// refused rather than merely slow.
11577    fn take_rejected_command_reason(&self, command_id: CommandId) -> Option<RejectedCommandReason> {
11578        let now = Instant::now();
11579        let mut reasons = self.rejected_command_reasons.lock()
11580            .unwrap_or_else(|poisoned| poisoned.into_inner());
11581        match reasons.remove(&command_id) {
11582            Some(reason) if reason.expires_at > now => Some(reason),
11583            _ => None,
11584        }
11585    }
11586
11587    async fn dispatch_bounded(
11588        &self,
11589        command: ControlCommand,
11590        timeout_duration: Duration,
11591    ) -> Result<CommandId, NodeFailure> {
11592        let deadline = Instant::now() + timeout_duration;
11593        loop {
11594            match self.dispatch(command.clone()) {
11595                Ok(command_id) => return Ok(command_id),
11596                Err(error) if error.code == NodeFailureCode::BackendBusy && Instant::now() < deadline => {
11597                    sleep(Duration::from_millis(2)).await;
11598                }
11599                Err(error) => return Err(error),
11600            }
11601        }
11602    }
11603
11604    fn internal_session_snapshot(
11605        &self,
11606        address: &SessionAddress,
11607    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11608        self.validate_address(address)?;
11609        self.handle
11610            .snapshot()
11611            .sessions
11612            .clone()
11613            .into_iter()
11614            .find(|session| {
11615                session.instance_id == address.session.instance_id
11616                    && session.generation == address.session.generation
11617            })
11618            .ok_or_else(|| {
11619                failure(
11620                    NodeFailureCode::UnknownSession,
11621                    "session snapshot is unavailable",
11622                )
11623            })
11624    }
11625
11626    /// Confirms `address` names a live session spawned over the ACP
11627    /// transport and returns its current snapshot -- the gate every
11628    /// `acp-control-v1` verb shares. `require_session_runtime_policy`
11629    /// cannot answer this by itself: `ProviderRuntimePolicy` carries no
11630    /// transport bit for `Acp`/`Inline` (see that method's own doc), only
11631    /// the session's own recorded `TransportKind` does, and that lives on
11632    /// the live snapshot, not the binding.
11633    fn require_acp_session(
11634        &self,
11635        address: &SessionAddress,
11636    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11637        let snapshot = self.internal_session_snapshot(address)?;
11638        if snapshot.transport != TransportKind::Acp {
11639            return Err(failure(
11640                NodeFailureCode::UnsupportedTransport,
11641                "this verb requires a session spawned over the ACP transport",
11642            ));
11643        }
11644        Ok(snapshot)
11645    }
11646
11647    async fn dispatch_history_bounded(
11648        &self,
11649        address: &SessionAddress,
11650        command: ControlCommand,
11651        operation: HistoryOperation,
11652    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11653        self.validate_address(address)?;
11654        let started_after = self.current_sequence();
11655        let command_id = self
11656            .dispatch_bounded(
11657                command,
11658                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
11659            )
11660            .await?;
11661        let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
11662        let mut scan_after = started_after;
11663        let mut accepted_after = None;
11664        loop {
11665            let events = self
11666                .history
11667                .lock()
11668                .unwrap_or_else(|poisoned| poisoned.into_inner())
11669                .events
11670                .iter()
11671                .filter(|envelope| envelope.sequence > scan_after)
11672                .cloned()
11673                .collect::<Vec<_>>();
11674            for envelope in events {
11675                scan_after = scan_after.max(envelope.sequence);
11676                let NodeEvent::Control {
11677                    address: event_address,
11678                    event,
11679                } = envelope.event
11680                else {
11681                    continue;
11682                };
11683                if event_address != *address {
11684                    continue;
11685                }
11686                if event.command_id == Some(command_id) {
11687                    match &event.event {
11688                        ControlEventKind::CommandRejected { .. } => {
11689                            return Err(failure(
11690                                NodeFailureCode::InvalidRequest,
11691                                "history request was rejected",
11692                            ));
11693                        }
11694                        ControlEventKind::HistoryRequested {
11695                            operation: requested,
11696                            ..
11697                        } if requested == &operation => {
11698                            accepted_after = Some(envelope.sequence);
11699                            continue;
11700                        }
11701                        _ => {}
11702                    }
11703                }
11704                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
11705                    match (&operation, &event.event) {
11706                        (
11707                            HistoryOperation::Discover { .. },
11708                            ControlEventKind::HistoryDiscovered { .. },
11709                        )
11710                        | (
11711                            HistoryOperation::Load { .. },
11712                            ControlEventKind::HistoryLoaded { .. },
11713                        ) => return self.internal_session_snapshot(address),
11714                        (_, ControlEventKind::HistoryFailed { .. }) => {
11715                            return Err(failure(
11716                                NodeFailureCode::BackendOperationFailed,
11717                                "history operation failed",
11718                            ));
11719                        }
11720                        _ => {}
11721                    }
11722                }
11723            }
11724            if Instant::now() >= deadline {
11725                return Err(failure(
11726                    NodeFailureCode::BackendBusy,
11727                    if accepted_after.is_some() {
11728                        "history operation did not settle before the bounded deadline"
11729                    } else {
11730                        "history operation was not accepted before the bounded deadline"
11731                    },
11732                ));
11733            }
11734            sleep(Duration::from_millis(2)).await;
11735        }
11736    }
11737
11738    async fn discover_history(
11739        &self,
11740        address: &SessionAddress,
11741        limit: u16,
11742    ) -> Result<Vec<HistoryCandidateSummary>, NodeFailure> {
11743        let query = HistoryQuery {
11744            working_directory: Some(self.workspace_root(&address.workspace_id)?),
11745            limit,
11746        };
11747        query
11748            .validate()
11749            .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history query"))?;
11750        let snapshot = self
11751            .dispatch_history_bounded(
11752                address,
11753                ControlCommand::DiscoverHistory {
11754                    instance_id: address.session.instance_id,
11755                    query: query.clone(),
11756                },
11757                HistoryOperation::Discover { query },
11758            )
11759            .await?;
11760        if snapshot.history.pending.is_some() || snapshot.history.loaded.is_some() {
11761            return Err(failure(
11762                NodeFailureCode::BackendOperationFailed,
11763                "history discovery settled into an invalid state",
11764            ));
11765        }
11766        Ok(snapshot.history.candidates)
11767    }
11768
11769    async fn load_history(
11770        &self,
11771        address: &SessionAddress,
11772        candidate_id: String,
11773    ) -> Result<HistorySessionRecord, NodeFailure> {
11774        validate_candidate_id(&candidate_id)
11775            .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history candidate"))?;
11776        let snapshot = self
11777            .dispatch_history_bounded(
11778                address,
11779                ControlCommand::LoadHistory {
11780                    instance_id: address.session.instance_id,
11781                    candidate_id: candidate_id.clone(),
11782                },
11783                HistoryOperation::Load {
11784                    candidate_id: candidate_id.clone(),
11785                },
11786            )
11787            .await?;
11788        if snapshot.history.pending.is_some()
11789            || snapshot.history.loaded_candidate_id.as_ref() != Some(&candidate_id)
11790        {
11791            return Err(failure(
11792                NodeFailureCode::BackendOperationFailed,
11793                "history load settled into an invalid state",
11794            ));
11795        }
11796        snapshot.history.loaded.ok_or_else(|| {
11797            failure(
11798                NodeFailureCode::BackendOperationFailed,
11799                "history load produced no bounded session",
11800            )
11801        })
11802    }
11803
11804    /// `allow_clean_detachment`: `false` for the on-demand
11805    /// `NodeRequest::ExportContextPackForSessionRecord` API path (the
11806    /// managed record must stay untouched, still live, for the whole call);
11807    /// `true` only for the reactive auto-export-at-exit path (`§2.1`), which
11808    /// reacts to the very clean-exit event that detaches the record out from
11809    /// under this same call — see `session_record_export_target_matches`.
11810    async fn export_context_pack_for_session_record(
11811        &self,
11812        record_id: &SessionRecordId,
11813        session: &SessionAddress,
11814        allow_clean_detachment: bool,
11815    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11816        // C2 replaces a relayed failure's prose with a canned per-code
11817        // string (`C2NodeFailure`'s own `From`), so this path's dozen
11818        // distinct refusals are indistinguishable to anyone downstream --
11819        // and a refusal here expires a continuation, which keeps no field
11820        // for why. The prose stays on the node, where it was written.
11821        let outcome = self
11822            .export_context_pack_for_session_record_inner(record_id, session, allow_clean_detachment)
11823            .await;
11824        if let Err(failure) = &outcome {
11825            tracing::warn!(
11826                record = %record_id,
11827                code = ?failure.code,
11828                reason = %failure.message,
11829                "session-record context-pack export refused",
11830            );
11831        }
11832        outcome
11833    }
11834
11835    async fn export_context_pack_for_session_record_inner(
11836        &self,
11837        record_id: &SessionRecordId,
11838        session: &SessionAddress,
11839        allow_clean_detachment: bool,
11840    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11841        // Several steps here refuse with the same BackendOperationFailed
11842        // code and no prose of their own, so which step failed is
11843        // otherwise unrecoverable -- and a refusal here expires a
11844        // continuation.
11845        let step = |name: &'static str| move |failure: NodeFailure| {
11846            tracing::warn!(step = name, code = ?failure.code, "context-pack export step refused");
11847            failure
11848        };
11849        let record = self.record(record_id).map_err(step("record-lookup"))?;
11850        // `provider_session` is `Some` only for a provider whose resolved
11851        // runtime policy admits live identity verification (raw PTY plus a
11852        // semantic PTY adapter -- `provider_runtime::policy_from_capability_flags`).
11853        // The PTY-sidecar-observation shape (no fleet provider today, but
11854        // capability-keyed rather than vendor-locked) hardcodes
11855        // `provider_session_identity: false` in `provider_runtime::evaluate`,
11856        // so its records go straight to `Live` with `provider_session: None`
11857        // and stay that way for the record's entire life -- that is this
11858        // shape's permanent resting state, not a race with observation, and
11859        // `discover_history`/`load_history` below do not need an identity to
11860        // do their job for it. `IdentityPending` is the actual race (an
11861        // identity-admitted provider whose observation has not landed yet):
11862        // it keeps refusing here rather than falling back, because that
11863        // provider's own history the fallback would otherwise trust is not
11864        // guaranteed to be exact without the identity it is still waiting on.
11865        let identity = match record.provider_session.clone() {
11866            Some(identity) => {
11867                if identity.key != ProviderSessionKey::SessionId {
11868                    return Err(step("provider-identity-key")(failure(
11869                        NodeFailureCode::BackendOperationFailed,
11870                        "managed session provider identity cannot bind history exactly",
11871                    )));
11872                }
11873                Some(identity)
11874            }
11875            None if record.state == ManagedSessionState::IdentityPending => {
11876                return Err(step("provider-session-identity")(failure(
11877                    NodeFailureCode::BackendOperationFailed,
11878                    "managed session has not yet observed its provider session identity",
11879                )));
11880            }
11881            None => None,
11882        };
11883        self.revalidate_session_record_context_export(
11884            &record,
11885            identity.as_ref(),
11886            session,
11887            allow_clean_detachment,
11888        ).map_err(step("revalidate-before-discovery"))?;
11889        let candidates = self
11890            .discover_history(session, HISTORY_DISCOVERY_LIMIT_MAX)
11891            .await
11892            .map_err(step("discover-history"))?;
11893        // With no verified identity to filter by, the discovered set must
11894        // still resolve to exactly one candidate: the same ambiguity refusal
11895        // an identity filter would otherwise produce is the only safety
11896        // property left available for this shape.
11897        let candidate_id = select_history_candidate(
11898            &candidates,
11899            identity.as_ref().map(|identity| identity.id.as_str()),
11900        ).map_err(step("unique-candidate"))?;
11901        let loaded = self.load_history(session, candidate_id.clone()).await
11902            .map_err(step("load-history"))?;
11903        if let Some(identity) = identity.as_ref() {
11904            if loaded.session_id != identity.id {
11905                return Err(failure(
11906                    NodeFailureCode::SessionRecordConflict,
11907                    "loaded history identity does not match the managed session record",
11908                ));
11909            }
11910        }
11911        self.revalidate_session_record_context_export(
11912            &record,
11913            identity.as_ref(),
11914            session,
11915            allow_clean_detachment,
11916        )?;
11917        self.revalidate_loaded_history(session, &candidate_id, &loaded)
11918            .map_err(step("revalidate-loaded-history"))?;
11919        let pack = self.materialize_context_pack(session).await
11920            .map_err(step("materialize-context-pack"))?;
11921        self.commit_context_pack_for_session_record(
11922            &record,
11923            identity.as_ref(),
11924            session,
11925            &candidate_id,
11926            &loaded,
11927            pack,
11928            allow_clean_detachment,
11929        )
11930    }
11931
11932    async fn export_context_pack(
11933        &self,
11934        address: &SessionAddress,
11935    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11936        let pack = self.materialize_context_pack(address).await?;
11937        self.commit_context_pack(pack)
11938    }
11939
11940    async fn materialize_context_pack(
11941        &self,
11942        address: &SessionAddress,
11943    ) -> Result<NodeContextPack, NodeFailure> {
11944        let snapshot = self.internal_session_snapshot(address)?;
11945        if !context_pack_source_status_is_usable(&snapshot.status) {
11946            return Err(failure(
11947                NodeFailureCode::InvalidRequest,
11948                "context export requires a running or successfully exited source session",
11949            ));
11950        }
11951        if snapshot.history.pending.is_some()
11952            || snapshot.history.loaded_candidate_id.is_none()
11953        {
11954            return Err(failure(
11955                NodeFailureCode::InvalidRequest,
11956                "context export requires settled loaded history",
11957            ));
11958        }
11959        let loaded = snapshot.history.loaded.clone().ok_or_else(|| {
11960            failure(
11961                NodeFailureCode::InvalidRequest,
11962                "context export requires settled loaded history",
11963            )
11964        })?;
11965        let source_provider = snapshot.agent_id.clone();
11966        let loaded_candidate_id = snapshot.history.loaded_candidate_id.clone();
11967        let repository = self
11968            .context_pack_repository(&address.workspace_id)
11969            .await?;
11970        let current = self.internal_session_snapshot(address)?;
11971        if !context_pack_source_status_is_usable(&current.status)
11972            || current.agent_id != source_provider
11973            || current.history.pending.is_some()
11974            || current.history.loaded_candidate_id != loaded_candidate_id
11975            || current.history.loaded.as_ref() != Some(&loaded)
11976        {
11977            return Err(failure(
11978                NodeFailureCode::ContextPackMaterializationFailed,
11979                "context source changed during bounded repository capture",
11980            ));
11981        }
11982        NodeContextPack::export_with_repository(
11983            ContextPackLineageReceipt {
11984                source_node_id: self.node_id.clone(),
11985                source_session: address.clone(),
11986                source_provider,
11987            },
11988            &loaded,
11989            Some(repository),
11990        )
11991        .map_err(|_| {
11992            failure(
11993                NodeFailureCode::ContextPackMaterializationFailed,
11994                "bounded context pack export failed",
11995            )
11996        })
11997    }
11998
11999    /// Write-through to the durable [`ContextPackStore`], if one is
12000    /// configured (`state_path` is `Some`). A node with no durable state
12001    /// path — fixtures, tests, ephemeral runs — has no `ContextPackStore`
12002    /// open at all and this is a no-op, matching `persist_state`'s own
12003    /// "durability optional, degrade gracefully when unconfigured" idiom.
12004    fn commit_context_pack_durably(&self, pack: &NodeContextPack) -> Result<(), NodeFailure> {
12005        let mut store = self
12006            .context_pack_store
12007            .lock()
12008            .unwrap_or_else(|poisoned| poisoned.into_inner());
12009        let Some(store) = store.as_mut() else {
12010            return Ok(());
12011        };
12012        store.commit(pack).map_err(|_| {
12013            failure(
12014                NodeFailureCode::ContextPackMaterializationFailed,
12015                "durable context pack commit failed",
12016            )
12017        })
12018    }
12019
12020    fn commit_context_pack(
12021        &self,
12022        pack: NodeContextPack,
12023    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12024        self.commit_context_pack_durably(&pack)?;
12025        self.context_catalog
12026            .write()
12027            .unwrap_or_else(|poisoned| poisoned.into_inner())
12028            .insert(pack)
12029            .map_err(|_| {
12030                failure(
12031                    NodeFailureCode::ContextPackMaterializationFailed,
12032                    "context pack catalog is full",
12033                )
12034            })
12035    }
12036
12037    fn commit_context_pack_for_session_record(
12038        &self,
12039        expected: &ManagedSessionRecord,
12040        identity: Option<&ProviderSessionIdentity>,
12041        session: &SessionAddress,
12042        candidate_id: &str,
12043        loaded: &HistorySessionRecord,
12044        pack: NodeContextPack,
12045        allow_clean_detachment: bool,
12046    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12047        let current_root = self.workspace_root(&expected.workspace_id)?;
12048        let _transaction = self
12049            .state_transaction
12050            .lock()
12051            .unwrap_or_else(|poisoned| poisoned.into_inner());
12052        let records = self
12053            .session_records
12054            .lock()
12055            .unwrap_or_else(|poisoned| poisoned.into_inner());
12056        let current = records.records.get(&expected.record_id).ok_or_else(|| {
12057            failure(
12058                NodeFailureCode::SessionRecordConflict,
12059                "managed session record disappeared during context export",
12060            )
12061        })?;
12062        if !session_record_export_target_matches(expected, current, session, allow_clean_detachment) {
12063            return Err(failure(
12064                NodeFailureCode::SessionRecordConflict,
12065                "managed session record changed during context export",
12066            ));
12067        }
12068        let bindings = self
12069            .session_bindings
12070            .lock()
12071            .unwrap_or_else(|poisoned| poisoned.into_inner());
12072        let binding = bindings.get(&session.session.instance_id).ok_or_else(|| {
12073            failure(
12074                NodeFailureCode::SessionRecordConflict,
12075                "managed session runtime binding disappeared during context export",
12076            )
12077        })?;
12078        let runtime = self
12079            .handle
12080            .snapshot()
12081            .sessions
12082            .iter()
12083            .find(|snapshot| {
12084                snapshot.instance_id == session.session.instance_id
12085                    && snapshot.generation == session.session.generation
12086            })
12087            .cloned()
12088            .ok_or_else(|| {
12089                failure(
12090                    NodeFailureCode::SessionRecordConflict,
12091                    "managed session runtime disappeared during context export",
12092                )
12093            })?;
12094        let receipt = pack.receipt();
12095        if !session_record_context_export_binding_is_exact(
12096            expected,
12097            current,
12098            identity,
12099            session,
12100            binding,
12101            &runtime.agent_id,
12102            &current_root,
12103            allow_clean_detachment,
12104        ) || !session_record_context_export_source_is_usable(
12105            current,
12106            &runtime.status,
12107            allow_clean_detachment,
12108        ) || runtime.history.pending.is_some()
12109            || runtime.history.loaded_candidate_id.as_deref() != Some(candidate_id)
12110            || runtime.history.loaded.as_ref() != Some(loaded)
12111            || receipt.lineage.source_node_id != self.node_id
12112            || receipt.lineage.source_session != *session
12113            || receipt.lineage.source_provider != current.provider
12114            || receipt.source_message_count != loaded.message_count
12115        {
12116            return Err(failure(
12117                NodeFailureCode::SessionRecordConflict,
12118                "managed session source changed before context catalog commit",
12119            ));
12120        }
12121        self.commit_context_pack_durably(&pack)?;
12122        self.context_catalog
12123            .write()
12124            .unwrap_or_else(|poisoned| poisoned.into_inner())
12125            .insert(pack)
12126            .map_err(|_| {
12127                failure(
12128                    NodeFailureCode::ContextPackMaterializationFailed,
12129                    "context pack catalog is full",
12130                )
12131            })
12132    }
12133
12134    async fn context_pack_repository(
12135        &self,
12136        workspace_id: &WorkspaceId,
12137    ) -> Result<ContextPackRepository, NodeFailure> {
12138        let first = self
12139            .context_pack_repository_observation(workspace_id)
12140            .await?;
12141        let second = self
12142            .context_pack_repository_observation(workspace_id)
12143            .await?;
12144        stable_context_pack_repository(first, second)
12145    }
12146
12147    async fn context_pack_repository_observation(
12148        &self,
12149        workspace_id: &WorkspaceId,
12150    ) -> Result<ContextPackRepositoryObservation, NodeFailure> {
12151        let git = self.inspect_workspace(workspace_id.clone()).await?.git;
12152        let head = if git.is_repository {
12153            let root = self.workspace_root(workspace_id)?;
12154            match resolve_base_commit_with_timeout(
12155                &root,
12156                "HEAD",
12157                GIT_COMMAND_TIMEOUT_MS,
12158            )
12159            .await
12160            {
12161                Ok(commit) => ContextPackRepositoryHead::Commit(commit),
12162                Err(error) if error.kind == GitWorktreeErrorKind::Conflict => {
12163                    ContextPackRepositoryHead::Unborn
12164                }
12165                Err(_) => {
12166                    return Err(failure(
12167                        NodeFailureCode::ContextPackMaterializationFailed,
12168                        "context pack HEAD observation failed",
12169                    ));
12170                }
12171            }
12172        } else {
12173            ContextPackRepositoryHead::NotRepository
12174        };
12175        let mut files = Vec::with_capacity(CONTEXT_PACK_SELECTED_FILES.len());
12176        for selected_path in CONTEXT_PACK_SELECTED_FILES {
12177            let path = RepositoryPath::utf8(selected_path.to_owned()).map_err(|_| {
12178                failure(
12179                    NodeFailureCode::InvalidRepositoryPath,
12180                    "context pack selected file path is invalid",
12181                )
12182            })?;
12183            match self.read_workspace_file(workspace_id.clone(), path).await {
12184                Ok(WorkspaceFileRead {
12185                    content: WorkspaceFileContent::Utf8 { text, byte_len },
12186                    ..
12187                }) => files.push(ContextPackRepositoryFileSource::utf8(
12188                    selected_path,
12189                    text,
12190                    byte_len,
12191                )),
12192                Ok(WorkspaceFileRead {
12193                    content: WorkspaceFileContent::NonUtf8 { byte_len },
12194                    ..
12195                }) => files.push(ContextPackRepositoryFileSource::skipped(
12196                    selected_path,
12197                    ContextPackSelectedFileSkipReason::NonUtf8,
12198                    Some(byte_len),
12199                )),
12200                Ok(WorkspaceFileRead {
12201                    content: WorkspaceFileContent::TooLarge { .. },
12202                    ..
12203                }) => files.push(ContextPackRepositoryFileSource::skipped(
12204                    selected_path,
12205                    ContextPackSelectedFileSkipReason::TooLarge,
12206                    None,
12207                )),
12208                Err(error) => {
12209                    let reason = if matches!(
12210                        error.code,
12211                        NodeFailureCode::InvalidRepositoryPath
12212                            | NodeFailureCode::RepositoryFileNotRegular
12213                            | NodeFailureCode::RepositoryPathUnsafe
12214                    ) {
12215                        ContextPackSelectedFileSkipReason::Unsafe
12216                    } else {
12217                        ContextPackSelectedFileSkipReason::Unavailable
12218                    };
12219                    files.push(ContextPackRepositoryFileSource::skipped(
12220                        selected_path,
12221                        reason,
12222                        None,
12223                    ));
12224                }
12225            }
12226        }
12227        Ok(ContextPackRepositoryObservation { head, git, files })
12228    }
12229
12230    fn forget_context_pack(&self, context_id: &SpawnContextId) -> Result<(), NodeFailure> {
12231        let referenced_by_binding = self
12232            .session_bindings
12233            .lock()
12234            .unwrap_or_else(|poisoned| poisoned.into_inner())
12235            .values()
12236            .any(|binding| {
12237                binding.context.as_ref().map(|receipt| &receipt.id) == Some(context_id)
12238            });
12239        let referenced_by_record = self
12240            .session_records
12241            .lock()
12242            .unwrap_or_else(|poisoned| poisoned.into_inner())
12243            .records
12244            .values()
12245            .any(|record| record.context_id.as_ref() == Some(context_id));
12246        let referenced_by_materialization = self
12247            .materializations
12248            .lock()
12249            .unwrap_or_else(|poisoned| poisoned.into_inner())
12250            .values()
12251            .any(|ownership| {
12252                ownership.context().map(|receipt| &receipt.id) == Some(context_id)
12253            });
12254        if referenced_by_binding
12255            || referenced_by_record
12256            || referenced_by_materialization
12257        {
12258            return Err(failure(
12259                NodeFailureCode::ContextPackBusy,
12260                "context pack is still referenced",
12261            ));
12262        }
12263        let mut catalog = self.context_catalog
12264            .write()
12265            .unwrap_or_else(|poisoned| poisoned.into_inner());
12266        if catalog.get(context_id).is_none() {
12267            return Err(failure(
12268                NodeFailureCode::UnknownContextPack,
12269                "context pack is unavailable",
12270            ));
12271        }
12272        self.spawn_idempotency
12273            .lock()
12274            .unwrap_or_else(|poisoned| poisoned.into_inner())
12275            .entries
12276            .retain(|_, entry| !entry.value.references_context(context_id));
12277        let removed = catalog.remove(context_id);
12278        debug_assert!(removed.is_some(), "validated context pack remains present");
12279        Ok(())
12280    }
12281
12282    async fn dispatch_input_bounded(
12283        &self,
12284        address: &SessionAddress,
12285        action: InputAction,
12286    ) -> Result<(), NodeFailure> {
12287        let started_after = self.current_sequence();
12288        self.pty_input_marks
12289            .lock()
12290            .unwrap_or_else(|poisoned| poisoned.into_inner())
12291            .insert(address.session.instance_id, (Instant::now(), false));
12292        self.pty_input_wake.notify_one();
12293        let command_id = self
12294            .dispatch_bounded(
12295                ControlCommand::SendInput {
12296                    instance_id: address.session.instance_id,
12297                    action,
12298                },
12299                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12300            )
12301            .await?;
12302        // A semantic prompt may spend the provider's full readiness budget before
12303        // the runtime emits InputCompleted. Keep the node request deadline bounded,
12304        // but strictly beyond every readiness policy in this node's catalog.
12305        let deadline = Instant::now() + Duration::from_millis(self.input_settle_timeout_ms);
12306        let mut scan_after = started_after;
12307        let mut accepted_after = None;
12308        loop {
12309            let events = self
12310                .history
12311                .lock()
12312                .unwrap_or_else(|poisoned| poisoned.into_inner())
12313                .events
12314                .iter()
12315                .filter(|envelope| envelope.sequence > scan_after)
12316                .cloned()
12317                .collect::<Vec<_>>();
12318            for envelope in events {
12319                scan_after = scan_after.max(envelope.sequence);
12320                let NodeEvent::Control {
12321                    address: event_address,
12322                    event,
12323                } = envelope.event
12324                else {
12325                    continue;
12326                };
12327                if event_address != *address {
12328                    continue;
12329                }
12330                if event.command_id == Some(command_id) {
12331                    match &event.event {
12332                        ControlEventKind::CommandRejected { message } => {
12333                            return Err(failure(NodeFailureCode::InvalidRequest, message));
12334                        }
12335                        ControlEventKind::InputRequested { .. } => {
12336                            accepted_after = Some(envelope.sequence);
12337                            continue;
12338                        }
12339                        _ => {}
12340                    }
12341                }
12342                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12343                    match &event.event {
12344                        ControlEventKind::InputCompleted { .. } => return Ok(()),
12345                        ControlEventKind::InputFailed { message, .. } => {
12346                            return Err(failure(
12347                                NodeFailureCode::BackendOperationFailed,
12348                                message,
12349                            ));
12350                        }
12351                        _ => {}
12352                    }
12353                }
12354            }
12355            if Instant::now() >= deadline {
12356                return Err(failure(
12357                    NodeFailureCode::BackendBusy,
12358                    if accepted_after.is_some() {
12359                        "input was accepted but did not settle before the bounded deadline"
12360                    } else {
12361                        "input was not accepted before the bounded deadline"
12362                    },
12363                ));
12364            }
12365            sleep(Duration::from_millis(2)).await;
12366        }
12367    }
12368
12369    async fn dispatch_resize_bounded(
12370        &self,
12371        address: &SessionAddress,
12372        size: gate4agent_types::TerminalSize,
12373    ) -> Result<(), NodeFailure> {
12374        let started_after = self.current_sequence();
12375        let command_id = self
12376            .dispatch_bounded(
12377                ControlCommand::Resize {
12378                    instance_id: address.session.instance_id,
12379                    size,
12380                },
12381                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12382            )
12383            .await?;
12384        let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
12385        let mut scan_after = started_after;
12386        let mut accepted_after = None;
12387        loop {
12388            let events = self
12389                .history
12390                .lock()
12391                .unwrap_or_else(|poisoned| poisoned.into_inner())
12392                .events
12393                .iter()
12394                .filter(|envelope| envelope.sequence > scan_after)
12395                .cloned()
12396                .collect::<Vec<_>>();
12397            for envelope in events {
12398                scan_after = scan_after.max(envelope.sequence);
12399                let NodeEvent::Control {
12400                    address: event_address,
12401                    event,
12402                } = envelope.event
12403                else {
12404                    continue;
12405                };
12406                if event_address != *address {
12407                    continue;
12408                }
12409                if event.command_id == Some(command_id) {
12410                    match &event.event {
12411                        ControlEventKind::CommandRejected { message } => {
12412                            return Err(failure(NodeFailureCode::InvalidRequest, message));
12413                        }
12414                        ControlEventKind::ResizeRequested {
12415                            size: requested_size,
12416                            ..
12417                        } if *requested_size == size => {
12418                            accepted_after = Some(envelope.sequence);
12419                            continue;
12420                        }
12421                        _ => {}
12422                    }
12423                }
12424                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12425                    match &event.event {
12426                        ControlEventKind::Resized { size: settled_size }
12427                            if *settled_size == size =>
12428                        {
12429                            return Ok(());
12430                        }
12431                        ControlEventKind::ResizeFailed { message } => {
12432                            return Err(failure(
12433                                NodeFailureCode::BackendOperationFailed,
12434                                message,
12435                            ));
12436                        }
12437                        _ => {}
12438                    }
12439                }
12440            }
12441            if Instant::now() >= deadline {
12442                return Err(failure(
12443                    NodeFailureCode::BackendBusy,
12444                    if accepted_after.is_some() {
12445                        "resize was accepted but did not settle before the bounded deadline"
12446                    } else {
12447                        "resize was not accepted before the bounded deadline"
12448                    },
12449                ));
12450            }
12451            sleep(Duration::from_millis(2)).await;
12452        }
12453    }
12454
12455    async fn spawn_session(
12456        &self,
12457        workspace_id: WorkspaceId,
12458        provider: AgentId,
12459        mode: SessionMode,
12460        terminal_size: gate4agent_types::TerminalSize,
12461        initial_prompt: Option<String>,
12462    ) -> Result<SessionAddress, NodeFailure> {
12463        if self.managed_worktrees.lock()
12464            .unwrap_or_else(|poisoned| poisoned.into_inner())
12465            .lease_for_workspace(&workspace_id)
12466            .is_some()
12467        {
12468            return Err(failure(
12469                NodeFailureCode::ManagedWorktreeOwnershipConflict,
12470                "managed worktree targets may only be spawned through their lease request",
12471            ));
12472        }
12473        self
12474            .spawn_session_with_deadline(
12475                workspace_id,
12476                provider,
12477                mode,
12478                terminal_size,
12479                initial_prompt,
12480                None,
12481                None,
12482                None,
12483                None,
12484                None,
12485                SpawnRecordPolicy::ProviderIdentityOnly,
12486                None,
12487                &[],
12488                None,
12489                // `NodeRequest::Spawn` (this method's only caller) carries no
12490                // approval-level selection of its own -- see
12491                // `HarnessOperatorRequestV1::SpawnSession`'s own field for
12492                // the one operator-wire path that can choose something other
12493                // than the axis default.
12494                ApprovalLevel::default(),
12495                None,
12496                None,
12497            )
12498            .await
12499            .map(|(session, _runtime_policy)| session)
12500    }
12501
12502    async fn spawn_session_with_deadline(
12503        &self,
12504        workspace_id: WorkspaceId,
12505        provider: AgentId,
12506        mode: SessionMode,
12507        terminal_size: gate4agent_types::TerminalSize,
12508        initial_prompt: Option<String>,
12509        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
12510        bundle: Option<ResolvedBundleReceipt>,
12511        context: Option<ResolvedContextPackReceipt>,
12512        managed_authority: Option<ManagedWorktreeLeaseId>,
12513        admitted_runtime_policy: Option<ProviderRuntimePolicy>,
12514        record_policy: SpawnRecordPolicy,
12515        deadline: Option<Instant>,
12516        required_capabilities: &[ProviderRuntimeCapability],
12517        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
12518        approval_level: ApprovalLevel,
12519        network_allowlist: Option<crate::protocol::SpawnNetworkAllowlistId>,
12520        // Opaque dig2browser station profile id (ids only). Feature
12521        // `dig2-station-probe`: after probe at resolve, spawn takes an
12522        // exclusive node-local lease; cleared on session end. Never
12523        // ImportSession / cookies on C2. Feature off: ignored (stub echo
12524        // already happened on the receipt path).
12525        browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
12526    ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
12527        let reserved_lease = self.managed_worktrees.lock()
12528            .unwrap_or_else(|poisoned| poisoned.into_inner())
12529            .lease_for_workspace(&workspace_id);
12530        match (reserved_lease.as_ref(), managed_authority.as_ref()) {
12531            (Some(expected), Some(provided)) if expected == provided => {}
12532            (None, None) => {}
12533            _ => {
12534                return Err(failure(
12535                    NodeFailureCode::ManagedWorktreeOwnershipConflict,
12536                    "spawn lacks exact managed worktree lease authority",
12537                ));
12538            }
12539        }
12540        // Refuse concurrent distinct environment profiles (incl. ProviderHome
12541        // bindings) on the same workspace cwd. Parallel path: managed worktree
12542        // spawn so each profile gets its own workspace_id.
12543        self.ensure_workspace_environment_profile_compatible(
12544            &workspace_id,
12545            environment_profile.as_ref(),
12546        )?;
12547        let runtime_requirement = match (mode, initial_prompt.is_some()) {
12548            (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
12549            (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
12550            (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
12551            (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
12552        };
12553        // This session's own transport -- computed once, here, from `mode`,
12554        // and reused both to admit spawn (below) and, further down, to
12555        // compute the final `runtime_policy` this session actually operates
12556        // under (`provider_runtime::policy_for_transport`). Registration
12557        // reuses this exact value rather than recomputing it a second time.
12558        let transport = match mode {
12559            SessionMode::Pty => TransportKind::Pty,
12560            SessionMode::Inline => TransportKind::Pipe,
12561            SessionMode::Acp => TransportKind::Acp,
12562        };
12563        let pipe_transport = self.provider_supports_pipe_transport(&provider);
12564        let acp_transport = self.provider_supports_acp_transport(&provider);
12565        let runtime_policy = if let Some(runtime_policy) = admitted_runtime_policy {
12566            require_policy(runtime_policy, pipe_transport, acp_transport, runtime_requirement).map_err(|_| failure(
12567                NodeFailureCode::UnsupportedSpawnCapability,
12568                "pre-admitted provider runtime no longer satisfies spawn requirements",
12569            ))?;
12570            runtime_policy
12571        } else if let Some(deadline) = deadline {
12572            let remaining = spawn_deadline_remaining(deadline)?;
12573            timeout(
12574                remaining,
12575                self.admit_provider_runtime(&provider, runtime_requirement),
12576            )
12577            .await
12578            .map_err(|_| {
12579                failure(
12580                    NodeFailureCode::SpawnDeadlineExceeded,
12581                    "provider admission exceeded the spawn deadline",
12582                )
12583            })??
12584        } else {
12585            self.admit_provider_runtime(&provider, runtime_requirement).await?
12586        };
12587        let runtime_policy = self.effective_spawn_runtime_policy(runtime_policy);
12588        // The single point where this session's `runtime_policy` becomes
12589        // transport-correct: `admit_provider_runtime` (and the pre-admitted
12590        // branch above) only ever compute a PTY-terminal-verification-shaped
12591        // value, sound outright only for `TransportKind::Pty` (Pipe still
12592        // relies on that same value downstream -- see `policy_for_transport`'s
12593        // own doc for why). `policy_for_transport` is the explicit,
12594        // exhaustive match Item 1 asks for -- this is the ONE place its
12595        // output becomes the session's `runtime_policy`, so every later
12596        // reader (`ControlCommand::Start`, `session_bindings`, and every
12597        // engine gate that reads `state.runtime_policy`) sees the same,
12598        // correctly-computed value.
12599        let runtime_policy =
12600            crate::provider_runtime::policy_for_transport(transport, runtime_policy);
12601        if required_capabilities
12602            .iter()
12603            .any(|capability| !runtime_policy.admits(*capability))
12604        {
12605            return Err(failure(
12606                NodeFailureCode::UnsupportedSpawnCapability,
12607                "provider runtime does not admit a required spawn capability",
12608            ));
12609        }
12610        if let Some(deadline) = deadline {
12611            spawn_deadline_remaining(deadline)?;
12612        }
12613        self.ensure_binding_capacity()?;
12614        let working_directory = self.workspace_root(&workspace_id)?;
12615        let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
12616        let session = SessionKey {
12617            instance_id,
12618            generation: SessionGeneration(1),
12619        };
12620        let address = SessionAddress {
12621            workspace_id: workspace_id.clone(),
12622            session,
12623        };
12624        // This is the request-accepted checkpoint: admission (managed
12625        // worktree authority, runtime capability, dispatch deadline) already
12626        // passed and a concrete session identity is minted. It is a distinct
12627        // state from the process actually running (`ControlEventKind::Running`,
12628        // logged in `publish_control`) or the terminal outcome (`Exited`/
12629        // `Failed`, logged there too) -- this line exists so that gap is no
12630        // longer silent.
12631        tracing::info!(
12632            node_id = %self.node_id,
12633            workspace_id = %workspace_id,
12634            provider = %provider,
12635            environment_profile = ?environment_profile.as_ref().map(|receipt| &receipt.profile_id),
12636            session = ?address.session,
12637            "session spawn request accepted",
12638        );
12639        let mut prepared_spawn = SpawnPreparer::prepare(
12640            self,
12641            SpawnPrepareRequest {
12642                instance_id,
12643                address: &address,
12644                provider: &provider,
12645                mode,
12646                environment_profile: environment_profile.as_ref(),
12647                bundle: bundle.as_ref(),
12648                context: context.as_ref(),
12649                managed_authority: managed_authority.as_ref(),
12650                harness_mcp,
12651                approval_level,
12652                network_allowlist: network_allowlist.as_ref(),
12653                browser_profile_id,
12654            },
12655        )?;
12656        let materialization_id = prepared_spawn.materialization_id.take();
12657        let dispatch_timeout = spawn_dispatch_timeout(deadline)?;
12658        let record_id = self.bind_spawn_session_with_materialization(
12659            &address,
12660            provider.clone(),
12661            mode,
12662            runtime_policy,
12663            record_policy,
12664            environment_profile,
12665            bundle,
12666            context,
12667            materialization_id,
12668        )?;
12669        prepared_spawn.retain_session_guards();
12670        // `transport` was already computed above, alongside this session's
12671        // `runtime_policy`; reused here for `Register` rather than derived a
12672        // second time.
12673        let agent_id = provider;
12674        let register_command_id = match self.dispatch_bounded(
12675            ControlCommand::Register {
12676                instance_id,
12677                agent_id,
12678                transport,
12679            },
12680            dispatch_timeout,
12681        )
12682        .await {
12683            Ok(command_id) => command_id,
12684            Err(error) => {
12685                if let Some(binding) = self.remove_binding(&address) {
12686                    SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
12687                }
12688                if let Some(record_id) = record_id.as_ref() {
12689                    self.discard_record(record_id)?;
12690                }
12691                return Err(spawn_dispatch_error(error, deadline));
12692            }
12693        };
12694        let start_timeout = match spawn_dispatch_timeout(deadline) {
12695            Ok(timeout) => timeout,
12696            Err(error) => {
12697                let recovery = self
12698                    .rollback_spawn(
12699                        &address,
12700                        Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12701                    )
12702                    .await;
12703                return match recovery {
12704                    Ok(()) => {
12705                        if let Some(record_id) = record_id.as_ref() {
12706                            self.discard_record(record_id)?;
12707                        }
12708                        Err(error)
12709                    }
12710                    Err(recovery_error) => {
12711                        if let Some(record_id) = record_id.as_ref() {
12712                            self.mark_record_error(
12713                                record_id,
12714                                "spawn deadline elapsed and registration recovery failed",
12715                            )?;
12716                        }
12717                        Err(recovery_error)
12718                    }
12719                };
12720            }
12721        };
12722        if let Err(start_error) = self.verify_harness_mcp_before_start(harness_mcp) {
12723            if let Err(recovery_error) = self
12724                .rollback_spawn(&address, Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS))
12725                .await
12726            {
12727                return Err(recovery_error);
12728            }
12729            if let Some(record_id) = record_id.as_ref() {
12730                self.discard_record(record_id)?;
12731            }
12732            return Err(start_error);
12733        }
12734        let start_result = self
12735            .dispatch_bounded(
12736                ControlCommand::Start {
12737                    instance_id,
12738                    runtime_policy,
12739                    request: StartRequest {
12740                        working_directory,
12741                        terminal_size,
12742                        initial_prompt,
12743                        session_options: SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten(),
12744                        approval_level,
12745                    },
12746                },
12747                start_timeout,
12748            )
12749            .await;
12750        let start_command_id = match start_result {
12751            Ok(command_id) => command_id,
12752            Err(start_error) => {
12753                let start_error = spawn_dispatch_error(start_error, deadline);
12754                let recovery = self
12755                    .rollback_spawn(
12756                        &address,
12757                        Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12758                    )
12759                    .await;
12760                return match recovery {
12761                    Ok(()) => {
12762                        if let Some(record_id) = record_id.as_ref() {
12763                            self.discard_record(record_id)?;
12764                        }
12765                        Err(start_error)
12766                    }
12767                    Err(recovery_error) => {
12768                        let diagnostic = format!(
12769                            "start failed and registration recovery failed: {}",
12770                            recovery_error.message,
12771                        );
12772                        if let Some(record_id) = record_id.as_ref() {
12773                            self.mark_record_error(record_id, &diagnostic)?;
12774                        }
12775                        Err(failure(recovery_error.code, &diagnostic))
12776                    }
12777                };
12778            }
12779        };
12780
12781        let commit_deadline = deadline.unwrap_or_else(|| Instant::now() + dispatch_timeout);
12782        loop {
12783            // `Register`/`Start` only fail synchronously for a port-level
12784            // ingress problem (see `dispatch_bounded`); the kernel refusing
12785            // either command's content -- an unsupported transport, an
12786            // unknown agent -- lands one drive-loop tick later, and no
12787            // session ever appears in `self.handle.snapshot()` for it to
12788            // notice below. Check for that named reason before falling
12789            // through to the blind presence poll, so a rejected spawn
12790            // reports why instead of just timing out.
12791            if let Some(reason) = self.take_rejected_command_reason(register_command_id)
12792                .or_else(|| self.take_rejected_command_reason(start_command_id))
12793            {
12794                self.rollback_spawn(
12795                    &address,
12796                    Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12797                ).await.map_err(|error| {
12798                        failure(
12799                            error.code,
12800                            &format!(
12801                                "spawn was rejected ({}) and registration recovery failed: {}",
12802                                reason.message, error.message
12803                            ),
12804                        )
12805                    })?;
12806                if let Some(record_id) = record_id.as_ref() {
12807                    self.discard_record(record_id)?;
12808                }
12809                let code = if reason.unsupported_transport {
12810                    NodeFailureCode::UnsupportedTransport
12811                } else {
12812                    NodeFailureCode::BackendOperationFailed
12813                };
12814                return Err(failure(code, &reason.message));
12815            }
12816            if let Some(current) = self
12817                .handle
12818                .snapshot()
12819                .sessions
12820                .iter()
12821                .find(|current| current.instance_id == instance_id)
12822            {
12823                if current.generation != session.generation {
12824                    let divergence = failure(
12825                        NodeFailureCode::StaleGeneration,
12826                        "spawn generation diverged",
12827                    );
12828                    return match self
12829                        .rollback_spawn(
12830                            &address,
12831                            Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12832                        )
12833                        .await
12834                    {
12835                        Ok(()) => {
12836                            if let Some(record_id) = record_id.as_ref() {
12837                                self.discard_record(record_id)?;
12838                            }
12839                            Err(divergence)
12840                        }
12841                        Err(recovery_error) => {
12842                            if let Some(record_id) = record_id.as_ref() {
12843                                self.mark_record_error(
12844                                    record_id,
12845                                    "spawn generation diverged and recovery failed",
12846                                )?;
12847                            }
12848                            Err(recovery_error)
12849                        }
12850                    };
12851                }
12852                if !matches!(current.status, gate4agent_types::SessionStatus::Registered) {
12853                    prepared_spawn.retain_launch_guards();
12854                    return Ok((address, runtime_policy));
12855                }
12856            }
12857            if Instant::now() >= commit_deadline {
12858                self.rollback_spawn(
12859                    &address,
12860                    Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12861                ).await.map_err(|error| {
12862                        failure(
12863                            error.code,
12864                            &format!(
12865                                "start did not commit and registration recovery failed: {}",
12866                                error.message
12867                            ),
12868                        )
12869                    })?;
12870                if let Some(record_id) = record_id.as_ref() {
12871                    self.discard_record(record_id)?;
12872                }
12873                return Err(failure(
12874                    if deadline.is_some() {
12875                        NodeFailureCode::SpawnDeadlineExceeded
12876                    } else {
12877                        NodeFailureCode::BackendBusy
12878                    },
12879                    "start did not commit before the bounded deadline; registration was removed",
12880                ));
12881            }
12882            sleep(Duration::from_millis(2)).await;
12883        }
12884    }
12885
12886    fn effective_spawn_runtime_policy(
12887        &self,
12888        runtime_policy: ProviderRuntimePolicy,
12889    ) -> ProviderRuntimePolicy {
12890        #[cfg(feature = "fixture")]
12891        let runtime_policy = if self.fixture_semantic_hook_policy {
12892            ProviderRuntimePolicy::new(true, true, true, true, false, true)
12893                .expect("monitoring Hook fixture policy is internally valid")
12894        } else {
12895            runtime_policy
12896        };
12897        runtime_policy
12898    }
12899
12900    fn verify_harness_mcp_before_start(
12901        &self,
12902        prepared: Option<&PreparedHarnessMcpSpawn>,
12903    ) -> Result<(), NodeFailure> {
12904        prepared
12905            .map(PreparedHarnessMcpSpawn::verify_helper_program)
12906            .transpose()
12907            .map(|_| ())
12908            .map_err(harness_mcp_failure)
12909    }
12910
12911    async fn rollback_spawn(
12912        &self,
12913        address: &SessionAddress,
12914        commit_timeout: Duration,
12915    ) -> Result<(), NodeFailure> {
12916        self.dispatch_bounded(
12917            ControlCommand::Remove {
12918                instance_id: address.session.instance_id,
12919            },
12920            commit_timeout,
12921        )
12922        .await?;
12923        self.wait_until_removed(address, commit_timeout).await?;
12924        if let Some(binding) = self.remove_binding(address) {
12925            SessionLifecycleHook::cleanup_session_materialization(self, address, &binding)?;
12926        }
12927        Ok(())
12928    }
12929
12930    async fn remove_session(&self, address: &SessionAddress) -> Result<(), NodeFailure> {
12931        let commit_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
12932        self.dispatch_bounded(
12933            ControlCommand::Remove {
12934                instance_id: address.session.instance_id,
12935            },
12936            commit_timeout,
12937        )
12938            .await?;
12939        self.wait_until_removed(address, commit_timeout).await?;
12940        let removed = self.remove_binding(address);
12941        if let Some(binding) = removed.as_ref() {
12942            SessionLifecycleHook::cleanup_session_materialization(self, address, binding)?;
12943        }
12944        if let Some(lease_id) = removed.and_then(|binding| binding.managed_worktree_lease_id) {
12945            let snapshot = self.managed_worktrees.lock()
12946                .unwrap_or_else(|poisoned| poisoned.into_inner())
12947                .release_session(
12948                    &lease_id,
12949                    self.incarnation_id,
12950                    address.session.instance_id,
12951                    address.session.generation,
12952                    unix_time_ms(),
12953                );
12954            if self.persist_state().is_err() {
12955                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
12956            }
12957            if let Some(snapshot) = snapshot {
12958                self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
12959            }
12960            let _ = self.cleanup_managed_worktree(&lease_id, false).await;
12961        }
12962        Ok(())
12963    }
12964
12965    /// Polls until `address`'s session leaves the engine's own session map,
12966    /// or the bounded deadline expires.
12967    ///
12968    /// A `Remove` sent while the session is not yet in a terminal status
12969    /// (e.g. still `Stopping`, mid-effect right after a `Stop`) is rejected
12970    /// once by the engine (`SessionStatus::allows_remove`) and
12971    /// then discarded -- nothing re-issues it on its own, so the removal
12972    /// that would satisfy this wait never arrives and the caller was
12973    /// guaranteed to time out even though the session settles microseconds
12974    /// later. So this re-dispatches on every poll tick: a session that
12975    /// settles mid-wait -- the common case right after a `Stop` -- still
12976    /// gets removed inside the same bounded deadline.
12977    ///
12978    /// It re-dispatches only once the status will ACCEPT the command. A
12979    /// rejected re-dispatch is not the harmless no-op this loop first
12980    /// assumed: every rejection publishes a `ControlEventKind::Command
12981    /// Rejected` to every subscriber and writes a WARN line. Ungated, one
12982    /// ordinary teardown produced 241 of them in 1.6 seconds -- the whole
12983    /// `Stopping` window at one known-doomed dispatch per 2ms tick, fanned
12984    /// out to the harness and every attached client. Gated, the same
12985    /// teardown sends exactly one `Remove`, at the first tick the engine
12986    /// can act on it.
12987    ///
12988    /// The status is read from the snapshot this loop already takes, so
12989    /// the gate costs nothing extra. It races -- the status can move
12990    /// between the read and the dispatch -- and that is fine: a rare lost
12991    /// race is one rejection, and the next tick retries.
12992    ///
12993    /// Re-dispatch is separately rate-limited to
12994    /// [`REMOVE_REDISPATCH_INTERVAL_MS`], because the status gate alone
12995    /// leaves a second burst behind it. Once the session settles, every
12996    /// 2ms tick sent another `Remove` while the accepted one was still in
12997    /// flight, and each of those landed on an instance the engine had
12998    /// already dropped -- `agent instance … is not registered`, observed
12999    /// live as a tail of rejections immediately after the `session
13000    /// removed` line. The presence poll stays at 2ms so the wait still
13001    /// exits promptly; only the sending slows down.
13002    ///
13003    /// Rate-limited rather than once-only on purpose: the status gate
13004    /// covers the status precondition, but `Gate4AgentEngine::remove` has
13005    /// others (a pending operation, an in-flight capability probe or
13006    /// history request) that clear on their own. A single shot would turn
13007    /// those into a guaranteed `BackendBusy` timeout; at 10Hz they still
13008    /// get their retries, just twenty of them across the deadline instead
13009    /// of a thousand.
13010    async fn wait_until_removed(
13011        &self,
13012        address: &SessionAddress,
13013        commit_timeout: Duration,
13014    ) -> Result<(), NodeFailure> {
13015        let deadline = Instant::now() + commit_timeout;
13016        let mut next_dispatch = Instant::now();
13017        loop {
13018            let snapshot = self.handle.snapshot();
13019            let status = snapshot
13020                .sessions
13021                .iter()
13022                .find(|session| session.instance_id == address.session.instance_id)
13023                .map(|session| session.status.clone());
13024            let Some(status) = status else {
13025                return Ok(());
13026            };
13027            let now = Instant::now();
13028            if now >= deadline {
13029                return Err(failure(
13030                    NodeFailureCode::BackendBusy,
13031                    "remove did not commit before the bounded deadline; session binding was retained",
13032                ));
13033            }
13034            if status.allows_remove() && now >= next_dispatch {
13035                let _ = self.dispatch(ControlCommand::Remove {
13036                    instance_id: address.session.instance_id,
13037                });
13038                next_dispatch = now + Duration::from_millis(REMOVE_REDISPATCH_INTERVAL_MS);
13039            }
13040            sleep(Duration::from_millis(2)).await;
13041        }
13042    }
13043
13044    fn resync(&self, after_sequence: u64) -> NodeResponse {
13045        let history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
13046        let event_sequence = history.last_sequence;
13047        let oldest_available_sequence = history.replay_floor_sequence;
13048        let events = history
13049            .events
13050            .iter()
13051            .filter(|event| event.sequence > after_sequence)
13052            .cloned()
13053            .collect();
13054        drop(history);
13055        NodeResponse::Resync {
13056            event_sequence,
13057            oldest_available_sequence,
13058            snapshot: self.snapshot(),
13059            events,
13060        }
13061    }
13062}
13063
13064/// Coarse label for one `AgentStreamChunkKindV1`, used only by
13065/// `NodeShared::publish_agent_stream_chunk`'s own debug log -- mirrors
13066/// `hatchery-harness-service`'s `agent_stream_event_kind_label` (same
13067/// "name what crossed this boundary" reasoning, one layer upstream of it).
13068fn agent_stream_chunk_kind_label(kind: &AgentStreamChunkKindV1) -> &'static str {
13069    match kind {
13070        AgentStreamChunkKindV1::Text { .. } => "text",
13071        AgentStreamChunkKindV1::Thinking { .. } => "thinking",
13072        AgentStreamChunkKindV1::InteractionPrompt { .. } => "interaction-prompt",
13073        AgentStreamChunkKindV1::ModeCatalog { .. } => "mode-catalog",
13074        AgentStreamChunkKindV1::ConfigOptions { .. } => "config-options",
13075        AgentStreamChunkKindV1::ModelCatalog { .. } => "model-catalog",
13076        AgentStreamChunkKindV1::Blocked { .. } => "blocked",
13077    }
13078}
13079
13080/// Serves this wire on the local endpoint, and -- when `call_home` names a
13081/// relay -- over an outbound connection to it as well.
13082///
13083/// The two live in one future rather than a fourth arm of `run`'s own
13084/// `select!` because they are one concern: this is where the node's wire
13085/// is served, and the direction a given connection was opened in does not
13086/// change what happens on it. It also means the existing graceful drain
13087/// below covers both without being duplicated.
13088///
13089/// Either side finishing ends the pair, which is correct: the listener
13090/// only returns on shutdown or a bind failure, and `call_home_loop` only
13091/// returns on shutdown -- a failed relay connection is retried inside it,
13092/// never propagated out.
13093async fn accept_connections(
13094    endpoint: &str,
13095    call_home: Option<std::net::SocketAddr>,
13096    identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13097    shared: Arc<NodeShared>,
13098) -> Result<(), NodeServerError> {
13099    let mut connections = JoinSet::new();
13100    let result = match call_home {
13101        None => accept_connections_inner(endpoint, shared, &mut connections).await,
13102        Some(relay) => {
13103            let listener =
13104                accept_connections_inner(endpoint, Arc::clone(&shared), &mut connections);
13105            let dialler = call_home_loop(relay, identity, Arc::clone(&shared));
13106            tokio::pin!(listener);
13107            tokio::pin!(dialler);
13108            tokio::select! {
13109                result = &mut listener => result,
13110                result = &mut dialler => result,
13111            }
13112        }
13113    };
13114    let graceful_deadline = Instant::now() + Duration::from_millis(CONNECTION_SHUTDOWN_GRACE_MS);
13115    while !connections.is_empty() {
13116        let remaining = graceful_deadline.saturating_duration_since(Instant::now());
13117        if remaining.is_zero()
13118            || timeout(remaining, connections.join_next()).await.is_err()
13119        {
13120            break;
13121        }
13122    }
13123    connections.abort_all();
13124    while connections.join_next().await.is_some() {}
13125    result
13126}
13127
13128/// Backoff ladder for a relay that will not answer, capped rather than
13129/// unbounded: a node whose relay is down is not broken and should keep
13130/// trying forever, but it should not spend a core doing it.
13131const CALL_HOME_BACKOFFS_MS: [u64; 5] = [500, 1_000, 2_000, 4_000, 8_000];
13132
13133/// Dials the relay, serves this wire over the connection, and does it
13134/// again when the connection ends.
13135///
13136/// The node is the SERVER on the socket it just opened. That inversion is
13137/// the whole point: `serve_connection` is the same function the local
13138/// listener feeds, and it neither knows nor cares which end placed the
13139/// call. The one thing this direction adds is the preface -- the relay
13140/// accepted a socket and cannot know whose it is, so the node says its
13141/// name before the handshake it would otherwise begin with.
13142///
13143/// Never returns an error. A relay that is down, refusing, or speaking a
13144/// different protocol is a condition to retry, not a reason to take the
13145/// node's local endpoint down with it; the only way out is shutdown.
13146async fn call_home_loop(
13147    relay: std::net::SocketAddr,
13148    identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13149    shared: Arc<NodeShared>,
13150) -> Result<(), NodeServerError> {
13151    let mut failures = 0_usize;
13152    loop {
13153        if shared.shutdown.load(Ordering::Acquire) {
13154            return Ok(());
13155        }
13156        let permit = tokio::select! {
13157            permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13158                permit.map_err(|_| NodeServerError::PreauthClosed)?
13159            }
13160            _ = shared.shutdown_notify.notified() => return Ok(()),
13161        };
13162        match dial_relay_and_serve(relay, identity.as_deref(), Arc::clone(&shared), permit).await {
13163            Ok(()) => {
13164                // A clean end is the relay hanging up, which it does on
13165                // its own restart. Reconnect immediately rather than
13166                // serving the backoff a peer that is plainly there.
13167                failures = 0;
13168                tracing::info!(node_id = %shared.node_id, %relay, "call-home connection closed");
13169            }
13170            Err(error) => {
13171                failures = failures.saturating_add(1);
13172                tracing::warn!(
13173                    node_id = %shared.node_id,
13174                    %relay,
13175                    cause = %error,
13176                    attempt = failures,
13177                    "call-home connection failed",
13178                );
13179                let backoff = CALL_HOME_BACKOFFS_MS
13180                    [failures.saturating_sub(1).min(CALL_HOME_BACKOFFS_MS.len() - 1)];
13181                tokio::select! {
13182                    _ = sleep(Duration::from_millis(backoff)) => {}
13183                    _ = shared.shutdown_notify.notified() => return Ok(()),
13184                }
13185            }
13186        }
13187    }
13188}
13189
13190/// One call-home attempt: connect, name this node, then serve.
13191async fn dial_relay_and_serve(
13192    relay: std::net::SocketAddr,
13193    identity: Option<&hatchery_enroll::NodeIdentity>,
13194    shared: Arc<NodeShared>,
13195    preauth_permit: OwnedSemaphorePermit,
13196) -> Result<(), NodeServerError> {
13197    let mut stream = tokio::net::TcpStream::connect(relay)
13198        .await
13199        .map_err(NodeServerError::CallHomeTransport)?;
13200    // Nagle off for the same reason the rest of this wire wants it off:
13201    // the traffic is small request/response frames, and coalescing them
13202    // adds latency to every one.
13203    let _ = stream.set_nodelay(true);
13204    gate4agent_node_wire::write_call_home_announce(&mut stream, &shared.node_id)
13205        .await
13206        .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13207    tracing::info!(node_id = %shared.node_id, %relay, "announced this node to the relay");
13208    let mut session_token = None;
13209    if let Some(identity) = identity {
13210        // An enrolled relay answers the announce with a signed-call-home challenge; a
13211        // token-mode relay sends its wire hello instead. Peek to tell them apart
13212        // without consuming either.
13213        let mut magic = [0_u8; 4];
13214        let peeked = timeout(Duration::from_secs(10), stream.peek(&mut magic))
13215            .await
13216            .map_err(|_| NodeServerError::Handshake("relay sent nothing after the announce".to_owned()))?
13217            .map_err(NodeServerError::CallHomeTransport)?;
13218        if peeked == 4 && &magic == hatchery_enroll::CHALLENGE_MAGIC {
13219            let mut frame = [0_u8; hatchery_enroll::CHALLENGE_LEN];
13220            timeout(Duration::from_secs(5), tokio::io::AsyncReadExt::read_exact(&mut stream, &mut frame))
13221                .await
13222                .map_err(|_| NodeServerError::Handshake("relay challenge timed out".to_owned()))?
13223                .map_err(NodeServerError::CallHomeTransport)?;
13224            let (answer, token) = identity
13225                .answer_challenge(shared.node_id.as_str(), &frame)
13226                .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13227            tokio::io::AsyncWriteExt::write_all(&mut stream, &answer).await.map_err(NodeServerError::CallHomeTransport)?;
13228            tracing::info!(node_id = %shared.node_id, "answered the relay's signed call-home challenge");
13229            session_token = Some(token);
13230        }
13231    }
13232    serve_connection(stream, shared, session_token, preauth_permit).await
13233}
13234
13235async fn accept_connections_inner(
13236    endpoint: &str,
13237    shared: Arc<NodeShared>,
13238    connections: &mut JoinSet<Result<(), NodeServerError>>,
13239) -> Result<(), NodeServerError> {
13240    let mut listener = OwnerOnlyLocalListener::bind(endpoint).await?;
13241    loop {
13242        while connections.try_join_next().is_some() {}
13243        if shared.shutdown.load(Ordering::Acquire) {
13244            return Ok(());
13245        }
13246        let preauth_permit = tokio::select! {
13247            permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13248                permit.map_err(|_| NodeServerError::PreauthClosed)?
13249            }
13250            _ = shared.shutdown_notify.notified() => return Ok(()),
13251        };
13252        let server = tokio::select! {
13253            result = listener.accept() => result?,
13254            _ = shared.shutdown_notify.notified() => return Ok(()),
13255        };
13256        let connection_shared = Arc::clone(&shared);
13257        connections.spawn(async move {
13258            serve_connection(server, connection_shared, None, preauth_permit).await
13259        });
13260        if shared.shutdown.load(Ordering::Acquire) {
13261            return Ok(());
13262        }
13263    }
13264}
13265
13266/// Serves one connection, whichever end opened it.
13267///
13268/// Generic over the stream rather than typed to `LocalServerStream`
13269/// because a call-home connection arrives on a `TcpStream` the node itself
13270/// dialled. Nothing in the body ever depended on the concrete type -- it
13271/// reads and writes framed JSON and then `tokio::io::split`s -- so this is
13272/// the whole of what welding "who dials" to "who serves" was costing.
13273async fn serve_connection<S>(
13274    mut pipe: S,
13275    shared: Arc<NodeShared>,
13276    session_token: Option<String>,
13277    preauth_permit: OwnedSemaphorePermit,
13278) -> Result<(), NodeServerError>
13279where
13280    S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
13281{
13282    // A signed call-home connection authenticates with its own per-connection token.
13283    let auth_token: &[u8] = session_token.as_deref().map_or(shared.access_token.as_bytes(), str::as_bytes);
13284    let hello_frame = timeout(
13285        Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13286        read_json_frame_limited_body_timeout(
13287            &mut pipe,
13288            MAX_NODE_HELLO_FRAME_BYTES,
13289            Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13290        ),
13291    )
13292    .await
13293    .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13294    let ClientFrame::Hello(hello) = hello_frame else {
13295        return Err(NodeServerError::Handshake("first frame must be hello".to_owned()));
13296    };
13297    if hello.build_stamp != BUILD_STAMP {
13298        return Err(NodeServerError::Handshake(format!(
13299            "build stamp mismatch: local={BUILD_STAMP} remote={}",
13300            hello.build_stamp,
13301        )));
13302    }
13303    let compatibility = match hello.compatibility.as_ref() {
13304        Some(offer) => {
13305            let mut selected = node_compatibility_support(&shared)?
13306                .negotiate(offer)
13307                .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13308            project_negotiated_provider_ids(&mut selected);
13309            Some(selected)
13310        }
13311        None => None,
13312    };
13313    let server_nonce = random_nonce().map_err(NodeServerError::Authentication)?;
13314    let server_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13315        (Some(offer), Some(selected)) => negotiated_auth_proof(
13316            auth_token,
13317            AuthDirection::Server,
13318            hello.role,
13319            &hello.client_nonce,
13320            &server_nonce,
13321            offer,
13322            selected,
13323        ),
13324        (None, None) => auth_proof(
13325            auth_token,
13326            AuthDirection::Server,
13327            hello.role,
13328            &hello.client_nonce,
13329            &server_nonce,
13330        ),
13331        _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13332    }
13333    .map_err(NodeServerError::Authentication)?;
13334    write_json_frame_limited(
13335        &mut pipe,
13336        &ServerFrame::Challenge(ServerChallenge {
13337            build_stamp: BUILD_STAMP.to_owned(),
13338            server_nonce,
13339            server_proof,
13340            compatibility: compatibility.clone(),
13341        }),
13342        MAX_NODE_HELLO_FRAME_BYTES,
13343    )
13344    .await?;
13345    let authentication = timeout(
13346        Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13347        read_json_frame_limited_body_timeout(
13348            &mut pipe,
13349            MAX_NODE_HELLO_FRAME_BYTES,
13350            Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13351        ),
13352    )
13353    .await
13354    .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13355    let ClientFrame::Authenticate(authentication) = authentication else {
13356        return Err(NodeServerError::Handshake("second frame must authenticate the challenge".to_owned()));
13357    };
13358    let expected_client_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13359        (Some(offer), Some(selected)) => negotiated_auth_proof(
13360            auth_token,
13361            AuthDirection::Client,
13362            hello.role,
13363            &hello.client_nonce,
13364            &server_nonce,
13365            offer,
13366            selected,
13367        ),
13368        (None, None) => auth_proof(
13369            auth_token,
13370            AuthDirection::Client,
13371            hello.role,
13372            &hello.client_nonce,
13373            &server_nonce,
13374        ),
13375        _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13376    }
13377    .map_err(NodeServerError::Authentication)?;
13378    if !proofs_match(&authentication.client_proof, &expected_client_proof) {
13379        return Err(NodeServerError::Handshake("access denied".to_owned()));
13380    }
13381    let selected_capabilities = compatibility
13382        .as_ref()
13383        .map(|selected| selected.capabilities.clone())
13384        .unwrap_or_default();
13385    let include_provider_runtime_status = selected_capabilities.iter().any(|capability| {
13386        capability.as_str() == NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY
13387    });
13388    let include_open_provider_ids = selected_capabilities.iter().any(|capability| {
13389        capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
13390    });
13391    let include_terminal_frame_events = selected_capabilities.iter().any(|capability| {
13392        capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
13393    });
13394    let include_agent_stream_events = selected_capabilities.iter().any(|capability| {
13395        capability.as_str() == NODE_AGENT_STREAM_EVENTS_CAPABILITY
13396    });
13397    let include_spawn_profiles = selected_capabilities.iter().any(|capability| {
13398        capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
13399    });
13400    let include_managed_worktrees = selected_capabilities.iter().any(|capability| {
13401        capability.as_str() == NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY
13402    }) && selected_capabilities.iter().any(|capability| {
13403        capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
13404    });
13405    let include_child_environment_profiles = selected_capabilities.iter().any(|capability| {
13406        capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
13407    });
13408    let include_session_bundles = selected_capabilities.iter().any(|capability| {
13409        capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
13410    });
13411    let include_history_context_packs = selected_capabilities.iter().any(|capability| {
13412        capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
13413    });
13414    let include_agent_progress = selected_capabilities.iter().any(|capability| {
13415        capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
13416    });
13417    let include_session_task_correlation = selected_capabilities.iter().any(|capability| {
13418        capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
13419    });
13420    let include_harness_mcp_proxy = selected_capabilities.iter().any(|capability| {
13421        capability.as_str() == NODE_HARNESS_MCP_READ_PROXY_CAPABILITY
13422    });
13423    let authenticated_permit = Arc::clone(&shared.authenticated_slots)
13424        .try_acquire_owned()
13425        .map_err(|_| NodeServerError::AuthenticatedConnectionLimit)?;
13426    let connection_id = shared.next_connection_id.fetch_add(1, Ordering::AcqRel);
13427    let _release_guard = ControllerReleaseGuard {
13428        shared: Arc::clone(&shared),
13429        connection_id,
13430    };
13431    drop(preauth_permit);
13432    let _authenticated_permit = authenticated_permit;
13433    let mut event_rx = shared.event_tx.subscribe();
13434    let mut terminal_event_rx = terminal_event_subscription(
13435        &shared,
13436        include_terminal_frame_events,
13437    );
13438    let mut agent_stream_event_rx = agent_stream_event_subscription(
13439        &shared,
13440        include_agent_stream_events,
13441    );
13442    let mut harness_mcp_proxy_event_rx = harness_mcp_proxy_event_subscription(
13443        &shared,
13444        include_harness_mcp_proxy,
13445    );
13446    write_json_frame(
13447        &mut pipe,
13448        &ServerFrame::Hello(NodeHello {
13449            build_stamp: BUILD_STAMP.to_owned(),
13450            incarnation_id: shared.incarnation_id,
13451            connection_id,
13452            role: hello.role,
13453            event_sequence: shared.current_sequence(),
13454            controller: shared.controller_state(),
13455            snapshot: snapshot_for_wire(
13456                &shared,
13457                include_provider_runtime_status,
13458                include_open_provider_ids,
13459                include_spawn_profiles,
13460                include_managed_worktrees,
13461                include_child_environment_profiles,
13462                include_session_bundles,
13463                include_history_context_packs,
13464                include_agent_progress,
13465                include_session_task_correlation,
13466            ),
13467            compatibility,
13468        }),
13469    )
13470    .await?;
13471    let (mut reader, mut writer) = tokio::io::split(pipe);
13472    let (frame_tx, mut frame_rx) = mpsc::channel(16);
13473    let reader_task = tokio::spawn(async move {
13474        loop {
13475            let frame = read_json_frame_limited_body_timeout::<_, ClientFrame>(
13476                &mut reader,
13477                MAX_NODE_CLIENT_FRAME_BYTES,
13478                Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13479            )
13480            .await;
13481            let terminal = frame.is_err();
13482            if frame_tx.send(frame).await.is_err() || terminal {
13483                break;
13484            }
13485        }
13486    });
13487    let _reader_abort = AbortTaskOnDrop(reader_task.abort_handle());
13488    let mut pending_events = Vec::new();
13489    let mut resync_required = false;
13490    let mut discard_events_through = 0_u64;
13491    loop {
13492        // One tick of the serve loop, whether or not it had anything to do.
13493        // Against `connection_events_sent` this separates "busy" from
13494        // "spinning": a loop turning over thousands of times a second while
13495        // sending nothing is not serving, it is burning a core.
13496        shared.connection_loop_iterations.fetch_add(1, Ordering::Relaxed);
13497        let durable_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13498            .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13499        for _ in 0..durable_drain_budget {
13500            match event_rx.try_recv() {
13501                Ok(event) => queue_connection_event(
13502                    &mut pending_events,
13503                    event,
13504                    discard_events_through,
13505                ),
13506                Err(broadcast::error::TryRecvError::Empty) => break,
13507                Err(broadcast::error::TryRecvError::Lagged(_)) => {
13508                    resync_required = true;
13509                    break;
13510                }
13511                Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13512            }
13513        }
13514        if let Some(receiver) = terminal_event_rx.as_mut() {
13515            match receiver.try_recv() {
13516                Ok(events) => queue_connection_event_batch(
13517                    &mut pending_events,
13518                    &events,
13519                    discard_events_through,
13520                ),
13521                Err(broadcast::error::TryRecvError::Empty) => {}
13522                Err(broadcast::error::TryRecvError::Lagged(_)) => {
13523                    resync_required = true;
13524                }
13525                Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13526            }
13527        }
13528        if let Some(receiver) = agent_stream_event_rx.as_mut() {
13529            // Loops up to the same remaining-room budget the durable
13530            // `event_rx` drain above uses, rather than one `try_recv` per
13531            // tick: each successful receive here is itself only ONE
13532            // `NodeEventEnvelope` batch (`publish_agent_stream_chunk` always
13533            // sends `vec![envelope]`, never more), so a single-shot drain
13534            // caps this connection's agent-stream throughput at one chunk
13535            // per outer-loop tick regardless of how many are already queued
13536            // -- exactly the kind of self-inflicted stall this crate's own
13537            // durable-event budget exists to avoid.
13538            let agent_stream_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13539                .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13540            for _ in 0..agent_stream_drain_budget {
13541                match receiver.try_recv() {
13542                    Ok(events) => queue_connection_event_batch(
13543                        &mut pending_events,
13544                        &events,
13545                        discard_events_through,
13546                    ),
13547                    Err(broadcast::error::TryRecvError::Empty) => break,
13548                    Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
13549                        tracing::warn!(
13550                            connection_id,
13551                            dropped,
13552                            "agent stream event subscription lagged; forcing a resync",
13553                        );
13554                        resync_required = true;
13555                        break;
13556                    }
13557                    Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13558                }
13559            }
13560        }
13561        if let Some(receiver) = harness_mcp_proxy_event_rx.as_mut() {
13562            // Mirrors the agent-stream drain immediately above: each
13563            // successful receive is one single-envelope batch
13564            // (`publish_transient` always sends `vec![envelope]`), so this
13565            // loops up to the same remaining-room budget rather than
13566            // stopping after one `try_recv`.
13567            let harness_mcp_proxy_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13568                .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13569            for _ in 0..harness_mcp_proxy_drain_budget {
13570                match receiver.try_recv() {
13571                    Ok(events) => queue_connection_event_batch(
13572                        &mut pending_events,
13573                        &events,
13574                        discard_events_through,
13575                    ),
13576                    Err(broadcast::error::TryRecvError::Empty) => break,
13577                    Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
13578                        tracing::warn!(
13579                            connection_id,
13580                            dropped,
13581                            "harness MCP proxy event subscription lagged; forcing a resync",
13582                        );
13583                        resync_required = true;
13584                        break;
13585                    }
13586                    Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13587                }
13588            }
13589        }
13590        if resync_required {
13591            let event = resync_required_event(&shared);
13592            apply_connection_resync_watermark(
13593                &mut pending_events,
13594                &mut discard_events_through,
13595                event.sequence,
13596            );
13597            write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
13598            resync_required = false;
13599            continue;
13600        }
13601        if !pending_events.is_empty() {
13602            pending_events.sort_unstable_by_key(|event| event.sequence);
13603            let burst_len = connection_event_burst_len(pending_events.len());
13604            shared
13605                .connection_events_sent
13606                .fetch_add(burst_len as u64, Ordering::Relaxed);
13607            for event in pending_events.drain(..burst_len) {
13608                let event = if include_managed_worktrees {
13609                    Some(event)
13610                } else {
13611                    project_event_without_managed_worktrees(event)
13612                };
13613                let event = event.and_then(|event| if include_open_provider_ids {
13614                    Some(event)
13615                } else {
13616                    project_event_legacy_provider_ids(&shared, event)
13617                });
13618                let event = event.map(|event| if include_child_environment_profiles {
13619                    event
13620                } else {
13621                    project_event_without_child_environment_profile(event)
13622                });
13623                let event = event.map(|event| if include_session_bundles {
13624                    event
13625                } else {
13626                    project_event_without_session_bundle(event)
13627                });
13628                let event = event.map(|event| {
13629                    project_event_history_for_wire(event, include_history_context_packs)
13630                });
13631                let event = event.map(|event| if include_history_context_packs {
13632                    event
13633                } else {
13634                    project_event_without_context_pack(event)
13635                });
13636                let event = event.map(|event| if include_session_task_correlation {
13637                    event
13638                } else {
13639                    project_event_without_session_task_binding(event)
13640                });
13641                let exact_harness_controller = hello.role == ClientRole::Operator
13642                    && shared.controller_state().is_some_and(|controller| {
13643                        controller.connection_id == connection_id
13644                    });
13645                let event = event.and_then(|event| {
13646                    if include_harness_mcp_proxy && exact_harness_controller {
13647                        Some(event)
13648                    } else {
13649                        project_event_without_harness_mcp_proxy(event)
13650                    }
13651                });
13652                if let Some(event) = event {
13653                    write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
13654                }
13655            }
13656        }
13657        tokio::select! {
13658            biased;
13659            frame = frame_rx.recv() => {
13660                let frame = match frame {
13661                    Some(frame) => frame,
13662                    None => break,
13663                };
13664                let frame = match frame {
13665                    Ok(frame) => frame,
13666                    Err(FrameError::Io(error)) if matches!(error.kind(), io::ErrorKind::BrokenPipe | io::ErrorKind::UnexpectedEof) => break,
13667                    Err(error) => return Err(error.into()),
13668                };
13669                let ClientFrame::Request(request) = frame else {
13670                    return Err(NodeServerError::Handshake("hello may only be sent once".to_owned()));
13671                };
13672                #[cfg(feature = "fixture")]
13673                let probe_spawn_managed_worktree_v2 = matches!(
13674                    &request.request,
13675                    NodeRequest::SpawnManagedWorktreeV2 { .. }
13676                );
13677                let requires_open_provider_ids =
13678                    request_requires_open_provider_ids(&shared, &request.request);
13679                let requires_child_environment_profile =
13680                    request_requires_child_environment_profile(&shared, &request.request);
13681                let requires_session_bundle =
13682                    request_requires_session_bundle(&shared, &request.request);
13683                let requires_history_context_pack =
13684                    request_requires_history_context_pack(&shared, &request.request);
13685                let mut reply = if requires_open_provider_ids && !include_open_provider_ids {
13686                    ResponseEnvelope {
13687                        request_id: request.request_id,
13688                        result: Err(failure(
13689                            NodeFailureCode::UnsupportedCapability,
13690                            "open provider IDs were not negotiated",
13691                        )),
13692                    }
13693                } else if requires_child_environment_profile
13694                    && !include_child_environment_profiles
13695                {
13696                    ResponseEnvelope {
13697                        request_id: request.request_id,
13698                        result: Err(failure(
13699                            NodeFailureCode::UnsupportedCapability,
13700                            "child environment profiles were not negotiated",
13701                        )),
13702                    }
13703                } else if requires_session_bundle && !include_session_bundles {
13704                    ResponseEnvelope {
13705                        request_id: request.request_id,
13706                        result: Err(failure(
13707                            NodeFailureCode::UnsupportedCapability,
13708                            "session bundles were not negotiated",
13709                        )),
13710                    }
13711                } else if requires_history_context_pack && !include_history_context_packs {
13712                    ResponseEnvelope {
13713                        request_id: request.request_id,
13714                        result: Err(failure(
13715                            NodeFailureCode::UnsupportedCapability,
13716                            "history context packs were not negotiated",
13717                        )),
13718                    }
13719                } else if request_uses_unnegotiated_capability(
13720                    &request.request,
13721                    &selected_capabilities,
13722                )
13723                {
13724                    ResponseEnvelope {
13725                        request_id: request.request_id,
13726                        result: Err(failure(
13727                            NodeFailureCode::UnsupportedCapability,
13728                            "request capability was not negotiated",
13729                        )),
13730                    }
13731                } else {
13732                    process_request(&shared, connection_id, hello.role, request).await
13733                };
13734                #[cfg(feature = "fixture")]
13735                if probe_spawn_managed_worktree_v2 {
13736                    shared
13737                        .fixture_spawn_managed_worktree_v2_failure_probe
13738                        .record(reply.result.as_ref().err().map(|failure| failure.code));
13739                }
13740                if !include_provider_runtime_status {
13741                    clear_response_provider_runtime_status(&mut reply);
13742                }
13743                if !include_managed_worktrees {
13744                    project_response_without_managed_worktrees(&mut reply);
13745                }
13746                if !include_open_provider_ids {
13747                    project_response_legacy_provider_ids(&shared, &mut reply);
13748                }
13749                if !include_child_environment_profiles {
13750                    project_response_without_child_environment_profile(&mut reply);
13751                }
13752                if !include_session_bundles {
13753                    project_response_without_session_bundle(&mut reply);
13754                }
13755                project_response_history_for_wire(
13756                    &mut reply,
13757                    include_history_context_packs,
13758                );
13759                if !include_history_context_packs {
13760                    project_response_without_context_pack(&mut reply);
13761                }
13762                if !include_agent_progress {
13763                    project_response_without_agent_progress(&mut reply);
13764                }
13765                if !include_session_task_correlation {
13766                    project_response_without_session_task_binding(&mut reply);
13767                }
13768                write_json_frame(&mut writer, &ServerFrame::Reply(reply)).await?;
13769            }
13770            _ = tokio::task::yield_now(), if !pending_events.is_empty() => {}
13771            event = event_rx.recv() => {
13772                match event {
13773                    Ok(event) => queue_connection_event(
13774                        &mut pending_events,
13775                        event,
13776                        discard_events_through,
13777                    ),
13778                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13779                    Err(broadcast::error::RecvError::Closed) => break,
13780                }
13781            }
13782            events = receive_terminal_event_batch(&mut terminal_event_rx) => {
13783                match events {
13784                    Ok(events) => queue_connection_event_batch(
13785                        &mut pending_events,
13786                        &events,
13787                        discard_events_through,
13788                    ),
13789                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13790                    Err(broadcast::error::RecvError::Closed) => break,
13791                }
13792            }
13793            events = receive_terminal_event_batch(&mut agent_stream_event_rx) => {
13794                match events {
13795                    Ok(events) => queue_connection_event_batch(
13796                        &mut pending_events,
13797                        &events,
13798                        discard_events_through,
13799                    ),
13800                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13801                    Err(broadcast::error::RecvError::Closed) => break,
13802                }
13803            }
13804            events = receive_terminal_event_batch(&mut harness_mcp_proxy_event_rx) => {
13805                match events {
13806                    Ok(events) => queue_connection_event_batch(
13807                        &mut pending_events,
13808                        &events,
13809                        discard_events_through,
13810                    ),
13811                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13812                    Err(broadcast::error::RecvError::Closed) => break,
13813                }
13814            }
13815            _ = shared.shutdown_notify.notified() => break,
13816        }
13817    }
13818    Ok(())
13819}
13820
13821async fn receive_terminal_event_batch(
13822    receiver: &mut Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>>,
13823) -> Result<Arc<Vec<NodeEventEnvelope>>, broadcast::error::RecvError> {
13824    match receiver {
13825        Some(receiver) => receiver.recv().await,
13826        None => std::future::pending().await,
13827    }
13828}
13829
13830fn terminal_event_subscription(
13831    shared: &NodeShared,
13832    enabled: bool,
13833) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13834    enabled.then(|| shared.terminal_event_tx.subscribe())
13835}
13836
13837/// Mirrors `terminal_event_subscription` exactly, over the agent stream's
13838/// own channel -- see `NodeShared::agent_stream_event_tx`'s own doc for why
13839/// this needs a dedicated channel rather than a flag on the terminal one.
13840fn agent_stream_event_subscription(
13841    shared: &NodeShared,
13842    enabled: bool,
13843) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13844    enabled.then(|| shared.agent_stream_event_tx.subscribe())
13845}
13846
13847/// Mirrors `agent_stream_event_subscription` exactly, over the harness MCP
13848/// proxy's own channel -- see `NodeShared::harness_mcp_proxy_event_tx`'s
13849/// own doc for why this needs a dedicated channel rather than a ride on
13850/// the durable `event_tx`.
13851fn harness_mcp_proxy_event_subscription(
13852    shared: &NodeShared,
13853    enabled: bool,
13854) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13855    enabled.then(|| shared.harness_mcp_proxy_event_tx.subscribe())
13856}
13857
13858fn queue_connection_event(
13859    pending: &mut Vec<NodeEventEnvelope>,
13860    event: NodeEventEnvelope,
13861    discard_through: u64,
13862) {
13863    if event.sequence <= discard_through
13864        && !event.event.requires_harness_mcp_proxy_capability()
13865    {
13866        return;
13867    }
13868    if let NodeEvent::TerminalFrame { address, .. } = &event.event {
13869        if let Some(index) = pending.iter().position(|current| {
13870            matches!(
13871                &current.event,
13872                NodeEvent::TerminalFrame {
13873                    address: current_address,
13874                    ..
13875                } if current_address == address
13876            )
13877        }) {
13878            pending.remove(index);
13879        }
13880    }
13881    pending.push(event);
13882}
13883
13884fn project_event_without_harness_mcp_proxy(
13885    envelope: NodeEventEnvelope,
13886) -> Option<NodeEventEnvelope> {
13887    (!envelope.event.requires_harness_mcp_proxy_capability()).then_some(envelope)
13888}
13889
13890fn queue_connection_event_batch(
13891    pending: &mut Vec<NodeEventEnvelope>,
13892    events: &[NodeEventEnvelope],
13893    discard_through: u64,
13894) {
13895    for event in events {
13896        queue_connection_event(pending, event.clone(), discard_through);
13897    }
13898}
13899
13900fn apply_connection_resync_watermark(
13901    pending: &mut Vec<NodeEventEnvelope>,
13902    discard_through: &mut u64,
13903    marker_sequence: u64,
13904) {
13905    *discard_through = (*discard_through).max(marker_sequence);
13906    pending.retain(|event| event.sequence > *discard_through);
13907}
13908
13909fn connection_event_burst_len(pending_len: usize) -> usize {
13910    pending_len.min(NODE_CONNECTION_EVENT_BURST_MAX)
13911}
13912
13913fn resync_required_event(shared: &NodeShared) -> NodeEventEnvelope {
13914    let history = shared
13915        .history
13916        .lock()
13917        .unwrap_or_else(|poisoned| poisoned.into_inner());
13918    let sequence = history.last_sequence;
13919    let oldest_available_sequence = history.replay_floor_sequence;
13920    NodeEventEnvelope {
13921        sequence,
13922        event: NodeEvent::ResyncRequired {
13923            oldest_available_sequence,
13924        },
13925    }
13926}
13927
13928fn node_compatibility_support(
13929    shared: &NodeShared,
13930) -> Result<NodeCompatibilitySupport, NodeServerError> {
13931    let mut support = node_compatibility_support_for_manifest(
13932        &shared.provider_contracts,
13933        &shared.provider_adapter_contracts,
13934    )?;
13935    if shared
13936        .delivery_store
13937        .lock()
13938        .unwrap_or_else(|poisoned| poisoned.into_inner())
13939        .is_some()
13940    {
13941        support.capabilities.push(
13942            CapabilityId::new(NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY)
13943                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13944        );
13945    }
13946    if shared.harness_mcp_registry.is_some() {
13947        support.capabilities.push(
13948            CapabilityId::new(NODE_HARNESS_MCP_READ_PROXY_CAPABILITY)
13949                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13950        );
13951    }
13952    Ok(support)
13953}
13954
13955fn node_compatibility_support_for_manifest(
13956    provider_contracts: &[ProviderContractSupport],
13957    provider_adapter_contracts: &[ProviderAdapterContractSupport],
13958) -> Result<NodeCompatibilitySupport, NodeServerError> {
13959    Ok(NodeCompatibilitySupport {
13960        build_stamp: BUILD_STAMP.to_owned(),
13961        capabilities: baseline_capabilities()?,
13962        host: platform::host_descriptor().map_err(NodeServerError::Handshake)?,
13963        path_semantics: platform::path_semantics(),
13964        local_transport: platform::local_transport(),
13965        state_schema: StateSchemaSupport {
13966            versions: ProtocolRange::new(NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10)
13967                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13968        },
13969        provider_contracts: provider_contracts.to_vec(),
13970        provider_adapter_contracts: provider_adapter_contracts.to_vec(),
13971    })
13972}
13973
13974fn baseline_capabilities() -> Result<Vec<CapabilityId>, NodeServerError> {
13975    let capabilities = [
13976        NODE_COMPATIBILITY_METADATA_CAPABILITY,
13977        CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
13978        NODE_REPOSITORY_PATH_CAPABILITY,
13979        NODE_WORKSPACE_FILE_READ_CAPABILITY,
13980        NODE_WORKSPACE_FILE_WRITE_CAPABILITY,
13981        NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
13982        NODE_GIT_READ_CAPABILITY,
13983        NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY,
13984        NODE_PROVIDER_ID_OPEN_CAPABILITY,
13985        NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
13986        NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY,
13987        NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
13988        NODE_AGENT_STREAM_EVENTS_CAPABILITY,
13989        NODE_ACP_CONTROL_CAPABILITY,
13990        NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
13991        NODE_SPAWN_PROFILE_REVISION_CAPABILITY,
13992        NODE_WORKTREE_SELECTION_CAPABILITY,
13993        NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
13994        NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
13995        NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
13996        NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
13997        NODE_HISTORY_CONTEXT_PACK_CAPABILITY,
13998        NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
13999        NODE_NATIVE_SESSION_CATALOG_CAPABILITY,
14000        NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
14001        NODE_NATIVE_SESSION_INDEX_CAPABILITY,
14002        NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
14003        NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
14004        NODE_SESSION_TASK_CORRELATION_CAPABILITY,
14005    ]
14006        .into_iter()
14007        .map(|capability| {
14008            CapabilityId::new(capability)
14009                .map_err(|error| NodeServerError::Handshake(error.to_string()))
14010        })
14011        .collect::<Result<Vec<_>, _>>()?;
14012    #[cfg(windows)]
14013    let capabilities = {
14014        let mut capabilities = capabilities;
14015        capabilities.push(
14016            CapabilityId::new(NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY)
14017                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14018        );
14019        capabilities
14020    };
14021    Ok(capabilities)
14022}
14023
14024fn request_uses_unnegotiated_capability(
14025    request: &NodeRequest,
14026    selected_capabilities: &[CapabilityId],
14027) -> bool {
14028    let selected = |required: &str| {
14029        selected_capabilities
14030            .iter()
14031            .any(|capability| capability.as_str() == required)
14032    };
14033    request
14034        .required_capability()
14035        .is_some_and(|required| !selected(required))
14036        || (request.requires_worktree_selection_capability()
14037            && !selected(NODE_WORKTREE_SELECTION_CAPABILITY))
14038        || (request.requires_spawn_spec_defaults_overrides_capability()
14039            && !selected(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY))
14040        || (request.requires_spawn_profile_revision_capability()
14041            && !selected(NODE_SPAWN_PROFILE_REVISION_CAPABILITY))
14042        || (request.requires_child_environment_profile_capability()
14043            && !selected(NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY))
14044        || (request.requires_session_bundle_materialization_capability()
14045            && !selected(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY))
14046        || (request.requires_history_context_pack_capability()
14047            && !selected(NODE_HISTORY_CONTEXT_PACK_CAPABILITY))
14048}
14049
14050fn snapshot_for_wire(
14051    shared: &NodeShared,
14052    include_provider_runtime_status: bool,
14053    include_open_provider_ids: bool,
14054    include_spawn_profiles: bool,
14055    include_managed_worktrees: bool,
14056    include_child_environment_profiles: bool,
14057    include_session_bundles: bool,
14058    include_history_context_packs: bool,
14059    include_agent_progress: bool,
14060    include_session_task_correlation: bool,
14061) -> NodeSnapshot {
14062    let mut snapshot = shared.snapshot();
14063    if !include_provider_runtime_status {
14064        snapshot.provider_runtime_statuses.clear();
14065    }
14066    if !include_open_provider_ids {
14067        project_snapshot_legacy_provider_ids(&mut snapshot);
14068    }
14069    if !include_managed_worktrees {
14070        snapshot.managed_worktrees.clear();
14071        for workspace in &mut snapshot.workspaces {
14072            workspace.managed_worktree_profiles = None;
14073        }
14074    }
14075    if let Some(inventory) = snapshot.launch_inventory.as_mut() {
14076        if !include_spawn_profiles {
14077            inventory.spawn_profiles = None;
14078        }
14079        if !include_session_bundles {
14080            inventory.bundles = None;
14081        }
14082        if inventory.spawn_profiles.is_none()
14083            && inventory.bundles.is_none()
14084            && inventory.network_allowlists.is_none()
14085        {
14086            snapshot.launch_inventory = None;
14087        }
14088    }
14089    if !include_child_environment_profiles {
14090        clear_snapshot_child_environment_profiles(&mut snapshot);
14091    }
14092    if !include_session_bundles {
14093        clear_snapshot_session_bundles(&mut snapshot);
14094    }
14095    project_snapshot_history_for_wire(&mut snapshot, include_history_context_packs);
14096    if !include_history_context_packs {
14097        clear_snapshot_context_packs(&mut snapshot);
14098    }
14099    if !include_agent_progress {
14100        snapshot.agent_progress.clear();
14101    }
14102    if !include_session_task_correlation {
14103        clear_snapshot_session_task_bindings(&mut snapshot);
14104    }
14105    snapshot
14106}
14107
14108fn clear_snapshot_session_task_bindings(snapshot: &mut NodeSnapshot) {
14109    for record in &mut snapshot.session_records {
14110        record.task_binding = None;
14111    }
14112}
14113
14114fn project_event_without_session_task_binding(
14115    mut envelope: NodeEventEnvelope,
14116) -> NodeEventEnvelope {
14117    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14118        record.task_binding = None;
14119    }
14120    envelope
14121}
14122
14123fn project_response_without_session_task_binding(reply: &mut ResponseEnvelope) {
14124    let Ok(response) = reply.result.as_mut() else { return };
14125    match response {
14126        NodeResponse::Snapshot { snapshot, .. } => {
14127            clear_snapshot_session_task_bindings(snapshot);
14128        }
14129        NodeResponse::Resync { snapshot, events, .. } => {
14130            clear_snapshot_session_task_bindings(snapshot);
14131            for event in events {
14132                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14133                    record.task_binding = None;
14134                }
14135            }
14136        }
14137        NodeResponse::SessionRecordUpdated { record }
14138        | NodeResponse::ProviderSessionIndexed { record }
14139        | NodeResponse::NativeSessionIndexed { record, .. }
14140        | NodeResponse::SessionRecordResumed { record, .. } => {
14141            record.task_binding = None;
14142        }
14143        _ => {}
14144    }
14145}
14146
14147fn project_response_without_agent_progress(reply: &mut ResponseEnvelope) {
14148    let Ok(response) = reply.result.as_mut() else { return };
14149    match response {
14150        NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
14151            snapshot.agent_progress.clear();
14152        }
14153        _ => {}
14154    }
14155}
14156
14157fn project_event_without_managed_worktrees(
14158    mut envelope: NodeEventEnvelope,
14159) -> Option<NodeEventEnvelope> {
14160    if matches!(
14161        envelope.event,
14162        NodeEvent::ManagedWorktreeUpserted { .. }
14163            | NodeEvent::ManagedWorktreeRemoved { .. }
14164    ) {
14165        return None;
14166    }
14167    if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
14168        workspace.managed_worktree_profiles = None;
14169    }
14170    Some(envelope)
14171}
14172
14173fn project_response_without_managed_worktrees(reply: &mut ResponseEnvelope) {
14174    if matches!(
14175        reply.result,
14176        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { .. })
14177            | Ok(NodeResponse::ManagedWorktreeCleanup { .. })
14178    ) {
14179        reply.result = Err(failure(
14180            NodeFailureCode::UnsupportedCapability,
14181            "managed worktree capability was not negotiated",
14182        ));
14183        return;
14184    }
14185    let Ok(response) = reply.result.as_mut() else { return };
14186    match response {
14187        NodeResponse::Snapshot { snapshot, .. } => {
14188            snapshot.managed_worktrees.clear();
14189            for workspace in &mut snapshot.workspaces {
14190                workspace.managed_worktree_profiles = None;
14191            }
14192        }
14193        NodeResponse::Resync { snapshot, events, .. } => {
14194            snapshot.managed_worktrees.clear();
14195            for workspace in &mut snapshot.workspaces {
14196                workspace.managed_worktree_profiles = None;
14197            }
14198            *events = std::mem::take(events)
14199                .into_iter()
14200                .filter_map(project_event_without_managed_worktrees)
14201                .collect();
14202        }
14203        NodeResponse::WorkspaceRegistered { workspace }
14204        | NodeResponse::StandaloneWorkspaceCreated { workspace }
14205        | NodeResponse::WorktreeCreated { workspace, .. } => {
14206            workspace.managed_worktree_profiles = None;
14207        }
14208        NodeResponse::WorkspaceInspected { inspection } => {
14209            inspection.git.managed_worktree = None;
14210        }
14211        _ => {}
14212    }
14213}
14214
14215fn project_negotiated_provider_ids(compatibility: &mut NegotiatedNodeCompatibility) {
14216    let includes_open_provider_ids = compatibility.capabilities.iter().any(|capability| {
14217        capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
14218    });
14219    if includes_open_provider_ids {
14220        return;
14221    }
14222    compatibility
14223        .provider_contracts
14224        .retain(|contract| provider_id_is_legacy(&contract.provider));
14225    compatibility
14226        .provider_adapter_contracts
14227        .retain(|contract| provider_id_is_legacy(&contract.provider));
14228}
14229
14230fn request_requires_open_provider_ids(shared: &NodeShared, request: &NodeRequest) -> bool {
14231    if let Some(spec) = match request {
14232        NodeRequest::SpawnSpec { spec }
14233        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14234        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14235        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14236        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14237        _ => None,
14238    } {
14239        return shared
14240            .resolve_spawn_spec(spec)
14241            .is_ok_and(|resolved| !provider_id_is_legacy(&resolved.provider));
14242    }
14243    request_requires_open_provider_ids_with(
14244        request,
14245        |session| shared.validate_address(session).ok(),
14246        |record_id| shared.record(record_id).ok().map(|record| record.provider),
14247    )
14248}
14249
14250fn request_requires_child_environment_profile(
14251    shared: &NodeShared,
14252    request: &NodeRequest,
14253) -> bool {
14254    if let Some(spec) = match request {
14255        NodeRequest::SpawnSpec { spec }
14256        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14257        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14258        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14259        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14260        _ => None,
14261    } {
14262        return shared
14263            .resolve_spawn_spec(spec)
14264            .is_ok_and(|resolved| resolved.environment_profile_id.is_some());
14265    }
14266    match request {
14267        NodeRequest::Resume { session, .. }
14268        | NodeRequest::Prompt { session, .. }
14269        | NodeRequest::Paste { session, .. }
14270        | NodeRequest::Input { session, .. }
14271        | NodeRequest::TerminalBytes { session, .. }
14272        | NodeRequest::TerminalControl { session, .. }
14273        | NodeRequest::Resize { session, .. }
14274        | NodeRequest::Interrupt { session }
14275        | NodeRequest::Stop { session, .. }
14276        | NodeRequest::Remove { session }
14277        | NodeRequest::ResolveInteraction { session, .. }
14278        | NodeRequest::SetSessionMode { session, .. }
14279        | NodeRequest::SetSessionConfigOption { session, .. }
14280        | NodeRequest::SetSessionModel { session, .. }
14281        | NodeRequest::DiscoverHistory { session, .. }
14282        | NodeRequest::LoadHistory { session, .. }
14283        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14284        | NodeRequest::ExportContextPack { session } => shared
14285            .session_bindings
14286            .lock()
14287            .unwrap_or_else(|poisoned| poisoned.into_inner())
14288            .get(&session.session.instance_id)
14289            .filter(|binding| {
14290                binding.workspace_id == session.workspace_id
14291                    && binding.generation == session.session.generation
14292            })
14293            .map_or(true, |binding| binding.environment_profile.is_some()),
14294        NodeRequest::RenameSessionRecord { record_id, .. }
14295        | NodeRequest::SetSessionTask { record_id, .. }
14296        | NodeRequest::ResumeSessionRecord { record_id, .. }
14297        | NodeRequest::ForgetSessionRecord { record_id } => shared
14298            .record(record_id)
14299            .map_or(true, |record| record.environment_profile.is_some()),
14300        NodeRequest::ArmHarnessMcpReservation { .. }
14301        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14302        | NodeRequest::ActivateHarnessMcpReservation { .. }
14303        | NodeRequest::AbortHarnessMcpReservation { .. }
14304        | NodeRequest::PutHarnessMcpReplyChunk { .. }
14305        | NodeRequest::RejectHarnessMcpCall { .. }
14306        | NodeRequest::Snapshot
14307        | NodeRequest::Resync { .. }
14308        | NodeRequest::BeginDeliveryStage { .. }
14309        | NodeRequest::PutDeliveryBlobChunk { .. }
14310        | NodeRequest::CommitDeliveryStage { .. }
14311        | NodeRequest::AbortDeliveryStage { .. }
14312        | NodeRequest::BrowseHostDirectories { .. }
14313        | NodeRequest::InspectWorkspace { .. }
14314        | NodeRequest::ReadWorkspaceFile { .. }
14315        | NodeRequest::WriteWorkspaceFile { .. }
14316        | NodeRequest::CreateWorkspaceFile { .. }
14317        | NodeRequest::CreateWorkspaceDirectory { .. }
14318        | NodeRequest::ReadGitHistory { .. }
14319        | NodeRequest::ReadGitDiff { .. }
14320        | NodeRequest::AcquireController { .. }
14321        | NodeRequest::ReleaseController
14322        | NodeRequest::Spawn { .. }
14323        | NodeRequest::SpawnWithOptions { .. }
14324        | NodeRequest::SpawnSpec { .. }
14325        | NodeRequest::RegisterWorkspace { .. }
14326        | NodeRequest::CreateStandaloneWorkspace { .. }
14327        | NodeRequest::UnregisterWorkspace { .. }
14328        | NodeRequest::CreateWorktree { .. }
14329        | NodeRequest::RemoveWorktree { .. }
14330        | NodeRequest::SpawnManagedWorktree { .. }
14331        | NodeRequest::SpawnManagedWorktreeV2 { .. }
14332        | NodeRequest::CleanupManagedWorktree { .. }
14333        | NodeRequest::IndexProviderSession { .. }
14334        | NodeRequest::IndexNativeSession { .. }
14335        | NodeRequest::CatalogNativeSessions { .. }
14336        | NodeRequest::PageNativeSessions { .. }
14337        | NodeRequest::PreviewNativeSession { .. }
14338        | NodeRequest::PreviewSessionRecord { .. }
14339        | NodeRequest::ForgetContextPack { .. }
14340        | NodeRequest::ResolveDurableContextPack { .. }
14341        | NodeRequest::ReadContextPack { .. }
14342        | NodeRequest::Shutdown => false,
14343    }
14344}
14345
14346fn request_requires_session_bundle(shared: &NodeShared, request: &NodeRequest) -> bool {
14347    if let Some(spec) = match request {
14348        NodeRequest::SpawnSpec { spec }
14349        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14350        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14351        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14352        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14353        _ => None,
14354    } {
14355        return shared
14356            .resolve_spawn_spec(spec)
14357            .is_ok_and(|resolved| resolved.bundle_id.is_some());
14358    }
14359    match request {
14360        NodeRequest::Resume { session, .. }
14361        | NodeRequest::Prompt { session, .. }
14362        | NodeRequest::Paste { session, .. }
14363        | NodeRequest::Input { session, .. }
14364        | NodeRequest::TerminalBytes { session, .. }
14365        | NodeRequest::TerminalControl { session, .. }
14366        | NodeRequest::Resize { session, .. }
14367        | NodeRequest::Interrupt { session }
14368        | NodeRequest::Stop { session, .. }
14369        | NodeRequest::Remove { session }
14370        | NodeRequest::ResolveInteraction { session, .. }
14371        | NodeRequest::SetSessionMode { session, .. }
14372        | NodeRequest::SetSessionConfigOption { session, .. }
14373        | NodeRequest::SetSessionModel { session, .. }
14374        | NodeRequest::DiscoverHistory { session, .. }
14375        | NodeRequest::LoadHistory { session, .. }
14376        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14377        | NodeRequest::ExportContextPack { session } => shared
14378            .session_bindings
14379            .lock()
14380            .unwrap_or_else(|poisoned| poisoned.into_inner())
14381            .get(&session.session.instance_id)
14382            .filter(|binding| {
14383                binding.workspace_id == session.workspace_id
14384                    && binding.generation == session.session.generation
14385            })
14386            .map_or(true, |binding| binding.bundle.is_some()),
14387        NodeRequest::RenameSessionRecord { record_id, .. }
14388        | NodeRequest::SetSessionTask { record_id, .. }
14389        | NodeRequest::ResumeSessionRecord { record_id, .. }
14390        | NodeRequest::ForgetSessionRecord { record_id } => shared
14391            .record(record_id)
14392            .map_or(true, |record| record.bundle.is_some()),
14393        NodeRequest::ArmHarnessMcpReservation { .. }
14394        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14395        | NodeRequest::ActivateHarnessMcpReservation { .. }
14396        | NodeRequest::AbortHarnessMcpReservation { .. }
14397        | NodeRequest::PutHarnessMcpReplyChunk { .. }
14398        | NodeRequest::RejectHarnessMcpCall { .. }
14399        | NodeRequest::Snapshot
14400        | NodeRequest::Resync { .. }
14401        | NodeRequest::BeginDeliveryStage { .. }
14402        | NodeRequest::PutDeliveryBlobChunk { .. }
14403        | NodeRequest::CommitDeliveryStage { .. }
14404        | NodeRequest::AbortDeliveryStage { .. }
14405        | NodeRequest::BrowseHostDirectories { .. }
14406        | NodeRequest::InspectWorkspace { .. }
14407        | NodeRequest::ReadWorkspaceFile { .. }
14408        | NodeRequest::WriteWorkspaceFile { .. }
14409        | NodeRequest::CreateWorkspaceFile { .. }
14410        | NodeRequest::CreateWorkspaceDirectory { .. }
14411        | NodeRequest::ReadGitHistory { .. }
14412        | NodeRequest::ReadGitDiff { .. }
14413        | NodeRequest::AcquireController { .. }
14414        | NodeRequest::ReleaseController
14415        | NodeRequest::Spawn { .. }
14416        | NodeRequest::SpawnWithOptions { .. }
14417        | NodeRequest::SpawnSpec { .. }
14418        | NodeRequest::RegisterWorkspace { .. }
14419        | NodeRequest::CreateStandaloneWorkspace { .. }
14420        | NodeRequest::UnregisterWorkspace { .. }
14421        | NodeRequest::CreateWorktree { .. }
14422        | NodeRequest::RemoveWorktree { .. }
14423        | NodeRequest::SpawnManagedWorktree { .. }
14424        | NodeRequest::SpawnManagedWorktreeV2 { .. }
14425        | NodeRequest::CleanupManagedWorktree { .. }
14426        | NodeRequest::IndexProviderSession { .. }
14427        | NodeRequest::IndexNativeSession { .. }
14428        | NodeRequest::CatalogNativeSessions { .. }
14429        | NodeRequest::PageNativeSessions { .. }
14430        | NodeRequest::PreviewNativeSession { .. }
14431        | NodeRequest::PreviewSessionRecord { .. }
14432        | NodeRequest::ForgetContextPack { .. }
14433        | NodeRequest::ResolveDurableContextPack { .. }
14434        | NodeRequest::ReadContextPack { .. }
14435        | NodeRequest::Shutdown => false,
14436    }
14437}
14438
14439fn request_requires_history_context_pack(shared: &NodeShared, request: &NodeRequest) -> bool {
14440    if request.requires_history_context_pack_capability() {
14441        return true;
14442    }
14443    if let Some(spec) = match request {
14444        NodeRequest::SpawnSpec { spec }
14445        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14446        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14447        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14448        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14449        _ => None,
14450    } {
14451        return shared
14452            .resolve_spawn_spec(spec)
14453            .map_or(true, |resolved| resolved.context_id.is_some());
14454    }
14455    match request {
14456        NodeRequest::Resume { session, .. }
14457        | NodeRequest::Prompt { session, .. }
14458        | NodeRequest::Paste { session, .. }
14459        | NodeRequest::Input { session, .. }
14460        | NodeRequest::TerminalBytes { session, .. }
14461        | NodeRequest::TerminalControl { session, .. }
14462        | NodeRequest::Resize { session, .. }
14463        | NodeRequest::Interrupt { session }
14464        | NodeRequest::Stop { session, .. }
14465        | NodeRequest::Remove { session }
14466        | NodeRequest::ResolveInteraction { session, .. }
14467        | NodeRequest::SetSessionMode { session, .. }
14468        | NodeRequest::SetSessionConfigOption { session, .. }
14469        | NodeRequest::SetSessionModel { session, .. } => shared
14470            .session_bindings
14471            .lock()
14472            .unwrap_or_else(|poisoned| poisoned.into_inner())
14473            .get(&session.session.instance_id)
14474            .filter(|binding| {
14475                binding.workspace_id == session.workspace_id
14476                    && binding.generation == session.session.generation
14477            })
14478            .map_or(true, |binding| binding.context.is_some()),
14479        NodeRequest::RenameSessionRecord { record_id, .. }
14480        | NodeRequest::SetSessionTask { record_id, .. }
14481        | NodeRequest::ResumeSessionRecord { record_id, .. }
14482        | NodeRequest::ForgetSessionRecord { record_id } => shared
14483            .record(record_id)
14484            .map_or(true, |record| record.context.is_some()),
14485        NodeRequest::ArmHarnessMcpReservation { .. }
14486        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14487        | NodeRequest::ActivateHarnessMcpReservation { .. }
14488        | NodeRequest::AbortHarnessMcpReservation { .. }
14489        | NodeRequest::PutHarnessMcpReplyChunk { .. }
14490        | NodeRequest::RejectHarnessMcpCall { .. }
14491        | NodeRequest::Snapshot
14492        | NodeRequest::Resync { .. }
14493        | NodeRequest::BeginDeliveryStage { .. }
14494        | NodeRequest::PutDeliveryBlobChunk { .. }
14495        | NodeRequest::CommitDeliveryStage { .. }
14496        | NodeRequest::AbortDeliveryStage { .. }
14497        | NodeRequest::BrowseHostDirectories { .. }
14498        | NodeRequest::InspectWorkspace { .. }
14499        | NodeRequest::ReadWorkspaceFile { .. }
14500        | NodeRequest::WriteWorkspaceFile { .. }
14501        | NodeRequest::CreateWorkspaceFile { .. }
14502        | NodeRequest::CreateWorkspaceDirectory { .. }
14503        | NodeRequest::ReadGitHistory { .. }
14504        | NodeRequest::ReadGitDiff { .. }
14505        | NodeRequest::AcquireController { .. }
14506        | NodeRequest::ReleaseController
14507        | NodeRequest::Spawn { .. }
14508        | NodeRequest::SpawnWithOptions { .. }
14509        | NodeRequest::SpawnSpec { .. }
14510        | NodeRequest::RegisterWorkspace { .. }
14511        | NodeRequest::CreateStandaloneWorkspace { .. }
14512        | NodeRequest::UnregisterWorkspace { .. }
14513        | NodeRequest::CreateWorktree { .. }
14514        | NodeRequest::RemoveWorktree { .. }
14515        | NodeRequest::SpawnManagedWorktree { .. }
14516        | NodeRequest::SpawnManagedWorktreeV2 { .. }
14517        | NodeRequest::CleanupManagedWorktree { .. }
14518        | NodeRequest::IndexProviderSession { .. }
14519        | NodeRequest::IndexNativeSession { .. }
14520        | NodeRequest::CatalogNativeSessions { .. }
14521        | NodeRequest::PageNativeSessions { .. }
14522        | NodeRequest::PreviewNativeSession { .. }
14523        | NodeRequest::PreviewSessionRecord { .. }
14524        | NodeRequest::DiscoverHistory { .. }
14525        | NodeRequest::LoadHistory { .. }
14526        | NodeRequest::ExportContextPackForSessionRecord { .. }
14527        | NodeRequest::ExportContextPack { .. }
14528        | NodeRequest::ForgetContextPack { .. }
14529        | NodeRequest::ResolveDurableContextPack { .. }
14530        | NodeRequest::ReadContextPack { .. }
14531        | NodeRequest::Shutdown => false,
14532    }
14533}
14534
14535fn request_requires_open_provider_ids_with(
14536    request: &NodeRequest,
14537    provider_for_session: impl Fn(&SessionAddress) -> Option<AgentId>,
14538    provider_for_record: impl Fn(&SessionRecordId) -> Option<AgentId>,
14539) -> bool {
14540    match request {
14541        NodeRequest::Spawn { provider, .. } | NodeRequest::SpawnWithOptions { provider, .. } => !provider_id_is_legacy(provider),
14542        NodeRequest::IndexProviderSession { provider, .. } => !provider_id_is_legacy(provider),
14543        NodeRequest::CatalogNativeSessions { route, .. }
14544        | NodeRequest::PageNativeSessions { route, .. } => {
14545            !provider_id_is_legacy(&route.provider)
14546        }
14547        NodeRequest::PreviewNativeSession { selection, .. }
14548        | NodeRequest::IndexNativeSession { selection, .. } => {
14549            !provider_id_is_legacy(&selection.route.provider)
14550        }
14551        NodeRequest::SpawnSpec { .. }
14552        | NodeRequest::SpawnSpecWithHarnessMcp { .. } => false,
14553        NodeRequest::SpawnManagedWorktree { .. }
14554        | NodeRequest::SpawnManagedWorktreeV2 { .. } => false,
14555        NodeRequest::Resume { session, .. }
14556        | NodeRequest::Prompt { session, .. }
14557        | NodeRequest::Paste { session, .. }
14558        | NodeRequest::Input { session, .. }
14559        | NodeRequest::TerminalBytes { session, .. }
14560        | NodeRequest::TerminalControl { session, .. }
14561        | NodeRequest::Resize { session, .. }
14562        | NodeRequest::Interrupt { session }
14563        | NodeRequest::Stop { session, .. }
14564        | NodeRequest::Remove { session }
14565        | NodeRequest::ResolveInteraction { session, .. }
14566        | NodeRequest::SetSessionMode { session, .. }
14567        | NodeRequest::SetSessionConfigOption { session, .. }
14568        | NodeRequest::SetSessionModel { session, .. }
14569        | NodeRequest::DiscoverHistory { session, .. }
14570        | NodeRequest::LoadHistory { session, .. }
14571        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14572        | NodeRequest::ExportContextPack { session } => provider_for_session(session)
14573            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14574        NodeRequest::RenameSessionRecord { record_id, .. }
14575        | NodeRequest::SetSessionTask { record_id, .. }
14576        | NodeRequest::ResumeSessionRecord { record_id, .. }
14577        | NodeRequest::ForgetSessionRecord { record_id } => provider_for_record(record_id)
14578            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14579        NodeRequest::PreviewSessionRecord { record_id, .. } => provider_for_record(record_id)
14580            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14581        NodeRequest::ArmHarnessMcpReservation { .. }
14582        | NodeRequest::ActivateHarnessMcpReservation { .. }
14583        | NodeRequest::AbortHarnessMcpReservation { .. }
14584        | NodeRequest::PutHarnessMcpReplyChunk { .. }
14585        | NodeRequest::RejectHarnessMcpCall { .. }
14586        | NodeRequest::Snapshot
14587        | NodeRequest::Resync { .. }
14588        | NodeRequest::BeginDeliveryStage { .. }
14589        | NodeRequest::PutDeliveryBlobChunk { .. }
14590        | NodeRequest::CommitDeliveryStage { .. }
14591        | NodeRequest::AbortDeliveryStage { .. }
14592        | NodeRequest::BrowseHostDirectories { .. }
14593        | NodeRequest::InspectWorkspace { .. }
14594        | NodeRequest::ReadWorkspaceFile { .. }
14595        | NodeRequest::WriteWorkspaceFile { .. }
14596        | NodeRequest::CreateWorkspaceFile { .. }
14597        | NodeRequest::CreateWorkspaceDirectory { .. }
14598        | NodeRequest::ReadGitHistory { .. }
14599        | NodeRequest::ReadGitDiff { .. }
14600        | NodeRequest::AcquireController { .. }
14601        | NodeRequest::ReleaseController
14602        | NodeRequest::RegisterWorkspace { .. }
14603        | NodeRequest::CreateStandaloneWorkspace { .. }
14604        | NodeRequest::UnregisterWorkspace { .. }
14605        | NodeRequest::CreateWorktree { .. }
14606        | NodeRequest::RemoveWorktree { .. }
14607        | NodeRequest::CleanupManagedWorktree { .. }
14608        | NodeRequest::Shutdown => false,
14609        NodeRequest::ForgetContextPack { .. } => true,
14610        // No session/record to cheaply resolve a provider from here either;
14611        // the resolved receipt can carry an open-id provider, so require the
14612        // same capability as ForgetContextPack unconditionally. `ReadContextPack`
14613        // is the same digest-keyed lookup one step further into the same
14614        // catalog, so it inherits the same conservative answer.
14615        NodeRequest::ResolveDurableContextPack { .. }
14616        | NodeRequest::ReadContextPack { .. } => true,
14617    }
14618}
14619
14620fn project_snapshot_legacy_provider_ids(snapshot: &mut NodeSnapshot) {
14621    snapshot.enabled_providers.retain(provider_id_is_legacy);
14622    snapshot.provider_runtime_statuses = ProviderRuntimeStatuses::new(
14623        snapshot
14624            .provider_runtime_statuses
14625            .as_slice()
14626            .iter()
14627            .filter(|status| provider_id_is_legacy(status.provider()))
14628            .cloned(),
14629    )
14630    .expect("a filtered provider runtime projection remains valid");
14631    snapshot
14632        .session_records
14633        .retain(|record| provider_id_is_legacy(&record.provider));
14634    for workspace in &mut snapshot.workspaces {
14635        workspace
14636            .sessions
14637            .retain(|session| provider_id_is_legacy(&session.agent_id));
14638    }
14639}
14640
14641fn clear_snapshot_child_environment_profiles(snapshot: &mut NodeSnapshot) {
14642    for record in &mut snapshot.session_records {
14643        record.environment_profile = None;
14644    }
14645    if let Some(profiles) = snapshot
14646        .launch_inventory
14647        .as_mut()
14648        .and_then(|inventory| inventory.spawn_profiles.as_mut())
14649    {
14650        for profile in profiles {
14651            profile.environment_profile = None;
14652        }
14653    }
14654}
14655
14656fn project_event_without_child_environment_profile(
14657    mut envelope: NodeEventEnvelope,
14658) -> NodeEventEnvelope {
14659    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14660        record.environment_profile = None;
14661    }
14662    envelope
14663}
14664
14665fn project_response_without_child_environment_profile(reply: &mut ResponseEnvelope) {
14666    let contains_environment_profile = match reply.result.as_ref() {
14667        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
14668            receipt.environment_profile.is_some()
14669        }
14670        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14671            receipt.spawn.environment_profile.is_some()
14672        }
14673        _ => false,
14674    };
14675    if contains_environment_profile {
14676        reply.result = Err(failure(
14677            NodeFailureCode::UnsupportedCapability,
14678            "child environment profiles were not negotiated",
14679        ));
14680        return;
14681    }
14682    let Ok(response) = reply.result.as_mut() else {
14683        return;
14684    };
14685    match response {
14686        NodeResponse::Snapshot { snapshot, .. } => {
14687            clear_snapshot_child_environment_profiles(snapshot);
14688        }
14689        NodeResponse::Resync { snapshot, events, .. } => {
14690            clear_snapshot_child_environment_profiles(snapshot);
14691            for event in events {
14692                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14693                    record.environment_profile = None;
14694                }
14695            }
14696        }
14697        NodeResponse::SessionRecordUpdated { record }
14698        | NodeResponse::ProviderSessionIndexed { record }
14699        | NodeResponse::NativeSessionIndexed { record, .. }
14700        | NodeResponse::SessionRecordResumed { record, .. } => {
14701            record.environment_profile = None;
14702        }
14703        NodeResponse::Armed { .. }
14704        | NodeResponse::Spawned { .. }
14705        | NodeResponse::Activated { .. }
14706        | NodeResponse::Aborted { .. }
14707        | NodeResponse::ReplyChunkAccepted { .. }
14708        | NodeResponse::CallRejected { .. }
14709        | NodeResponse::DeliveryStageBegun { .. }
14710        | NodeResponse::DeliveryBlobChunkAccepted { .. }
14711        | NodeResponse::DeliveryCommitted { .. }
14712        | NodeResponse::DeliveryStageAborted { .. }
14713        | NodeResponse::WorkspaceInspected { .. }
14714        | NodeResponse::HostDirectoriesBrowsed { .. }
14715        | NodeResponse::WorkspaceFileRead { .. }
14716        | NodeResponse::WorkspaceFileWritten { .. }
14717        | NodeResponse::WorkspaceFileCreated { .. }
14718        | NodeResponse::WorkspaceDirectoryCreated { .. }
14719        | NodeResponse::GitHistoryRead { .. }
14720        | NodeResponse::GitDiffRead { .. }
14721        | NodeResponse::Controller { .. }
14722        | NodeResponse::SpawnAccepted { .. }
14723        | NodeResponse::SpawnSpecAccepted { .. }
14724        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14725        | NodeResponse::ManagedWorktreeCleanup { .. }
14726        | NodeResponse::NativeSessionsCataloged { .. }
14727        | NodeResponse::NativeSessionsPaged { .. }
14728        | NodeResponse::NativeSessionPreviewed { .. }
14729        | NodeResponse::SessionRecordPreviewed { .. }
14730        | NodeResponse::HistoryDiscovered { .. }
14731        | NodeResponse::HistoryLoaded { .. }
14732        | NodeResponse::ContextPackForSessionRecordExported { .. }
14733        | NodeResponse::ContextPackExported { .. }
14734        | NodeResponse::ContextPackForgotten { .. }
14735        | NodeResponse::DurableContextPackResolved { .. }
14736        | NodeResponse::ContextPackBytesRead { .. }
14737        | NodeResponse::SessionRecordForgotten { .. }
14738        | NodeResponse::WorkspaceRegistered { .. }
14739        | NodeResponse::StandaloneWorkspaceCreated { .. }
14740        | NodeResponse::WorkspaceUnregistered { .. }
14741        | NodeResponse::WorktreeCreated { .. }
14742        | NodeResponse::WorktreeRemoved { .. }
14743        | NodeResponse::Accepted
14744        | NodeResponse::ShuttingDown => {}
14745    }
14746}
14747
14748fn clear_snapshot_session_bundles(snapshot: &mut NodeSnapshot) {
14749    for record in &mut snapshot.session_records {
14750        record.bundle = None;
14751    }
14752}
14753
14754fn project_event_without_session_bundle(
14755    mut envelope: NodeEventEnvelope,
14756) -> NodeEventEnvelope {
14757    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14758        record.bundle = None;
14759    }
14760    envelope
14761}
14762
14763fn project_response_without_session_bundle(reply: &mut ResponseEnvelope) {
14764    let contains_bundle = match reply.result.as_ref() {
14765        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.bundle.is_some(),
14766        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14767            receipt.spawn.bundle.is_some()
14768        }
14769        _ => false,
14770    };
14771    if contains_bundle {
14772        reply.result = Err(failure(
14773            NodeFailureCode::UnsupportedCapability,
14774            "session bundles were not negotiated",
14775        ));
14776        return;
14777    }
14778    let Ok(response) = reply.result.as_mut() else {
14779        return;
14780    };
14781    match response {
14782        NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_session_bundles(snapshot),
14783        NodeResponse::Resync { snapshot, events, .. } => {
14784            clear_snapshot_session_bundles(snapshot);
14785            for event in events {
14786                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14787                    record.bundle = None;
14788                }
14789            }
14790        }
14791        NodeResponse::SessionRecordUpdated { record }
14792        | NodeResponse::ProviderSessionIndexed { record }
14793        | NodeResponse::NativeSessionIndexed { record, .. }
14794        | NodeResponse::SessionRecordResumed { record, .. } => record.bundle = None,
14795        NodeResponse::Armed { .. }
14796        | NodeResponse::Spawned { .. }
14797        | NodeResponse::Activated { .. }
14798        | NodeResponse::Aborted { .. }
14799        | NodeResponse::ReplyChunkAccepted { .. }
14800        | NodeResponse::CallRejected { .. }
14801        | NodeResponse::DeliveryStageBegun { .. }
14802        | NodeResponse::DeliveryBlobChunkAccepted { .. }
14803        | NodeResponse::DeliveryCommitted { .. }
14804        | NodeResponse::DeliveryStageAborted { .. }
14805        | NodeResponse::WorkspaceInspected { .. }
14806        | NodeResponse::HostDirectoriesBrowsed { .. }
14807        | NodeResponse::WorkspaceFileRead { .. }
14808        | NodeResponse::WorkspaceFileWritten { .. }
14809        | NodeResponse::WorkspaceFileCreated { .. }
14810        | NodeResponse::WorkspaceDirectoryCreated { .. }
14811        | NodeResponse::GitHistoryRead { .. }
14812        | NodeResponse::GitDiffRead { .. }
14813        | NodeResponse::Controller { .. }
14814        | NodeResponse::SpawnAccepted { .. }
14815        | NodeResponse::SpawnSpecAccepted { .. }
14816        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14817        | NodeResponse::ManagedWorktreeCleanup { .. }
14818        | NodeResponse::NativeSessionsCataloged { .. }
14819        | NodeResponse::NativeSessionsPaged { .. }
14820        | NodeResponse::NativeSessionPreviewed { .. }
14821        | NodeResponse::SessionRecordPreviewed { .. }
14822        | NodeResponse::HistoryDiscovered { .. }
14823        | NodeResponse::HistoryLoaded { .. }
14824        | NodeResponse::ContextPackForSessionRecordExported { .. }
14825        | NodeResponse::ContextPackExported { .. }
14826        | NodeResponse::ContextPackForgotten { .. }
14827        | NodeResponse::DurableContextPackResolved { .. }
14828        | NodeResponse::ContextPackBytesRead { .. }
14829        | NodeResponse::SessionRecordForgotten { .. }
14830        | NodeResponse::WorkspaceRegistered { .. }
14831        | NodeResponse::StandaloneWorkspaceCreated { .. }
14832        | NodeResponse::WorkspaceUnregistered { .. }
14833        | NodeResponse::WorktreeCreated { .. }
14834        | NodeResponse::WorktreeRemoved { .. }
14835        | NodeResponse::Accepted
14836        | NodeResponse::ShuttingDown => {}
14837    }
14838}
14839
14840fn project_snapshot_history_for_wire(
14841    snapshot: &mut NodeSnapshot,
14842    include_history_metadata: bool,
14843) {
14844    for workspace in &mut snapshot.workspaces {
14845        for session in &mut workspace.sessions {
14846            if include_history_metadata {
14847                session.history.loaded = None;
14848            } else {
14849                session.history = Default::default();
14850            }
14851        }
14852    }
14853}
14854
14855fn project_event_history_for_wire(
14856    mut envelope: NodeEventEnvelope,
14857    include_history_metadata: bool,
14858) -> NodeEventEnvelope {
14859    if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
14860        for session in &mut workspace.sessions {
14861            if include_history_metadata {
14862                session.history.loaded = None;
14863            } else {
14864                session.history = Default::default();
14865            }
14866        }
14867    }
14868    envelope
14869}
14870
14871fn project_response_history_for_wire(
14872    reply: &mut ResponseEnvelope,
14873    include_history_metadata: bool,
14874) {
14875    let Ok(response) = reply.result.as_mut() else {
14876        return;
14877    };
14878    match response {
14879        NodeResponse::Snapshot { snapshot, .. } => {
14880            project_snapshot_history_for_wire(snapshot, include_history_metadata);
14881        }
14882        NodeResponse::Resync {
14883            snapshot, events, ..
14884        } => {
14885            project_snapshot_history_for_wire(snapshot, include_history_metadata);
14886            for event in events {
14887                *event = project_event_history_for_wire(
14888                    event.clone(),
14889                    include_history_metadata,
14890                );
14891            }
14892        }
14893        NodeResponse::WorkspaceRegistered { workspace }
14894        | NodeResponse::StandaloneWorkspaceCreated { workspace }
14895        | NodeResponse::WorktreeCreated { workspace, .. } => {
14896            for session in &mut workspace.sessions {
14897                if include_history_metadata {
14898                    session.history.loaded = None;
14899                } else {
14900                    session.history = Default::default();
14901                }
14902            }
14903        }
14904        _ => {}
14905    }
14906}
14907
14908fn clear_snapshot_context_packs(snapshot: &mut NodeSnapshot) {
14909    for record in &mut snapshot.session_records {
14910        record.context_id = None;
14911        record.context = None;
14912        record.exported_context = None;
14913    }
14914}
14915
14916fn project_event_without_context_pack(
14917    mut envelope: NodeEventEnvelope,
14918) -> NodeEventEnvelope {
14919    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14920        record.context_id = None;
14921        record.context = None;
14922        record.exported_context = None;
14923    }
14924    envelope
14925}
14926
14927fn project_response_without_context_pack(reply: &mut ResponseEnvelope) {
14928    let contains_context = match reply.result.as_ref() {
14929        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.context.is_some(),
14930        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14931            receipt.spawn.context.is_some()
14932        }
14933        Ok(NodeResponse::HistoryDiscovered { .. })
14934        | Ok(NodeResponse::HistoryLoaded { .. })
14935        | Ok(NodeResponse::ContextPackForSessionRecordExported { .. })
14936        | Ok(NodeResponse::ContextPackExported { .. })
14937        | Ok(NodeResponse::ContextPackForgotten { .. })
14938        | Ok(NodeResponse::DurableContextPackResolved { .. }) => true,
14939        _ => false,
14940    };
14941    if contains_context {
14942        reply.result = Err(failure(
14943            NodeFailureCode::UnsupportedCapability,
14944            "history context packs were not negotiated",
14945        ));
14946        return;
14947    }
14948    let Ok(response) = reply.result.as_mut() else {
14949        return;
14950    };
14951    match response {
14952        NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_context_packs(snapshot),
14953        NodeResponse::Resync {
14954            snapshot, events, ..
14955        } => {
14956            clear_snapshot_context_packs(snapshot);
14957            for event in events {
14958                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14959                    record.context_id = None;
14960                    record.context = None;
14961                    record.exported_context = None;
14962                }
14963            }
14964        }
14965        NodeResponse::SessionRecordUpdated { record }
14966        | NodeResponse::ProviderSessionIndexed { record }
14967        | NodeResponse::NativeSessionIndexed { record, .. }
14968        | NodeResponse::SessionRecordResumed { record, .. } => {
14969            record.context_id = None;
14970            record.context = None;
14971            record.exported_context = None;
14972        }
14973        _ => {}
14974    }
14975}
14976
14977fn project_response_legacy_provider_ids(shared: &NodeShared, reply: &mut ResponseEnvelope) {
14978    let contains_open_record = match reply.result.as_ref() {
14979        Ok(NodeResponse::SessionRecordUpdated { record })
14980        | Ok(NodeResponse::ProviderSessionIndexed { record })
14981        | Ok(NodeResponse::SessionRecordResumed { record, .. }) => {
14982            !provider_id_is_legacy(&record.provider)
14983        }
14984        Ok(NodeResponse::NativeSessionIndexed { selection, record }) => {
14985            !provider_id_is_legacy(&selection.route.provider)
14986                || !provider_id_is_legacy(&record.provider)
14987        }
14988        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
14989            !provider_id_is_legacy(&receipt.provider)
14990        }
14991        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14992            !provider_id_is_legacy(&receipt.spawn.provider)
14993        }
14994        Ok(NodeResponse::NativeSessionsCataloged { route, .. })
14995        | Ok(NodeResponse::NativeSessionsPaged { route, .. }) => {
14996            !provider_id_is_legacy(&route.provider)
14997        }
14998        Ok(NodeResponse::NativeSessionPreviewed { selection, .. }) => {
14999            !provider_id_is_legacy(&selection.route.provider)
15000        }
15001        _ => false,
15002    };
15003    if contains_open_record {
15004        reply.result = Err(failure(
15005            NodeFailureCode::UnsupportedCapability,
15006            "open provider IDs were not negotiated",
15007        ));
15008        return;
15009    }
15010    let Ok(response) = reply.result.as_mut() else {
15011        return;
15012    };
15013    match response {
15014        NodeResponse::Snapshot { snapshot, .. } => {
15015            project_snapshot_legacy_provider_ids(snapshot);
15016        }
15017        NodeResponse::Resync { snapshot, events, .. } => {
15018            project_snapshot_legacy_provider_ids(snapshot);
15019            *events = std::mem::take(events)
15020                .into_iter()
15021                .filter_map(|event| project_event_legacy_provider_ids(shared, event))
15022                .collect();
15023        }
15024        NodeResponse::WorkspaceRegistered { workspace }
15025        | NodeResponse::StandaloneWorkspaceCreated { workspace }
15026        | NodeResponse::WorktreeCreated { workspace, .. } => {
15027            workspace
15028                .sessions
15029                .retain(|session| provider_id_is_legacy(&session.agent_id));
15030        }
15031        NodeResponse::Armed { .. }
15032        | NodeResponse::Spawned { .. }
15033        | NodeResponse::Activated { .. }
15034        | NodeResponse::Aborted { .. }
15035        | NodeResponse::ReplyChunkAccepted { .. }
15036        | NodeResponse::CallRejected { .. }
15037        | NodeResponse::DeliveryStageBegun { .. }
15038        | NodeResponse::DeliveryBlobChunkAccepted { .. }
15039        | NodeResponse::DeliveryCommitted { .. }
15040        | NodeResponse::DeliveryStageAborted { .. }
15041        | NodeResponse::WorkspaceInspected { .. }
15042        | NodeResponse::HostDirectoriesBrowsed { .. }
15043        | NodeResponse::WorkspaceFileRead { .. }
15044        | NodeResponse::WorkspaceFileWritten { .. }
15045        | NodeResponse::WorkspaceFileCreated { .. }
15046        | NodeResponse::WorkspaceDirectoryCreated { .. }
15047        | NodeResponse::GitHistoryRead { .. }
15048        | NodeResponse::GitDiffRead { .. }
15049        | NodeResponse::Controller { .. }
15050        | NodeResponse::SpawnAccepted { .. }
15051        | NodeResponse::SpawnSpecAccepted { .. }
15052        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15053        | NodeResponse::ManagedWorktreeCleanup { .. }
15054        | NodeResponse::NativeSessionsCataloged { .. }
15055        | NodeResponse::NativeSessionsPaged { .. }
15056        | NodeResponse::NativeSessionPreviewed { .. }
15057        | NodeResponse::SessionRecordPreviewed { .. }
15058        | NodeResponse::HistoryDiscovered { .. }
15059        | NodeResponse::HistoryLoaded { .. }
15060        | NodeResponse::ContextPackForSessionRecordExported { .. }
15061        | NodeResponse::ContextPackExported { .. }
15062        | NodeResponse::ContextPackForgotten { .. }
15063        | NodeResponse::DurableContextPackResolved { .. }
15064        | NodeResponse::ContextPackBytesRead { .. }
15065        | NodeResponse::SessionRecordUpdated { .. }
15066        | NodeResponse::ProviderSessionIndexed { .. }
15067        | NodeResponse::NativeSessionIndexed { .. }
15068        | NodeResponse::SessionRecordResumed { .. }
15069        | NodeResponse::SessionRecordForgotten { .. }
15070        | NodeResponse::WorkspaceUnregistered { .. }
15071        | NodeResponse::WorktreeRemoved { .. }
15072        | NodeResponse::Accepted
15073        | NodeResponse::ShuttingDown => {}
15074    }
15075}
15076
15077fn project_event_legacy_provider_ids(
15078    shared: &NodeShared,
15079    mut envelope: NodeEventEnvelope,
15080) -> Option<NodeEventEnvelope> {
15081    let include = match &mut envelope.event {
15082        NodeEvent::HarnessMcpReadCall { .. } => true,
15083        NodeEvent::Control { address, .. }
15084        | NodeEvent::AgentStream { address, .. }
15085        | NodeEvent::TerminalFrame { address, .. } => shared
15086            .validate_address(address)
15087            .is_ok_and(|provider| provider_id_is_legacy(&provider)),
15088        NodeEvent::WorkspaceAdded { workspace } => {
15089            workspace
15090                .sessions
15091                .retain(|session| provider_id_is_legacy(&session.agent_id));
15092            true
15093        }
15094        NodeEvent::SessionRecordUpserted { record } => {
15095            provider_id_is_legacy(&record.provider)
15096        }
15097        NodeEvent::SessionRecordHistorySummarized { .. } => false,
15098        NodeEvent::SessionRecordRemoved { .. } => shared
15099            .history
15100            .lock()
15101            .unwrap_or_else(|poisoned| poisoned.into_inner())
15102            .removed_record_providers
15103            .get(&envelope.sequence)
15104            .is_some_and(provider_id_is_legacy),
15105        NodeEvent::ControllerChanged { .. }
15106        | NodeEvent::ManagedWorktreeUpserted { .. }
15107        | NodeEvent::ManagedWorktreeRemoved { .. }
15108        | NodeEvent::WorkspaceRemoved { .. }
15109        | NodeEvent::ResyncRequired { .. } => true,
15110    };
15111    include.then_some(envelope)
15112}
15113
15114fn clear_response_provider_runtime_status(reply: &mut ResponseEnvelope) {
15115    let Ok(response) = reply.result.as_mut() else {
15116        return;
15117    };
15118    match response {
15119        NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
15120            snapshot.provider_runtime_statuses.clear();
15121        }
15122        NodeResponse::Armed { .. }
15123        | NodeResponse::Spawned { .. }
15124        | NodeResponse::Activated { .. }
15125        | NodeResponse::Aborted { .. }
15126        | NodeResponse::ReplyChunkAccepted { .. }
15127        | NodeResponse::CallRejected { .. }
15128        | NodeResponse::DeliveryStageBegun { .. }
15129        | NodeResponse::DeliveryBlobChunkAccepted { .. }
15130        | NodeResponse::DeliveryCommitted { .. }
15131        | NodeResponse::DeliveryStageAborted { .. }
15132        | NodeResponse::WorkspaceInspected { .. }
15133        | NodeResponse::HostDirectoriesBrowsed { .. }
15134        | NodeResponse::WorkspaceFileRead { .. }
15135        | NodeResponse::WorkspaceFileWritten { .. }
15136        | NodeResponse::WorkspaceFileCreated { .. }
15137        | NodeResponse::WorkspaceDirectoryCreated { .. }
15138        | NodeResponse::GitHistoryRead { .. }
15139        | NodeResponse::GitDiffRead { .. }
15140        | NodeResponse::Controller { .. }
15141        | NodeResponse::SpawnAccepted { .. }
15142        | NodeResponse::SpawnSpecAccepted { .. }
15143        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15144        | NodeResponse::ManagedWorktreeCleanup { .. }
15145        | NodeResponse::NativeSessionsCataloged { .. }
15146        | NodeResponse::NativeSessionsPaged { .. }
15147        | NodeResponse::NativeSessionPreviewed { .. }
15148        | NodeResponse::SessionRecordPreviewed { .. }
15149        | NodeResponse::HistoryDiscovered { .. }
15150        | NodeResponse::HistoryLoaded { .. }
15151        | NodeResponse::ContextPackForSessionRecordExported { .. }
15152        | NodeResponse::ContextPackExported { .. }
15153        | NodeResponse::ContextPackForgotten { .. }
15154        | NodeResponse::DurableContextPackResolved { .. }
15155        | NodeResponse::ContextPackBytesRead { .. }
15156        | NodeResponse::SessionRecordUpdated { .. }
15157        | NodeResponse::ProviderSessionIndexed { .. }
15158        | NodeResponse::NativeSessionIndexed { .. }
15159        | NodeResponse::SessionRecordResumed { .. }
15160        | NodeResponse::SessionRecordForgotten { .. }
15161        | NodeResponse::WorkspaceRegistered { .. }
15162        | NodeResponse::StandaloneWorkspaceCreated { .. }
15163        | NodeResponse::WorkspaceUnregistered { .. }
15164        | NodeResponse::WorktreeCreated { .. }
15165        | NodeResponse::WorktreeRemoved { .. }
15166        | NodeResponse::Accepted
15167        | NodeResponse::ShuttingDown => {}
15168    }
15169}
15170
15171struct ControllerReleaseGuard {
15172    shared: Arc<NodeShared>,
15173    connection_id: u64,
15174}
15175
15176impl Drop for ControllerReleaseGuard {
15177    fn drop(&mut self) {
15178        self.shared.release_controller(self.connection_id);
15179    }
15180}
15181
15182struct AbortTaskOnDrop(AbortHandle);
15183
15184impl Drop for AbortTaskOnDrop {
15185    fn drop(&mut self) {
15186        self.0.abort();
15187    }
15188}
15189
15190/// Reads an unsigned env-config override, following the pattern of every
15191/// other bounded knob in this module: missing/unparsable/zero falls back to
15192/// `default`, and the resolved value is always clamped to `max` so a bad
15193/// override cannot reopen a bound this budget exists to enforce.
15194fn env_config_bounded(name: &str, default: u64, max: u64) -> u64 {
15195    std::env::var(name)
15196        .ok()
15197        .and_then(|value| value.parse::<u64>().ok())
15198        .filter(|value| *value > 0)
15199        .unwrap_or(default)
15200        .min(max)
15201}
15202
15203fn workspace_inspection_time_budget_ms() -> u64 {
15204    env_config_bounded(
15205        "GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS",
15206        WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT,
15207        WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX,
15208    )
15209}
15210
15211fn workspace_inspection_entry_cap() -> usize {
15212    env_config_bounded(
15213        "GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP",
15214        WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT as u64,
15215        WORKSPACE_INSPECTION_ENTRY_CAP_MAX as u64,
15216    ) as usize
15217}
15218
15219/// Tracks the shared walk-side budget across the whole recursive walk: a
15220/// wall-clock deadline (shared with the git phase that runs after the walk)
15221/// and a cap on directory entries visited, whether or not they were pushed
15222/// to the response. Cheap to check per entry — one `Instant::now()` and one
15223/// integer compare.
15224struct WorkspaceWalkBudget {
15225    deadline: Instant,
15226    entry_cap: usize,
15227    entries_visited: u64,
15228    time_budget_exceeded: bool,
15229    entry_cap_exceeded: bool,
15230}
15231
15232impl WorkspaceWalkBudget {
15233    fn new(deadline: Instant, entry_cap: usize) -> Self {
15234        Self {
15235            deadline,
15236            entry_cap,
15237            entries_visited: 0,
15238            time_budget_exceeded: false,
15239            entry_cap_exceeded: false,
15240        }
15241    }
15242
15243    fn is_exhausted(&self) -> bool {
15244        self.time_budget_exceeded || self.entry_cap_exceeded
15245    }
15246
15247    /// Records one visited directory entry (pushed to the response or not)
15248    /// and reports whether the walk must stop now.
15249    fn record_visit(&mut self) -> bool {
15250        self.entries_visited += 1;
15251        if self.entries_visited as usize >= self.entry_cap {
15252            self.entry_cap_exceeded = true;
15253        }
15254        if Instant::now() >= self.deadline {
15255            self.time_budget_exceeded = true;
15256        }
15257        self.is_exhausted()
15258    }
15259}
15260
15261fn collect_workspace_entries(
15262    root: &Path,
15263    deadline: Instant,
15264    entry_cap: usize,
15265) -> (Vec<WorkspaceEntry>, bool, WorkspaceWalkBudget) {
15266    let mut entries = Vec::new();
15267    let mut truncated = false;
15268    let mut budget = WorkspaceWalkBudget::new(deadline, entry_cap);
15269    walk_workspace_directory(root, None, 0, &mut entries, &mut truncated, &mut budget);
15270    // DFS pre-order emits `x/child` between prefix-named siblings `x` and
15271    // `x-y` ('-' sorts below '/'), while the bounded operator projections
15272    // require strict global byte order over relative paths.
15273    entries.sort_by(|left, right| left.relative_path.as_utf8().cmp(&right.relative_path.as_utf8()));
15274    (entries, truncated, budget)
15275}
15276
15277/// Builds the additive `WorkspaceInspectionTruncationV1` for an
15278/// `inspect_workspace` response — `None` once the walk and git phase both
15279/// completed inside their shared budget.
15280fn workspace_inspection_truncation(
15281    walk_budget: &WorkspaceWalkBudget,
15282    git_time_budget_exceeded: bool,
15283    elapsed: Duration,
15284) -> Option<WorkspaceInspectionTruncationV1> {
15285    if !walk_budget.time_budget_exceeded
15286        && !walk_budget.entry_cap_exceeded
15287        && !git_time_budget_exceeded
15288    {
15289        return None;
15290    }
15291    Some(WorkspaceInspectionTruncationV1 {
15292        walk_time_budget_exceeded: walk_budget.time_budget_exceeded,
15293        walk_entry_cap_exceeded: walk_budget.entry_cap_exceeded,
15294        git_time_budget_exceeded,
15295        entries_visited: walk_budget.entries_visited,
15296        elapsed_ms: u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX),
15297    })
15298}
15299
15300fn walk_workspace_directory(
15301    directory: &Path,
15302    relative_directory: Option<&RepositoryPath>,
15303    depth: usize,
15304    entries: &mut Vec<WorkspaceEntry>,
15305    truncated: &mut bool,
15306    budget: &mut WorkspaceWalkBudget,
15307) {
15308    if budget.is_exhausted() {
15309        *truncated = true;
15310        return;
15311    }
15312    let read_dir = match std::fs::read_dir(directory) {
15313        Ok(read_dir) => read_dir,
15314        Err(_) => {
15315            *truncated = true;
15316            return;
15317        }
15318    };
15319    let mut children = Vec::new();
15320    for child in read_dir {
15321        match child {
15322            Ok(child) => children.push(child),
15323            Err(_) => *truncated = true,
15324        }
15325    }
15326    children.sort_by_key(|child| child.file_name());
15327    for child in children {
15328        if budget.record_visit() {
15329            *truncated = true;
15330            return;
15331        }
15332        if entries.len() >= WORKSPACE_TREE_MAX_ENTRIES {
15333            *truncated = true;
15334            return;
15335        }
15336        let file_type = match child.file_type() {
15337            Ok(file_type) => file_type,
15338            Err(_) => {
15339                *truncated = true;
15340                continue;
15341            }
15342        };
15343        if file_type.is_symlink() {
15344            continue;
15345        }
15346        let name = match child.file_name().into_string() {
15347            Ok(name) => name,
15348            Err(_) => {
15349                *truncated = true;
15350                continue;
15351            }
15352        };
15353        if file_type.is_dir() && is_skipped_workspace_directory(&name, &child.path()) {
15354            continue;
15355        }
15356        let relative_path = match relative_directory {
15357            None => windows_repository_path(name),
15358            Some(parent) => parent
15359                .as_utf8()
15360                .and_then(|parent| windows_repository_path(format!("{parent}/{name}"))),
15361        };
15362        let Some(relative_path) = relative_path else {
15363            *truncated = true;
15364            continue;
15365        };
15366        let kind = if file_type.is_dir() {
15367            WorkspaceEntryKind::Directory
15368        } else if file_type.is_file() {
15369            WorkspaceEntryKind::File
15370        } else {
15371            continue;
15372        };
15373        entries.push(WorkspaceEntry {
15374            relative_path: relative_path.clone(),
15375            kind,
15376        });
15377        if file_type.is_dir() {
15378            if depth + 1 < WORKSPACE_TREE_MAX_DEPTH {
15379                walk_workspace_directory(
15380                    &child.path(),
15381                    Some(&relative_path),
15382                    depth + 1,
15383                    entries,
15384                    truncated,
15385                    budget,
15386                );
15387            } else {
15388                *truncated = true;
15389            }
15390        }
15391    }
15392}
15393
15394fn windows_repository_path(value: String) -> Option<RepositoryPath> {
15395    if value.contains('\\') {
15396        return None;
15397    }
15398    RepositoryPath::utf8(value).ok()
15399}
15400
15401fn windows_repository_path_from_bytes(value: &[u8]) -> Option<RepositoryPath> {
15402    windows_repository_path(std::str::from_utf8(value).ok()?.to_owned())
15403}
15404
15405/// Excludes a directory from the workspace tree walk. The three literal
15406/// names are the common convention-based cases; the `CACHEDIR.TAG` check
15407/// beneath them catches every build-output directory regardless of name
15408/// (cargo writes `CACHEDIR.TAG` into every `--target-dir` it creates, so
15409/// this covers `target-a2`, `target-a2-tui`, and any future renamed build
15410/// directory without maintaining a name heuristic list). The name check
15411/// runs first and returns early so a directory rejected by name never pays
15412/// for the extra `CACHEDIR.TAG` stat.
15413fn is_skipped_workspace_directory(name: &str, path: &Path) -> bool {
15414    if name.eq_ignore_ascii_case(".git")
15415        || name.eq_ignore_ascii_case("target")
15416        || name.eq_ignore_ascii_case("node_modules")
15417    {
15418        return true;
15419    }
15420    path.join("CACHEDIR.TAG").is_file()
15421}
15422
15423fn context_pack_source_status_is_usable(status: &SessionStatus) -> bool {
15424    matches!(
15425        status,
15426        SessionStatus::Running | SessionStatus::Exited { exit_code: Some(0) }
15427    )
15428}
15429
15430#[derive(Clone, Debug, Eq, PartialEq)]
15431enum ContextPackRepositoryHead {
15432    NotRepository,
15433    Unborn,
15434    Commit(String),
15435}
15436
15437#[derive(Clone, Debug, Eq, PartialEq)]
15438struct ContextPackRepositoryObservation {
15439    head: ContextPackRepositoryHead,
15440    git: GitSnapshot,
15441    files: Vec<ContextPackRepositoryFileSource>,
15442}
15443
15444fn stable_context_pack_repository(
15445    first: ContextPackRepositoryObservation,
15446    second: ContextPackRepositoryObservation,
15447) -> Result<ContextPackRepository, NodeFailure> {
15448    if first != second {
15449        return Err(failure(
15450            NodeFailureCode::ContextPackMaterializationFailed,
15451            "context repository changed during bounded capture",
15452        ));
15453    }
15454    Ok(ContextPackRepository::from_sources(
15455        &second.git,
15456        second.files,
15457    ))
15458}
15459
15460/// Appends a "skipped: budget exhausted" diagnostic and returns `true` once
15461/// (and only once) the shared walk+git `deadline` has already passed. Every
15462/// remaining phase in `inspect_git_workspace` checks this immediately
15463/// before issuing its own git call, so a budget spent by the walk (or by an
15464/// earlier git call) skips the rest of the chain instead of paying for it
15465/// anyway.
15466fn git_budget_exhausted(snapshot: &mut GitSnapshot, deadline: Instant, stage: &str) -> bool {
15467    if Instant::now() < deadline {
15468        return false;
15469    }
15470    append_git_diagnostic(
15471        snapshot,
15472        &format!("{stage} skipped: workspace inspection time budget exhausted"),
15473    );
15474    true
15475}
15476
15477/// Deadline-aware sibling of `run_git_bounded`, used only by
15478/// `inspect_git_workspace`'s six-call chain: caps the per-call timeout to
15479/// whatever remains of the shared `deadline` (never more than
15480/// `GIT_COMMAND_TIMEOUT_MS`), so a slow walk phase that already spent most
15481/// of the budget cannot still hand a full per-call timeout to every git
15482/// probe behind it. `inspect_git_workspace` already guards each call site
15483/// with `git_budget_exhausted`, so `deadline` is guaranteed not to have
15484/// passed yet here.
15485async fn run_git_bounded_with_deadline(
15486    root: &str,
15487    arguments: &[&str],
15488    output_limit: usize,
15489    deadline: Instant,
15490) -> io::Result<GitCommandOutput> {
15491    let remaining = deadline.checked_duration_since(Instant::now());
15492    let timeout_ms = remaining
15493        .and_then(|remaining| u64::try_from(remaining.as_millis()).ok())
15494        .unwrap_or(0)
15495        .clamp(1, GIT_COMMAND_TIMEOUT_MS);
15496    run_git_read_bounded(root, arguments, output_limit, timeout_ms).await
15497}
15498
15499/// Runs the bounded six-call git probe chain used by `inspect_workspace`.
15500/// `deadline` is the same absolute instant the preceding directory walk was
15501/// bounded by, so time already spent walking counts against what remains
15502/// for git enrichment. Returns the resulting `GitSnapshot` plus whether the
15503/// shared budget (not an individual per-call timeout) is what cut the git
15504/// phase short.
15505async fn inspect_git_workspace(root: &str, deadline: Instant) -> (GitSnapshot, bool) {
15506    let mut snapshot = GitSnapshot {
15507        is_repository: false,
15508        branch: None,
15509        status: Vec::new(),
15510        recent_commits: Vec::new(),
15511        worktrees: Vec::new(),
15512        managed_worktree: None,
15513        truncated: false,
15514        diagnostic: None,
15515    };
15516    if git_budget_exhausted(&mut snapshot, deadline, "git repository probe") {
15517        return (snapshot, true);
15518    }
15519    let repository = match run_git_bounded_with_deadline(
15520        root,
15521        &["rev-parse", "--is-inside-work-tree"],
15522        4 * 1_024,
15523        deadline,
15524    )
15525    .await
15526    {
15527        Ok(output) => output,
15528        Err(error) => {
15529            append_git_diagnostic(
15530                &mut snapshot,
15531                &format!("git inspection unavailable: {error}"),
15532            );
15533            return (snapshot, false);
15534        }
15535    };
15536    snapshot.truncated |= repository.truncated;
15537    if repository.timed_out {
15538        append_git_diagnostic(&mut snapshot, "git repository probe timed out");
15539        return (snapshot, false);
15540    }
15541    if !repository.success
15542        || String::from_utf8_lossy(&repository.stdout).trim() != "true"
15543    {
15544        let stderr = String::from_utf8_lossy(&repository.stderr);
15545        if !stderr.to_ascii_lowercase().contains("not a git repository") {
15546            append_git_diagnostic(&mut snapshot, stderr.trim());
15547        }
15548        return (snapshot, false);
15549    }
15550    snapshot.is_repository = true;
15551
15552    if git_budget_exhausted(&mut snapshot, deadline, "git branch query") {
15553        return (snapshot, true);
15554    }
15555    match run_git_bounded_with_deadline(root, &["branch", "--show-current"], 4 * 1_024, deadline).await {
15556        Ok(output) => {
15557            snapshot.truncated |= output.truncated;
15558            if output.timed_out {
15559                append_git_diagnostic(&mut snapshot, "git branch query timed out");
15560            } else if output.success {
15561                let branch = String::from_utf8_lossy(&output.stdout).trim().to_owned();
15562                if !branch.is_empty() {
15563                    snapshot.branch = Some(truncate_git_field(
15564                        &branch,
15565                        MAX_REPOSITORY_PATH_BYTES,
15566                        &mut snapshot.truncated,
15567                    ));
15568                }
15569            }
15570        }
15571        Err(error) => append_git_diagnostic(
15572            &mut snapshot,
15573            &format!("git branch query failed: {error}"),
15574        ),
15575    }
15576    if snapshot.branch.is_none() && !git_budget_exhausted(&mut snapshot, deadline, "git detached-HEAD probe") {
15577        if let Ok(output) = run_git_bounded_with_deadline(root, &["rev-parse", "--short", "HEAD"], 4 * 1_024, deadline).await {
15578            snapshot.truncated |= output.truncated;
15579            if output.success && !output.timed_out {
15580                let head = String::from_utf8_lossy(&output.stdout).trim().to_owned();
15581                if !head.is_empty() {
15582                    snapshot.branch = Some(format!("detached:{head}"));
15583                }
15584            }
15585        }
15586    }
15587
15588    if git_budget_exhausted(&mut snapshot, deadline, "git status") {
15589        return (snapshot, true);
15590    }
15591    match run_git_bounded_with_deadline(
15592        root,
15593        &[
15594            "status",
15595            "--porcelain=v1",
15596            "-z",
15597            "--renames",
15598            "--untracked-files=normal",
15599            "--",
15600            ".",
15601        ],
15602        GIT_OUTPUT_MAX_BYTES,
15603        deadline,
15604    )
15605    .await
15606    {
15607        Ok(output) => {
15608            snapshot.truncated |= output.truncated;
15609            if output.timed_out {
15610                append_git_diagnostic(&mut snapshot, "git status timed out");
15611            } else if output.success {
15612                parse_git_status(&output.stdout, &mut snapshot);
15613            } else {
15614                append_git_diagnostic(
15615                    &mut snapshot,
15616                    String::from_utf8_lossy(&output.stderr).trim(),
15617                );
15618            }
15619        }
15620        Err(error) => append_git_diagnostic(
15621            &mut snapshot,
15622            &format!("git status failed: {error}"),
15623        ),
15624    }
15625
15626    if git_budget_exhausted(&mut snapshot, deadline, "git log") {
15627        return (snapshot, true);
15628    }
15629    let log_limit = (GIT_COMMIT_MAX_ENTRIES + 1).to_string();
15630    match run_git_bounded_with_deadline(
15631        root,
15632        &["log", "-n", &log_limit, "--pretty=format:%H%x1f%s", "--", "."],
15633        16 * 1_024,
15634        deadline,
15635    )
15636    .await
15637    {
15638        Ok(output) => {
15639            snapshot.truncated |= output.truncated;
15640            if output.timed_out {
15641                append_git_diagnostic(&mut snapshot, "git log timed out");
15642            } else if output.success {
15643                parse_git_commits(&output.stdout, &mut snapshot);
15644            } else {
15645                let stderr = String::from_utf8_lossy(&output.stderr);
15646                if !stderr.to_ascii_lowercase().contains("does not have any commits") {
15647                    append_git_diagnostic(&mut snapshot, stderr.trim());
15648                }
15649            }
15650        }
15651        Err(error) => append_git_diagnostic(
15652            &mut snapshot,
15653            &format!("git log failed: {error}"),
15654        ),
15655    }
15656
15657    if git_budget_exhausted(&mut snapshot, deadline, "git worktree list") {
15658        return (snapshot, true);
15659    }
15660    match list_git_worktrees_with_deadline(root, deadline).await {
15661        Ok(worktrees) => {
15662            snapshot.worktrees = worktrees.into_iter().map(protocol_worktree).collect()
15663        }
15664        Err(error) => append_git_diagnostic(
15665            &mut snapshot,
15666            &format!("git worktree list failed: {}", error.message),
15667        ),
15668    }
15669    (snapshot, false)
15670}
15671
15672fn parse_git_status(output: &[u8], snapshot: &mut GitSnapshot) {
15673    let mut remaining = output;
15674    while !remaining.is_empty() {
15675        if snapshot.status.len() >= GIT_STATUS_MAX_ENTRIES {
15676            snapshot.truncated = true;
15677            break;
15678        }
15679        let Some(record_end) = remaining.iter().position(|byte| *byte == 0) else {
15680            snapshot.truncated = true;
15681            break;
15682        };
15683        let record = &remaining[..record_end];
15684        remaining = &remaining[record_end + 1..];
15685        let has_previous_path = record
15686            .get(..2)
15687            .is_some_and(|status| status.iter().any(|byte| matches!(*byte, b'R' | b'C')));
15688        let previous_path_bytes = if has_previous_path {
15689            let Some(previous_end) = remaining.iter().position(|byte| *byte == 0) else {
15690                snapshot.truncated = true;
15691                break;
15692            };
15693            let previous = &remaining[..previous_end];
15694            remaining = &remaining[previous_end + 1..];
15695            Some(previous)
15696        } else {
15697            None
15698        };
15699        if record.len() < 4
15700            || record[2] != b' '
15701            || !is_git_status_code(record[0])
15702            || !is_git_status_code(record[1])
15703        {
15704            snapshot.truncated = true;
15705            continue;
15706        }
15707        let Some(path) = windows_repository_path_from_bytes(&record[3..]) else {
15708            snapshot.truncated = true;
15709            continue;
15710        };
15711        let previous_path = match previous_path_bytes {
15712            Some(previous) => {
15713                let Some(previous) = windows_repository_path_from_bytes(previous) else {
15714                    snapshot.truncated = true;
15715                    continue;
15716                };
15717                Some(previous)
15718            }
15719            None => None,
15720        };
15721        snapshot.status.push(GitStatusEntry {
15722            index_status: (record[0] as char).to_string(),
15723            worktree_status: (record[1] as char).to_string(),
15724            path,
15725            previous_path,
15726        });
15727    }
15728}
15729
15730fn is_git_status_code(byte: u8) -> bool {
15731    matches!(byte, b' ' | b'M' | b'T' | b'A' | b'D' | b'R' | b'C' | b'U' | b'?' | b'!')
15732}
15733
15734fn parse_git_commits(output: &[u8], snapshot: &mut GitSnapshot) {
15735    for line in String::from_utf8_lossy(output).lines() {
15736        let Some((id, summary)) = line.split_once('\u{1f}') else {
15737            continue;
15738        };
15739        if snapshot.recent_commits.len() >= GIT_COMMIT_MAX_ENTRIES {
15740            snapshot.truncated = true;
15741            break;
15742        }
15743        let id = truncate_git_field(id, 64, &mut snapshot.truncated);
15744        let summary = truncate_git_field(summary, 512, &mut snapshot.truncated);
15745        snapshot.recent_commits.push(GitCommitSummary { id, summary });
15746    }
15747}
15748
15749fn truncate_git_field(value: &str, max_bytes: usize, truncated: &mut bool) -> String {
15750    if value.len() <= max_bytes {
15751        return value.to_owned();
15752    }
15753    *truncated = true;
15754    let mut boundary = max_bytes;
15755    while boundary > 0 && !value.is_char_boundary(boundary) {
15756        boundary -= 1;
15757    }
15758    value[..boundary].to_owned()
15759}
15760
15761fn append_git_diagnostic(snapshot: &mut GitSnapshot, message: &str) {
15762    if message.is_empty() {
15763        return;
15764    }
15765    let normalized = message
15766        .chars()
15767        .map(|character| if character.is_control() { ' ' } else { character })
15768        .collect::<String>();
15769    let mut diagnostic = snapshot.diagnostic.take().unwrap_or_default();
15770    if !diagnostic.is_empty() {
15771        diagnostic.push_str("; ");
15772    }
15773    diagnostic.push_str(normalized.trim());
15774    snapshot.diagnostic = Some(truncate_git_field(
15775        &diagnostic,
15776        GIT_DIAGNOSTIC_MAX_BYTES,
15777        &mut snapshot.truncated,
15778    ));
15779}
15780
15781async fn run_git_bounded(
15782    root: &str,
15783    arguments: &[&str],
15784    output_limit: usize,
15785) -> io::Result<GitCommandOutput> {
15786    run_git_read_bounded(root, arguments, output_limit, GIT_COMMAND_TIMEOUT_MS).await
15787}
15788
15789async fn read_git_history_bounded(
15790    root: &str,
15791    path: Option<&RepositoryPath>,
15792    before: Option<&GitObjectId>,
15793    limit: u16,
15794) -> Result<GitHistoryPage, NodeFailure> {
15795    let path = path
15796        .map(|path| {
15797            path.as_utf8().ok_or_else(|| {
15798                failure(
15799                    NodeFailureCode::InvalidRepositoryPath,
15800                    "git history path must use UTF-8",
15801                )
15802            })
15803        })
15804        .transpose()?
15805        .unwrap_or(".");
15806    let count = (usize::from(limit) + 1).to_string();
15807    let start = before
15808        .map(|revision| format!("{}^", revision.as_str()))
15809        .unwrap_or_else(|| "HEAD".to_owned());
15810    let arguments = [
15811        "--no-pager",
15812        "log",
15813        "--no-decorate",
15814        "--date=iso-strict",
15815        "--pretty=format:%H%x1f%P%x1f%s%x1f%an%x1f%ae%x1f%aI%x1f%cn%x1f%ce%x1f%cI%x1e",
15816        "-n",
15817        count.as_str(),
15818        start.as_str(),
15819        "--",
15820        path,
15821    ];
15822    let output = run_git_bounded(root, &arguments, 256 * 1_024)
15823        .await
15824        .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git history command failed"))?;
15825    if output.timed_out {
15826        return Err(failure(NodeFailureCode::GitReadTimedOut, "git history exceeded its bounded deadline"));
15827    }
15828    if !output.success {
15829        return Err(failure(NodeFailureCode::GitReadFailed, "git history could not be read"));
15830    }
15831    let mut commits = parse_git_history(&output.stdout);
15832    let has_more = commits.len() > usize::from(limit) || output.truncated;
15833    commits.truncate(usize::from(limit));
15834    let next_before = has_more.then(|| commits.last().map(|commit| commit.id.clone())).flatten();
15835    Ok(GitHistoryPage { commits, next_before, truncated: output.truncated })
15836}
15837
15838fn parse_git_history(output: &[u8]) -> Vec<GitCommitDetails> {
15839    String::from_utf8_lossy(output)
15840        .split('\u{1e}')
15841        .filter_map(|record| {
15842            let fields = record.trim_matches(['\r', '\n']).split('\u{1f}').collect::<Vec<_>>();
15843            if fields.len() != 9 {
15844                return None;
15845            }
15846            let id = GitObjectId::new(fields[0].to_owned()).ok()?;
15847            let parents = if fields[1].is_empty() {
15848                Vec::new()
15849            } else {
15850                fields[1]
15851                    .split(' ')
15852                    .map(|value| GitObjectId::new(value.to_owned()))
15853                    .collect::<Result<Vec<_>, _>>()
15854                    .ok()?
15855            };
15856            Some(GitCommitDetails {
15857                id,
15858                parents,
15859                subject: bounded_git_text(fields[2], 512),
15860                author_name: bounded_git_text(fields[3], 256),
15861                author_email: bounded_git_text(fields[4], 320),
15862                authored_at: bounded_git_text(fields[5], 64),
15863                committer_name: bounded_git_text(fields[6], 256),
15864                committer_email: bounded_git_text(fields[7], 320),
15865                committed_at: bounded_git_text(fields[8], 64),
15866                signature_status: GitSignatureStatus::NoSignature,
15867                signer: None,
15868            })
15869        })
15870        .take(usize::from(MAX_GIT_HISTORY_COMMITS) + 1)
15871        .collect()
15872}
15873
15874fn bounded_git_text(value: &str, max_bytes: usize) -> String {
15875    let normalized = value
15876        .chars()
15877        .map(|character| if character.is_control() { ' ' } else { character })
15878        .collect::<String>();
15879    if normalized.len() <= max_bytes {
15880        return normalized;
15881    }
15882    let mut boundary = max_bytes;
15883    while boundary > 0 && !normalized.is_char_boundary(boundary) {
15884        boundary -= 1;
15885    }
15886    normalized[..boundary].to_owned()
15887}
15888
15889async fn read_git_diff_bounded(root: &str, request: GitDiffRequest) -> Result<GitDiff, NodeFailure> {
15890    let path = request.path.as_ref().map(|path| {
15891        path.as_utf8()
15892            .ok_or_else(|| failure(NodeFailureCode::InvalidRepositoryPath, "git diff path must use UTF-8"))
15893    }).transpose()?;
15894    let mut arguments = match &request.mode {
15895        GitDiffMode::Working => vec!["--no-pager", "-c", "diff.external=", "diff", "--no-ext-diff", "--no-textconv"],
15896        GitDiffMode::Staged => vec!["--no-pager", "-c", "diff.external=", "diff", "--cached", "--no-ext-diff", "--no-textconv"],
15897        GitDiffMode::Commit { .. } => vec!["--no-pager", "-c", "diff.external=", "show", "--format=fuller", "--no-ext-diff", "--no-textconv"],
15898    };
15899    if let GitDiffMode::Commit { revision } = &request.mode {
15900        arguments.push(revision.as_str());
15901    }
15902    arguments.push("--");
15903    if let Some(path) = path {
15904        arguments.push(path);
15905    }
15906    let output = run_git_bounded(root, &arguments, MAX_GIT_DIFF_BYTES)
15907        .await
15908        .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git diff command failed"))?;
15909    if output.timed_out {
15910        return Err(failure(NodeFailureCode::GitReadTimedOut, "git diff exceeded its bounded deadline"));
15911    }
15912    if !output.success {
15913        return Err(failure(NodeFailureCode::GitReadFailed, "git diff could not be read"));
15914    }
15915    Ok(GitDiff {
15916        mode: request.mode,
15917        path: request.path,
15918        text: String::from_utf8_lossy(&output.stdout).into_owned(),
15919        truncated: output.truncated,
15920    })
15921}
15922
15923async fn process_request(shared: &NodeShared, connection_id: u64, role: ClientRole, envelope: RequestEnvelope) -> ResponseEnvelope {
15924    let result = process_request_inner(shared, connection_id, role, envelope.request).await;
15925    ResponseEnvelope { request_id: envelope.request_id, result }
15926}
15927
15928async fn process_request_inner(shared: &NodeShared, connection_id: u64, role: ClientRole, request: NodeRequest) -> Result<NodeResponse, NodeFailure> {
15929    if !request.harness_mcp_contract_is_valid_at(unix_time_ms()) {
15930        return Err(failure(NodeFailureCode::InvalidRequest, "harness MCP request is invalid or stale"));
15931    }
15932    let read_only = matches!(
15933        &request,
15934        NodeRequest::Snapshot
15935            | NodeRequest::Resync { .. }
15936            | NodeRequest::BrowseHostDirectories { .. }
15937            | NodeRequest::InspectWorkspace { .. }
15938            | NodeRequest::ReadWorkspaceFile { .. }
15939            | NodeRequest::ReadGitHistory { .. }
15940            | NodeRequest::ReadGitDiff { .. }
15941            | NodeRequest::CatalogNativeSessions { .. }
15942            | NodeRequest::PageNativeSessions { .. }
15943            | NodeRequest::PreviewNativeSession { .. }
15944            | NodeRequest::PreviewSessionRecord { .. }
15945    );
15946    let _mutation_guard = if read_only {
15947        None
15948    } else {
15949        let guard = shared.mutation_gate.lock().await;
15950        if shared.shutdown.load(Ordering::Acquire) {
15951            return Err(failure(NodeFailureCode::ShuttingDown, "node shutdown has begun"));
15952        }
15953        Some(guard)
15954    };
15955    match request {
15956        NodeRequest::Snapshot => Ok(NodeResponse::Snapshot {
15957            event_sequence: shared.current_sequence(),
15958            controller: shared.controller_state(),
15959            snapshot: shared.snapshot(),
15960        }),
15961        NodeRequest::Resync { after_sequence } => Ok(shared.resync(after_sequence)),
15962        NodeRequest::ArmHarnessMcpReservation {
15963            reservation_id,
15964            activation_digest,
15965            spawn_spec,
15966            launch,
15967            expires_at_unix_ms,
15968        } => {
15969            shared.require_controller(connection_id, role)?;
15970            let registry = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15971                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15972            })?;
15973            let expires_at_unix_ms = registry
15974                .arm(reservation_id.clone(), activation_digest.clone(), spawn_spec, launch, expires_at_unix_ms)
15975                .await
15976                .map_err(harness_mcp_failure)?;
15977            Ok(NodeResponse::Armed { reservation_id, activation_digest, expires_at_unix_ms })
15978        }
15979        NodeRequest::SpawnSpecWithHarnessMcp {
15980            reservation_id,
15981            activation_digest,
15982            spec,
15983            deadline_unix_ms,
15984        } => {
15985            shared.require_controller(connection_id, role)?;
15986            let receipt = shared
15987                .spawn_from_spec_with_harness_mcp(
15988                    reservation_id.clone(), activation_digest.clone(), spec, deadline_unix_ms,
15989                )
15990                .await?;
15991            Ok(NodeResponse::Spawned { reservation_id, activation_digest, receipt })
15992        }
15993        NodeRequest::ActivateHarnessMcpReservation {
15994            reservation_id,
15995            activation_digest,
15996            record_id,
15997            session,
15998        } => {
15999            shared.require_controller(connection_id, role)?;
16000            let provider_root_pid = shared.handle.snapshot().sessions.iter().find(|current| {
16001                current.instance_id == session.session.instance_id
16002                    && current.generation == session.session.generation
16003            }).and_then(|current| current.process_id).ok_or_else(|| {
16004                failure(NodeFailureCode::BindingMismatch, "provider root process is unavailable")
16005            })?;
16006            shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16007                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16008            })?.activate(
16009                &reservation_id, &activation_digest, &record_id, &session, provider_root_pid,
16010            ).map_err(harness_mcp_failure)?;
16011            Ok(NodeResponse::Activated { reservation_id, activation_digest, record_id, session })
16012        }
16013        NodeRequest::AbortHarnessMcpReservation { reservation_id, activation_digest } => {
16014            shared.require_controller(connection_id, role)?;
16015            let instance_id = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16016                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16017            })?.abort(&reservation_id, &activation_digest).map_err(harness_mcp_failure)?;
16018            if let Some(instance_id) = instance_id {
16019                SessionLifecycleHook::on_harness_reservation_aborted(&*shared, instance_id);
16020            }
16021            Ok(NodeResponse::Aborted { reservation_id, activation_digest })
16022        }
16023        NodeRequest::PutHarnessMcpReplyChunk {
16024            reservation_id, activation_digest, record_id, session, call_id,
16025            offset, final_chunk, chunk_hex,
16026        } => {
16027            shared.require_controller(connection_id, role)?;
16028            let (next_offset, completed) = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16029                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16030            })?.put_reply_chunk(
16031                &reservation_id, &activation_digest, &record_id, &session, &call_id,
16032                offset, final_chunk, &chunk_hex,
16033            ).map_err(harness_mcp_failure)?;
16034            Ok(NodeResponse::ReplyChunkAccepted {
16035                reservation_id, activation_digest, record_id, session, call_id,
16036                next_offset, completed,
16037            })
16038        }
16039        NodeRequest::RejectHarnessMcpCall {
16040            reservation_id, activation_digest, record_id, session, call_id, reason,
16041        } => {
16042            shared.require_controller(connection_id, role)?;
16043            shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16044                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16045            })?.reject_call(
16046                &reservation_id, &activation_digest, &record_id, &session, &call_id, reason,
16047            ).map_err(harness_mcp_failure)?;
16048            Ok(NodeResponse::CallRejected {
16049                reservation_id, activation_digest, record_id, session, call_id,
16050            })
16051        }
16052        NodeRequest::BeginDeliveryStage { manifest } => {
16053            shared.require_controller(connection_id, role)?;
16054            shared.begin_delivery_stage(manifest)
16055        }
16056        NodeRequest::PutDeliveryBlobChunk {
16057            stage_id,
16058            blob_digest,
16059            offset,
16060            chunk_hex,
16061        } => {
16062            shared.require_controller(connection_id, role)?;
16063            shared.put_delivery_blob_chunk(stage_id, blob_digest, offset, chunk_hex)
16064        }
16065        NodeRequest::CommitDeliveryStage { stage_id } => {
16066            shared.require_controller(connection_id, role)?;
16067            let receipt = shared.commit_delivery_stage(stage_id)?;
16068            Ok(NodeResponse::DeliveryCommitted { receipt })
16069        }
16070        NodeRequest::AbortDeliveryStage { stage_id } => {
16071            shared.require_controller(connection_id, role)?;
16072            shared.abort_delivery_stage(&stage_id)?;
16073            Ok(NodeResponse::DeliveryStageAborted { stage_id })
16074        }
16075        NodeRequest::BrowseHostDirectories { directory, after } => {
16076            let listing = shared.browse_host_directories(directory, after).await?;
16077            Ok(NodeResponse::HostDirectoriesBrowsed { listing })
16078        }
16079        NodeRequest::InspectWorkspace { workspace_id } => {
16080            let inspection = shared.inspect_workspace(workspace_id).await?;
16081            Ok(NodeResponse::WorkspaceInspected { inspection })
16082        }
16083        NodeRequest::ReadWorkspaceFile { workspace_id, path } => {
16084            let file = shared.read_workspace_file(workspace_id, path).await?;
16085            Ok(NodeResponse::WorkspaceFileRead { file })
16086        }
16087        NodeRequest::WriteWorkspaceFile { workspace_id, path, expected_revision, text } => {
16088            shared.require_controller(connection_id, role)?;
16089            let file = shared
16090                .write_workspace_file(workspace_id, path, expected_revision, text)
16091                .await?;
16092            Ok(NodeResponse::WorkspaceFileWritten { file })
16093        }
16094        NodeRequest::CreateWorkspaceFile { workspace_id, path } => {
16095            shared.require_controller(connection_id, role)?;
16096            let file = shared.create_workspace_file(workspace_id, path).await?;
16097            Ok(NodeResponse::WorkspaceFileCreated { file })
16098        }
16099        NodeRequest::CreateWorkspaceDirectory { workspace_id, path } => {
16100            shared.require_controller(connection_id, role)?;
16101            let entry = shared
16102                .create_workspace_directory(workspace_id.clone(), path)
16103                .await?;
16104            Ok(NodeResponse::WorkspaceDirectoryCreated { workspace_id, entry })
16105        }
16106        NodeRequest::ReadGitHistory { workspace_id, path, before, limit } => {
16107            if role != ClientRole::Operator {
16108                return Err(failure(NodeFailureCode::ObserverReadOnly, "git history requires an operator connection"));
16109            }
16110            if !(1..=MAX_GIT_HISTORY_COMMITS).contains(&limit) {
16111                return Err(failure(NodeFailureCode::InvalidRequest, "git history limit is invalid"));
16112            }
16113            let page = shared
16114                .read_git_history(workspace_id.clone(), path, before, limit)
16115                .await?;
16116            Ok(NodeResponse::GitHistoryRead { workspace_id, page })
16117        }
16118        NodeRequest::ReadGitDiff { workspace_id, request } => {
16119            if role != ClientRole::Operator {
16120                return Err(failure(NodeFailureCode::ObserverReadOnly, "git diff requires an operator connection"));
16121            }
16122            let diff = shared.read_git_diff(workspace_id.clone(), request).await?;
16123            Ok(NodeResponse::GitDiffRead { workspace_id, diff })
16124        }
16125        NodeRequest::CatalogNativeSessions { route, limit } => {
16126            if role != ClientRole::Operator {
16127                return Err(failure(
16128                    NodeFailureCode::ObserverReadOnly,
16129                    "native session catalog requires an operator connection",
16130                ));
16131            }
16132            if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit) {
16133                return Err(failure(
16134                    NodeFailureCode::InvalidRequest,
16135                    "native session catalog limit is invalid",
16136                ));
16137            }
16138            let (entries, summary) = shared
16139                .catalog_native_sessions(route.clone(), limit)
16140                .await?;
16141            Ok(NodeResponse::NativeSessionsCataloged {
16142                route,
16143                entries,
16144                summary: Some(summary),
16145            })
16146        }
16147        NodeRequest::PageNativeSessions {
16148            route,
16149            window,
16150            catalog_revision,
16151            recent_cutoff_unix_ms,
16152            after_selection_id,
16153            limit,
16154        } => {
16155            if role != ClientRole::Operator {
16156                return Err(failure(
16157                    NodeFailureCode::ObserverReadOnly,
16158                    "native session catalog paging requires an operator connection",
16159                ));
16160            }
16161            if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit)
16162                || after_selection_id
16163                    .as_deref()
16164                    .is_some_and(|cursor| validate_candidate_id(cursor).is_err())
16165            {
16166                return Err(failure(
16167                    NodeFailureCode::InvalidRequest,
16168                    "native session catalog page request is invalid",
16169                ));
16170            }
16171            let page = shared
16172                .page_native_sessions(
16173                    route.clone(),
16174                    window,
16175                    catalog_revision,
16176                    recent_cutoff_unix_ms,
16177                    after_selection_id,
16178                    limit,
16179                )
16180                .await?;
16181            Ok(NodeResponse::NativeSessionsPaged {
16182                route,
16183                page,
16184            })
16185        }
16186        NodeRequest::PreviewNativeSession {
16187            selection,
16188            message_limit,
16189        } => {
16190            if role != ClientRole::Operator {
16191                return Err(failure(
16192                    NodeFailureCode::ObserverReadOnly,
16193                    "native session preview requires an operator connection",
16194                ));
16195            }
16196            if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16197                .contains(&message_limit)
16198                || selection.validate().is_err()
16199            {
16200                return Err(failure(
16201                    NodeFailureCode::InvalidRequest,
16202                    "native session preview request is invalid",
16203                ));
16204            }
16205            let preview = shared
16206                .preview_native_session(selection.clone(), message_limit)
16207                .await?;
16208            Ok(NodeResponse::NativeSessionPreviewed {
16209                selection,
16210                preview,
16211            })
16212        }
16213        NodeRequest::IndexNativeSession {
16214            selection,
16215            display_name,
16216        } => {
16217            shared.require_controller(connection_id, role)?;
16218            let record = shared
16219                .index_native_session(selection.clone(), display_name)
16220                .await?;
16221            Ok(NodeResponse::NativeSessionIndexed { selection, record })
16222        }
16223        NodeRequest::PreviewSessionRecord {
16224            record_id,
16225            message_limit,
16226        } => {
16227            if role != ClientRole::Operator {
16228                return Err(failure(
16229                    NodeFailureCode::ObserverReadOnly,
16230                    "native session preview requires an operator connection",
16231                ));
16232            }
16233            if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16234                .contains(&message_limit)
16235            {
16236                return Err(failure(
16237                    NodeFailureCode::InvalidRequest,
16238                    "native session preview request is invalid",
16239                ));
16240            }
16241            let record = shared.record(&record_id)?;
16242            let working_directory = shared.workspace_root(&record.workspace_id)?;
16243            if !platform::roots_equal(
16244                &working_directory,
16245                windows_path_text(&record.canonical_root),
16246            ) {
16247                return Err(failure(
16248                    NodeFailureCode::SessionRecordConflict,
16249                    "managed session workspace root changed; refusing to preview another directory",
16250                ));
16251            }
16252            let identity = record.provider_session.clone().ok_or_else(|| {
16253                failure(
16254                    NodeFailureCode::BackendOperationFailed,
16255                    "native session preview is unavailable for this session record",
16256                )
16257            })?;
16258            if identity.key != ProviderSessionKey::SessionId {
16259                return Err(failure(
16260                    NodeFailureCode::BackendOperationFailed,
16261                    "native session preview is unavailable for this session identity",
16262                ));
16263            }
16264            let preview = shared
16265                .preview_native_session_by_session_id(
16266                    record.workspace_id.clone(), record.provider.clone(), identity.id.clone(), message_limit,
16267                )
16268                .await?;
16269            if preview.session_id != identity.id {
16270                return Err(failure(
16271                    NodeFailureCode::SessionRecordConflict,
16272                    "native session preview identity changed while loading",
16273                ));
16274            }
16275            shared.revalidate_session_record_preview(&record, &identity)?;
16276            let preview: gate4agent_types::SessionRecordPreview = preview.into();
16277            shared.publish(NodeEvent::SessionRecordHistorySummarized {
16278                record_id: record_id.clone(),
16279                summary: SessionHistorySummaryV1::from(&preview),
16280            });
16281            Ok(NodeResponse::SessionRecordPreviewed {
16282                record_id,
16283                preview,
16284            })
16285        }
16286        NodeRequest::AcquireController { lease_ms } => {
16287            let controller = shared.acquire_controller(connection_id, role, lease_ms)?;
16288            Ok(NodeResponse::Controller { controller: Some(controller) })
16289        }
16290        NodeRequest::ReleaseController => {
16291            shared.release_controller(connection_id);
16292            Ok(NodeResponse::Controller { controller: shared.controller_state() })
16293        }
16294        NodeRequest::RegisterWorkspace { workspace_id, root } => {
16295            shared.require_controller(connection_id, role)?;
16296            let root = require_windows_path(root)?;
16297            shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16298            let workspace = shared.register_workspace(workspace_id, root).await?;
16299            Ok(NodeResponse::WorkspaceRegistered { workspace })
16300        }
16301        NodeRequest::CreateStandaloneWorkspace {
16302            workspace_id,
16303            root,
16304            initial_branch,
16305        } => {
16306            shared.require_controller(connection_id, role)?;
16307            let root = require_windows_path(root)?;
16308            shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16309            let workspace = shared
16310                .create_standalone_workspace(workspace_id, root, initial_branch)
16311                .await?;
16312            Ok(NodeResponse::StandaloneWorkspaceCreated { workspace })
16313        }
16314        NodeRequest::UnregisterWorkspace { workspace_id } => {
16315            shared.require_controller(connection_id, role)?;
16316            shared.reject_managed_reservation(Some(&workspace_id), None)?;
16317            shared.unregister_workspace(&workspace_id)?;
16318            Ok(NodeResponse::WorkspaceUnregistered { workspace_id })
16319        }
16320        NodeRequest::CreateWorktree {
16321            source_workspace_id,
16322            workspace_id,
16323            target_root,
16324            branch,
16325            base,
16326        } => {
16327            shared.require_controller(connection_id, role)?;
16328            let target_root = require_windows_path(target_root)?;
16329            shared.reject_managed_reservation(Some(&workspace_id), Some(&target_root))?;
16330            let (worktree, workspace) = shared
16331                .create_worktree(
16332                    source_workspace_id,
16333                    workspace_id,
16334                    target_root,
16335                    branch,
16336                    base,
16337                )
16338                .await?;
16339            Ok(NodeResponse::WorktreeCreated { worktree, workspace })
16340        }
16341        NodeRequest::RemoveWorktree {
16342            source_workspace_id,
16343            target_root,
16344        } => {
16345            shared.require_controller(connection_id, role)?;
16346            let native_target_root = require_windows_path(target_root.clone())?;
16347            shared.reject_managed_reservation(None, Some(&native_target_root))?;
16348            let workspace_id = shared
16349                .remove_worktree(source_workspace_id, native_target_root)
16350                .await?;
16351            Ok(NodeResponse::WorktreeRemoved {
16352                target_root,
16353                workspace_id,
16354            })
16355        }
16356        NodeRequest::Spawn { workspace_id, provider, mode, terminal_size, initial_prompt } => {
16357            shared.require_controller(connection_id, role)?;
16358            if !terminal_size.is_valid() {
16359                return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
16360            }
16361            if let Some(prompt) = initial_prompt.as_deref() {
16362                validate_node_text("spawn initial prompt", prompt)?;
16363            }
16364            match shared
16365                .spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt)
16366                .await
16367            {
16368                Ok(session) => {
16369                    shared.mark_spawn_provisional(session.clone());
16370                    Ok(NodeResponse::SpawnAccepted { session })
16371                }
16372                Err(err) => {
16373                    tracing::warn!(
16374                        %provider,
16375                        %workspace_id,
16376                        ?mode,
16377                        code = ?err.code,
16378                        message = %err.message,
16379                        "session spawn refused"
16380                    );
16381                    Err(err)
16382                }
16383            }
16384        }
16385        NodeRequest::SpawnWithOptions { workspace_id, provider, mode, terminal_size, initial_prompt, options } => {
16386            shared.require_controller(connection_id, role)?;
16387            if !terminal_size.is_valid() {
16388                return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
16389            }
16390            if let Some(prompt) = initial_prompt.as_deref() {
16391                validate_node_text("spawn initial prompt", prompt)?;
16392            }
16393            let plan = backend_paths::plan_launch_options(
16394                provider.as_str(),
16395                mode,
16396                options.model.is_some(),
16397                options.effort.is_some(),
16398            )
16399            .map_err(|r| {
16400                tracing::warn!(%provider, reason = %r.message, "launch option path refused");
16401                failure(NodeFailureCode::InvalidRequest, "launch option path refused")
16402            })?;
16403            let selection = spawn_selection(&options)?;
16404            let pty_selection = match plan {
16405                backend_paths::LaunchOptionsPlan::PtyFlags => selection,
16406                _ => None,
16407            };
16408            let spawned = SPAWN_SESSION_OPTIONS
16409                .scope(pty_selection, shared.spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt))
16410                .await;
16411            match spawned {
16412                Ok(session) => {
16413                    if plan == backend_paths::LaunchOptionsPlan::AcpAfterSpawn {
16414                        shared
16415                            .pending_acp_options
16416                            .lock()
16417                            .unwrap_or_else(|p| p.into_inner())
16418                            .insert(session.session.instance_id, (options.model.clone(), options.effort.clone()));
16419                    }
16420                    shared.mark_spawn_provisional(session.clone());
16421                    tracing::info!(%provider, model = ?options.model, effort = ?options.effort, "session spawned with launch options");
16422                    Ok(NodeResponse::SpawnAccepted { session })
16423                }
16424                Err(err) => {
16425                    tracing::warn!(%provider, %workspace_id, code = ?err.code, message = %err.message, "session spawn with options refused");
16426                    Err(err)
16427                }
16428            }
16429        }
16430        NodeRequest::SpawnSpec { spec } => {
16431            shared.require_controller(connection_id, role)?;
16432            let receipt = shared.spawn_from_spec(spec).await?;
16433            Ok(NodeResponse::SpawnSpecAccepted { receipt })
16434        }
16435        NodeRequest::SpawnManagedWorktree { request } => {
16436            shared.require_controller(connection_id, role)?;
16437            let receipt = shared.spawn_managed_worktree(request).await?;
16438            Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
16439        }
16440        NodeRequest::SpawnManagedWorktreeV2 { request } => {
16441            shared.require_controller(connection_id, role)?;
16442            let receipt = shared.spawn_managed_worktree_v2(request).await?;
16443            Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
16444        }
16445        NodeRequest::CleanupManagedWorktree { lease_id } => {
16446            shared.require_controller(connection_id, role)?;
16447            let lease = shared.cleanup_managed_worktree(&lease_id, true).await?;
16448            Ok(NodeResponse::ManagedWorktreeCleanup { lease })
16449        }
16450        NodeRequest::DiscoverHistory { session, limit } => {
16451            controlled_session(shared, connection_id, role, &session)?;
16452            let candidates = shared.discover_history(&session, limit).await?;
16453            Ok(NodeResponse::HistoryDiscovered {
16454                session,
16455                candidates,
16456            })
16457        }
16458        NodeRequest::LoadHistory {
16459            session,
16460            candidate_id,
16461        } => {
16462            controlled_session(shared, connection_id, role, &session)?;
16463            let loaded = shared
16464                .load_history(&session, candidate_id)
16465                .await?;
16466            Ok(NodeResponse::HistoryLoaded {
16467                session,
16468                session_id: loaded.session_id,
16469                message_count: loaded.message_count,
16470                completed_turn_count: loaded.completed_turn_count,
16471            })
16472        }
16473        NodeRequest::ExportContextPackForSessionRecord { record_id, session } => {
16474            shared.require_controller(connection_id, role)?;
16475            let context = shared
16476                .export_context_pack_for_session_record(&record_id, &session, false)
16477                .await?;
16478            Ok(NodeResponse::ContextPackForSessionRecordExported {
16479                record_id,
16480                session,
16481                context,
16482            })
16483        }
16484        NodeRequest::ExportContextPack { session } => {
16485            controlled_session(shared, connection_id, role, &session)?;
16486            let context = shared.export_context_pack(&session).await?;
16487            Ok(NodeResponse::ContextPackExported { context })
16488        }
16489        NodeRequest::ForgetContextPack { context_id } => {
16490            shared.require_controller(connection_id, role)?;
16491            shared.forget_context_pack(&context_id)?;
16492            Ok(NodeResponse::ContextPackForgotten { context_id })
16493        }
16494        NodeRequest::ResolveDurableContextPack { context_id } => {
16495            // Read-only, idempotent, no controller lease required: this lets
16496            // a caller preflight "is this durably exported pack still here"
16497            // without holding the Node's mutation controller.
16498            let context = shared
16499                .context_catalog
16500                .read()
16501                .unwrap_or_else(|poisoned| poisoned.into_inner())
16502                .get(&context_id)
16503                .map(|pack| pack.receipt().clone())
16504                .ok_or_else(|| {
16505                    failure(
16506                        NodeFailureCode::UnknownContextPack,
16507                        "context pack is not present",
16508                    )
16509                })?;
16510            Ok(NodeResponse::DurableContextPackResolved { context })
16511        }
16512        NodeRequest::ReadContextPack { digest } => {
16513            // Read-only fetch of the pack's own bytes, keyed by content digest
16514            // rather than the caller's `SpawnContextId`: same no-controller-lease
16515            // rationale as `ResolveDurableContextPack` above, one step further
16516            // down the same catalog.
16517            let (id, byte_len, bytes) = shared
16518                .context_catalog
16519                .read()
16520                .unwrap_or_else(|poisoned| poisoned.into_inner())
16521                .get_by_digest(&digest)
16522                .map(|pack| {
16523                    (pack.receipt().id.clone(), pack.receipt().byte_len, pack.bytes().to_vec())
16524                })
16525                .ok_or_else(|| {
16526                    failure(
16527                        NodeFailureCode::UnknownContextPack,
16528                        &format!("context pack {digest} not on this node"),
16529                    )
16530                })?;
16531            tracing::debug!(
16532                digest = digest.as_str(),
16533                byte_len,
16534                "read context pack bytes",
16535            );
16536            Ok(NodeResponse::ContextPackBytesRead {
16537                pack: ContextPackBytesRead {
16538                    digest,
16539                    id,
16540                    byte_len,
16541                    bytes,
16542                },
16543            })
16544        }
16545        NodeRequest::Resume { session, terminal_size, initial_prompt } => {
16546            let provider = controlled_session(shared, connection_id, role, &session)?;
16547            if let Some(prompt) = initial_prompt.as_deref() {
16548                validate_node_text("resume initial prompt", prompt)?;
16549            }
16550            let runtime_requirement = if initial_prompt.is_some() {
16551                ProviderRuntimeRequirement::ResumeWithPrompt
16552            } else {
16553                ProviderRuntimeRequirement::Resume
16554            };
16555            shared.require_session_runtime_policy(&session, runtime_requirement)?;
16556            let runtime_policy = shared
16557                .admit_provider_runtime(&provider, runtime_requirement)
16558                .await?;
16559            let working_directory = shared.bound_workspace_root(&session)?;
16560            let request = ResumeLaunchRequest {
16561                working_directory,
16562                terminal_size,
16563                initial_prompt,
16564            };
16565            request
16566                .validate()
16567                .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
16568            let command = shared.prepare_command(ControlCommand::Resume {
16569                instance_id: session.session.instance_id,
16570                target: ResumeTarget::CurrentProvider,
16571                runtime_policy,
16572                request,
16573            });
16574            shared.arm_resume(&session, command.id, runtime_policy)?;
16575            let dispatch = shared.dispatch_envelope(command);
16576            if let Err(error) = dispatch {
16577                shared.clear_armed_resume(&session);
16578                return Err(error);
16579            }
16580            Ok(NodeResponse::Accepted)
16581        }
16582        NodeRequest::RenameSessionRecord {
16583            record_id,
16584            display_name,
16585        } => {
16586            shared.require_controller(connection_id, role)?;
16587            let record = shared.rename_session_record(&record_id, display_name)?;
16588            Ok(NodeResponse::SessionRecordUpdated { record })
16589        }
16590        NodeRequest::SetSessionTask {
16591            record_id,
16592            expected_revision,
16593            target,
16594        } => {
16595            shared.require_controller(connection_id, role)?;
16596            let record = shared.set_session_task(&record_id, expected_revision, target)?;
16597            Ok(NodeResponse::SessionRecordUpdated { record })
16598        }
16599        NodeRequest::IndexProviderSession {
16600            workspace_id,
16601            provider,
16602            identity,
16603            display_name,
16604        } => {
16605            shared.require_controller(connection_id, role)?;
16606            let record = shared.index_provider_session(
16607                workspace_id,
16608                provider,
16609                identity,
16610                display_name,
16611            )?;
16612            Ok(NodeResponse::ProviderSessionIndexed { record })
16613        }
16614        NodeRequest::ResumeSessionRecord {
16615            record_id,
16616            terminal_size,
16617            initial_prompt,
16618        } => {
16619            shared.require_controller(connection_id, role)?;
16620            if let Some(prompt) = initial_prompt.as_deref() {
16621                validate_node_text("managed resume initial prompt", prompt)?;
16622            }
16623            let (record, session) = shared
16624                .resume_session_record(
16625                    &record_id,
16626                    terminal_size,
16627                    initial_prompt,
16628                )
16629                .await?;
16630            Ok(NodeResponse::SessionRecordResumed { record, session })
16631        }
16632        NodeRequest::ForgetSessionRecord { record_id } => {
16633            shared.require_controller(connection_id, role)?;
16634            shared.forget_session_record(&record_id).await?;
16635            Ok(NodeResponse::SessionRecordForgotten { record_id })
16636        }
16637        NodeRequest::Prompt { session, text } => {
16638            let agent_id = controlled_session(shared, connection_id, role, &session)?;
16639            validate_node_text("prompt", &text)?;
16640            shared.require_session_runtime_policy(
16641                &session,
16642                ProviderRuntimeRequirement::SemanticPrompt,
16643            )?;
16644            let framing = prompt_framing(&agent_id);
16645            shared
16646                .dispatch_input_bounded(
16647                    &session,
16648                    InputAction::SubmitPrompt(PromptPayload { text, framing }),
16649                )
16650                .await?;
16651            Ok(NodeResponse::Accepted)
16652        }
16653        NodeRequest::Paste { session, text } => {
16654            controlled_session(shared, connection_id, role, &session)?;
16655            validate_node_text("paste", &text)?;
16656            shared.require_session_runtime_policy(
16657                &session,
16658                ProviderRuntimeRequirement::SemanticPrompt,
16659            )?;
16660            shared
16661                .dispatch_input_bounded(
16662                    &session,
16663                    InputAction::InsertDraft(PromptPayload {
16664                        text,
16665                        framing: PromptFraming::BracketedPaste,
16666                    }),
16667                )
16668                .await?;
16669            Ok(NodeResponse::Accepted)
16670        }
16671        NodeRequest::Input { session, text } => {
16672            controlled_session(shared, connection_id, role, &session)?;
16673            validate_node_text("terminal input", &text)?;
16674            shared
16675                .dispatch_input_bounded(
16676                    &session,
16677                    InputAction::TerminalText(TerminalText { text }),
16678                )
16679                .await?;
16680            Ok(NodeResponse::Accepted)
16681        }
16682        NodeRequest::TerminalBytes { session, bytes } => {
16683            controlled_session(shared, connection_id, role, &session)?;
16684            shared.sessionize_spawn(session.session.instance_id);
16685            let action = terminal_bytes_action(bytes)?;
16686            shared
16687                .dispatch_input_bounded(&session, action)
16688                .await?;
16689            Ok(NodeResponse::Accepted)
16690        }
16691        NodeRequest::TerminalControl { session, control } => {
16692            controlled_session(shared, connection_id, role, &session)?;
16693            shared
16694                .dispatch_input_bounded(&session, InputAction::TerminalControl(control))
16695                .await?;
16696            Ok(NodeResponse::Accepted)
16697        }
16698        NodeRequest::Resize { session, size } => {
16699            controlled_session(shared, connection_id, role, &session)?;
16700            shared.sessionize_spawn(session.session.instance_id);
16701            if !size.is_valid() {
16702                return Err(failure(NodeFailureCode::InvalidRequest, "terminal size is invalid"));
16703            }
16704            shared.dispatch_resize_bounded(&session, size).await?;
16705            Ok(NodeResponse::Accepted)
16706        }
16707        NodeRequest::Interrupt { session } => {
16708            controlled_session(shared, connection_id, role, &session)?;
16709            shared
16710                .dispatch_input_bounded(
16711                    &session,
16712                    InputAction::TerminalControl(TerminalControl::Interrupt),
16713                )
16714                .await?;
16715            Ok(NodeResponse::Accepted)
16716        }
16717        NodeRequest::Stop { session, force } => {
16718            controlled_session(shared, connection_id, role, &session)?;
16719            shared.sessionize_spawn(session.session.instance_id);
16720            shared.dispatch(ControlCommand::Stop { instance_id: session.session.instance_id, force })?;
16721            Ok(NodeResponse::Accepted)
16722        }
16723        NodeRequest::Remove { session } => {
16724            controlled_session(shared, connection_id, role, &session)?;
16725            shared.remove_session(&session).await?;
16726            Ok(NodeResponse::Accepted)
16727        }
16728        NodeRequest::ResolveInteraction { session, correlation_id, response } => {
16729            shared.require_controller(connection_id, role)?;
16730            let snapshot = shared.require_acp_session(&session)?;
16731            let interaction = snapshot
16732                .provider
16733                .interactions
16734                .iter()
16735                .find(|interaction| {
16736                    interaction_correlation(
16737                        session.session.instance_id,
16738                        session.session.generation,
16739                        interaction.id.0,
16740                    ) == correlation_id
16741                })
16742                .ok_or_else(|| failure(
16743                    NodeFailureCode::InvalidRequest,
16744                    "unknown interaction correlation",
16745                ))?;
16746            if interaction.status != ProviderInteractionStatus::Pending {
16747                return Err(failure(
16748                    NodeFailureCode::BackendOperationFailed,
16749                    "interaction has already been resolved",
16750                ));
16751            }
16752            response
16753                .validate_for(interaction.interaction_kind)
16754                .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
16755            let interaction_id = interaction.id;
16756            let response_kind = response.kind();
16757            let response_outcome = response.outcome();
16758            shared.dispatch(ControlCommand::ResolveInteraction {
16759                instance_id: session.session.instance_id,
16760                generation: session.session.generation,
16761                interaction_id,
16762                response,
16763            })?;
16764            // The one line that lets an operator compare what THIS node
16765            // recorded against what the provider itself reports doing --
16766            // measured live on a kimi run where our side resolved `true`
16767            // while the agent reported "rejected" and neither layer had a
16768            // line to line up against the other.
16769            tracing::info!(
16770                node_id = %shared.node_id,
16771                session = ?session.session,
16772                correlation_id = %correlation_id,
16773                interaction_id = interaction_id.0,
16774                response_kind = ?response_kind,
16775                outcome = ?response_outcome,
16776                "interaction resolved",
16777            );
16778            Ok(NodeResponse::Accepted)
16779        }
16780        NodeRequest::SetSessionMode { session, mode_id } => {
16781            shared.require_controller(connection_id, role)?;
16782            shared.require_acp_session(&session)?;
16783            shared.dispatch(ControlCommand::SetSessionMode {
16784                instance_id: session.session.instance_id,
16785                mode_id,
16786            })?;
16787            Ok(NodeResponse::Accepted)
16788        }
16789        NodeRequest::SetSessionConfigOption {
16790            session,
16791            option_id,
16792            value_json,
16793        } => {
16794            shared.require_controller(connection_id, role)?;
16795            shared.require_acp_session(&session)?;
16796            shared.dispatch(ControlCommand::SetSessionConfigOption {
16797                instance_id: session.session.instance_id,
16798                option_id,
16799                value_json,
16800            })?;
16801            Ok(NodeResponse::Accepted)
16802        }
16803        NodeRequest::SetSessionModel { session, model_id } => {
16804            shared.require_controller(connection_id, role)?;
16805            shared.require_acp_session(&session)?;
16806            shared.dispatch(ControlCommand::SetSessionModel {
16807                instance_id: session.session.instance_id,
16808                model_id,
16809            })?;
16810            Ok(NodeResponse::Accepted)
16811        }
16812        NodeRequest::Shutdown => {
16813            shared.require_controller(connection_id, role)?;
16814            shared.begin_shutdown_locked().await?;
16815            Ok(NodeResponse::ShuttingDown)
16816        }
16817    }
16818}
16819
16820fn controlled_session(shared: &NodeShared, connection_id: u64, role: ClientRole, session: &SessionAddress) -> Result<AgentId, NodeFailure> {
16821    shared.require_controller(connection_id, role)?;
16822    shared.validate_address(session)
16823}
16824
16825/// Selects the sole history candidate matching `provider_session_id` when
16826/// the managed record carries a verified identity, or the sole candidate in
16827/// the discovered set at all when it does not (see
16828/// `export_context_pack_for_session_record_inner`'s identity match). Either
16829/// way, more than one match refuses instead of guessing which one is exact.
16830fn select_history_candidate(
16831    candidates: &[HistoryCandidateSummary],
16832    provider_session_id: Option<&str>,
16833) -> Result<String, NodeFailure> {
16834    let mut matches = candidates.iter().filter(|candidate| match provider_session_id {
16835        Some(id) => candidate.session_id_hint == id,
16836        None => true,
16837    });
16838    let candidate = matches.next().ok_or_else(|| {
16839        failure(
16840            NodeFailureCode::SessionRecordConflict,
16841            if provider_session_id.is_some() {
16842                "no history candidate matches the managed provider session"
16843            } else {
16844                "no history candidate exists for the managed session"
16845            },
16846        )
16847    })?;
16848    if matches.next().is_some() {
16849        return Err(failure(
16850            NodeFailureCode::SessionRecordConflict,
16851            if provider_session_id.is_some() {
16852                "history identity is ambiguous for the managed provider session"
16853            } else {
16854                "history is ambiguous without a verified provider session identity"
16855            },
16856        ));
16857    }
16858    Ok(candidate.id.clone())
16859}
16860
16861fn resolved_spawn_profile_summary(
16862    profile: &SpawnProfileDefaults,
16863    environment_profiles: &BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>,
16864) -> Option<SpawnProfileSummary> {
16865    let environment_profile = match profile.environment_profile_id.as_ref() {
16866        None => None,
16867        Some(profile_id) => {
16868            let binding = environment_profiles.get(profile_id)?;
16869            if binding.provider != profile.provider
16870                || binding.native_profile_id(profile.mode).is_none()
16871            {
16872                return None;
16873            }
16874            Some(ResolvedEnvironmentProfileReceipt {
16875                profile_id: binding.id.clone(),
16876                profile_revision: binding.revision.clone(),
16877                network_allowlist: None,
16878                browser_profile_id: None,
16879            })
16880        }
16881    };
16882    Some(SpawnProfileSummary {
16883        id: profile.profile_id.clone(),
16884        revision: profile.revision.clone(),
16885        environment_profile,
16886    })
16887}
16888
16889/// True if `current` is an acceptable version of `expected` at export
16890/// revalidation/commit time.
16891///
16892/// When `allow_clean_detachment` is `false` (the on-demand,
16893/// `NodeRequest::ExportContextPackForSessionRecord` API path), `current` must
16894/// be byte-identical to `expected` and still actively bound to `session` —
16895/// the original, unchanged invariant.
16896///
16897/// When `true` (the reactive auto-export-at-exit path, `§2.1`), `current` may
16898/// ALSO be the exact Live -> Dormant transition that the same clean exit this
16899/// export reacts to produces on its own: `state` Live -> Dormant,
16900/// `active_session` `Some(session)` -> `None`, `updated_at_unix_ms`
16901/// advancing. Every other field — including `provider_session`, `context`,
16902/// `bundle`, `task_binding` — must still match exactly. A record that changed
16903/// in any other way (rebind, a different active session, an identity
16904/// conflict, capacity eviction) is rejected either way; this widens nothing
16905/// about which sessions are eligible, only how the record's own expected
16906/// detachment is tolerated mid-export.
16907fn session_record_export_target_matches(
16908    expected: &ManagedSessionRecord,
16909    current: &ManagedSessionRecord,
16910    session: &SessionAddress,
16911    allow_clean_detachment: bool,
16912) -> bool {
16913    if current == expected && current.active_session.as_ref() == Some(session) {
16914        return current.state == ManagedSessionState::Live;
16915    }
16916    allow_clean_detachment
16917        && current.state == ManagedSessionState::Dormant
16918        && current.active_session.is_none()
16919        && current.record_id == expected.record_id
16920        && current.display_name == expected.display_name
16921        && current.provider == expected.provider
16922        && current.mode == expected.mode
16923        && current.workspace_id == expected.workspace_id
16924        && current.canonical_root == expected.canonical_root
16925        && current.provider_session == expected.provider_session
16926        && current.environment_profile == expected.environment_profile
16927        && current.bundle == expected.bundle
16928        && current.context_id == expected.context_id
16929        && current.context == expected.context
16930        && current.exported_context == expected.exported_context
16931        && current.task_binding == expected.task_binding
16932        && current.created_at_unix_ms == expected.created_at_unix_ms
16933        && current.last_error == expected.last_error
16934}
16935
16936fn session_record_context_export_binding_is_exact(
16937    expected: &ManagedSessionRecord,
16938    current: &ManagedSessionRecord,
16939    identity: Option<&ProviderSessionIdentity>,
16940    session: &SessionAddress,
16941    binding: &SessionBinding,
16942    provider: &AgentId,
16943    current_root: &str,
16944    allow_clean_detachment: bool,
16945) -> bool {
16946    session_record_export_target_matches(expected, current, session, allow_clean_detachment)
16947        && current.provider_session.as_ref() == identity
16948        && current.workspace_id == session.workspace_id
16949        && provider == &current.provider
16950        && binding.workspace_id == session.workspace_id
16951        && binding.generation == session.session.generation
16952        && binding.record_id.as_ref() == Some(&current.record_id)
16953        && platform::roots_equal(
16954            current_root,
16955            windows_path_text(&current.canonical_root),
16956        )
16957}
16958
16959fn session_record_context_export_source_is_usable(
16960    record: &ManagedSessionRecord,
16961    status: &SessionStatus,
16962    allow_clean_detachment: bool,
16963) -> bool {
16964    (record.state == ManagedSessionState::Live
16965        || (allow_clean_detachment && record.state == ManagedSessionState::Dormant))
16966        && context_pack_source_status_is_usable(status)
16967}
16968
16969fn prompt_framing(_agent_id: &AgentId) -> PromptFraming {
16970    PromptFraming::BracketedPaste
16971}
16972
16973fn validate_node_text(field: &str, text: &str) -> Result<(), NodeFailure> {
16974    if text.len() > MAX_NODE_TEXT_BYTES {
16975        return Err(failure(
16976            NodeFailureCode::InvalidRequest,
16977            &format!(
16978                "{field} length {} exceeds the {MAX_NODE_TEXT_BYTES}-byte node limit",
16979                text.len(),
16980            ),
16981        ));
16982    }
16983    Ok(())
16984}
16985
16986fn validate_terminal_bytes(bytes: &[u8]) -> Result<(), NodeFailure> {
16987    if bytes.is_empty() || bytes.len() > MAX_NODE_TERMINAL_BYTES {
16988        return Err(failure(
16989            NodeFailureCode::InvalidRequest,
16990            &format!(
16991                "terminal byte sequence must contain 1..={MAX_NODE_TERMINAL_BYTES} bytes; received {}",
16992                bytes.len(),
16993            ),
16994        ));
16995    }
16996    Ok(())
16997}
16998
16999fn terminal_bytes_action(bytes: Vec<u8>) -> Result<InputAction, NodeFailure> {
17000    validate_terminal_bytes(&bytes)?;
17001    Ok(InputAction::TerminalBytes(bytes))
17002}
17003
17004fn validate_workspace_request_root(root: &str) -> Result<(), NodeFailure> {
17005    if root.is_empty()
17006        || root.len() > MAX_WORKSPACE_ROOT_BYTES
17007        || root.chars().any(char::is_control)
17008    {
17009        return Err(failure(
17010            NodeFailureCode::InvalidWorkspaceRoot,
17011            &format!(
17012                "workspace root must contain 1..={MAX_WORKSPACE_ROOT_BYTES} bytes and no control characters",
17013            ),
17014        ));
17015    }
17016    Ok(())
17017}
17018
17019fn canonical_standalone_workspace_candidate(
17020    workspace_id: &WorkspaceId,
17021    root: &str,
17022) -> Result<String, NodeServerError> {
17023    let path = Path::new(root);
17024    match std::fs::symlink_metadata(path) {
17025        Ok(_) => WorkspaceConfig::new(workspace_id.clone(), path)
17026            .map(|workspace| workspace.canonical_root),
17027        Err(error) if error.kind() == io::ErrorKind::NotFound => {
17028            if !path.is_absolute() {
17029                return Err(NodeServerError::InvalidWorkspaceRoot {
17030                    workspace_id: workspace_id.clone(),
17031                    path: root.to_owned(),
17032                    message: "path is not absolute".to_owned(),
17033                });
17034            }
17035            let name = path.file_name().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17036                workspace_id: workspace_id.clone(),
17037                path: root.to_owned(),
17038                message: "path has no standalone directory name".to_owned(),
17039            })?;
17040            let parent = path.parent().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17041                workspace_id: workspace_id.clone(),
17042                path: root.to_owned(),
17043                message: "path has no parent directory".to_owned(),
17044            })?;
17045            let canonical = std::fs::canonicalize(parent)
17046                .map(|parent| parent.join(name))
17047                .map_err(|error| NodeServerError::InvalidWorkspaceRoot {
17048                    workspace_id: workspace_id.clone(),
17049                    path: root.to_owned(),
17050                    message: error.to_string(),
17051                })?;
17052            let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
17053                NodeServerError::InvalidWorkspaceRoot {
17054                    workspace_id: workspace_id.clone(),
17055                    path: path.to_string_lossy().into_owned(),
17056                    message: "canonical path is not valid Unicode".to_owned(),
17057                }
17058            })?;
17059            let canonical_root = platform::normalize_canonical_root(canonical_root);
17060            validate_workspace_root(workspace_id, &canonical_root)?;
17061            Ok(canonical_root)
17062        }
17063        Err(error) => Err(NodeServerError::InvalidWorkspaceRoot {
17064            workspace_id: workspace_id.clone(),
17065            path: root.to_owned(),
17066            message: error.to_string(),
17067        }),
17068    }
17069}
17070
17071fn validate_git_revision(field: &str, revision: Option<&str>) -> Result<(), NodeFailure> {
17072    let Some(revision) = revision else {
17073        return Ok(());
17074    };
17075    if revision.is_empty()
17076        || revision.len() > MAX_REPOSITORY_PATH_BYTES
17077        || revision.chars().any(char::is_control)
17078    {
17079        return Err(failure(
17080            NodeFailureCode::InvalidRequest,
17081            &format!(
17082                "{field} must contain 1..={MAX_REPOSITORY_PATH_BYTES} bytes and no control characters",
17083            ),
17084        ));
17085    }
17086    Ok(())
17087}
17088
17089fn git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17090    let code = match error.kind {
17091        GitWorktreeErrorKind::Invalid => NodeFailureCode::InvalidRequest,
17092        GitWorktreeErrorKind::NotRepository => NodeFailureCode::NotGitRepository,
17093        GitWorktreeErrorKind::Conflict => NodeFailureCode::WorktreeConflict,
17094        GitWorktreeErrorKind::Protected => NodeFailureCode::WorktreeProtected,
17095        GitWorktreeErrorKind::Dirty => NodeFailureCode::WorktreeDirty,
17096        GitWorktreeErrorKind::Locked => NodeFailureCode::WorktreeLocked,
17097        GitWorktreeErrorKind::Failed => NodeFailureCode::BackendOperationFailed,
17098    };
17099    failure(code, &error.message)
17100}
17101
17102fn standalone_workspace_failure(error: StandaloneWorkspaceError) -> NodeFailure {
17103    let code = match error.kind {
17104        StandaloneWorkspaceErrorKind::InvalidRoot => NodeFailureCode::InvalidWorkspaceRoot,
17105        StandaloneWorkspaceErrorKind::InvalidInitialBranch => NodeFailureCode::InvalidRequest,
17106        StandaloneWorkspaceErrorKind::GitInitializationFailed => {
17107            NodeFailureCode::BackendOperationFailed
17108        }
17109        StandaloneWorkspaceErrorKind::RecoveryRequired => {
17110            NodeFailureCode::StandaloneWorkspaceRecoveryRequired
17111        }
17112    };
17113    failure(code, error.message)
17114}
17115
17116fn managed_git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17117    let (code, message) = match error.kind {
17118        GitWorktreeErrorKind::Invalid => (
17119            NodeFailureCode::InvalidRequest,
17120            "managed Git input is invalid",
17121        ),
17122        GitWorktreeErrorKind::NotRepository => (
17123            NodeFailureCode::NotGitRepository,
17124            "managed source is not a Git repository",
17125        ),
17126        GitWorktreeErrorKind::Conflict => (
17127            NodeFailureCode::WorktreeConflict,
17128            "managed Git identity conflicts with existing repository state",
17129        ),
17130        GitWorktreeErrorKind::Protected => (
17131            NodeFailureCode::ManagedWorktreeOwnershipConflict,
17132            "managed worktree ownership validation failed",
17133        ),
17134        GitWorktreeErrorKind::Dirty => (
17135            NodeFailureCode::WorktreeDirty,
17136            "managed worktree contains uncommitted changes",
17137        ),
17138        GitWorktreeErrorKind::Locked => (
17139            NodeFailureCode::WorktreeLocked,
17140            "managed worktree is locked",
17141        ),
17142        GitWorktreeErrorKind::Failed => (
17143            NodeFailureCode::BackendOperationFailed,
17144            "managed Git operation failed",
17145        ),
17146    };
17147    NodeFailure { code, message: message.to_owned() }
17148}
17149
17150fn managed_spawn_receipt(
17151    resolved: &ResolvedSpawnSpec,
17152    incarnation_id: NodeIncarnationId,
17153    session: SessionAddress,
17154    lease: ManagedWorktreeLeaseSnapshot,
17155    environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
17156    bundle: Option<ResolvedBundleReceipt>,
17157    context: Option<ResolvedContextPackReceipt>,
17158) -> ManagedWorktreeSpawnReceipt {
17159    let mut managed = resolved.clone();
17160    managed.target.worktree_id = Some(lease.workspace_id.clone());
17161    ManagedWorktreeSpawnReceipt {
17162        spawn: managed.receipt_with_materialization(
17163            incarnation_id,
17164            session,
17165            environment_profile,
17166            bundle,
17167            context,
17168        ),
17169        lease,
17170    }
17171}
17172
17173fn validate_selected_worktree(
17174    source_root: &str,
17175    selected_root: &str,
17176    worktrees: &[NativeGitWorktreeSnapshot],
17177) -> Result<(), NodeFailure> {
17178    if !worktrees
17179        .iter()
17180        .any(|worktree| worktree_paths_equal(&worktree.path, source_root) && !worktree.is_bare)
17181    {
17182        return Err(failure(
17183            NodeFailureCode::WorktreeProtected,
17184            "source workspace is not an authoritative non-bare Git worktree root",
17185        ));
17186    }
17187    if !worktrees.iter().any(|worktree| {
17188        worktree_paths_equal(&worktree.path, selected_root)
17189            && !worktree.is_main
17190            && !worktree.is_bare
17191    }) {
17192        return Err(failure(
17193            NodeFailureCode::WorktreeProtected,
17194            "selected workspace is not an eligible Git worktree in the source repository",
17195        ));
17196    }
17197    Ok(())
17198}
17199
17200fn workspace_file_failure(error: WorkspaceFileReadError) -> NodeFailure {
17201    let code = match error.kind() {
17202        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17203        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17204            NodeFailureCode::RepositoryPathUnsafe
17205        }
17206        WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17207        WorkspaceFileReadErrorKind::NotRegularFile => {
17208            NodeFailureCode::RepositoryFileNotRegular
17209        }
17210        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17211            NodeFailureCode::RepositoryFileReadFailed
17212        }
17213        WorkspaceFileReadErrorKind::RevisionConflict => {
17214            NodeFailureCode::RepositoryFileRevisionConflict
17215        }
17216        WorkspaceFileReadErrorKind::AlreadyExists
17217        | WorkspaceFileReadErrorKind::ParentNotFound
17218        | WorkspaceFileReadErrorKind::ParentNotDirectory
17219        | WorkspaceFileReadErrorKind::Canceled => {
17220            NodeFailureCode::RepositoryFileReadFailed
17221        }
17222    };
17223    failure(code, "repository file read failed")
17224}
17225
17226fn workspace_file_write_failure(error: WorkspaceFileReadError) -> NodeFailure {
17227    let code = match error.kind() {
17228        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17229        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17230            NodeFailureCode::RepositoryPathUnsafe
17231        }
17232        WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17233        WorkspaceFileReadErrorKind::NotRegularFile => {
17234            NodeFailureCode::RepositoryFileNotRegular
17235        }
17236        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17237            NodeFailureCode::RepositoryFileWriteFailed
17238        }
17239        WorkspaceFileReadErrorKind::RevisionConflict => {
17240            NodeFailureCode::RepositoryFileRevisionConflict
17241        }
17242        WorkspaceFileReadErrorKind::AlreadyExists
17243        | WorkspaceFileReadErrorKind::ParentNotFound
17244        | WorkspaceFileReadErrorKind::ParentNotDirectory
17245        | WorkspaceFileReadErrorKind::Canceled => {
17246            NodeFailureCode::RepositoryFileWriteFailed
17247        }
17248    };
17249    failure(code, "repository file write failed")
17250}
17251
17252async fn settle_workspace_entry_create<T>(
17253    mut task: tokio::task::JoinHandle<Result<T, WorkspaceFileReadError>>,
17254    commit_state: Arc<AtomicU8>,
17255    deadline: Duration,
17256    timeout_message: &'static str,
17257    task_failure_message: &'static str,
17258) -> Result<T, NodeFailure> {
17259    let joined = match timeout(deadline, &mut task).await {
17260        Ok(joined) => joined,
17261        Err(_) => {
17262            if commit_state
17263                .compare_exchange(
17264                    WORKSPACE_ENTRY_CREATE_PENDING,
17265                    WORKSPACE_ENTRY_CREATE_CANCELED,
17266                    Ordering::AcqRel,
17267                    Ordering::Acquire,
17268                )
17269                .is_ok()
17270            {
17271                return Err(failure(
17272                    NodeFailureCode::RepositoryEntryCreateTimedOut,
17273                    timeout_message,
17274                ));
17275            }
17276            debug_assert_eq!(
17277                commit_state.load(Ordering::Acquire),
17278                WORKSPACE_ENTRY_CREATE_COMMITTING,
17279            );
17280            task.await
17281        }
17282    };
17283    joined
17284        .map_err(|_| {
17285            failure(
17286                NodeFailureCode::RepositoryEntryCreateFailed,
17287                task_failure_message,
17288            )
17289        })?
17290        .map_err(workspace_entry_create_failure)
17291}
17292
17293fn workspace_entry_create_failure(error: WorkspaceFileReadError) -> NodeFailure {
17294    let code = match error.kind() {
17295        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17296        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17297            NodeFailureCode::RepositoryPathUnsafe
17298        }
17299        WorkspaceFileReadErrorKind::AlreadyExists => {
17300            NodeFailureCode::RepositoryEntryAlreadyExists
17301        }
17302        WorkspaceFileReadErrorKind::ParentNotFound | WorkspaceFileReadErrorKind::NotFound => {
17303            NodeFailureCode::RepositoryParentNotFound
17304        }
17305        WorkspaceFileReadErrorKind::ParentNotDirectory
17306        | WorkspaceFileReadErrorKind::NotRegularFile => {
17307            NodeFailureCode::RepositoryParentNotDirectory
17308        }
17309        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17310            NodeFailureCode::RepositoryEntryCreateFailed
17311        }
17312        WorkspaceFileReadErrorKind::RevisionConflict => {
17313            NodeFailureCode::RepositoryEntryCreateFailed
17314        }
17315        WorkspaceFileReadErrorKind::Canceled => {
17316            NodeFailureCode::RepositoryEntryCreateTimedOut
17317        }
17318    };
17319    failure(code, "repository entry create failed")
17320}
17321
17322fn workspace_file_revision(bytes: &[u8]) -> WorkspaceFileRevision {
17323    let value = digest(&SHA256, bytes)
17324        .as_ref()
17325        .iter()
17326        .map(|byte| format!("{byte:02x}"))
17327        .collect();
17328    WorkspaceFileRevision::new(value).expect("SHA-256 produces a valid workspace revision")
17329}
17330
17331fn managed_spawn_request_digest_v2(
17332    request: &ManagedWorktreeSpawnRequestV2,
17333) -> Result<String, NodeFailure> {
17334    let encoded = serde_json::to_vec(request).map_err(|_| {
17335        failure(
17336            NodeFailureCode::InvalidRequest,
17337            "managed worktree V2 request could not be canonicalized",
17338        )
17339    })?;
17340    let mut value = String::with_capacity(71);
17341    value.push_str("sha256:");
17342    for byte in digest(&SHA256, &encoded).as_ref() {
17343        use std::fmt::Write as _;
17344        write!(&mut value, "{byte:02x}").expect("writing to a String cannot fail");
17345    }
17346    Ok(value)
17347}
17348
17349fn native_session_preview_failure(error: NativeSessionPreviewError) -> NodeFailure {
17350    let code = match error {
17351        NativeSessionPreviewError::InvalidLimit => NodeFailureCode::InvalidRequest,
17352        NativeSessionPreviewError::WorkspaceUnavailable => NodeFailureCode::UnknownWorkspace,
17353        NativeSessionPreviewError::StaleCatalog => NodeFailureCode::StaleNativeSessionCatalog,
17354        NativeSessionPreviewError::UnsupportedProvider
17355        | NativeSessionPreviewError::PreviewUnavailable
17356        | NativeSessionPreviewError::SessionNotFound
17357        | NativeSessionPreviewError::AmbiguousSession => {
17358            NodeFailureCode::BackendOperationFailed
17359        }
17360    };
17361    failure(code, "native session preview failed")
17362}
17363
17364fn host_directory_failure(error: HostDirectoryBrowseError) -> NodeFailure {
17365    let code = match error.kind() {
17366        HostDirectoryBrowseErrorKind::Invalid => NodeFailureCode::HostDirectoryInvalid,
17367        HostDirectoryBrowseErrorKind::ReadFailed => NodeFailureCode::HostDirectoryReadFailed,
17368    };
17369    failure(code, "host directory browse failed")
17370}
17371
17372fn delivery_failure(error: DeliveryStoreError) -> NodeFailure {
17373    let code = match error {
17374        DeliveryStoreError::InvalidManifest => NodeFailureCode::DeliveryManifestInvalid,
17375        DeliveryStoreError::UnknownStage => NodeFailureCode::UnknownDeliveryStage,
17376        DeliveryStoreError::Capacity | DeliveryStoreError::StageConflict => {
17377            NodeFailureCode::DeliveryStageConflict
17378        }
17379        DeliveryStoreError::UnexpectedBlob => NodeFailureCode::DeliveryBlobUnexpected,
17380        DeliveryStoreError::ChunkOutOfOrder | DeliveryStoreError::ChunkOverflow => {
17381            NodeFailureCode::DeliveryChunkOutOfOrder
17382        }
17383        DeliveryStoreError::BlobDigestMismatch => {
17384            NodeFailureCode::DeliveryBlobDigestMismatch
17385        }
17386        DeliveryStoreError::BundleDigestMismatch => {
17387            NodeFailureCode::DeliveryBundleDigestMismatch
17388        }
17389        DeliveryStoreError::StageIncomplete => NodeFailureCode::DeliveryStageIncomplete,
17390        DeliveryStoreError::Unavailable
17391        | DeliveryStoreError::Corrupt
17392        | DeliveryStoreError::Storage(_) => NodeFailureCode::DeliveryStageStorageFailed,
17393    };
17394    failure(code, "delivery operation failed")
17395}
17396
17397fn validate_workspace_root(
17398    workspace_id: &WorkspaceId,
17399    canonical_root: &str,
17400) -> Result<(), NodeServerError> {
17401    #[cfg(windows)]
17402    if canonical_root.starts_with(r"\\") {
17403        return Err(NodeServerError::InvalidWorkspaceRoot {
17404            workspace_id: workspace_id.clone(),
17405            path: canonical_root.to_owned(),
17406            message: "UNC workspace roots are unsupported by Windows PTY providers".to_owned(),
17407        });
17408    }
17409    if !platform::workspace_root_supported(canonical_root)
17410        || canonical_root.len() > WORKING_DIRECTORY_MAX_BYTES
17411    {
17412        return Err(NodeServerError::InvalidWorkspaceRoot {
17413            workspace_id: workspace_id.clone(),
17414            path: canonical_root.to_owned(),
17415            message: "canonical path must be absolute, bounded, and free of control characters"
17416                .to_owned(),
17417        });
17418    }
17419    Ok(())
17420}
17421
17422fn failure(code: NodeFailureCode, _message: &str) -> NodeFailure {
17423    NodeFailure {
17424        code,
17425        message: node_failure_category(code).to_owned(),
17426    }
17427}
17428
17429fn harness_mcp_failure(error: HarnessMcpProxyError) -> NodeFailure {
17430    let code = match error {
17431        HarnessMcpProxyError::Unavailable => NodeFailureCode::HarnessMcpUnavailable,
17432        HarnessMcpProxyError::NotFound => NodeFailureCode::ReservationNotFound,
17433        HarnessMcpProxyError::Conflict => NodeFailureCode::ReservationConflict,
17434        HarnessMcpProxyError::Expired => NodeFailureCode::ReservationExpired,
17435        HarnessMcpProxyError::BindingMismatch => NodeFailureCode::BindingMismatch,
17436        HarnessMcpProxyError::NotActivated => NodeFailureCode::NotActivated,
17437        HarnessMcpProxyError::CallNotFound => NodeFailureCode::CallNotFound,
17438        HarnessMcpProxyError::ChunkOutOfOrder => NodeFailureCode::ChunkOutOfOrder,
17439        HarnessMcpProxyError::ResponseTooLarge => NodeFailureCode::ResponseTooLarge,
17440    };
17441    failure(code, "harness MCP proxy operation failed")
17442}
17443
17444/// Numbers each harness-MCP overlay this process constructs, in
17445/// construction order, so two overlays built back to back in one node process
17446/// still get distinct trace files.
17447static NEXT_HARNESS_MCP_TRACE_SEQUENCE: AtomicU64 = AtomicU64::new(1);
17448
17449/// Builds the optional trace environment entry of one harness-MCP overlay.
17450/// The caller's launch description names both the entry and the node-process
17451/// variable that opts in: when that variable holds a non-empty directory, the
17452/// entry names a trace file under it. Returns `None` when the launch has no
17453/// trace, or the directory is unset, empty, or not valid Unicode. Never creates
17454/// the directory; that is the operator's job.
17455fn harness_mcp_trace_env_entry(launch: &HarnessMcpLaunchV1) -> Option<(OsString, OsString)> {
17456    let trace = launch.trace.as_ref()?;
17457    let trace_dir = std::env::var(&trace.dir_env).ok()?;
17458    if trace_dir.is_empty() {
17459        return None;
17460    }
17461    let sequence = NEXT_HARNESS_MCP_TRACE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
17462    let path = std::path::Path::new(&trace_dir).join(format!("harness-mcp-{sequence}.trace"));
17463    Some((OsString::from(trace.env.as_str()), path.into_os_string()))
17464}
17465
17466fn spawn_deadline_remaining(deadline: Instant) -> Result<Duration, NodeFailure> {
17467    let remaining = deadline.saturating_duration_since(Instant::now());
17468    if remaining.is_zero() {
17469        Err(failure(
17470            NodeFailureCode::SpawnDeadlineExceeded,
17471            "spawn deadline elapsed",
17472        ))
17473    } else {
17474        Ok(remaining)
17475    }
17476}
17477
17478fn spawn_dispatch_timeout(deadline: Option<Instant>) -> Result<Duration, NodeFailure> {
17479    let bounded = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
17480    match deadline {
17481        Some(deadline) => Ok(spawn_deadline_remaining(deadline)?.min(bounded)),
17482        None => Ok(bounded),
17483    }
17484}
17485
17486fn spawn_dispatch_error(error: NodeFailure, deadline: Option<Instant>) -> NodeFailure {
17487    if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
17488        failure(
17489            NodeFailureCode::SpawnDeadlineExceeded,
17490            "spawn dispatch exceeded the spawn deadline",
17491        )
17492    } else {
17493        error
17494    }
17495}
17496
17497fn spawn_runtime_capabilities(
17498    capabilities: &SpawnRequiredCapabilities,
17499) -> Result<Vec<ProviderRuntimeCapability>, NodeFailure> {
17500    capabilities
17501        .iter()
17502        .map(|capability| match capability.as_str() {
17503            SPAWN_RUNTIME_RAW_PTY_LIFECYCLE => Ok(ProviderRuntimeCapability::RawPtyLifecycle),
17504            SPAWN_RUNTIME_SEMANTIC_READINESS => Ok(ProviderRuntimeCapability::SemanticReadiness),
17505            SPAWN_RUNTIME_STRUCTURED_PROMPT => Ok(ProviderRuntimeCapability::StructuredPrompt),
17506            SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY => {
17507                Ok(ProviderRuntimeCapability::ProviderSessionIdentity)
17508            }
17509            SPAWN_RUNTIME_SEMANTIC_RESUME => Ok(ProviderRuntimeCapability::SemanticResume),
17510            _ => Err(failure(
17511                NodeFailureCode::UnsupportedSpawnCapability,
17512                "spawn required capability is unknown",
17513            )),
17514        })
17515        .collect()
17516}
17517
17518fn persistence_failure(_error: io::Error) -> NodeFailure {
17519    NodeFailure {
17520        code: NodeFailureCode::BackendOperationFailed,
17521        message: DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned(),
17522    }
17523}
17524
17525fn node_failure_category(code: NodeFailureCode) -> &'static str {
17526    match code {
17527        NodeFailureCode::InvalidRequest => "invalid-request",
17528        NodeFailureCode::UnsupportedCapability => "unsupported-capability",
17529        NodeFailureCode::HarnessMcpUnavailable => "harness-mcp-unavailable",
17530        NodeFailureCode::ReservationNotFound => "reservation-not-found",
17531        NodeFailureCode::ReservationConflict => "reservation-conflict",
17532        NodeFailureCode::ReservationExpired => "reservation-expired",
17533        NodeFailureCode::BindingMismatch => "binding-mismatch",
17534        NodeFailureCode::NotActivated => "not-activated",
17535        NodeFailureCode::CallNotFound => "call-not-found",
17536        NodeFailureCode::ChunkOutOfOrder => "chunk-out-of-order",
17537        NodeFailureCode::ResponseTooLarge => "response-too-large",
17538        NodeFailureCode::DeliveryManifestInvalid => "delivery-manifest-invalid",
17539        NodeFailureCode::UnknownDeliveryStage => "unknown-delivery-stage",
17540        NodeFailureCode::DeliveryStageConflict => "delivery-stage-conflict",
17541        NodeFailureCode::DeliveryBlobUnexpected => "delivery-blob-unexpected",
17542        NodeFailureCode::DeliveryChunkOutOfOrder => "delivery-chunk-out-of-order",
17543        NodeFailureCode::DeliveryBlobDigestMismatch => "delivery-blob-digest-mismatch",
17544        NodeFailureCode::DeliveryBundleDigestMismatch => "delivery-bundle-digest-mismatch",
17545        NodeFailureCode::DeliveryStageIncomplete => "delivery-stage-incomplete",
17546        NodeFailureCode::DeliveryStageStorageFailed => "delivery-stage-storage-failed",
17547        NodeFailureCode::Unauthorized => "unauthorized",
17548        NodeFailureCode::ObserverReadOnly => "observer-read-only",
17549        NodeFailureCode::ControllerBusy => "controller-busy",
17550        NodeFailureCode::ControllerRequired => "controller-required",
17551        NodeFailureCode::UnknownWorkspace => "unknown-workspace",
17552        NodeFailureCode::HostDirectoryInvalid => "host-directory-invalid",
17553        NodeFailureCode::HostDirectoryReadFailed => "host-directory-read-failed",
17554        NodeFailureCode::HostDirectoryReadTimedOut => "host-directory-read-timed-out",
17555        NodeFailureCode::InvalidRepositoryPath => "invalid-repository-path",
17556        NodeFailureCode::RepositoryFileNotFound => "repository-file-not-found",
17557        NodeFailureCode::RepositoryFileNotRegular => "repository-file-not-regular",
17558        NodeFailureCode::RepositoryPathUnsafe => "repository-path-unsafe",
17559        NodeFailureCode::RepositoryFileReadTimedOut => "repository-file-read-timed-out",
17560        NodeFailureCode::RepositoryFileReadFailed => "repository-file-read-failed",
17561        NodeFailureCode::RepositoryFileWriteTimedOut => "repository-file-write-timed-out",
17562        NodeFailureCode::RepositoryFileWriteFailed => "repository-file-write-failed",
17563        NodeFailureCode::RepositoryFileRevisionConflict => "repository-file-revision-conflict",
17564        NodeFailureCode::RepositoryEntryAlreadyExists => "repository-entry-already-exists",
17565        NodeFailureCode::RepositoryParentNotFound => "repository-parent-not-found",
17566        NodeFailureCode::RepositoryParentNotDirectory => "repository-parent-not-directory",
17567        NodeFailureCode::RepositoryEntryCreateTimedOut => "repository-entry-create-timed-out",
17568        NodeFailureCode::RepositoryEntryCreateFailed => "repository-entry-create-failed",
17569        NodeFailureCode::GitReadTimedOut => "git-read-timed-out",
17570        NodeFailureCode::GitReadFailed => "git-read-failed",
17571        NodeFailureCode::UnknownSpawnProfile => "unknown-spawn-profile",
17572        NodeFailureCode::SpawnProfileRevisionMismatch => "spawn-profile-revision-mismatch",
17573        NodeFailureCode::SpawnTargetMismatch => "spawn-target-mismatch",
17574        NodeFailureCode::SpawnIdempotencyConflict => "spawn-idempotency-conflict",
17575        NodeFailureCode::SpawnIdempotencyCapacity => "spawn-idempotency-capacity",
17576        NodeFailureCode::SpawnDeadlineExceeded => "spawn-deadline-exceeded",
17577        NodeFailureCode::UnsupportedSpawnCapability => "unsupported-spawn-capability",
17578        NodeFailureCode::UnsupportedTransport => "unsupported-transport",
17579        NodeFailureCode::TurnInFlight => "turn-in-flight",
17580        NodeFailureCode::UnknownEnvironmentProfile => "unknown-environment-profile",
17581        NodeFailureCode::UnknownNetworkAllowlist => "unknown-network-allowlist",
17582        NodeFailureCode::UnsupportedNetworkAllowlistMapping => {
17583            "unsupported-network-allowlist-mapping"
17584        }
17585        NodeFailureCode::BrowserStationProbeUnavailable => {
17586            "browser-station-probe-unavailable"
17587        }
17588        NodeFailureCode::BrowserStationUnreachable => "browser-station-unreachable",
17589        NodeFailureCode::BrowserStationProfileBusy => "browser-station-profile-busy",
17590        NodeFailureCode::EnvironmentProfileBindingMismatch => {
17591            "environment-profile-binding-mismatch"
17592        }
17593        NodeFailureCode::UnknownBundle => "unknown-bundle",
17594        NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
17595        NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
17596        NodeFailureCode::UnknownContextPack => "unknown-context-pack",
17597        NodeFailureCode::ContextPackBusy => "context-pack-busy",
17598        NodeFailureCode::ContextPackMaterializationFailed => {
17599            "context-pack-materialization-failed"
17600        }
17601        NodeFailureCode::InvalidWorkspaceRoot => "invalid-workspace-root",
17602        NodeFailureCode::DuplicateWorkspaceId => "duplicate-workspace-id",
17603        NodeFailureCode::DuplicateWorkspaceRoot => "duplicate-workspace-root",
17604        NodeFailureCode::WorkspaceBusy => "workspace-busy",
17605        NodeFailureCode::LastWorkspace => "last-workspace",
17606        NodeFailureCode::NotGitRepository => "not-git-repository",
17607        NodeFailureCode::WorktreeConflict => "worktree-conflict",
17608        NodeFailureCode::WorktreeProtected => "worktree-protected",
17609        NodeFailureCode::WorktreeDirty => "worktree-dirty",
17610        NodeFailureCode::WorktreeLocked => "worktree-locked",
17611        NodeFailureCode::UnknownManagedWorktreeLease => "unknown-managed-worktree-lease",
17612        NodeFailureCode::ManagedWorktreeBusy => "managed-worktree-busy",
17613        NodeFailureCode::ManagedWorktreeOwnershipConflict => "managed-worktree-ownership-conflict",
17614        NodeFailureCode::ManagedWorktreeProfileRevisionMismatch => {
17615            "managed-worktree-profile-revision-mismatch"
17616        }
17617        NodeFailureCode::ManagedWorktreeRecoveryRequired => "managed-worktree-recovery-required",
17618        NodeFailureCode::StandaloneWorkspaceRecoveryRequired => {
17619            "standalone-workspace-recovery-required"
17620        }
17621        NodeFailureCode::UnknownSession => "unknown-session",
17622        NodeFailureCode::UnknownSessionRecord => "unknown-session-record",
17623        NodeFailureCode::SessionRecordNotResumable => "session-record-not-resumable",
17624        NodeFailureCode::SessionRecordBusy => "session-record-busy",
17625        NodeFailureCode::SessionRecordConflict => "session-record-conflict",
17626        NodeFailureCode::SessionWorkspaceMismatch => "session-workspace-mismatch",
17627        NodeFailureCode::WorkspaceRegistrationRequired => {
17628            "workspace-registration-required"
17629        }
17630        NodeFailureCode::StaleNativeSessionCatalog => "stale-native-session-catalog",
17631        NodeFailureCode::StaleGeneration => "stale-generation",
17632        NodeFailureCode::BackendBusy => "backend-busy",
17633        NodeFailureCode::BackendDisconnected => "backend-disconnected",
17634        NodeFailureCode::BackendOperationFailed => "backend-operation-failed",
17635        NodeFailureCode::ShuttingDown => "shutting-down",
17636    }
17637}
17638
17639fn durable_state_server_error(error: io::Error, category: &'static str) -> NodeServerError {
17640    NodeServerError::DurableState(io::Error::new(error.kind(), category))
17641}
17642
17643fn durable_state_load_error(error: io::Error) -> NodeServerError {
17644    let category = match session_registry::state_load_refusal(&error) {
17645        Some(session_registry::StateLoadRefusal::UnsupportedSchema) => {
17646            DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR
17647        }
17648        Some(session_registry::StateLoadRefusal::PathSemanticsUnsupported) => {
17649            DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR
17650        }
17651        Some(session_registry::StateLoadRefusal::NodeIdentityMismatch) => {
17652            DURABLE_STATE_CONFLICT_ERROR
17653        }
17654        _ => DURABLE_STATE_LOAD_FAILED_ERROR,
17655    };
17656    durable_state_server_error(error, category)
17657}
17658
17659fn unix_time_ms() -> u64 {
17660    std::time::SystemTime::now()
17661        .duration_since(std::time::UNIX_EPOCH)
17662        .unwrap_or_default()
17663        .as_millis()
17664        .min(u64::MAX as u128) as u64
17665}
17666
17667fn detached_record_state(record: &ManagedSessionRecord) -> ManagedSessionState {
17668    if record.provider_session.is_some() {
17669        ManagedSessionState::Dormant
17670    } else {
17671        ManagedSessionState::Unavailable
17672    }
17673}
17674
17675fn active_record_state(
17676    identity_admitted: bool,
17677    provider_session_is_known: bool,
17678) -> ManagedSessionState {
17679    if identity_admitted && !provider_session_is_known {
17680        ManagedSessionState::IdentityPending
17681    } else {
17682        ManagedSessionState::Live
17683    }
17684}
17685
17686fn managed_worktree_record_holders(
17687    records: &[ManagedSessionRecord],
17688) -> Vec<(SessionRecordId, WorkspaceId)> {
17689    records
17690        .iter()
17691        .filter(|record| record.provider_session.is_some())
17692        .map(|record| (record.record_id.clone(), record.workspace_id.clone()))
17693        .collect()
17694}
17695
17696fn managed_spawn_record_ownership(
17697    binding: &SessionBinding,
17698    effective_runtime_policy: ProviderRuntimePolicy,
17699) -> (Option<SessionRecordId>, Option<SessionRecordId>) {
17700    debug_assert_eq!(binding.runtime_policy, effective_runtime_policy);
17701    if effective_runtime_policy.provider_session_identity {
17702        (binding.record_id.clone(), None)
17703    } else {
17704        (None, binding.record_id.clone())
17705    }
17706}
17707
17708#[derive(Debug, Error)]
17709pub enum NodeServerError {
17710    #[error("node endpoint must be a bounded absolute local transport path")]
17711    InvalidEndpoint,
17712    #[error("node access token must contain 1..=4096 bytes")]
17713    InvalidAccessToken,
17714    #[error("node HTTP observer must listen on a loopback address: {0}")]
17715    InvalidApiListen(std::net::SocketAddr),
17716    #[error("node browser bridge must listen on a loopback address: {0}")]
17717    InvalidBridgeListen(std::net::SocketAddr),
17718    #[error("node browser bridge token must contain 1..=4096 bytes")]
17719    InvalidBridgeToken,
17720    #[error("bridge underlay requires --bridge-listen (local HTTP+WS door)")]
17721    BridgeUnderlayRequiresBridgeListen,
17722    #[error("bridge underlay requires GATE4AGENT_BRIDGE_TOKEN (application barrier)")]
17723    BridgeUnderlayRequiresBridgeToken,
17724    #[error("mesh underlay auth token must contain 1..=4096 bytes")]
17725    InvalidBridgeUnderlayToken,
17726    #[error("mesh underlay transport key must be 32 bytes (64 hex digits)")]
17727    InvalidBridgeUnderlayKey,
17728    #[error("node call-home relay must be loopback or a concrete C2 tunnel address with a nonzero port: {0}")]
17729    InvalidCallHome(std::net::SocketAddr),
17730    #[error("node could not reach its call-home relay: {0}")]
17731    CallHomeTransport(#[source] std::io::Error),
17732    #[error("node durable state path must be an absolute file path: {0}")]
17733    InvalidStatePath(String),
17734    #[error("harness MCP helper must be an exact reviewed absolute regular file")]
17735    InvalidHarnessMcpHelper,
17736    #[error("network allowlist catalog path must be an absolute regular file: {0}")]
17737    InvalidNetworkAllowlistCatalogPath(String),
17738    #[error("network allowlist catalog could not be read: {0}")]
17739    NetworkAllowlistCatalogIo(#[source] std::io::Error),
17740    #[error("network allowlist catalog line {line}: {message}")]
17741    InvalidNetworkAllowlistCatalogEntry { line: usize, message: String },
17742    #[error("network allowlist catalog: {0}")]
17743    InvalidNetworkAllowlistCatalog(String),
17744    #[error("network allowlist catalog exceeds the {max}-entry limit")]
17745    NetworkAllowlistCatalogCapacity { max: usize },
17746    #[error("the local state directory is unavailable; supply an explicit state path")]
17747    LocalStateDirectoryUnavailable,
17748    #[error("the local runtime directory is unavailable; supply an explicit endpoint")]
17749    LocalRuntimeDirectoryUnavailable,
17750    #[error("node durable state failed: {0}")]
17751    DurableState(io::Error),
17752    #[error("node requires at least one configured workspace")]
17753    NoWorkspaces,
17754    #[error("workspace '{workspace_id}' root '{path}' is invalid: {message}")]
17755    InvalidWorkspaceRoot {
17756        workspace_id: WorkspaceId,
17757        path: String,
17758        message: String,
17759    },
17760    #[error("duplicate workspace ID: {0}")]
17761    DuplicateWorkspaceId(WorkspaceId),
17762    #[error("workspaces '{first}' and '{second}' resolve to the same root '{root}'")]
17763    DuplicateWorkspaceRoot {
17764        first: WorkspaceId,
17765        second: WorkspaceId,
17766        root: String,
17767    },
17768    #[error("workspace '{0}' uses managed worktrees but has no local profile")]
17769    ManagedWorktreeProfileRequired(WorkspaceId),
17770    #[error("workspace '{0}' has managed worktree profiles but is not in managed mode")]
17771    ManagedWorktreeProfileModeMismatch(WorkspaceId),
17772    #[error("workspace '{workspace_id}' has duplicate managed worktree profile '{profile_id}'")]
17773    DuplicateManagedWorktreeProfile {
17774        workspace_id: WorkspaceId,
17775        profile_id: WorktreeProfileId,
17776    },
17777    #[error("workspace '{workspace_id}' exceeds the {max}-profile managed worktree limit")]
17778    ManagedWorktreeProfileCapacity {
17779        workspace_id: WorkspaceId,
17780        max: usize,
17781    },
17782    #[error("workspace '{workspace_id}' managed worktree profile '{profile_id}' is invalid: {message}")]
17783    InvalidManagedWorktreeProfile {
17784        workspace_id: WorkspaceId,
17785        profile_id: WorktreeProfileId,
17786        message: String,
17787    },
17788    #[error("duplicate node environment profile: {0}")]
17789    DuplicateEnvironmentProfile(SpawnEnvironmentProfileId),
17790    #[error("node environment profile provider is unavailable: {0}")]
17791    EnvironmentProfileProviderUnavailable(AgentId),
17792    #[error("node environment profile capacity is {max}")]
17793    EnvironmentProfileCapacity { max: usize },
17794    #[error("node environment profiles reuse native profile ID '{0}'")]
17795    DuplicateNativeEnvironmentProfile(String),
17796    #[error("node native environment profile is invalid: {0}")]
17797    NativeEnvironmentProfile(String),
17798    #[error("node session-environment materialization root must be absolute")]
17799    InvalidSessionEnvironmentRoot,
17800    #[error("node session-environment materializer is unavailable")]
17801    SessionEnvironmentMaterializerRequired,
17802    #[error("node session-environment materializer failed to initialize")]
17803    SessionEnvironmentMaterializer,
17804    #[error("node bundle catalog is invalid: {0}")]
17805    BundleCatalog(String),
17806    #[error("node delivery store failed to initialize: {0}")]
17807    DeliveryStore(String),
17808    #[error("node context pack store failed to initialize")]
17809    ContextPackStore,
17810    #[error("active agent registry failed: {0}")]
17811    Registry(String),
17812    #[error("node provider contract manifest is invalid: {0}")]
17813    ProviderContractManifest(String),
17814    #[error("named pipe I/O failed: {0}")]
17815    Io(#[from] io::Error),
17816    #[error("node HTTP observer failed: {0}")]
17817    HttpApi(io::Error),
17818    #[error("node browser bridge failed: {0}")]
17819    BridgeApi(io::Error),
17820    #[error(transparent)]
17821    Frame(#[from] FrameError),
17822    #[error("node handshake failed: {0}")]
17823    Handshake(String),
17824    #[error("node authentication frame was not received before the bounded deadline")]
17825    AuthenticationTimedOut,
17826    #[error("node authentication primitive failed: {0}")]
17827    Authentication(String),
17828    #[error("node incarnation identity generation failed: {0}")]
17829    IncarnationIdentity(String),
17830    #[error("node pre-authentication limiter is closed")]
17831    PreauthClosed,
17832    #[error("node authenticated connection limit was reached")]
17833    AuthenticatedConnectionLimit,
17834    #[error("node Ctrl+C/CtrlBreak signal stream closed")]
17835    SignalStreamClosed,
17836    #[error("node stop-all dispatch failed: {0:?}")]
17837    ShutdownDispatch(NodeFailure),
17838    #[error("node shutdown timed out with {active_native_sessions} physical sessions retained")]
17839    ShutdownTimedOut { active_native_sessions: usize },
17840}
17841
17842impl From<crate::network_allowlist_catalog::NetworkAllowlistCatalogError> for NodeServerError {
17843    fn from(error: crate::network_allowlist_catalog::NetworkAllowlistCatalogError) -> Self {
17844        use crate::network_allowlist_catalog::NetworkAllowlistCatalogError as E;
17845        match error {
17846            E::InvalidPath(path) => Self::InvalidNetworkAllowlistCatalogPath(path),
17847            E::Io(error) => Self::NetworkAllowlistCatalogIo(error),
17848            E::InvalidEntry { line, message } => {
17849                Self::InvalidNetworkAllowlistCatalogEntry { line, message }
17850            }
17851            E::InvalidCatalog(message) => Self::InvalidNetworkAllowlistCatalog(message),
17852            E::Capacity { max } => Self::NetworkAllowlistCatalogCapacity { max },
17853        }
17854    }
17855}
17856
17857#[cfg(test)]
17858mod agent_stream_blocked_tests {
17859    use super::*;
17860    use gate4agent_types::{
17861        AdapterId, AdapterVerification, HostRequestOutcome as ProviderHostRequestOutcome,
17862        ProviderSource,
17863    };
17864
17865    fn provider_source(family: AdapterFamily, adapter: &str) -> ProviderSource {
17866        ProviderSource {
17867            family,
17868            binding: AdapterBinding::new(
17869                AdapterId::new(adapter).unwrap(),
17870                "fixture/v1",
17871                AdapterVerification::SyntheticFixture,
17872            )
17873            .unwrap(),
17874        }
17875    }
17876
17877    fn provider_control_event_at(
17878        family: AdapterFamily,
17879        adapter: &str,
17880        source_sequence: u64,
17881        provider_event: ProviderEvent,
17882    ) -> ControlEvent {
17883        ControlEvent {
17884            sequence: 41,
17885            command_id: None,
17886            instance_id: AgentInstanceId(7),
17887            generation: SessionGeneration(3),
17888            event: ControlEventKind::ProviderEvent {
17889                sequence: 8,
17890                source: provider_source(family, adapter),
17891                source_sequence,
17892                event: provider_event,
17893            },
17894        }
17895    }
17896
17897    fn provider_control_event(family: AdapterFamily, provider_event: ProviderEvent) -> ControlEvent {
17898        provider_control_event_at(family, "fixture", 9, provider_event)
17899    }
17900
17901    fn denied_host_request(
17902        by: ProviderHostDecisionAuthority,
17903        reason: Option<String>,
17904    ) -> ControlEvent {
17905        provider_control_event_at(
17906            AdapterFamily::Acp,
17907            "claude-code",
17908            2,
17909            ProviderEvent::HostRequestObserved {
17910                method: "terminal/create".to_owned(),
17911                params_json: String::new(),
17912                decision: ProviderHostRequestDecision::Denied { by },
17913                outcome: ProviderHostRequestOutcome::Executed,
17914                reason,
17915            },
17916        )
17917    }
17918
17919    fn blocked(
17920        event: &ControlEvent,
17921    ) -> (
17922        Option<String>,
17923        String,
17924        BlockAuthorityV1,
17925        Option<String>,
17926        String,
17927        Option<String>,
17928    ) {
17929        let chunk = agent_stream_chunk(event).expect("expected a Blocked chunk");
17930        chunk.validate().expect("minted chunk must validate");
17931        let AgentStreamChunkKindV1::Blocked {
17932            correlation_id,
17933            tool_class,
17934            authority,
17935            reason_kind,
17936            reason,
17937            help,
17938        } = chunk.kind
17939        else {
17940            panic!("expected AgentStreamChunkKindV1::Blocked");
17941        };
17942        (correlation_id, tool_class, authority, reason_kind, reason, help)
17943    }
17944
17945    /// Pins the fleet and every adapter each member declares: the fleet is
17946    /// exactly what `active_registry` says, and no member declares a hook
17947    /// adapter (lifecycle hooks are retired).
17948    #[test]
17949    fn the_enabled_provider_matrix_is_exact() {
17950        let registry = active_registry().unwrap();
17951        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
17952        assert_eq!(
17953            providers
17954                .iter()
17955                .map(|contract| contract.provider.as_str())
17956                .collect::<Vec<_>>(),
17957            // `shell` is the plain PTY provider (operator terminals and smoke tests).
17958            vec!["shell", "claude", "codex", "grok", "kimi"],
17959        );
17960        assert!(adapters.iter().all(|contract| {
17961            !matches!(contract.family, AdapterFamily::Hook | AdapterFamily::ManagedHook)
17962        }));
17963    }
17964
17965    /// A gate-denied host request, WITH its reason text carried all the way
17966    /// from the ACP host, mints a `Blocked` chunk naming the gate.
17967    #[test]
17968    fn gate_denied_host_request_mints_a_blocked_chunk() {
17969        let gate_text =
17970            "blocked by dangerous-command gate: rule=filesystem-wipe, argument=rm -rf /";
17971        let event = denied_host_request(
17972            ProviderHostDecisionAuthority::Gate,
17973            Some(gate_text.to_owned()),
17974        );
17975        let chunk = agent_stream_chunk(&event).expect("expected a Blocked chunk");
17976        assert_eq!(chunk.source_sequence, 2);
17977        let (correlation_id, tool_class, authority, reason_kind, reason, help) = blocked(&event);
17978        assert_eq!(authority, BlockAuthorityV1::HostGate);
17979        assert_eq!(reason, gate_text);
17980        assert_eq!(correlation_id, None);
17981        assert_eq!(reason_kind, None);
17982        assert_eq!(help, None);
17983        assert_eq!(tool_class, "Write");
17984    }
17985
17986    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.";
17987
17988    /// The owner's classifier sample, verbatim, arriving as a
17989    /// `tool_call_update`-derived `ProviderEvent::ToolCompleted` with
17990    /// `non_execution_kind: "permission-rule"` -- mints a `Blocked` chunk with
17991    /// `authority: ProviderPermissionRule`, the `Reason:` sentence as `reason`
17992    /// and the guidance tail as `help`.
17993    #[test]
17994    fn classifier_sample_permission_rule_mints_a_blocked_chunk() {
17995        let event = provider_control_event(
17996            AdapterFamily::Acp,
17997            ProviderEvent::ToolCompleted {
17998                id: "toolu_9".to_owned(),
17999                output: CLASSIFIER_SAMPLE.to_owned(),
18000                is_error: true,
18001                duration_ms: None,
18002                agent_id: None,
18003                non_execution_kind: Some("permission-rule".to_owned()),
18004            },
18005        );
18006        let (correlation_id, _, authority, reason_kind, reason, help) = blocked(&event);
18007        assert_eq!(authority, BlockAuthorityV1::ProviderPermissionRule);
18008        assert_eq!(reason, "Blocked by classifier");
18009        assert_eq!(reason_kind, Some("permission-rule".to_owned()));
18010        assert!(help.as_deref().is_some_and(|help| help.starts_with("If you have other tasks")));
18011        assert!(correlation_id.is_some(), "a tool-correlated block must carry a correlation id");
18012    }
18013
18014    /// A `session/prompt` RPC error with `data.codexErrorInfo:
18015    /// "usageLimitExceeded"` (measured live 2026-09-05) mints a `Blocked` chunk
18016    /// with `authority: ProviderQuota`, the message verbatim as `reason` and the
18017    /// vendor code as `reason_kind`.
18018    #[test]
18019    fn codex_quota_rpc_error_mints_a_blocked_chunk() {
18020        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.";
18021        let event = provider_control_event(
18022            AdapterFamily::Acp,
18023            ProviderEvent::SessionEnded {
18024                result: "JSON-RPC error -32603: Internal error".to_owned(),
18025                cost_usd: None,
18026                is_error: true,
18027                stop_reason: Some(ProviderStopReason::ProviderError {
18028                    code: -32603,
18029                    message: message.to_owned(),
18030                    vendor_code: Some("usageLimitExceeded".to_owned()),
18031                }),
18032            },
18033        );
18034        let (_, tool_class, authority, reason_kind, reason, _) = blocked(&event);
18035        assert_eq!(authority, BlockAuthorityV1::ProviderQuota);
18036        assert_eq!(reason, message);
18037        assert_eq!(reason_kind, Some("usageLimitExceeded".to_owned()));
18038        assert_eq!(tool_class, "Session");
18039    }
18040
18041    /// ACP's `stopReason: "refusal"` mints a `Blocked` chunk with
18042    /// `authority: ProviderRefusal`.
18043    #[test]
18044    fn session_end_stop_reason_refusal_mints_a_blocked_chunk() {
18045        let event = provider_control_event(
18046            AdapterFamily::Acp,
18047            ProviderEvent::SessionEnded {
18048                result: "refusal".to_owned(),
18049                cost_usd: None,
18050                is_error: true,
18051                stop_reason: Some(ProviderStopReason::Refusal),
18052            },
18053        );
18054        let (_, _, authority, _, _, _) = blocked(&event);
18055        assert_eq!(authority, BlockAuthorityV1::ProviderRefusal);
18056    }
18057
18058    /// A failed tool completion with no typed `non_execution_kind` -- or one
18059    /// that only says the turn stopped -- is a plain execution failure, never a
18060    /// block; nor is an agent's own narrative claiming one.
18061    #[test]
18062    fn failures_and_narrative_without_a_typed_block_mint_no_blocked_chunk() {
18063        for non_execution_kind in [None, Some("interrupted".to_owned()), Some("cancelled".to_owned())] {
18064            let event = provider_control_event(
18065                AdapterFamily::Acp,
18066                ProviderEvent::ToolCompleted {
18067                    id: "toolu_1".to_owned(),
18068                    output: "command exited with status 1".to_owned(),
18069                    is_error: true,
18070                    duration_ms: Some(12),
18071                    agent_id: None,
18072                    non_execution_kind,
18073                },
18074            );
18075            assert!(agent_stream_chunk(&event).is_none());
18076        }
18077        let narrative = provider_control_event(
18078            AdapterFamily::Acp,
18079            ProviderEvent::ToolCompleted {
18080                id: "toolu_2".to_owned(),
18081                output: "the harness blocked the write, approval needed".to_owned(),
18082                is_error: false,
18083                duration_ms: Some(3),
18084                agent_id: None,
18085                non_execution_kind: None,
18086            },
18087        );
18088        assert!(agent_stream_chunk(&narrative).is_none());
18089    }
18090
18091    /// `Granted`/`Deferred` decisions never mint a `Blocked` chunk, whatever the
18092    /// execution outcome: a granted request that failed while running was
18093    /// still authorized.
18094    #[test]
18095    fn granted_and_deferred_host_requests_never_mint_a_blocked_chunk() {
18096        for (decision, outcome) in [
18097            (
18098                ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18099                ProviderHostRequestOutcome::Executed,
18100            ),
18101            (ProviderHostRequestDecision::Deferred, ProviderHostRequestOutcome::Executed),
18102            (
18103                ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18104                ProviderHostRequestOutcome::Failed {
18105                    error: "terminal/create spawn failed: os error 3".to_owned(),
18106                },
18107            ),
18108        ] {
18109            let event = provider_control_event_at(
18110                AdapterFamily::Acp,
18111                "claude-code",
18112                3,
18113                ProviderEvent::HostRequestObserved {
18114                    method: "fs/read_text_file".to_owned(),
18115                    params_json: String::new(),
18116                    decision: decision.clone(),
18117                    outcome,
18118                    reason: None,
18119                },
18120            );
18121            assert!(agent_stream_chunk(&event).is_none(), "{decision:?}");
18122        }
18123    }
18124
18125    /// A deferred `session/request_permission` resolved by an operator or an
18126    /// expired deadline carries no reason text -- the chunk falls back to a
18127    /// categorical sentence naming the authority rather than inventing detail.
18128    #[test]
18129    fn denied_host_request_with_no_reason_falls_back_to_a_categorical_sentence() {
18130        for (by, expected_authority, expected_reason) in [
18131            (
18132                ProviderHostDecisionAuthority::Operator,
18133                BlockAuthorityV1::Operator,
18134                "declined by the operator",
18135            ),
18136            (
18137                ProviderHostDecisionAuthority::DeadlinePolicy,
18138                BlockAuthorityV1::HostDeadline,
18139                "denied by host policy after the operator deadline expired",
18140            ),
18141        ] {
18142            let (_, _, authority, _, reason, _) = blocked(&denied_host_request(by, None));
18143            assert_eq!(authority, expected_authority);
18144            assert_eq!(reason, expected_reason);
18145        }
18146    }
18147
18148    /// An over-long reason is cut to the wire bound BEFORE the chunk is built,
18149    /// at a safe UTF-8 boundary -- `validate` rejects an over-long value
18150    /// outright rather than truncating.
18151    #[test]
18152    fn overlong_blocked_reason_is_truncated_to_the_wire_bound() {
18153        let oversized = "x".repeat(MAX_ACP_BLOCKED_REASON_BYTES + 500);
18154        let (_, _, _, _, reason, _) = blocked(&denied_host_request(
18155            ProviderHostDecisionAuthority::Policy,
18156            Some(oversized),
18157        ));
18158        assert!(reason.len() <= MAX_ACP_BLOCKED_REASON_BYTES);
18159    }
18160}
18161
18162#[cfg(test)]
18163mod standalone_capability_platform_tests {
18164    use super::*;
18165
18166    #[test]
18167    fn standalone_workspace_capability_requires_windows_identity_guards() {
18168        let capabilities = baseline_capabilities().unwrap();
18169        let advertised = capabilities.iter().any(|capability| {
18170            capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
18171        });
18172        assert_eq!(advertised, cfg!(windows));
18173
18174        let request = NodeRequest::CreateStandaloneWorkspace {
18175            workspace_id: WorkspaceId::new("standalone-platform").unwrap(),
18176            root: OpaqueHostPath::utf8("standalone-platform-root".to_owned()).unwrap(),
18177            initial_branch: None,
18178        };
18179        assert_eq!(
18180            request_uses_unnegotiated_capability(&request, &capabilities),
18181            !cfg!(windows),
18182        );
18183    }
18184}
18185
18186
18187#[cfg(test)]
18188mod station_knobs_resolve_tests {
18189    use super::*;
18190    use gate4agent_catalog::EnvMutation;
18191    use gate4agent_runtime_native::{
18192        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
18193        NativeLaunchProfileId,
18194    };
18195    use std::ffi::OsString;
18196    use std::sync::Arc;
18197
18198    struct EmptyEnvironmentResolver;
18199
18200    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
18201        fn resolve_child_environment(
18202            &self,
18203        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
18204            Ok(vec![EnvMutation {
18205                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
18206                value: None,
18207            }])
18208        }
18209    }
18210
18211    fn agent(value: &str) -> AgentId {
18212        AgentId::new(value).unwrap()
18213    }
18214
18215    fn station_knobs_fixture() -> (NodeShared, crate::protocol::SpawnProfileDefaults) {
18216        let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
18217        let profile_revision =
18218            crate::protocol::SpawnEnvironmentProfileRevision::new("local-claude-r1").unwrap();
18219        let spawn_defaults = crate::protocol::SpawnProfileDefaults {
18220            profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
18221            revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
18222            provider: agent("claude"),
18223            mode: SessionMode::Pty,
18224            terminal_size: gate4agent_types::TerminalSize {
18225                rows: 24,
18226                columns: 80,
18227            },
18228            prompt: None,
18229            bundle_id: None,
18230            context_id: None,
18231            environment_profile_id: Some(profile_id.clone()),
18232        };
18233        let spawn_profiles = SpawnProfileRegistry::new([spawn_defaults.clone()]).unwrap();
18234        let catalog = active_registry().unwrap();
18235        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18236        let control = runtime.native_launch_profile_control();
18237        let node_profile = NodeEnvironmentProfile::new(
18238            profile_id.clone(),
18239            profile_revision,
18240            agent("claude"),
18241            [NativeLaunchProfile::new(
18242                NativeLaunchProfileId::new("local-claude-pty").unwrap(),
18243                agent("claude"),
18244                TransportKind::Pty,
18245                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
18246                Arc::new(EmptyEnvironmentResolver),
18247            )
18248            .unwrap()],
18249        )
18250        .unwrap();
18251        let (binding, native_profiles, _materialization) = node_profile.into_parts();
18252        for native_profile in native_profiles {
18253            runtime.upsert_native_launch_profile(native_profile).unwrap();
18254        }
18255        let workspace = WorkspaceConfig::new(
18256            WorkspaceId::new("primary").unwrap(),
18257            std::env::current_dir().unwrap(),
18258        )
18259        .unwrap();
18260        let shared = NodeShared::new_with_incarnation(
18261            handle,
18262            "fixture-token".to_owned(),
18263            NodeId::new("node-station-knobs-test").unwrap(),
18264            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
18265            vec![workspace],
18266            vec![agent("claude")],
18267            ProviderRuntimeStatuses::default(),
18268            None,
18269            Vec::new(),
18270            Vec::new(),
18271            spawn_profiles,
18272            Some(control),
18273            None,
18274            None,
18275            None,
18276            Vec::new(),
18277            Vec::new(),
18278            Vec::new(),
18279            Vec::new(),
18280            None,
18281            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
18282        );
18283        shared
18284            .environment_profiles
18285            .write()
18286            .unwrap_or_else(|poisoned| poisoned.into_inner())
18287            .insert(binding.id.clone(), binding);
18288        (shared, spawn_defaults)
18289    }
18290
18291    /// Empty-default catalog refuses unknown network_allowlist ids.
18292    /// Feature-off dig2-station-probe keeps browser_profile stub echo.
18293    #[test]
18294    fn resolve_environment_profile_refuses_unknown_network_allowlist() {
18295        let (shared, defaults) = station_knobs_fixture();
18296        let unknown = crate::protocol::SpawnNetworkAllowlistId::new("not-registered").unwrap();
18297        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18298        let spec = crate::protocol::SpawnSpec {
18299            target: crate::protocol::SpawnTarget {
18300                node_id: shared.node_id.clone(),
18301                workspace_id: WorkspaceId::new("primary").unwrap(),
18302                worktree_id: None,
18303            },
18304            profile_id: defaults.profile_id.clone(),
18305            expected_profile_revision: defaults.revision.clone(),
18306            overrides: crate::protocol::SpawnOverrides {
18307                network_allowlist: Some(unknown),
18308                browser_profile_id: Some(browser),
18309                ..crate::protocol::SpawnOverrides::default()
18310            },
18311            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18312            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18313                "station-knobs-unknown-allowlist",
18314            )
18315            .unwrap(),
18316            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18317        };
18318        let resolved = spec.resolve(&defaults).unwrap();
18319        let failure = shared
18320            .resolve_environment_profile(&resolved)
18321            .expect_err("unknown allowlist must refuse");
18322        assert_eq!(failure.code, NodeFailureCode::UnknownNetworkAllowlist);
18323    }
18324
18325    /// Registered network_allowlist (+ browser_profile_id when feature off)
18326    /// echo onto the env-profile receipt. Empty catalog still allows omit
18327    /// (None). Empty/whitespace refuse is type-level. Feature-off
18328    /// dig2-station-probe keeps stub browser echo; feature-on refuse is
18329    /// covered by `…_refuses_browser_profile_when_station_probe_fails`.
18330    #[test]
18331    fn resolve_environment_profile_echoes_registered_station_network_and_browser_knobs() {
18332        let (shared, defaults) = station_knobs_fixture();
18333        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18334        #[cfg(not(feature = "dig2-station-probe"))]
18335        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18336        shared
18337            .network_allowlist_catalog
18338            .write()
18339            .unwrap_or_else(|poisoned| poisoned.into_inner())
18340            .insert_id(allowlist.clone())
18341            .unwrap();
18342        let spec = crate::protocol::SpawnSpec {
18343            target: crate::protocol::SpawnTarget {
18344                node_id: shared.node_id.clone(),
18345                workspace_id: WorkspaceId::new("primary").unwrap(),
18346                worktree_id: None,
18347            },
18348            profile_id: defaults.profile_id.clone(),
18349            expected_profile_revision: defaults.revision.clone(),
18350            overrides: crate::protocol::SpawnOverrides {
18351                network_allowlist: Some(allowlist.clone()),
18352                #[cfg(not(feature = "dig2-station-probe"))]
18353                browser_profile_id: Some(browser.clone()),
18354                ..crate::protocol::SpawnOverrides::default()
18355            },
18356            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18357            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-echo")
18358                .unwrap(),
18359            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18360        };
18361        let resolved = spec.resolve(&defaults).unwrap();
18362        assert_eq!(resolved.network_allowlist.as_ref(), Some(&allowlist));
18363        #[cfg(not(feature = "dig2-station-probe"))]
18364        assert_eq!(resolved.browser_profile_id.as_ref(), Some(&browser));
18365        let receipt = shared
18366            .resolve_environment_profile(&resolved)
18367            .expect("resolve ok")
18368            .expect("env profile present");
18369        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18370        #[cfg(not(feature = "dig2-station-probe"))]
18371        assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
18372        #[cfg(feature = "dig2-station-probe")]
18373        assert_eq!(receipt.browser_profile_id, None);
18374        assert_eq!(receipt.profile_id.as_str(), "local-claude");
18375
18376        let omitted = crate::protocol::SpawnSpec {
18377            overrides: crate::protocol::SpawnOverrides::default(),
18378            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-omit")
18379                .unwrap(),
18380            ..spec
18381        };
18382        let resolved_omit = omitted.resolve(&defaults).unwrap();
18383        let bare = shared
18384            .resolve_environment_profile(&resolved_omit)
18385            .expect("resolve ok")
18386            .expect("env profile present");
18387        assert_eq!(bare.network_allowlist, None);
18388        assert_eq!(bare.browser_profile_id, None);
18389    }
18390
18391    /// Feature `dig2-station-probe`: browser_profile_id set refuses when the
18392    /// cheap station probe cannot succeed. On non-Windows (this CI host) that
18393    /// is `BrowserStationProbeUnavailable`. Never cookies on C2. Lease map is
18394    /// unit-tested separately (probe refuses before lease on Linux).
18395    #[cfg(feature = "dig2-station-probe")]
18396    #[test]
18397    fn resolve_environment_profile_refuses_browser_profile_when_station_probe_fails() {
18398        let (shared, defaults) = station_knobs_fixture();
18399        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18400        let spec = crate::protocol::SpawnSpec {
18401            target: crate::protocol::SpawnTarget {
18402                node_id: shared.node_id.clone(),
18403                workspace_id: WorkspaceId::new("primary").unwrap(),
18404                worktree_id: None,
18405            },
18406            profile_id: defaults.profile_id.clone(),
18407            expected_profile_revision: defaults.revision.clone(),
18408            overrides: crate::protocol::SpawnOverrides {
18409                browser_profile_id: Some(browser),
18410                ..crate::protocol::SpawnOverrides::default()
18411            },
18412            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18413            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18414                "station-knobs-probe-refuse",
18415            )
18416            .unwrap(),
18417            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18418        };
18419        let resolved = spec.resolve(&defaults).unwrap();
18420        let failure = shared
18421            .resolve_environment_profile(&resolved)
18422            .expect_err("browser_profile with failing probe must refuse");
18423        #[cfg(windows)]
18424        {
18425            assert!(
18426                matches!(
18427                    failure.code,
18428                    NodeFailureCode::BrowserStationUnreachable
18429                        | NodeFailureCode::BrowserStationProbeUnavailable
18430                ),
18431                "unexpected code {:?}",
18432                failure.code
18433            );
18434        }
18435        #[cfg(not(windows))]
18436        {
18437            assert_eq!(
18438                failure.code,
18439                NodeFailureCode::BrowserStationProbeUnavailable
18440            );
18441        }
18442    }
18443
18444    /// Feature on: omitting browser_profile_id still resolves (no probe).
18445    #[cfg(feature = "dig2-station-probe")]
18446    #[test]
18447    fn resolve_environment_profile_skips_station_probe_when_browser_profile_omitted() {
18448        let (shared, defaults) = station_knobs_fixture();
18449        let spec = crate::protocol::SpawnSpec {
18450            target: crate::protocol::SpawnTarget {
18451                node_id: shared.node_id.clone(),
18452                workspace_id: WorkspaceId::new("primary").unwrap(),
18453                worktree_id: None,
18454            },
18455            profile_id: defaults.profile_id.clone(),
18456            expected_profile_revision: defaults.revision.clone(),
18457            overrides: crate::protocol::SpawnOverrides::default(),
18458            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18459            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18460                "station-knobs-probe-omit",
18461            )
18462            .unwrap(),
18463            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18464        };
18465        let resolved = spec.resolve(&defaults).unwrap();
18466        let receipt = shared
18467            .resolve_environment_profile(&resolved)
18468            .expect("omit browser_profile must not probe")
18469            .expect("env profile present");
18470        assert_eq!(receipt.browser_profile_id, None);
18471    }
18472
18473    /// Feature dig2-station-probe: exclusive BrowserStationLease on NodeShared
18474    /// refuses a second holder for the same opaque profile id; release on
18475    /// remove_binding allows reacquire. Never ImportSession/cookies on C2.
18476    #[cfg(feature = "dig2-station-probe")]
18477    #[test]
18478    fn browser_station_lease_exclusive_and_clears_on_remove_binding() {
18479        let (shared, _defaults) = station_knobs_fixture();
18480        let profile = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18481        let workspace_id = WorkspaceId::new("primary").unwrap();
18482        let address_a = SessionAddress {
18483            workspace_id: workspace_id.clone(),
18484            session: SessionKey {
18485                instance_id: AgentInstanceId(101),
18486                generation: SessionGeneration(1),
18487            },
18488        };
18489        let address_b = SessionAddress {
18490            workspace_id: workspace_id.clone(),
18491            session: SessionKey {
18492                instance_id: AgentInstanceId(102),
18493                generation: SessionGeneration(1),
18494            },
18495        };
18496        let guard_a = crate::dig2_station_lease::acquire_lease(
18497            &shared.browser_station_leases,
18498            profile.clone(),
18499            address_a.session.instance_id,
18500            1,
18501        )
18502        .expect("first lease");
18503        let busy = crate::dig2_station_lease::acquire_lease(
18504            &shared.browser_station_leases,
18505            profile.clone(),
18506            address_b.session.instance_id,
18507            2,
18508        )
18509        .expect_err("second holder must refuse");
18510        assert!(matches!(
18511            busy,
18512            crate::dig2_station_lease::BrowserStationLeaseError::ProfileBusy { .. }
18513        ));
18514        // Simulate a bound session so remove_binding clears the lease.
18515        shared.bind_session_with_policy(
18516            &address_a,
18517            ProviderRuntimePolicy::raw_pty(),
18518            None,
18519        );
18520        guard_a.retain();
18521        assert!(shared.remove_binding(&address_a).is_some());
18522        crate::dig2_station_lease::acquire_lease(
18523            &shared.browser_station_leases,
18524            profile,
18525            address_b.session.instance_id,
18526            3,
18527        )
18528        .expect("reacquire after clear")
18529        .retain();
18530        assert!(
18531            crate::dig2_station_lease::release_lease_for_holder(
18532                &shared.browser_station_leases,
18533                address_b.session.instance_id,
18534            )
18535        );
18536    }
18537
18538    /// Feature-off: no browser_station_leases field; stub echo documented in
18539    /// resolve_environment_profile_echoes_registered_station_network_and_browser_knobs.
18540    #[cfg(not(feature = "dig2-station-probe"))]
18541    #[test]
18542    fn browser_station_lease_feature_off_keeps_stub_without_lease_table() {
18543        let (shared, defaults) = station_knobs_fixture();
18544        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18545        let spec = crate::protocol::SpawnSpec {
18546            target: crate::protocol::SpawnTarget {
18547                node_id: shared.node_id.clone(),
18548                workspace_id: WorkspaceId::new("primary").unwrap(),
18549                worktree_id: None,
18550            },
18551            profile_id: defaults.profile_id.clone(),
18552            expected_profile_revision: defaults.revision.clone(),
18553            overrides: crate::protocol::SpawnOverrides {
18554                browser_profile_id: Some(browser.clone()),
18555                ..crate::protocol::SpawnOverrides::default()
18556            },
18557            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18558            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("lease-stub-off")
18559                .unwrap(),
18560            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18561        };
18562        let resolved = spec.resolve(&defaults).unwrap();
18563        let receipt = shared
18564            .resolve_environment_profile(&resolved)
18565            .expect("stub resolve")
18566            .expect("env profile");
18567        assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
18568    }
18569
18570
18571    /// Startup file loader registers ids into the empty-default catalog.
18572    /// Feature-off dig2 probe stays stub. Never secrets on C2.
18573    #[test]
18574    fn network_allowlist_catalog_loads_from_temp_file() {
18575        let unique = std::time::SystemTime::now()
18576            .duration_since(std::time::UNIX_EPOCH)
18577            .unwrap()
18578            .as_nanos();
18579        let path = std::env::temp_dir().join(format!(
18580            "gate4agent-node-allowlist-config-{}-{unique}.txt",
18581            std::process::id(),
18582        ));
18583        std::fs::write(
18584            &path,
18585            "# opaque station allowlist ids\negress-default\nlab-egress\n",
18586        )
18587        .unwrap();
18588        let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
18589            .expect("temp catalog file must load");
18590        let _ = std::fs::remove_file(&path);
18591        assert_eq!(loaded.len(), 2);
18592        let (shared, defaults) = station_knobs_fixture();
18593        {
18594            let mut catalog = shared
18595                .network_allowlist_catalog
18596                .write()
18597                .unwrap_or_else(|poisoned| poisoned.into_inner());
18598            *catalog = loaded;
18599        }
18600        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18601        let spec = crate::protocol::SpawnSpec {
18602            target: crate::protocol::SpawnTarget {
18603                node_id: shared.node_id.clone(),
18604                workspace_id: WorkspaceId::new("primary").unwrap(),
18605                worktree_id: None,
18606            },
18607            profile_id: defaults.profile_id.clone(),
18608            expected_profile_revision: defaults.revision.clone(),
18609            overrides: crate::protocol::SpawnOverrides {
18610                network_allowlist: Some(allowlist.clone()),
18611                ..crate::protocol::SpawnOverrides::default()
18612            },
18613            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18614            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18615                "station-knobs-catalog-file",
18616            )
18617            .unwrap(),
18618            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18619        };
18620        let resolved = spec.resolve(&defaults).unwrap();
18621        let receipt = shared
18622            .resolve_environment_profile(&resolved)
18623            .expect("registered via file load")
18624            .expect("env profile present");
18625        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18626    }
18627
18628    /// Snapshot launch inventory echoes registered catalog ids (opaque).
18629    /// Dig2 station probe is orthogonal (feature `dig2-station-probe`). Never secrets on C2.
18630    #[test]
18631    fn snapshot_launch_inventory_lists_network_allowlist_catalog_ids() {
18632        let (shared, _defaults) = station_knobs_fixture();
18633        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18634        shared
18635            .network_allowlist_catalog
18636            .write()
18637            .unwrap_or_else(|poisoned| poisoned.into_inner())
18638            .insert_id(allowlist.clone())
18639            .unwrap();
18640        let snapshot = shared.snapshot();
18641        let inventory = snapshot
18642            .launch_inventory
18643            .expect("launch inventory present");
18644        let ids = inventory
18645            .network_allowlists
18646            .expect("non-empty catalog surfaces ids");
18647        assert_eq!(ids, vec![allowlist]);
18648    }
18649
18650    /// Track B: registered id with provider_native codex_network_access refuses
18651    /// clearly for Claude (fixture provider). Unknown id still UnknownNetworkAllowlist.
18652    /// Never secrets on C2. Codex Moderate maps to `-c` overlay; RO/FullAuto refuse.
18653    #[test]
18654    fn resolve_environment_profile_refuses_unsupported_provider_native_mapping() {
18655        let (shared, defaults) = station_knobs_fixture();
18656        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-only").unwrap();
18657        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18658            id: allowlist.clone(),
18659            permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
18660                protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18661                peer: "127.0.0.1:443".to_owned(),
18662            }],
18663            provider_native: Some(
18664                crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
18665                    codex_network_access: Some(true),
18666                },
18667            ),
18668        };
18669        shared
18670            .network_allowlist_catalog
18671            .write()
18672            .unwrap_or_else(|poisoned| poisoned.into_inner())
18673            .insert(entry)
18674            .unwrap();
18675        let spec = crate::protocol::SpawnSpec {
18676            target: crate::protocol::SpawnTarget {
18677                node_id: shared.node_id.clone(),
18678                workspace_id: WorkspaceId::new("primary").unwrap(),
18679                worktree_id: None,
18680            },
18681            profile_id: defaults.profile_id.clone(),
18682            expected_profile_revision: defaults.revision.clone(),
18683            overrides: crate::protocol::SpawnOverrides {
18684                network_allowlist: Some(allowlist),
18685                ..crate::protocol::SpawnOverrides::default()
18686            },
18687            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18688            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18689                "station-knobs-unsupported-mapping",
18690            )
18691            .unwrap(),
18692            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18693        };
18694        let resolved = spec.resolve(&defaults).unwrap();
18695        assert_eq!(resolved.provider.as_str(), "claude");
18696        let failure = shared
18697            .resolve_environment_profile(&resolved)
18698            .expect_err("claude + codex_network_access must refuse");
18699        assert_eq!(
18700            failure.code,
18701            NodeFailureCode::UnsupportedNetworkAllowlistMapping
18702        );
18703    }
18704
18705    /// Codex Moderate + codex_network_access yields `-c` overlay argv.
18706    /// ReadOnly / FullAuto refuse UnsupportedNetworkAllowlistMapping.
18707    /// Never secrets on C2.
18708    #[test]
18709    fn resolve_codex_network_access_overlay_moderate_only() {
18710        let (shared, defaults) = station_knobs_fixture();
18711        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-net").unwrap();
18712        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18713            id: allowlist.clone(),
18714            permits: Vec::new(),
18715            provider_native: Some(
18716                crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
18717                    codex_network_access: Some(true),
18718                },
18719            ),
18720        };
18721        shared
18722            .network_allowlist_catalog
18723            .write()
18724            .unwrap_or_else(|poisoned| poisoned.into_inner())
18725            .insert(entry)
18726            .unwrap();
18727
18728        let overlay = shared
18729            .resolve_codex_network_access_overlay_args(
18730                &agent("codex"),
18731                SessionMode::Pty,
18732                ApprovalLevel::Moderate,
18733                Some(&allowlist),
18734            )
18735            .expect("Moderate Codex maps network_access");
18736        assert_eq!(
18737            overlay,
18738            vec![
18739                std::ffi::OsString::from("-c"),
18740                std::ffi::OsString::from("sandbox_workspace_write.network_access=true"),
18741            ]
18742        );
18743
18744        let acp = shared
18745            .resolve_codex_network_access_overlay_args(
18746                &agent("codex"),
18747                SessionMode::Acp,
18748                ApprovalLevel::Moderate,
18749                Some(&allowlist),
18750            )
18751            .unwrap();
18752        assert_eq!(acp, overlay);
18753
18754        let inline = shared
18755            .resolve_codex_network_access_overlay_args(
18756                &agent("codex"),
18757                SessionMode::Inline,
18758                ApprovalLevel::Moderate,
18759                Some(&allowlist),
18760            )
18761            .unwrap_err();
18762        assert_eq!(
18763            inline.code,
18764            NodeFailureCode::UnsupportedNetworkAllowlistMapping
18765        );
18766
18767        for level in [ApprovalLevel::ReadOnly, ApprovalLevel::FullAuto] {
18768            let spec = crate::protocol::SpawnSpec {
18769                target: crate::protocol::SpawnTarget {
18770                    node_id: shared.node_id.clone(),
18771                    workspace_id: WorkspaceId::new("primary").unwrap(),
18772                    worktree_id: None,
18773                },
18774                profile_id: defaults.profile_id.clone(),
18775                expected_profile_revision: defaults.revision.clone(),
18776                overrides: crate::protocol::SpawnOverrides {
18777                    provider: crate::protocol::SpawnOverride::Set {
18778                        value: agent("codex"),
18779                    },
18780                    approval_level: Some(level),
18781                    network_allowlist: Some(allowlist.clone()),
18782                    ..crate::protocol::SpawnOverrides::default()
18783                },
18784                deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18785                idempotency_key: crate::protocol::SpawnIdempotencyKey::new(format!(
18786                    "codex-net-refuse-{}",
18787                    match level {
18788                        ApprovalLevel::ReadOnly => "ro",
18789                        ApprovalLevel::FullAuto => "fa",
18790                        _ => "other",
18791                    }
18792                ))
18793                .unwrap(),
18794                required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18795            };
18796            let resolved = spec.resolve(&defaults).unwrap();
18797            let failure = shared
18798                .resolve_environment_profile(&resolved)
18799                .expect_err("RO/FullAuto must refuse");
18800            assert_eq!(
18801                failure.code,
18802                NodeFailureCode::UnsupportedNetworkAllowlistMapping
18803            );
18804        }
18805    }
18806
18807    /// Claude PTY + catalog permits → ProviderHome `--settings` sidecar with
18808    /// sandbox.network.allowedDomains. ACP / missing ProviderHome refuse.
18809    /// Never secrets on C2; does not invent provider_native Claude keys.
18810    #[test]
18811    fn resolve_claude_settings_network_overlay_pty_writes_sidecar() {
18812        let (shared, _defaults) = station_knobs_fixture();
18813        let allowlist =
18814            crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-domains").unwrap();
18815        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18816            id: allowlist.clone(),
18817            permits: vec![
18818                crate::network_allowlist_catalog::NetworkPermitSketch {
18819                    protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18820                    peer: "github.com:443".to_owned(),
18821                },
18822                crate::network_allowlist_catalog::NetworkPermitSketch {
18823                    protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18824                    peer: "*.npmjs.org".to_owned(),
18825                },
18826            ],
18827            provider_native: None,
18828        };
18829        shared
18830            .network_allowlist_catalog
18831            .write()
18832            .unwrap_or_else(|poisoned| poisoned.into_inner())
18833            .insert(entry)
18834            .unwrap();
18835
18836        let unique = std::time::SystemTime::now()
18837            .duration_since(std::time::UNIX_EPOCH)
18838            .unwrap()
18839            .as_nanos();
18840        let provider_home = std::env::temp_dir().join(format!(
18841            "gate4agent-claude-provider-home-{}-{unique}",
18842            std::process::id(),
18843        ));
18844        std::fs::create_dir_all(&provider_home).unwrap();
18845
18846        let overlay = shared
18847            .resolve_network_allowlist_overlay_args(
18848                &agent("claude"),
18849                SessionMode::Pty,
18850                ApprovalLevel::Moderate,
18851                Some(&allowlist),
18852                Some(provider_home.as_path()),
18853            )
18854            .expect("Claude PTY settings overlay on unix");
18855        let settings_path = provider_home.join(
18856            crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
18857        );
18858        assert_eq!(
18859            overlay,
18860            vec![
18861                std::ffi::OsString::from("--settings"),
18862                std::ffi::OsString::from(settings_path.as_os_str()),
18863            ]
18864        );
18865        let body = std::fs::read_to_string(&settings_path).unwrap();
18866        let value: serde_json::Value = serde_json::from_str(&body).unwrap();
18867        assert_eq!(value["sandbox"]["enabled"], true);
18868        assert_eq!(
18869            value["sandbox"]["network"]["allowedDomains"],
18870            serde_json::json!(["github.com", "*.npmjs.org"])
18871        );
18872        assert!(settings_path.starts_with(&provider_home));
18873
18874        let acp = shared
18875            .resolve_network_allowlist_overlay_args(
18876                &agent("claude"),
18877                SessionMode::Acp,
18878                ApprovalLevel::Moderate,
18879                Some(&allowlist),
18880                Some(provider_home.as_path()),
18881            )
18882            .expect_err("Claude ACP must refuse settings overlay");
18883        assert_eq!(
18884            acp.code,
18885            NodeFailureCode::UnsupportedNetworkAllowlistMapping
18886        );
18887
18888        let no_home = shared
18889            .resolve_network_allowlist_overlay_args(
18890                &agent("claude"),
18891                SessionMode::Pty,
18892                ApprovalLevel::Moderate,
18893                Some(&allowlist),
18894                None,
18895            )
18896            .expect_err("Claude overlay requires ProviderHome");
18897        assert_eq!(
18898            no_home.code,
18899            NodeFailureCode::UnsupportedNetworkAllowlistMapping
18900        );
18901
18902        let bare = crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-bare").unwrap();
18903        shared
18904            .network_allowlist_catalog
18905            .write()
18906            .unwrap_or_else(|poisoned| poisoned.into_inner())
18907            .insert_id(bare.clone())
18908            .unwrap();
18909        let empty = shared
18910            .resolve_network_allowlist_overlay_args(
18911                &agent("claude"),
18912                SessionMode::Pty,
18913                ApprovalLevel::Moderate,
18914                Some(&bare),
18915                Some(provider_home.as_path()),
18916            )
18917            .unwrap();
18918        assert!(empty.is_empty());
18919
18920        let _ = std::fs::remove_dir_all(&provider_home);
18921    }
18922
18923    /// Kimi / Grok with permits stay without invented argv overlays.
18924    #[test]
18925    fn resolve_kimi_grok_network_overlay_stays_empty_without_native() {
18926        let (shared, _defaults) = station_knobs_fixture();
18927        let allowlist =
18928            crate::protocol::SpawnNetworkAllowlistId::new("egress-kimi-permits").unwrap();
18929        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18930            id: allowlist.clone(),
18931            permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
18932                protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18933                peer: "example.com:443".to_owned(),
18934            }],
18935            provider_native: None,
18936        };
18937        shared
18938            .network_allowlist_catalog
18939            .write()
18940            .unwrap_or_else(|poisoned| poisoned.into_inner())
18941            .insert(entry)
18942            .unwrap();
18943        for provider in ["kimi", "grok"] {
18944            let overlay = shared
18945                .resolve_network_allowlist_overlay_args(
18946                    &agent(provider),
18947                    SessionMode::Pty,
18948                    ApprovalLevel::Moderate,
18949                    Some(&allowlist),
18950                    None,
18951                )
18952                .unwrap();
18953            assert!(
18954                overlay.is_empty(),
18955                "{provider} must not invent network argv from permits"
18956            );
18957        }
18958    }
18959
18960    /// Track B: JSON v2 catalog file loads permits; inventory still ids only.
18961    #[test]
18962    fn network_allowlist_catalog_loads_json_v2_and_lists_ids_only() {
18963        let unique = std::time::SystemTime::now()
18964            .duration_since(std::time::UNIX_EPOCH)
18965            .unwrap()
18966            .as_nanos();
18967        let path = std::env::temp_dir().join(format!(
18968            "gate4agent-node-allowlist-json-{}-{unique}.json",
18969            std::process::id(),
18970        ));
18971        std::fs::write(
18972            &path,
18973            r#"{
18974              "schema_version": 2,
18975              "entries": [
18976                {
18977                  "id": "egress-default",
18978                  "permits": [{ "protocol": "tcp", "peer": "127.0.0.1:443" }]
18979                },
18980                { "id": "lab-egress" }
18981              ]
18982            }"#,
18983        )
18984        .unwrap();
18985        let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
18986            .expect("json v2 catalog must load");
18987        let _ = std::fs::remove_file(&path);
18988        assert_eq!(loaded.len(), 2);
18989        let entry = loaded
18990            .get(&crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap())
18991            .unwrap();
18992        assert_eq!(entry.permits.len(), 1);
18993        let (shared, defaults) = station_knobs_fixture();
18994        {
18995            let mut catalog = shared
18996                .network_allowlist_catalog
18997                .write()
18998                .unwrap_or_else(|poisoned| poisoned.into_inner());
18999            *catalog = loaded;
19000        }
19001        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19002        let spec = crate::protocol::SpawnSpec {
19003            target: crate::protocol::SpawnTarget {
19004                node_id: shared.node_id.clone(),
19005                workspace_id: WorkspaceId::new("primary").unwrap(),
19006                worktree_id: None,
19007            },
19008            profile_id: defaults.profile_id.clone(),
19009            expected_profile_revision: defaults.revision.clone(),
19010            overrides: crate::protocol::SpawnOverrides {
19011                network_allowlist: Some(allowlist.clone()),
19012                ..crate::protocol::SpawnOverrides::default()
19013            },
19014            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19015            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19016                "station-knobs-catalog-json",
19017            )
19018            .unwrap(),
19019            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19020        };
19021        let resolved = spec.resolve(&defaults).unwrap();
19022        let receipt = shared
19023            .resolve_environment_profile(&resolved)
19024            .expect("permits-only entry ok for claude")
19025            .expect("env profile present");
19026        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
19027        let inventory = shared
19028            .snapshot()
19029            .launch_inventory
19030            .expect("launch inventory present");
19031        let ids = inventory.network_allowlists.expect("ids surface");
19032        assert_eq!(
19033            ids,
19034            vec![
19035                crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap(),
19036                crate::protocol::SpawnNetworkAllowlistId::new("lab-egress").unwrap(),
19037            ]
19038        );
19039    }
19040
19041
19042}
19043
19044
19045#[cfg(test)]
19046mod workspace_environment_collision_tests {
19047    use super::*;
19048    use crate::session_environment::{
19049        NodeSessionMaterializationProfile, NodeSessionPathBinding, NodeSessionPathClass,
19050    };
19051    use gate4agent_catalog::EnvMutation;
19052    use gate4agent_runtime_native::{
19053        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
19054        NativeLaunchProfileId,
19055    };
19056    use std::ffi::OsString;
19057    use std::sync::Arc;
19058
19059    struct EmptyEnvironmentResolver;
19060
19061    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19062        fn resolve_child_environment(
19063            &self,
19064        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19065            Ok(vec![EnvMutation {
19066                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19067                value: None,
19068            }])
19069        }
19070    }
19071
19072    fn agent(value: &str) -> AgentId {
19073        AgentId::new(value).unwrap()
19074    }
19075
19076    /// Two Claude ProviderHome profiles (`CLAUDE_CONFIG_DIR` A vs B) on one node.
19077    fn claude_config_dir_collision_fixture() -> (
19078        NodeShared,
19079        ResolvedEnvironmentProfileReceipt,
19080        ResolvedEnvironmentProfileReceipt,
19081    ) {
19082        let catalog = active_registry().unwrap();
19083        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19084        let control = runtime.native_launch_profile_control();
19085        let workspace = WorkspaceConfig::new(
19086            WorkspaceId::new("primary").unwrap(),
19087            std::env::current_dir().unwrap(),
19088        )
19089        .unwrap();
19090        let shared = NodeShared::new_with_incarnation(
19091            handle,
19092            "fixture-token".to_owned(),
19093            NodeId::new("node-env-collision-test").unwrap(),
19094            NodeIncarnationId::from_bytes([3; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19095            vec![workspace],
19096            vec![agent("claude")],
19097            ProviderRuntimeStatuses::default(),
19098            None,
19099            Vec::new(),
19100            Vec::new(),
19101            SpawnProfileRegistry::default(),
19102            Some(control),
19103            None,
19104            None,
19105            None,
19106            Vec::new(),
19107            Vec::new(),
19108            Vec::new(),
19109            Vec::new(),
19110            None,
19111            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19112        );
19113
19114        let mut receipts = Vec::new();
19115        for (suffix, native_suffix) in [("a", "a-pty"), ("b", "b-pty")] {
19116            let profile_id =
19117                SpawnEnvironmentProfileId::new(format!("claude-config-dir-{suffix}")).unwrap();
19118            let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(format!(
19119                "claude-config-dir-{suffix}-r1"
19120            ))
19121            .unwrap();
19122            let materialization = NodeSessionMaterializationProfile::new(
19123                Vec::new(),
19124                vec![NodeSessionPathBinding::new(
19125                    "CLAUDE_CONFIG_DIR",
19126                    NodeSessionPathClass::ProviderHome,
19127                )
19128                .unwrap()],
19129                Vec::new(),
19130            )
19131            .unwrap();
19132            let node_profile = NodeEnvironmentProfile::new_with_materialization(
19133                profile_id.clone(),
19134                profile_revision.clone(),
19135                agent("claude"),
19136                [NativeLaunchProfile::new(
19137                    NativeLaunchProfileId::new(format!("claude-config-dir-{native_suffix}"))
19138                        .unwrap(),
19139                    agent("claude"),
19140                    TransportKind::Pty,
19141                    vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19142                    Arc::new(EmptyEnvironmentResolver),
19143                )
19144                .unwrap()],
19145                Some(materialization),
19146            )
19147            .unwrap();
19148            let (binding, native_profiles, materialization) = node_profile.into_parts();
19149            for native_profile in native_profiles {
19150                runtime.upsert_native_launch_profile(native_profile).unwrap();
19151            }
19152            shared
19153                .environment_profiles
19154                .write()
19155                .unwrap_or_else(|poisoned| poisoned.into_inner())
19156                .insert(binding.id.clone(), binding);
19157            shared
19158                .environment_materialization_profiles
19159                .write()
19160                .unwrap_or_else(|poisoned| poisoned.into_inner())
19161                .insert(profile_id.clone(), materialization.unwrap());
19162            receipts.push(ResolvedEnvironmentProfileReceipt {
19163                profile_id,
19164                profile_revision,
19165                network_allowlist: None,
19166                browser_profile_id: None,
19167            });
19168        }
19169        let profile_b = receipts.pop().unwrap();
19170        let profile_a = receipts.pop().unwrap();
19171        (shared, profile_a, profile_b)
19172    }
19173
19174    #[test]
19175    fn refuse_distinct_claude_config_dir_profiles_on_same_workspace_cwd() {
19176        let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19177        let primary = WorkspaceId::new("primary").unwrap();
19178        let worktree = WorkspaceId::new("worktree-b").unwrap();
19179        let address = SessionAddress {
19180            workspace_id: primary.clone(),
19181            session: SessionKey {
19182                instance_id: AgentInstanceId(41),
19183                generation: SessionGeneration(1),
19184            },
19185        };
19186        shared.bind_session_with_policy(
19187            &address,
19188            ProviderRuntimePolicy::raw_pty(),
19189            Some(profile_a.clone()),
19190        );
19191
19192        let refused = shared
19193            .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_b))
19194            .unwrap_err();
19195        assert_eq!(refused.code, NodeFailureCode::WorkspaceBusy);
19196
19197        // Some vs None on the same cwd is also a collision.
19198        let refused_none = shared
19199            .ensure_workspace_environment_profile_compatible(&primary, None)
19200            .unwrap_err();
19201        assert_eq!(refused_none.code, NodeFailureCode::WorkspaceBusy);
19202
19203        // Same profile on the same workspace remains allowed.
19204        shared
19205            .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_a))
19206            .unwrap();
19207
19208        // Distinct workspace id (managed-worktree parallel path) allows profile B.
19209        shared
19210            .ensure_workspace_environment_profile_compatible(&worktree, Some(&profile_b))
19211            .unwrap();
19212    }
19213
19214    #[tokio::test]
19215    async fn spawn_session_refuses_conflicting_environment_profile_before_mutation() {
19216        let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19217        let primary = WorkspaceId::new("primary").unwrap();
19218        let address = SessionAddress {
19219            workspace_id: primary.clone(),
19220            session: SessionKey {
19221                instance_id: AgentInstanceId(41),
19222                generation: SessionGeneration(1),
19223            },
19224        };
19225        shared.bind_session_with_policy(
19226            &address,
19227            ProviderRuntimePolicy::raw_pty(),
19228            Some(profile_a),
19229        );
19230
19231        let failure = shared
19232            .spawn_session_with_deadline(
19233                primary,
19234                agent("claude"),
19235                SessionMode::Pty,
19236                gate4agent_types::TerminalSize {
19237                    rows: 24,
19238                    columns: 80,
19239                },
19240                None,
19241                Some(profile_b),
19242                None,
19243                None,
19244                None,
19245                None,
19246                SpawnRecordPolicy::ProviderIdentityOnly,
19247                Some(Instant::now() + Duration::from_secs(30)),
19248                &[],
19249                None,
19250                ApprovalLevel::default(),
19251                None,
19252                None,
19253            )
19254            .await
19255            .unwrap_err();
19256        assert_eq!(failure.code, NodeFailureCode::WorkspaceBusy);
19257        // Guard runs before instance id mint / bind.
19258        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
19259        assert!(shared.handle.snapshot().sessions.is_empty());
19260    }
19261}
19262
19263#[cfg(test)]
19264mod spawn_characterization_tests {
19265    use super::*;
19266    use crate::session_environment::{
19267        NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
19268        NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
19269        NodeSessionPathClass,
19270    };
19271    use gate4agent_catalog::EnvMutation;
19272    use gate4agent_runtime_native::{
19273        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
19274        NativeLaunchProfile, NativeLaunchProfileId,
19275    };
19276    use std::ffi::OsString;
19277    use std::sync::Arc;
19278
19279    struct EmptyEnvironmentResolver;
19280
19281    struct FixtureSecretResolver {
19282        deny: Arc<AtomicBool>,
19283    }
19284
19285    impl NodeSecretResolver for FixtureSecretResolver {
19286        fn resolve(
19287            &self,
19288            _reference: &NodeSecretReference,
19289        ) -> Result<NodeSecretValue, NodeSecretResolveError> {
19290            if self.deny.load(Ordering::Acquire) {
19291                Err(NodeSecretResolveError::Denied)
19292            } else {
19293                NodeSecretValue::text("fixture-secret")
19294                    .map_err(|_| NodeSecretResolveError::Unavailable)
19295            }
19296        }
19297    }
19298
19299    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19300        fn resolve_child_environment(
19301            &self,
19302        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19303            Ok(vec![EnvMutation {
19304                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19305                value: None,
19306            }])
19307        }
19308    }
19309
19310    fn agent(value: &str) -> AgentId {
19311        AgentId::new(value).unwrap()
19312    }
19313
19314    fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
19315        let unique = std::time::SystemTime::now()
19316            .duration_since(std::time::UNIX_EPOCH)
19317            .unwrap()
19318            .as_nanos();
19319        std::env::temp_dir().join(format!(
19320            "gate4agent-node-{label}-{}-{unique}",
19321            std::process::id(),
19322        ))
19323    }
19324
19325    fn materialization_test_shared(
19326        deny_secret: bool,
19327    ) -> (
19328        NodeShared,
19329        NativeRuntime,
19330        ResolvedEnvironmentProfileReceipt,
19331        PathBuf,
19332        Arc<AtomicBool>,
19333    ) {
19334        let workspace = WorkspaceConfig::new(
19335            WorkspaceId::new("primary").unwrap(),
19336            std::env::current_dir().unwrap(),
19337        )
19338        .unwrap();
19339        materialization_test_shared_with_workspace(deny_secret, workspace)
19340    }
19341
19342    fn materialization_test_shared_with_workspace(
19343        deny_secret: bool,
19344        workspace: WorkspaceConfig,
19345    ) -> (
19346        NodeShared,
19347        NativeRuntime,
19348        ResolvedEnvironmentProfileReceipt,
19349        PathBuf,
19350        Arc<AtomicBool>,
19351    ) {
19352        let root = temporary_workspace_root(if deny_secret {
19353            "materialization-denied"
19354        } else {
19355            "materialization-ready"
19356        });
19357        std::fs::create_dir_all(&root).unwrap();
19358        let materialization_root = root.join("session-environments");
19359        let deny = Arc::new(AtomicBool::new(deny_secret));
19360        let resolver = Arc::new(FixtureSecretResolver {
19361            deny: Arc::clone(&deny),
19362        });
19363        let materializer = SessionEnvironmentMaterializer::new(
19364            materialization_root,
19365            resolver,
19366        )
19367        .unwrap();
19368        let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
19369        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19370            "materialized-claude-r1",
19371        )
19372        .unwrap();
19373        let materialization = NodeSessionMaterializationProfile::new(
19374            vec![NodeSessionEnvironmentMutation::SetSecret {
19375                key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
19376                reference: NodeSecretReference::new("fixture-token").unwrap(),
19377            }],
19378            vec![NodeSessionPathBinding::new(
19379                "GATE4AGENT_PROVIDER_HOME",
19380                NodeSessionPathClass::ProviderHome,
19381            )
19382            .unwrap()],
19383            vec![NodeSessionFile::secret(
19384                NodeSessionPathClass::Config,
19385                "auth/token",
19386                NodeSecretReference::new("fixture-token").unwrap(),
19387            )
19388            .unwrap()],
19389        )
19390        .unwrap();
19391        let catalog = active_registry().unwrap();
19392        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19393        let control = runtime.native_launch_profile_control();
19394        let node_profile = NodeEnvironmentProfile::new_with_materialization(
19395            profile_id.clone(),
19396            profile_revision.clone(),
19397            agent("claude"),
19398            [NativeLaunchProfile::new(
19399                NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
19400                agent("claude"),
19401                TransportKind::Pty,
19402                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19403                Arc::new(EmptyEnvironmentResolver),
19404            )
19405            .unwrap()],
19406            Some(materialization),
19407        )
19408        .unwrap();
19409        let (binding, native_profiles, materialization) = node_profile.into_parts();
19410        for native_profile in native_profiles {
19411            runtime.upsert_native_launch_profile(native_profile).unwrap();
19412        }
19413        let shared = NodeShared::new_with_incarnation(
19414            handle,
19415            "fixture-token".to_owned(),
19416            NodeId::new("node-materialization-test").unwrap(),
19417            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19418            vec![workspace],
19419            vec![agent("claude")],
19420            ProviderRuntimeStatuses::default(),
19421            None,
19422            Vec::new(),
19423            Vec::new(),
19424            SpawnProfileRegistry::default(),
19425            Some(control),
19426            None,
19427            Some(materializer),
19428            None,
19429            Vec::new(),
19430            Vec::new(),
19431            Vec::new(),
19432            Vec::new(),
19433            None,
19434            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19435        );
19436        shared
19437            .environment_profiles
19438            .write()
19439            .unwrap_or_else(|poisoned| poisoned.into_inner())
19440            .insert(binding.id.clone(), binding);
19441        shared
19442            .environment_materialization_profiles
19443            .write()
19444            .unwrap_or_else(|poisoned| poisoned.into_inner())
19445            .insert(profile_id.clone(), materialization.unwrap());
19446        (
19447            shared,
19448            runtime,
19449            ResolvedEnvironmentProfileReceipt {
19450                profile_id,
19451                profile_revision,
19452                network_allowlist: None,
19453                browser_profile_id: None,
19454            },
19455            root,
19456            deny,
19457        )
19458    }
19459
19460
19461    // ---- Characterization of the spawn preparation block (pre-SpawnPreparer) ----
19462
19463    async fn characterization_spawn(
19464        shared: &NodeShared,
19465        mode: SessionMode,
19466        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
19467        required: &[ProviderRuntimeCapability],
19468        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
19469    ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
19470        shared
19471            .spawn_session_with_deadline(
19472                WorkspaceId::new("primary").unwrap(),
19473                agent("claude"),
19474                mode,
19475                gate4agent_types::TerminalSize { rows: 24, columns: 80 },
19476                None,
19477                environment_profile,
19478                None,
19479                None,
19480                None,
19481                Some(ProviderRuntimePolicy::raw_pty()),
19482                SpawnRecordPolicy::ProviderIdentityOnly,
19483                Some(Instant::now() + Duration::from_secs(30)),
19484                required,
19485                harness_mcp,
19486                ApprovalLevel::default(),
19487                None,
19488                None,
19489            )
19490            .await
19491    }
19492
19493    fn assert_no_session_state(shared: &NodeShared) {
19494        assert!(shared.handle.snapshot().sessions.is_empty());
19495        assert!(shared
19496            .session_bindings
19497            .lock()
19498            .unwrap_or_else(|poisoned| poisoned.into_inner())
19499            .is_empty());
19500        assert!(shared
19501            .materializations
19502            .lock()
19503            .unwrap_or_else(|poisoned| poisoned.into_inner())
19504            .is_empty());
19505    }
19506
19507    #[tokio::test]
19508    async fn spawn_unadmitted_required_capability_refuses_before_id_mint() {
19509        let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19510        let error = characterization_spawn(
19511            &shared,
19512            SessionMode::Pty,
19513            None,
19514            &[ProviderRuntimeCapability::SemanticReadiness],
19515            None,
19516        )
19517        .await
19518        .unwrap_err();
19519        assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
19520        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
19521        assert_no_session_state(&shared);
19522        drop(shared);
19523        drop(runtime);
19524        std::fs::remove_dir_all(root).unwrap();
19525    }
19526
19527    #[tokio::test]
19528    async fn spawn_denied_environment_materialization_fails_after_id_mint_with_no_state() {
19529        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
19530        let error = characterization_spawn(&shared, SessionMode::Pty, Some(receipt), &[], None)
19531            .await
19532            .unwrap_err();
19533        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
19534        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 2);
19535        assert_no_session_state(&shared);
19536        drop(shared);
19537        drop(runtime);
19538        std::fs::remove_dir_all(root).unwrap();
19539    }
19540
19541    #[test]
19542    fn remove_binding_returns_binding_and_is_idempotent_for_environment_session() {
19543        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
19544        let address = SessionAddress {
19545            workspace_id: WorkspaceId::new("primary").unwrap(),
19546            session: SessionKey {
19547                instance_id: AgentInstanceId(77),
19548                generation: SessionGeneration(1),
19549            },
19550        };
19551        shared.bind_session_with_policy(&address, ProviderRuntimePolicy::raw_pty(), Some(receipt));
19552        let removed = shared.remove_binding(&address).expect("binding removed");
19553        assert!(removed.environment_profile.is_some());
19554        assert!(shared.remove_binding(&address).is_none());
19555        assert_no_session_state(&shared);
19556        drop(shared);
19557        drop(runtime);
19558        std::fs::remove_dir_all(root).unwrap();
19559    }
19560
19561    async fn characterization_prepared_harness(provider: &str) -> PreparedHarnessMcpSpawn {
19562        use crate::harness_mcp_proxy::{HarnessMcpProxyRegistry, ReviewedHarnessMcpProgram};
19563        use crate::protocol::{HarnessMcpActivationDigest, HarnessMcpLaunchV1, HarnessMcpReservationId};
19564        let helper = ReviewedHarnessMcpProgram::review(std::env::current_exe().unwrap())
19565            .expect("review current test executable as fixture helper");
19566        let registry = HarnessMcpProxyRegistry::new(helper);
19567        let spec = crate::protocol::SpawnSpec {
19568            target: crate::protocol::SpawnTarget {
19569                node_id: NodeId::new("node-characterization").unwrap(),
19570                workspace_id: WorkspaceId::new("primary").unwrap(),
19571                worktree_id: None,
19572            },
19573            profile_id: crate::protocol::SpawnProfileId::new("claude").unwrap(),
19574            expected_profile_revision: crate::protocol::SpawnProfileRevision::new("r1").unwrap(),
19575            overrides: crate::protocol::SpawnOverrides::default(),
19576            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19577            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("characterization-harness").unwrap(),
19578            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19579        };
19580        let reservation = HarnessMcpReservationId::new(format!("hmcpres_{}", "c".repeat(24))).unwrap();
19581        let digest = HarnessMcpActivationDigest::new(format!("sha256:{}", "c".repeat(64))).unwrap();
19582        let launch = HarnessMcpLaunchV1 {
19583            server_name: "fixture-harness".to_owned(),
19584            args: vec!["--session-proxy".to_owned()],
19585            endpoint_env: "FIXTURE_SESSION_ENDPOINT".to_owned(),
19586            token_env: "FIXTURE_SESSION_TOKEN".to_owned(),
19587            program_env: "FIXTURE_MCP_PROGRAM".to_owned(),
19588            trace: None,
19589            scrub_env: Vec::new(),
19590        };
19591        let expires = unix_time_ms().saturating_add(30_000);
19592        registry.arm(reservation.clone(), digest.clone(), spec.clone(), launch, expires).await.unwrap();
19593        registry
19594            .prepare_spawn(&reservation, &digest, &spec, expires, agent(provider))
19595            .unwrap()
19596    }
19597
19598    #[tokio::test]
19599    async fn spawn_harness_overlay_refuses_inline_and_provider_mismatch_with_no_state() {
19600        let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19601        let prepared = characterization_prepared_harness("claude").await;
19602        let error = characterization_spawn(&shared, SessionMode::Inline, None, &[], Some(&prepared))
19603            .await
19604            .unwrap_err();
19605        // Inline is stopped by runtime admission before the harness check runs.
19606        assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
19607        let mismatched = characterization_prepared_harness("codex").await;
19608        let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&mismatched))
19609            .await
19610            .unwrap_err();
19611        assert_eq!(error.code, NodeFailureCode::BindingMismatch);
19612        assert_no_session_state(&shared);
19613        drop(shared);
19614        drop(runtime);
19615        std::fs::remove_dir_all(root).unwrap();
19616    }
19617
19618    #[tokio::test]
19619    async fn spawn_harness_overlay_without_launch_control_is_unavailable_with_no_state() {
19620        let (mut shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19621        shared.native_launch_profile_control = None;
19622        let prepared = characterization_prepared_harness("claude").await;
19623        let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&prepared))
19624            .await
19625            .unwrap_err();
19626        assert_eq!(error.code, NodeFailureCode::HarnessMcpUnavailable);
19627        assert_no_session_state(&shared);
19628        drop(shared);
19629        drop(runtime);
19630        std::fs::remove_dir_all(root).unwrap();
19631    }
19632}
19633
19634#[cfg(all(test, windows))]
19635mod tests {
19636    use super::*;
19637    use gate4agent_types::ProviderActivity;
19638    use crate::session_environment::{
19639        NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
19640        NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
19641        NodeSessionPathClass,
19642    };
19643    use gate4agent_catalog::EnvMutation;
19644    use gate4agent_runtime_native::{
19645        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
19646        NativeLaunchProfile, NativeLaunchProfileId,
19647    };
19648    use std::ffi::OsString;
19649    use std::sync::Arc;
19650
19651    struct EmptyEnvironmentResolver;
19652
19653    struct FixtureSecretResolver {
19654        deny: Arc<AtomicBool>,
19655    }
19656
19657    impl NodeSecretResolver for FixtureSecretResolver {
19658        fn resolve(
19659            &self,
19660            _reference: &NodeSecretReference,
19661        ) -> Result<NodeSecretValue, NodeSecretResolveError> {
19662            if self.deny.load(Ordering::Acquire) {
19663                Err(NodeSecretResolveError::Denied)
19664            } else {
19665                NodeSecretValue::text("fixture-secret")
19666                    .map_err(|_| NodeSecretResolveError::Unavailable)
19667            }
19668        }
19669    }
19670
19671    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19672        fn resolve_child_environment(
19673            &self,
19674        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19675            Ok(vec![EnvMutation {
19676                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19677                value: None,
19678            }])
19679        }
19680    }
19681
19682    fn agent(value: &str) -> AgentId {
19683        AgentId::new(value).unwrap()
19684    }
19685
19686    fn terminal_test_shared_with_mode(mode: WorktreeServiceMode) -> NodeShared {
19687        let catalog = active_registry().unwrap();
19688        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19689        let workspace = WorkspaceConfig::new(
19690            WorkspaceId::new("primary").unwrap(),
19691            std::env::current_dir().unwrap(),
19692        )
19693        .unwrap()
19694        .with_worktree_service_mode(mode);
19695        NodeShared::new(
19696            handle,
19697            "fixture-token".to_owned(),
19698            NodeId::new("node-terminal-test").unwrap(),
19699            vec![workspace],
19700            vec![agent("claude")],
19701        )
19702    }
19703
19704    fn terminal_test_shared() -> NodeShared {
19705        terminal_test_shared_with_mode(WorktreeServiceMode::Manual)
19706    }
19707
19708    #[cfg(feature = "fixture")]
19709    #[test]
19710    fn managed_worktree_v2_failure_probe_keeps_only_the_latest_code() {
19711        let probe = SpawnManagedWorktreeV2FailureProbe::default();
19712        assert_eq!(probe.latest_code(), None);
19713
19714        probe.record(Some(NodeFailureCode::ManagedWorktreeOwnershipConflict));
19715        assert_eq!(
19716            probe.latest_code(),
19717            Some(NodeFailureCode::ManagedWorktreeOwnershipConflict),
19718        );
19719
19720        probe.record(Some(NodeFailureCode::SpawnDeadlineExceeded));
19721        assert_eq!(
19722            probe.latest_code(),
19723            Some(NodeFailureCode::SpawnDeadlineExceeded),
19724        );
19725
19726        probe.record(None);
19727        assert_eq!(probe.latest_code(), None);
19728    }
19729
19730    #[test]
19731    fn launch_snapshot_is_authoritative_bounded_and_path_free() {
19732        let source = temporary_workspace_root("launch-inventory-source");
19733        let allocation = temporary_workspace_root("launch-inventory-allocation");
19734        std::fs::create_dir_all(&source).unwrap();
19735        std::fs::create_dir_all(&allocation).unwrap();
19736        let workspace_id = WorkspaceId::new("primary").unwrap();
19737        let mut workspace = WorkspaceConfig::new(workspace_id.clone(), &source)
19738            .unwrap()
19739            .with_worktree_service_mode(WorktreeServiceMode::Managed);
19740        for index in 0..MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE {
19741            workspace = workspace
19742                .with_managed_worktree_profile(
19743                    ManagedWorktreeProfile::new(
19744                        WorktreeProfileId::new(format!("profile-{index}")).unwrap(),
19745                        crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
19746                        &allocation,
19747                        "gate4agent",
19748                        "HEAD",
19749                        ManagedWorktreeRetention::RemoveWhenReleased,
19750                    )
19751                    .unwrap(),
19752                )
19753                .unwrap();
19754        }
19755        let overflow = workspace.clone().with_managed_worktree_profile(
19756            ManagedWorktreeProfile::new(
19757                WorktreeProfileId::new("overflow").unwrap(),
19758                crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
19759                &allocation,
19760                "gate4agent",
19761                "HEAD",
19762                ManagedWorktreeRetention::Retain,
19763            )
19764            .unwrap(),
19765        );
19766        assert!(matches!(
19767            overflow,
19768            Err(NodeServerError::ManagedWorktreeProfileCapacity {
19769                max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
19770                ..
19771            }),
19772        ));
19773
19774        let catalog = active_registry().unwrap();
19775        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19776        let shared = NodeShared::new(
19777            handle,
19778            "fixture-token".to_owned(),
19779            NodeId::new("node-launch-inventory").unwrap(),
19780            vec![workspace],
19781            vec![agent("claude")],
19782        );
19783        let snapshot = shared.snapshot();
19784        let launch = snapshot.launch_inventory.as_ref().unwrap();
19785        assert_eq!(launch.spawn_profiles.as_ref().unwrap().len(), 1);
19786        assert!(launch.bundles.as_ref().unwrap().is_empty());
19787        let profiles = snapshot.workspaces[0]
19788            .managed_worktree_profiles
19789            .as_ref()
19790            .unwrap();
19791        assert_eq!(
19792            snapshot.workspaces[0].worktree_service_mode,
19793            Some(WorktreeServiceMode::Managed),
19794        );
19795        assert_eq!(
19796            profiles.profiles.len(),
19797            MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
19798        );
19799        let public_inventory = serde_json::to_string(&(launch, profiles)).unwrap();
19800        for forbidden in ["allocation_root", "branch_prefix", "base", "prompt", "environment", "home"] {
19801            assert!(!public_inventory.contains(forbidden), "launch inventory leaked {forbidden}");
19802        }
19803        let legacy = snapshot_for_wire(
19804            &shared,
19805            true,
19806            true,
19807            false,
19808            false,
19809            true,
19810            false,
19811            true,
19812            false,
19813            true,
19814        );
19815        assert!(legacy.launch_inventory.is_none());
19816        assert!(legacy.workspaces[0].managed_worktree_profiles.is_none());
19817
19818        drop(shared);
19819        std::fs::remove_dir_all(source).unwrap();
19820        std::fs::remove_dir_all(allocation).unwrap();
19821    }
19822
19823    #[test]
19824    fn agent_progress_projection_omits_sensitive_provider_payloads_and_is_capability_gated() {
19825        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
19826            capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
19827        }));
19828        let source = gate4agent_types::ProviderSource {
19829            family: AdapterFamily::PtySemantic,
19830            binding: AdapterBinding::new(
19831                gate4agent_types::AdapterId::new("claude").unwrap(),
19832                "fixture/v1",
19833                gate4agent_types::AdapterVerification::SyntheticFixture,
19834            )
19835            .unwrap(),
19836        };
19837        let provider = ProviderSnapshot {
19838            sequence: 17,
19839            session: Some(ProviderSessionIdentity {
19840                key: ProviderSessionKey::SessionId,
19841                id: "PRIVATE_PROVIDER_SESSION".to_owned(),
19842                transcript_path: Some(r"C:\PRIVATE_PROVIDER_PATH".to_owned()),
19843            }),
19844            model: Some("PRIVATE_MODEL".to_owned()),
19845            tools: vec!["PRIVATE_TOOL_INVENTORY".to_owned()],
19846            completed_turns: 3,
19847            usage: gate4agent_types::TokenUsage {
19848                input_tokens: 10,
19849                output_tokens: 20,
19850                cache_read_tokens: 30,
19851                cache_write_tokens: 40,
19852                reasoning_tokens: 50,
19853                context_window: Some(200_000),
19854            },
19855            lead_activity: ProviderActivity::WaitingForInput,
19856            activity: ProviderActivity::WaitingForInput,
19857            current_prompt: Some("PRIVATE_CURRENT_PROMPT".to_owned()),
19858            active_tools: (0..9)
19859                .map(|index| gate4agent_types::ActiveProviderTool {
19860                    id: format!("private-tool-id-{index}"),
19861                    name: if index == 0 {
19862                        format!("  Read\u{0000}{}", "x".repeat(80))
19863                    } else {
19864                        format!("Tool-{index}")
19865                    },
19866                    input_json: "PRIVATE_TOOL_INPUT_JSON".to_owned(),
19867                })
19868                .collect(),
19869            interactions: vec![gate4agent_types::ProviderInteraction {
19870                id: gate4agent_types::ProviderInteractionId(17),
19871                source: source.clone(),
19872                provider_request_id: Some("PRIVATE_PROVIDER_REQUEST".to_owned()),
19873                interaction_kind: ProviderInteractionKind::Approval,
19874                tool_name: " Approve\u{0007}Tool ".to_owned(),
19875                prompt: "PRIVATE_INTERACTION_PROMPT".to_owned(),
19876                agent_id: Some("PRIVATE_INTERACTION_AGENT".to_owned()),
19877                resume_lead_activity: Some(ProviderActivity::Working),
19878                status: ProviderInteractionStatus::Pending,
19879            }],
19880            subagents: vec![gate4agent_types::ProviderSubagent {
19881                source: source.clone(),
19882                provider_agent_id: "PRIVATE_SUBAGENT_ID".to_owned(),
19883                agent_type: Some("PRIVATE_SUBAGENT_TYPE".to_owned()),
19884                description: Some("PRIVATE_SUBAGENT_DESCRIPTION".to_owned()),
19885            }],
19886            sources: vec![gate4agent_types::ProviderSourceCursor {
19887                source,
19888                sequence: 17,
19889                gap_count: 2,
19890                stale: true,
19891            }],
19892            last_event: Some(ProviderEvent::Error {
19893                message: "PRIVATE_PROVIDER_ERROR".to_owned(),
19894            }),
19895            gap_count: 2,
19896            stale: true,
19897        };
19898        let address = terminal_address(3);
19899        let entry = agent_progress_from_provider_snapshot(address.clone(), &provider).unwrap();
19900        assert_eq!(entry.address, address);
19901        assert_eq!(entry.progress.provider_sequence, 17);
19902        assert_eq!(entry.progress.active_tool_count, 9);
19903        assert_eq!(
19904            entry.progress.active_tool_labels.len(),
19905            MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS,
19906        );
19907        assert_eq!(entry.progress.subagent_count, 1);
19908        assert_eq!(entry.progress.last_event_kind, Some(AgentProgressEventKindV1::Error));
19909        assert!(entry.progress.truncated);
19910        let encoded = serde_json::to_string(&entry).unwrap();
19911        for forbidden in [
19912            "PRIVATE_PROVIDER_SESSION",
19913            "PRIVATE_PROVIDER_PATH",
19914            "PRIVATE_MODEL",
19915            "PRIVATE_TOOL_INVENTORY",
19916            "PRIVATE_CURRENT_PROMPT",
19917            "PRIVATE_TOOL_INPUT_JSON",
19918            "PRIVATE_PROVIDER_REQUEST",
19919            "PRIVATE_INTERACTION_PROMPT",
19920            "PRIVATE_INTERACTION_AGENT",
19921            "PRIVATE_SUBAGENT_ID",
19922            "PRIVATE_SUBAGENT_TYPE",
19923            "PRIVATE_SUBAGENT_DESCRIPTION",
19924            "PRIVATE_PROVIDER_ERROR",
19925            "input_json",
19926            "current_prompt",
19927            "session_id",
19928        ] {
19929            assert!(!encoded.contains(forbidden), "agent progress leaked {forbidden}");
19930        }
19931
19932        let snapshot = NodeSnapshot {
19933            node_id: NodeId::new("node-progress-test").unwrap(),
19934            enabled_providers: vec![agent("claude")],
19935            provider_runtime_statuses: ProviderRuntimeStatuses::default(),
19936            workspaces: Vec::new(),
19937            session_records: Vec::new(),
19938            managed_worktrees: Vec::new(),
19939            launch_inventory: None,
19940            agent_progress: vec![entry],
19941        };
19942        let mut legacy_reply = ResponseEnvelope {
19943            request_id: 1,
19944            result: Ok(NodeResponse::Snapshot {
19945                event_sequence: 0,
19946                controller: None,
19947                snapshot: snapshot.clone(),
19948            }),
19949        };
19950        project_response_without_agent_progress(&mut legacy_reply);
19951        let Ok(NodeResponse::Snapshot { snapshot: legacy, .. }) = legacy_reply.result else {
19952            panic!("agent progress projection changed response kind");
19953        };
19954        assert!(legacy.agent_progress.is_empty());
19955        assert_eq!(snapshot.agent_progress.len(), 1);
19956    }
19957
19958    fn terminal_address(generation: u64) -> SessionAddress {
19959        SessionAddress {
19960            workspace_id: WorkspaceId::new("primary").unwrap(),
19961            session: SessionKey {
19962                instance_id: AgentInstanceId(41),
19963                generation: SessionGeneration(generation),
19964            },
19965        }
19966    }
19967
19968    fn spawn_spec_fixture() -> SpawnSpec {
19969        SpawnSpec {
19970            target: crate::protocol::SpawnTarget {
19971                node_id: NodeId::new("node-terminal-test").unwrap(),
19972                workspace_id: WorkspaceId::new("primary").unwrap(),
19973                worktree_id: None,
19974            },
19975            profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
19976            expected_profile_revision: crate::protocol::SpawnProfileRevision::new("builtin-v1")
19977                .unwrap(),
19978            overrides: crate::protocol::SpawnOverrides::default(),
19979            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19980            idempotency_key: SpawnIdempotencyKey::new("spawn-spec-fixture").unwrap(),
19981            required_capabilities: SpawnRequiredCapabilities::default(),
19982        }
19983    }
19984
19985    fn environment_profile_test_shared(
19986    ) -> (NodeShared, NativeRuntime, ResolvedEnvironmentProfileReceipt) {
19987        let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
19988        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19989            "local-claude-r1",
19990        )
19991        .unwrap();
19992        let spawn_profiles = SpawnProfileRegistry::new([
19993            crate::protocol::SpawnProfileDefaults {
19994                profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
19995                revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
19996                provider: agent("claude"),
19997                mode: SessionMode::Pty,
19998                terminal_size: gate4agent_types::TerminalSize {
19999                    rows: 24,
20000                    columns: 80,
20001                },
20002                prompt: None,
20003                bundle_id: None,
20004                context_id: None,
20005                environment_profile_id: Some(profile_id.clone()),
20006            },
20007        ])
20008        .unwrap();
20009        let catalog = active_registry().unwrap();
20010        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20011        let control = runtime.native_launch_profile_control();
20012        let node_profile = NodeEnvironmentProfile::new(
20013            profile_id.clone(),
20014            profile_revision.clone(),
20015            agent("claude"),
20016            [NativeLaunchProfile::new(
20017                NativeLaunchProfileId::new("local-claude-pty").unwrap(),
20018                agent("claude"),
20019                TransportKind::Pty,
20020                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20021                Arc::new(EmptyEnvironmentResolver),
20022            )
20023            .unwrap()],
20024        )
20025        .unwrap();
20026        let (binding, native_profiles, _materialization) = node_profile.into_parts();
20027        for native_profile in native_profiles {
20028            runtime.upsert_native_launch_profile(native_profile).unwrap();
20029        }
20030        let workspace = WorkspaceConfig::new(
20031            WorkspaceId::new("primary").unwrap(),
20032            std::env::current_dir().unwrap(),
20033        )
20034        .unwrap();
20035        let shared = NodeShared::new_with_incarnation(
20036            handle,
20037            "fixture-token".to_owned(),
20038            NodeId::new("node-terminal-test").unwrap(),
20039            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20040            vec![workspace],
20041            vec![agent("claude")],
20042            ProviderRuntimeStatuses::default(),
20043            None,
20044            Vec::new(),
20045            Vec::new(),
20046            spawn_profiles,
20047            Some(control),
20048            None,
20049            None,
20050            None,
20051            Vec::new(),
20052            Vec::new(),
20053            Vec::new(),
20054            Vec::new(),
20055            None,
20056            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20057        );
20058        shared
20059            .environment_profiles
20060            .write()
20061            .unwrap_or_else(|poisoned| poisoned.into_inner())
20062            .insert(binding.id.clone(), binding);
20063        (
20064            shared,
20065            runtime,
20066            ResolvedEnvironmentProfileReceipt {
20067                profile_id,
20068                profile_revision,
20069                network_allowlist: None,
20070                browser_profile_id: None,
20071            },
20072        )
20073    }
20074
20075    fn materialization_test_shared(
20076        deny_secret: bool,
20077    ) -> (
20078        NodeShared,
20079        NativeRuntime,
20080        ResolvedEnvironmentProfileReceipt,
20081        PathBuf,
20082        Arc<AtomicBool>,
20083    ) {
20084        let workspace = WorkspaceConfig::new(
20085            WorkspaceId::new("primary").unwrap(),
20086            std::env::current_dir().unwrap(),
20087        )
20088        .unwrap();
20089        materialization_test_shared_with_workspace(deny_secret, workspace)
20090    }
20091
20092    fn materialization_test_shared_with_workspace(
20093        deny_secret: bool,
20094        workspace: WorkspaceConfig,
20095    ) -> (
20096        NodeShared,
20097        NativeRuntime,
20098        ResolvedEnvironmentProfileReceipt,
20099        PathBuf,
20100        Arc<AtomicBool>,
20101    ) {
20102        let root = temporary_workspace_root(if deny_secret {
20103            "materialization-denied"
20104        } else {
20105            "materialization-ready"
20106        });
20107        std::fs::create_dir_all(&root).unwrap();
20108        let materialization_root = root.join("session-environments");
20109        let deny = Arc::new(AtomicBool::new(deny_secret));
20110        let resolver = Arc::new(FixtureSecretResolver {
20111            deny: Arc::clone(&deny),
20112        });
20113        let materializer = SessionEnvironmentMaterializer::new(
20114            materialization_root,
20115            resolver,
20116        )
20117        .unwrap();
20118        let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
20119        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20120            "materialized-claude-r1",
20121        )
20122        .unwrap();
20123        let materialization = NodeSessionMaterializationProfile::new(
20124            vec![NodeSessionEnvironmentMutation::SetSecret {
20125                key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
20126                reference: NodeSecretReference::new("fixture-token").unwrap(),
20127            }],
20128            vec![NodeSessionPathBinding::new(
20129                "GATE4AGENT_PROVIDER_HOME",
20130                NodeSessionPathClass::ProviderHome,
20131            )
20132            .unwrap()],
20133            vec![NodeSessionFile::secret(
20134                NodeSessionPathClass::Config,
20135                "auth/token",
20136                NodeSecretReference::new("fixture-token").unwrap(),
20137            )
20138            .unwrap()],
20139        )
20140        .unwrap();
20141        let catalog = active_registry().unwrap();
20142        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20143        let control = runtime.native_launch_profile_control();
20144        let node_profile = NodeEnvironmentProfile::new_with_materialization(
20145            profile_id.clone(),
20146            profile_revision.clone(),
20147            agent("claude"),
20148            [NativeLaunchProfile::new(
20149                NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
20150                agent("claude"),
20151                TransportKind::Pty,
20152                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20153                Arc::new(EmptyEnvironmentResolver),
20154            )
20155            .unwrap()],
20156            Some(materialization),
20157        )
20158        .unwrap();
20159        let (binding, native_profiles, materialization) = node_profile.into_parts();
20160        for native_profile in native_profiles {
20161            runtime.upsert_native_launch_profile(native_profile).unwrap();
20162        }
20163        let shared = NodeShared::new_with_incarnation(
20164            handle,
20165            "fixture-token".to_owned(),
20166            NodeId::new("node-materialization-test").unwrap(),
20167            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20168            vec![workspace],
20169            vec![agent("claude")],
20170            ProviderRuntimeStatuses::default(),
20171            None,
20172            Vec::new(),
20173            Vec::new(),
20174            SpawnProfileRegistry::default(),
20175            Some(control),
20176            None,
20177            Some(materializer),
20178            None,
20179            Vec::new(),
20180            Vec::new(),
20181            Vec::new(),
20182            Vec::new(),
20183            None,
20184            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20185        );
20186        shared
20187            .environment_profiles
20188            .write()
20189            .unwrap_or_else(|poisoned| poisoned.into_inner())
20190            .insert(binding.id.clone(), binding);
20191        shared
20192            .environment_materialization_profiles
20193            .write()
20194            .unwrap_or_else(|poisoned| poisoned.into_inner())
20195            .insert(profile_id.clone(), materialization.unwrap());
20196        (
20197            shared,
20198            runtime,
20199            ResolvedEnvironmentProfileReceipt {
20200                profile_id,
20201                profile_revision,
20202                network_allowlist: None,
20203                browser_profile_id: None,
20204            },
20205            root,
20206            deny,
20207        )
20208    }
20209
20210    #[cfg(feature = "fixture")]
20211    #[derive(Clone, Copy)]
20212    enum CodexHomeProfile {
20213        Missing,
20214        WrongClass,
20215        Exact,
20216    }
20217
20218    #[cfg(feature = "fixture")]
20219    fn codex_bundle_test_shared(
20220        home_profile: CodexHomeProfile,
20221    ) -> (
20222        NodeShared,
20223        NativeRuntime,
20224        ResolvedEnvironmentProfileReceipt,
20225        NodeBundle,
20226        PathBuf,
20227    ) {
20228        const ROOT_MANIFEST: &[u8] = br#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"review-tools"}"#;
20229        const CLAUDE_MANIFEST: &[u8] =
20230            br#"{"name":"review-tools","version":"1.0.0","description":"Review helpers"}"#;
20231        const SKILL: &[u8] = b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n";
20232        const DIGEST: &str =
20233            "sha256:941f20868a6ef49b4329bf1bb1368515763f5b64d8279b05cd9700966083d707";
20234
20235        let (shared, mut runtime, _claude_receipt, root, _deny) =
20236            materialization_test_shared(false);
20237        let profile_id = SpawnEnvironmentProfileId::new(match home_profile {
20238            CodexHomeProfile::Missing => "codex-home-missing",
20239            CodexHomeProfile::WrongClass => "codex-home-wrong",
20240            CodexHomeProfile::Exact => "codex-home-exact",
20241        })
20242        .unwrap();
20243        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20244            "codex-home-r1",
20245        )
20246        .unwrap();
20247        let materialization = match home_profile {
20248            CodexHomeProfile::Missing => None,
20249            CodexHomeProfile::WrongClass | CodexHomeProfile::Exact => {
20250                Some(NodeSessionMaterializationProfile::new(
20251                    Vec::new(),
20252                    vec![NodeSessionPathBinding::new(
20253                        "CODEX_HOME",
20254                        if matches!(home_profile, CodexHomeProfile::Exact) {
20255                            NodeSessionPathClass::ProviderHome
20256                        } else {
20257                            NodeSessionPathClass::Config
20258                        },
20259                    )
20260                    .unwrap()],
20261                    Vec::new(),
20262                )
20263                .unwrap())
20264            }
20265        };
20266        let node_profile = NodeEnvironmentProfile::new_with_materialization(
20267            profile_id.clone(),
20268            profile_revision.clone(),
20269            agent("codex"),
20270            [NativeLaunchProfile::new(
20271                NativeLaunchProfileId::new(format!("{profile_id}-pty")).unwrap(),
20272                agent("codex"),
20273                TransportKind::Pty,
20274                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20275                Arc::new(EmptyEnvironmentResolver),
20276            )
20277            .unwrap()],
20278            materialization,
20279        )
20280        .unwrap();
20281        let (binding, native_profiles, materialization) = node_profile.into_parts();
20282        for native_profile in native_profiles {
20283            runtime.upsert_native_launch_profile(native_profile).unwrap();
20284        }
20285        shared
20286            .environment_profiles
20287            .write()
20288            .unwrap_or_else(|poisoned| poisoned.into_inner())
20289            .insert(binding.id.clone(), binding);
20290        if let Some(materialization) = materialization {
20291            shared
20292                .environment_materialization_profiles
20293                .write()
20294                .unwrap_or_else(|poisoned| poisoned.into_inner())
20295                .insert(profile_id.clone(), materialization);
20296        }
20297
20298        let bundle_source = root.join(format!("bundle-source-{profile_id}"));
20299        std::fs::create_dir_all(bundle_source.join(".claude-plugin")).unwrap();
20300        std::fs::create_dir_all(bundle_source.join("skills/review-code")).unwrap();
20301        std::fs::write(bundle_source.join("plugin.json"), ROOT_MANIFEST).unwrap();
20302        std::fs::write(
20303            bundle_source.join(".claude-plugin/plugin.json"),
20304            CLAUDE_MANIFEST,
20305        )
20306        .unwrap();
20307        std::fs::write(bundle_source.join("skills/review-code/SKILL.md"), SKILL).unwrap();
20308        crate::bundle_catalog::protect_bundle_source_tree_fixture(&bundle_source).unwrap();
20309        let bundle = NodeBundle::new(
20310            crate::protocol::SpawnBundleId::new("review-tools").unwrap(),
20311            crate::protocol::SpawnBundleRevision::new("review-tools-r1").unwrap(),
20312            crate::protocol::SpawnBundleDigest::new(DIGEST).unwrap(),
20313            &bundle_source,
20314        )
20315        .unwrap();
20316        *shared
20317            .bundle_catalog
20318            .write()
20319            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
20320            BundleCatalog::new([bundle.clone()]).unwrap();
20321        (
20322            shared,
20323            runtime,
20324            ResolvedEnvironmentProfileReceipt {
20325                profile_id,
20326                profile_revision,
20327                network_allowlist: None,
20328                browser_profile_id: None,
20329            },
20330            bundle,
20331            root,
20332        )
20333    }
20334
20335    #[cfg(feature = "fixture")]
20336    #[test]
20337    fn codex_bundle_preflight_rejects_missing_or_wrong_home_before_materialization() {
20338        for home_profile in [CodexHomeProfile::Missing, CodexHomeProfile::WrongClass] {
20339            let (shared, runtime, receipt, bundle, root) =
20340                codex_bundle_test_shared(home_profile);
20341            let error = shared
20342                .bundle_layout(
20343                    &agent("codex"),
20344                    SessionMode::Pty,
20345                    Some(&receipt),
20346                    &bundle,
20347                )
20348                .unwrap_err();
20349            assert_eq!(error.code, NodeFailureCode::BundleBindingMismatch);
20350            assert!(shared
20351                .materializations
20352                .lock()
20353                .unwrap_or_else(|poisoned| poisoned.into_inner())
20354                .is_empty());
20355            let materialization_root = root.join("session-environments");
20356            assert!(!std::fs::read_dir(&materialization_root)
20357                .unwrap()
20358                .filter_map(Result::ok)
20359                .any(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())));
20360            drop(shared);
20361            drop(runtime);
20362            let _ = std::fs::remove_dir_all(root);
20363        }
20364    }
20365
20366    #[cfg(feature = "fixture")]
20367    #[test]
20368    fn codex_bundle_exact_home_profile_materializes_only_the_skill_tree() {
20369        let (shared, runtime, receipt, bundle, root) =
20370            codex_bundle_test_shared(CodexHomeProfile::Exact);
20371        let address = SessionAddress {
20372            workspace_id: WorkspaceId::new("primary").unwrap(),
20373            session: SessionKey {
20374                instance_id: AgentInstanceId(310),
20375                generation: SessionGeneration(1),
20376            },
20377        };
20378        let (overlay, guard) = shared
20379            .prepare_session_materialization(
20380                &address,
20381                &agent("codex"),
20382                SessionMode::Pty,
20383                Some(&receipt),
20384                Some(&bundle.receipt()),
20385                None,
20386                None,
20387            )
20388            .unwrap()
20389            .unwrap();
20390        assert!(matches!(overlay, Some(PreparedNativeLaunchOverlay::Instance(_))));
20391        let ownership = shared
20392            .materializations
20393            .lock()
20394            .unwrap_or_else(|poisoned| poisoned.into_inner())
20395            .get(guard.id().unwrap())
20396            .unwrap()
20397            .clone();
20398        let (profile, _, _) = shared
20399            .materialization_profile(
20400                &agent("codex"),
20401                SessionMode::Pty,
20402                Some(&receipt),
20403                Some(&bundle.receipt()),
20404                None,
20405            )
20406            .unwrap()
20407            .unwrap();
20408        let environment = shared
20409            .session_environment_materializer
20410            .as_ref()
20411            .unwrap()
20412            .resolve_environment(&ownership, &profile)
20413            .unwrap();
20414        assert!(environment.iter().any(|mutation| {
20415            mutation.key == OsString::from("CODEX_HOME")
20416                && mutation.value.as_deref() == Some(ownership.provider_home().as_os_str())
20417        }));
20418        assert_eq!(
20419            std::fs::read(ownership.provider_home().join("skills/review-code/SKILL.md"))
20420                .unwrap(),
20421            b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n",
20422        );
20423        assert!(std::fs::read_dir(ownership.bundle_root())
20424            .unwrap()
20425            .next()
20426            .is_none());
20427        drop(guard);
20428        drop(shared);
20429        drop(runtime);
20430        let _ = std::fs::remove_dir_all(root);
20431    }
20432
20433    #[cfg(feature = "fixture")]
20434    #[test]
20435    fn codex_cold_record_resume_revalidates_the_isolated_skill_tree() {
20436        let (shared, runtime, receipt, bundle, root) =
20437            codex_bundle_test_shared(CodexHomeProfile::Exact);
20438        let bundle_receipt = bundle.receipt();
20439        let address = SessionAddress {
20440            workspace_id: WorkspaceId::new("primary").unwrap(),
20441            session: SessionKey {
20442                instance_id: AgentInstanceId(311),
20443                generation: SessionGeneration(1),
20444            },
20445        };
20446        let (_overlay, guard) = shared
20447            .prepare_session_materialization(
20448                &address,
20449                &agent("codex"),
20450                SessionMode::Pty,
20451                Some(&receipt),
20452                Some(&bundle_receipt),
20453                None,
20454                None,
20455            )
20456            .unwrap()
20457            .unwrap();
20458        let materialization_id = guard.id().unwrap().clone();
20459        let skill_path = shared
20460            .materializations
20461            .lock()
20462            .unwrap_or_else(|poisoned| poisoned.into_inner())
20463            .get(&materialization_id)
20464            .unwrap()
20465            .provider_home()
20466            .join("skills/review-code/SKILL.md");
20467        let record_id = shared
20468            .bind_spawn_session_with_materialization(
20469                &address,
20470                agent("codex"),
20471                SessionMode::Pty,
20472                ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap(),
20473                SpawnRecordPolicy::ProviderIdentityOnly,
20474                Some(receipt.clone()),
20475                Some(bundle_receipt.clone()),
20476                None,
20477                Some(materialization_id.clone()),
20478            )
20479            .unwrap()
20480            .unwrap();
20481        guard.retain();
20482        std::fs::write(skill_path, b"changed-after-record-bind").unwrap();
20483
20484        let error = match shared.resolve_record_materialization(
20485                &record_id,
20486                &agent("codex"),
20487                SessionMode::Pty,
20488                Some(&receipt),
20489                Some(&bundle_receipt),
20490                None,
20491            ) {
20492                Ok(_) => panic!("changed Codex skill tree unexpectedly revalidated"),
20493                Err(error) => error,
20494            };
20495        assert_eq!(error.code, NodeFailureCode::BundleMaterializationFailed);
20496        assert_eq!(
20497            shared
20498                .materializations
20499                .lock()
20500                .unwrap_or_else(|poisoned| poisoned.into_inner())
20501                .get(&materialization_id)
20502                .unwrap()
20503                .state(),
20504            MaterializationState::RecoveryRequired,
20505        );
20506        drop(shared);
20507        drop(runtime);
20508        let _ = std::fs::remove_dir_all(root);
20509    }
20510
20511    fn bind_record_owned_materialization_fixture(
20512        shared: &NodeShared,
20513        receipt: &ResolvedEnvironmentProfileReceipt,
20514        instance_id: u64,
20515    ) -> (SessionAddress, SessionRecordId, MaterializationId, PathBuf) {
20516        let address = SessionAddress {
20517            workspace_id: WorkspaceId::new("primary").unwrap(),
20518            session: SessionKey {
20519                instance_id: AgentInstanceId(instance_id),
20520                generation: SessionGeneration(1),
20521            },
20522        };
20523        let (overlay, materialization_guard) = shared
20524            .prepare_session_materialization(
20525                &address,
20526                &agent("claude"),
20527                SessionMode::Pty,
20528                Some(receipt),
20529                None,
20530                None,
20531                None,
20532            )
20533            .unwrap()
20534            .unwrap();
20535        let materialization_id = materialization_guard.id().unwrap().clone();
20536        let materialization_root = shared
20537            .materializations
20538            .lock()
20539            .unwrap_or_else(|poisoned| poisoned.into_inner())
20540            .get(&materialization_id)
20541            .unwrap()
20542            .root()
20543            .to_path_buf();
20544        let selection = shared
20545            .select_environment_profile(
20546                address.session.instance_id,
20547                &agent("claude"),
20548                SessionMode::Pty,
20549                Some(receipt),
20550            )
20551            .unwrap()
20552            .unwrap();
20553        assert!(shared
20554            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
20555            .unwrap()
20556            .is_none());
20557        let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
20558        let record_id = shared
20559            .bind_spawn_session_with_materialization(
20560                &address,
20561                agent("claude"),
20562                SessionMode::Pty,
20563                policy,
20564                SpawnRecordPolicy::ProviderIdentityOnly,
20565                Some(receipt.clone()),
20566                None,
20567                None,
20568                Some(materialization_id.clone()),
20569            )
20570            .unwrap()
20571            .unwrap();
20572        selection.retain();
20573        materialization_guard.retain();
20574        (address, record_id, materialization_id, materialization_root)
20575    }
20576
20577    #[test]
20578    fn child_environment_profile_capability_is_state_aware_and_legacy_safe() {
20579        let (shared, _runtime, environment_profile) = environment_profile_test_shared();
20580        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20581            capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
20582        }));
20583        let support = node_compatibility_support_for_manifest(&[], &[]).unwrap();
20584        assert_eq!(support.state_schema.versions.maximum(), NODE_STATE_SCHEMA_V10);
20585        let mut spec = spawn_spec_fixture();
20586        spec.expected_profile_revision =
20587            crate::protocol::SpawnProfileRevision::new("test-r1").unwrap();
20588        assert!(matches!(
20589            &spec.overrides.environment_profile_id,
20590            crate::protocol::SpawnOverride::Inherit,
20591        ));
20592        assert!(!NodeRequest::SpawnSpec { spec: spec.clone() }
20593            .requires_child_environment_profile_capability());
20594        assert!(request_requires_child_environment_profile(
20595            &shared,
20596            &NodeRequest::SpawnSpec { spec: spec.clone() },
20597        ));
20598
20599        let address = terminal_address(1);
20600        shared.bind_session_with_policy(
20601            &address,
20602            ProviderRuntimePolicy::raw_pty(),
20603            Some(environment_profile.clone()),
20604        );
20605        assert!(request_requires_child_environment_profile(
20606            &shared,
20607            &NodeRequest::Input {
20608                session: address.clone(),
20609                text: "x".to_owned(),
20610            },
20611        ));
20612
20613        let mut record = record("claude", "profile-record");
20614        record.active_session = Some(address.clone());
20615        record.environment_profile = Some(environment_profile.clone());
20616        let record_id = record.record_id.clone();
20617        shared.insert_record(record.clone()).unwrap();
20618        assert!(request_requires_child_environment_profile(
20619            &shared,
20620            &NodeRequest::ResumeSessionRecord {
20621                record_id,
20622                terminal_size: gate4agent_types::TerminalSize {
20623                    rows: 24,
20624                    columns: 80,
20625                },
20626                initial_prompt: None,
20627            },
20628        ));
20629
20630        let snapshot = snapshot_for_wire(
20631            &shared, false, true, true, true, false, false, false, false, true,
20632        );
20633        assert_eq!(snapshot.session_records.len(), 1);
20634        assert!(snapshot.session_records[0].environment_profile.is_none());
20635        let event = project_event_without_child_environment_profile(NodeEventEnvelope {
20636            sequence: 1,
20637            event: NodeEvent::SessionRecordUpserted { record },
20638        });
20639        let NodeEvent::SessionRecordUpserted { record } = event.event else {
20640            panic!("record projection changed the event kind");
20641        };
20642        assert!(record.environment_profile.is_none());
20643
20644        let resolved = shared.resolve_spawn_spec(&spec).unwrap();
20645        let mut reply = ResponseEnvelope {
20646            request_id: 1,
20647            result: Ok(NodeResponse::SpawnSpecAccepted {
20648                receipt: resolved.receipt_with_environment(
20649                    shared.incarnation_id,
20650                    address,
20651                    Some(environment_profile),
20652                ),
20653            }),
20654        };
20655        project_response_without_child_environment_profile(&mut reply);
20656        assert_eq!(
20657            reply.result.unwrap_err().code,
20658            NodeFailureCode::UnsupportedCapability,
20659        );
20660    }
20661
20662    #[test]
20663    fn spawn_profile_environment_authority_is_exact_and_legacy_safe() {
20664        let (shared, _runtime, environment_profile) = environment_profile_test_shared();
20665        let snapshot = shared.snapshot();
20666        let profiles = snapshot
20667            .launch_inventory
20668            .as_ref()
20669            .and_then(|inventory| inventory.spawn_profiles.as_ref())
20670            .unwrap();
20671        assert_eq!(profiles.len(), 1);
20672        assert_eq!(profiles[0].environment_profile, Some(environment_profile));
20673        assert!(snapshot.requires_child_environment_profile_capability());
20674
20675        let mut legacy = snapshot.clone();
20676        clear_snapshot_child_environment_profiles(&mut legacy);
20677        assert!(legacy
20678            .launch_inventory
20679            .as_ref()
20680            .unwrap()
20681            .spawn_profiles
20682            .as_ref()
20683            .unwrap()[0]
20684            .environment_profile
20685            .is_none());
20686        assert!(!legacy.requires_child_environment_profile_capability());
20687
20688        let profile = shared.spawn_profiles.iter().next().unwrap().clone();
20689        let bindings = shared
20690            .environment_profiles
20691            .read()
20692            .unwrap_or_else(|poisoned| poisoned.into_inner())
20693            .clone();
20694        let mut provider_mismatch = bindings.clone();
20695        provider_mismatch
20696            .values_mut()
20697            .next()
20698            .unwrap()
20699            .provider = agent("codex");
20700        assert!(resolved_spawn_profile_summary(&profile, &provider_mismatch).is_none());
20701        let mut mode_mismatch = profile;
20702        mode_mismatch.mode = SessionMode::Inline;
20703        assert!(resolved_spawn_profile_summary(&mode_mismatch, &bindings).is_none());
20704
20705        shared
20706            .environment_profiles
20707            .write()
20708            .unwrap_or_else(|poisoned| poisoned.into_inner())
20709            .clear();
20710        assert!(shared
20711            .snapshot()
20712            .launch_inventory
20713            .unwrap()
20714            .spawn_profiles
20715            .unwrap()
20716            .is_empty());
20717    }
20718
20719    #[test]
20720    fn session_bundle_capability_is_state_aware_and_legacy_safe() {
20721        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20722            capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
20723        }));
20724        let shared = terminal_test_shared();
20725        let bundle = ResolvedBundleReceipt {
20726            id: crate::protocol::SpawnBundleId::new("review-bundle").unwrap(),
20727            revision: crate::protocol::SpawnBundleRevision::new("r1").unwrap(),
20728            digest: crate::protocol::SpawnBundleDigest::new(format!(
20729                "sha256:{}",
20730                "0".repeat(64),
20731            ))
20732            .unwrap(),
20733        };
20734        let mut bundled = record("claude", "bundle-record");
20735        bundled.bundle = Some(bundle.clone());
20736        let record_id = bundled.record_id.clone();
20737        shared.insert_record(bundled.clone()).unwrap();
20738        assert!(request_requires_session_bundle(
20739            &shared,
20740            &NodeRequest::ForgetSessionRecord {
20741                record_id: record_id.clone(),
20742            },
20743        ));
20744
20745        let snapshot = snapshot_for_wire(
20746            &shared, false, true, true, true, true, false, false, false, true,
20747        );
20748        assert!(snapshot.session_records[0].bundle.is_none());
20749        let launch = snapshot.launch_inventory.as_ref().unwrap();
20750        assert!(launch.spawn_profiles.is_some());
20751        assert!(launch.bundles.is_none());
20752        let event = project_event_without_session_bundle(NodeEventEnvelope {
20753            sequence: 1,
20754            event: NodeEvent::SessionRecordUpserted { record: bundled },
20755        });
20756        let NodeEvent::SessionRecordUpserted { record } = event.event else {
20757            panic!("record projection changed the event kind");
20758        };
20759        assert!(record.bundle.is_none());
20760
20761        let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
20762        let mut reply = ResponseEnvelope {
20763            request_id: 1,
20764            result: Ok(NodeResponse::SpawnSpecAccepted {
20765                receipt: resolved.receipt_with_materialization(
20766                    shared.incarnation_id,
20767                    terminal_address(17),
20768                    None,
20769                    Some(bundle),
20770                    None,
20771                ),
20772            }),
20773        };
20774        project_response_without_session_bundle(&mut reply);
20775        assert_eq!(
20776            reply.result.unwrap_err().code,
20777            NodeFailureCode::UnsupportedCapability,
20778        );
20779    }
20780
20781    #[test]
20782    fn history_context_pack_capability_redacts_loaded_messages_and_legacy_metadata() {
20783        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20784            capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
20785        }));
20786        let context_id = SpawnContextId::new("context-review").unwrap();
20787        let context = ResolvedContextPackReceipt {
20788            id: context_id.clone(),
20789            digest: crate::protocol::SpawnContextDigest::new(format!(
20790                "sha256:{}",
20791                "c".repeat(64),
20792            ))
20793            .unwrap(),
20794            lineage: ContextPackLineageReceipt {
20795                source_node_id: NodeId::new("node-source").unwrap(),
20796                source_session: terminal_address(1),
20797                source_provider: agent("kimi"),
20798            },
20799            source_message_count: 2,
20800            retained_message_count: 2,
20801            byte_len: 256,
20802            truncated: false,
20803        };
20804        assert!(context.is_valid());
20805        let mut bound_record = record("codex", "context-record");
20806        bound_record.context_id = Some(context_id);
20807        bound_record.context = Some(context.clone());
20808        let session = gate4agent_types::SessionSnapshot {
20809            instance_id: AgentInstanceId(91),
20810            agent_id: agent("kimi"),
20811            transport: TransportKind::Pty,
20812            generation: SessionGeneration(3),
20813            status: SessionStatus::Running,
20814            pending_operation: None,
20815            pending_input: None,
20816            process_id: Some(9001),
20817            terminal_size: None,
20818            terminal_frame: None,
20819            terminal_stale: None,
20820            session_options: None,
20821            capabilities: gate4agent_types::CapabilitySnapshot::default(),
20822            history: gate4agent_types::HistorySnapshot {
20823                pending: None,
20824                candidates: vec![HistoryCandidateSummary {
20825                    id: "candidate-opaque".to_owned(),
20826                    session_id_hint: "session-hint".to_owned(),
20827                    modified_at_unix_ms: Some(1),
20828                }],
20829                loaded_candidate_id: Some("candidate-opaque".to_owned()),
20830                loaded: Some(HistorySessionRecord {
20831                    session_id: "private-vendor-session".to_owned(),
20832                    title: Some("private title".to_owned()),
20833                    cwd: Some(r"C:\private\history-root".to_owned()),
20834                    model: Some("private-model".to_owned()),
20835                    message_count: 2,
20836                    completed_turn_count: None,
20837                    total_tokens: 19,
20838                    messages: vec![
20839                        gate4agent_types::HistoryMessageRecord {
20840                            role: gate4agent_types::HistoryMessageRole::User,
20841                            text: "F7_PRIVATE_HISTORY_MESSAGE".to_owned(),
20842                        },
20843                        gate4agent_types::HistoryMessageRecord {
20844                            role: gate4agent_types::HistoryMessageRole::Assistant,
20845                            text: "private answer".to_owned(),
20846                        },
20847                    ],
20848                }),
20849                last_error: None,
20850            },
20851            resume: gate4agent_types::ResumeSnapshot::default(),
20852            foreground: gate4agent_types::ForegroundSnapshot::default(),
20853            provider: gate4agent_types::ProviderSnapshot::default(),
20854            screen_state: gate4agent_types::PtyScreenState::default(),
20855        };
20856        let original = NodeSnapshot {
20857            node_id: NodeId::new("node-terminal-test").unwrap(),
20858            enabled_providers: vec![agent("kimi"), agent("codex")],
20859            provider_runtime_statuses: ProviderRuntimeStatuses::default(),
20860            workspaces: vec![WorkspaceSnapshot {
20861                workspace_id: WorkspaceId::new("primary").unwrap(),
20862                canonical_root: opaque_windows_path(
20863                    std::env::current_dir().unwrap().to_string_lossy().into_owned(),
20864                ),
20865                sessions: vec![session],
20866                worktree_service_mode: None,
20867                managed_worktree_profiles: None,
20868            }],
20869            session_records: vec![bound_record],
20870            managed_worktrees: Vec::new(),
20871            launch_inventory: None,
20872            agent_progress: Vec::new(),
20873        };
20874
20875        let mut negotiated = original.clone();
20876        project_snapshot_history_for_wire(&mut negotiated, true);
20877        assert_eq!(negotiated.workspaces[0].sessions[0].history.candidates.len(), 1);
20878        assert!(negotiated.workspaces[0].sessions[0].history.loaded.is_none());
20879        assert_eq!(negotiated.session_records[0].context.as_ref(), Some(&context));
20880        assert!(!serde_json::to_string(&negotiated)
20881            .unwrap()
20882            .contains("F7_PRIVATE_HISTORY_MESSAGE"));
20883
20884        let mut legacy = original;
20885        project_snapshot_history_for_wire(&mut legacy, false);
20886        clear_snapshot_context_packs(&mut legacy);
20887        assert_eq!(
20888            legacy.workspaces[0].sessions[0].history,
20889            gate4agent_types::HistorySnapshot::default(),
20890        );
20891        assert!(legacy.session_records[0].context_id.is_none());
20892        assert!(legacy.session_records[0].context.is_none());
20893        let encoded = serde_json::to_string(&legacy).unwrap();
20894        assert!(!encoded.contains("F7_PRIVATE_HISTORY_MESSAGE"));
20895        assert!(!encoded.contains("context-review"));
20896
20897        let mut reply = ResponseEnvelope {
20898            request_id: 7,
20899            result: Ok(NodeResponse::ContextPackExported { context }),
20900        };
20901        project_response_without_context_pack(&mut reply);
20902        assert_eq!(
20903            reply.result.unwrap_err().code,
20904            NodeFailureCode::UnsupportedCapability,
20905        );
20906    }
20907
20908    #[test]
20909    fn forgetting_unbound_context_pack_evicts_replay_but_retained_record_is_busy() {
20910        let shared = terminal_test_shared();
20911        let history = HistorySessionRecord {
20912            session_id: "fixture-history".to_owned(),
20913            title: None,
20914            cwd: None,
20915            model: None,
20916            message_count: 1,
20917            completed_turn_count: None,
20918            total_tokens: 0,
20919            messages: vec![gate4agent_types::HistoryMessageRecord {
20920                role: gate4agent_types::HistoryMessageRole::User,
20921                text: "bounded handoff".to_owned(),
20922            }],
20923        };
20924        let pack = NodeContextPack::export(
20925            ContextPackLineageReceipt {
20926                source_node_id: shared.node_id.clone(),
20927                source_session: terminal_address(1),
20928                source_provider: agent("claude"),
20929            },
20930            &history,
20931        )
20932        .unwrap();
20933        let context = shared
20934            .context_catalog
20935            .write()
20936            .unwrap_or_else(|poisoned| poisoned.into_inner())
20937            .insert(pack)
20938            .unwrap();
20939        let mut retained = record("codex", "context-retained-record");
20940        retained.context_id = Some(context.id.clone());
20941        retained.context = Some(context.clone());
20942        let record_id = retained.record_id.clone();
20943        shared.insert_record(retained).unwrap();
20944        assert_eq!(
20945            shared.forget_context_pack(&context.id).unwrap_err().code,
20946            NodeFailureCode::ContextPackBusy,
20947        );
20948        assert!(shared
20949            .context_catalog
20950            .read()
20951            .unwrap_or_else(|poisoned| poisoned.into_inner())
20952            .get(&context.id)
20953            .is_some());
20954        shared
20955            .session_records
20956            .lock()
20957            .unwrap_or_else(|poisoned| poisoned.into_inner())
20958            .records
20959            .remove(&record_id);
20960
20961        let mut spec = spawn_spec_fixture();
20962        spec.overrides.context_id = crate::protocol::SpawnOverride::Set {
20963            value: context.id.clone(),
20964        };
20965        let resolved = shared.resolve_spawn_spec(&spec).unwrap();
20966        let receipt = resolved.receipt_with_materialization(
20967            shared.incarnation_id,
20968            terminal_address(2),
20969            None,
20970            None,
20971            Some(context.clone()),
20972        );
20973        assert!(receipt.context_binding_is_valid());
20974        shared.remember_spawn_spec(spec.clone(), Ok(receipt), Instant::now());
20975
20976        shared.forget_context_pack(&context.id).unwrap();
20977        assert!(shared
20978            .context_catalog
20979            .read()
20980            .unwrap_or_else(|poisoned| poisoned.into_inner())
20981            .get(&context.id)
20982            .is_none());
20983        assert!(shared
20984            .replay_spawn_spec(&spec, Instant::now())
20985            .unwrap()
20986            .is_none());
20987    }
20988
20989    #[test]
20990    fn environment_profile_selection_is_owned_by_the_exact_session_binding() {
20991        let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
20992        let address = terminal_address(1);
20993        let selection = shared
20994            .select_environment_profile(
20995                address.session.instance_id,
20996                &agent("claude"),
20997                SessionMode::Pty,
20998                Some(&environment_profile),
20999            )
21000            .unwrap()
21001            .unwrap();
21002        shared.bind_session_with_policy(
21003            &address,
21004            ProviderRuntimePolicy::raw_pty(),
21005            Some(environment_profile.clone()),
21006        );
21007        selection.retain();
21008        let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21009        assert!(matches!(
21010            runtime.remove_native_launch_profile(&native_id),
21011            Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21012        ));
21013        assert!(shared.remove_binding(&address).is_some());
21014        assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21015
21016        let mismatched = ResolvedEnvironmentProfileReceipt {
21017            profile_id: environment_profile.profile_id,
21018            profile_revision: crate::protocol::SpawnEnvironmentProfileRevision::new(
21019                "local-claude-r2",
21020            )
21021            .unwrap(),
21022            network_allowlist: None,
21023            browser_profile_id: None,
21024        };
21025        let error = match shared.select_environment_profile(
21026                AgentInstanceId(42),
21027                &agent("claude"),
21028                SessionMode::Pty,
21029                Some(&mismatched),
21030            ) {
21031            Ok(_) => panic!("mismatched environment profile revision was selected"),
21032            Err(error) => error,
21033        };
21034        assert_eq!(
21035            error.code,
21036            NodeFailureCode::EnvironmentProfileBindingMismatch,
21037        );
21038        assert!(!shared
21039            .session_bindings
21040            .lock()
21041            .unwrap_or_else(|poisoned| poisoned.into_inner())
21042            .contains_key(&AgentInstanceId(42)));
21043    }
21044
21045    #[test]
21046    fn combined_environment_and_bundle_overlay_clears_with_its_binding() {
21047        let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
21048        let address = terminal_address(1);
21049        let selection = shared
21050            .select_environment_profile(
21051                address.session.instance_id,
21052                &agent("claude"),
21053                SessionMode::Pty,
21054                Some(&environment_profile),
21055            )
21056            .unwrap()
21057            .unwrap();
21058        let overlay = NativeInstanceLaunchOverlay::new(
21059            agent("claude"),
21060            TransportKind::Pty,
21061            vec![EnvMutation {
21062                key: OsString::from("GATE4AGENT_SESSION_TOKEN"),
21063                value: Some(OsString::from("fixture-secret")),
21064            }],
21065            vec![
21066                OsString::from("--plugin-dir"),
21067                std::env::current_dir().unwrap().into_os_string(),
21068            ],
21069        )
21070        .unwrap();
21071        let instance_overlay = shared
21072            .install_prepared_launch_overlay(
21073                address.session.instance_id,
21074                PreparedNativeLaunchOverlay::Instance(overlay),
21075            )
21076            .unwrap()
21077            .unwrap();
21078        let bundle = ResolvedBundleReceipt {
21079            id: crate::protocol::SpawnBundleId::new("combined-bundle").unwrap(),
21080            revision: crate::protocol::SpawnBundleRevision::new("combined-r1").unwrap(),
21081            digest: crate::protocol::SpawnBundleDigest::new(format!(
21082                "sha256:{}",
21083                "0".repeat(64),
21084            ))
21085            .unwrap(),
21086        };
21087        shared.bind_session_with_materialization(
21088            &address,
21089            ProviderRuntimePolicy::raw_pty(),
21090            Some(environment_profile),
21091            Some(bundle),
21092            None,
21093            None,
21094        );
21095        selection.retain();
21096        instance_overlay.retain();
21097
21098        let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21099        assert!(matches!(
21100            runtime.remove_native_launch_profile(&native_id),
21101            Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21102        ));
21103        assert!(shared.remove_binding(&address).is_some());
21104        assert!(!shared
21105            .native_launch_profile_control
21106            .as_ref()
21107            .unwrap()
21108            .clear_native_instance_launch_overlay(address.session.instance_id));
21109        assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21110    }
21111
21112    #[test]
21113    fn session_environment_materialization_failure_is_pre_child_and_durable() {
21114        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
21115        let address = SessionAddress {
21116            workspace_id: WorkspaceId::new("primary").unwrap(),
21117            session: SessionKey {
21118                instance_id: AgentInstanceId(201),
21119                generation: SessionGeneration(1),
21120            },
21121        };
21122        let error = match shared.prepare_session_materialization(
21123            &address,
21124            &agent("claude"),
21125            SessionMode::Pty,
21126            Some(&receipt),
21127            None,
21128            None,
21129            None,
21130        ) {
21131            Ok(_) => panic!("denied local reference unexpectedly materialized"),
21132            Err(error) => error,
21133        };
21134        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21135        assert!(shared.handle.snapshot().sessions.is_empty());
21136        assert!(shared
21137            .session_bindings
21138            .lock()
21139            .unwrap_or_else(|poisoned| poisoned.into_inner())
21140            .is_empty());
21141        assert!(shared
21142            .materializations
21143            .lock()
21144            .unwrap_or_else(|poisoned| poisoned.into_inner())
21145            .is_empty());
21146        drop(shared);
21147        drop(runtime);
21148        std::fs::remove_dir_all(root).unwrap();
21149    }
21150
21151    #[test]
21152    fn session_environment_binding_retains_on_stop_and_cleans_after_remove_reap() {
21153        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21154        let address = SessionAddress {
21155            workspace_id: WorkspaceId::new("primary").unwrap(),
21156            session: SessionKey {
21157                instance_id: AgentInstanceId(202),
21158                generation: SessionGeneration(1),
21159            },
21160        };
21161        let (overlay, materialization_guard) = shared
21162            .prepare_session_materialization(
21163                &address,
21164                &agent("claude"),
21165                SessionMode::Pty,
21166                Some(&receipt),
21167                None,
21168                None,
21169                None,
21170            )
21171            .unwrap()
21172            .unwrap();
21173        let materialization_id = materialization_guard.id().unwrap().clone();
21174        let materialization_root = shared
21175            .materializations
21176            .lock()
21177            .unwrap_or_else(|poisoned| poisoned.into_inner())
21178            .get(&materialization_id)
21179            .unwrap()
21180            .root()
21181            .to_path_buf();
21182        let selection = shared
21183            .select_environment_profile(
21184                address.session.instance_id,
21185                &agent("claude"),
21186                SessionMode::Pty,
21187                Some(&receipt),
21188            )
21189            .unwrap()
21190            .unwrap();
21191        assert!(shared
21192            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21193            .unwrap()
21194            .is_none());
21195        shared.bind_session_with_materialization(
21196            &address,
21197            ProviderRuntimePolicy::raw_pty(),
21198            Some(receipt),
21199            None,
21200            None,
21201            Some(materialization_id),
21202        );
21203        selection.retain();
21204        materialization_guard.retain();
21205
21206        assert!(materialization_root.is_dir());
21207        assert!(shared
21208            .session_bindings
21209            .lock()
21210            .unwrap_or_else(|poisoned| poisoned.into_inner())
21211            .contains_key(&address.session.instance_id));
21212
21213        let removed = shared.remove_binding(&address).unwrap();
21214        shared
21215            .cleanup_session_owned_materialization(&address, &removed)
21216            .unwrap();
21217        assert!(!materialization_root.exists());
21218        assert!(shared
21219            .materializations
21220            .lock()
21221            .unwrap_or_else(|poisoned| poisoned.into_inner())
21222            .is_empty());
21223        drop(shared);
21224        drop(runtime);
21225        std::fs::remove_dir_all(root).unwrap();
21226    }
21227
21228    #[tokio::test]
21229    async fn record_owned_session_environment_reuses_on_resume_and_forget_cleans() {
21230        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21231        let address = SessionAddress {
21232            workspace_id: WorkspaceId::new("primary").unwrap(),
21233            session: SessionKey {
21234                instance_id: AgentInstanceId(203),
21235                generation: SessionGeneration(1),
21236            },
21237        };
21238        let (overlay, materialization_guard) = shared
21239            .prepare_session_materialization(
21240                &address,
21241                &agent("claude"),
21242                SessionMode::Pty,
21243                Some(&receipt),
21244                None,
21245                None,
21246                None,
21247            )
21248            .unwrap()
21249            .unwrap();
21250        let materialization_id = materialization_guard.id().unwrap().clone();
21251        let materialization_root = shared
21252            .materializations
21253            .lock()
21254            .unwrap_or_else(|poisoned| poisoned.into_inner())
21255            .get(&materialization_id)
21256            .unwrap()
21257            .root()
21258            .to_path_buf();
21259        let selection = shared
21260            .select_environment_profile(
21261                address.session.instance_id,
21262                &agent("claude"),
21263                SessionMode::Pty,
21264                Some(&receipt),
21265            )
21266            .unwrap()
21267            .unwrap();
21268        assert!(shared
21269            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21270            .unwrap()
21271            .is_none());
21272        let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
21273        let record_id = shared
21274            .bind_spawn_session_with_materialization(
21275                &address,
21276                agent("claude"),
21277                SessionMode::Pty,
21278                policy,
21279                SpawnRecordPolicy::ProviderIdentityOnly,
21280                Some(receipt.clone()),
21281                None,
21282                None,
21283                Some(materialization_id.clone()),
21284            )
21285            .unwrap()
21286            .unwrap();
21287        selection.retain();
21288        materialization_guard.retain();
21289        assert_eq!(
21290            shared
21291                .materializations
21292                .lock()
21293                .unwrap_or_else(|poisoned| poisoned.into_inner())
21294                .get(&materialization_id)
21295                .unwrap()
21296                .owner(),
21297            &MaterializationOwner::Record {
21298                record_id: record_id.clone(),
21299            },
21300        );
21301
21302        let removed = shared.remove_binding(&address).unwrap();
21303        shared
21304            .cleanup_session_owned_materialization(&address, &removed)
21305            .unwrap();
21306        assert!(materialization_root.is_dir());
21307        assert!(shared
21308            .resolve_record_materialization(
21309                &record_id,
21310                &agent("claude"),
21311                SessionMode::Pty,
21312                Some(&receipt),
21313                None,
21314                None,
21315            )
21316            .unwrap()
21317            .is_some());
21318        {
21319            let mut records = shared
21320                .session_records
21321                .lock()
21322                .unwrap_or_else(|poisoned| poisoned.into_inner());
21323            let record = records.records.get_mut(&record_id).unwrap();
21324            record.active_session = None;
21325            record.state = ManagedSessionState::Unavailable;
21326        }
21327        shared.forget_session_record(&record_id).await.unwrap();
21328        assert!(!materialization_root.exists());
21329        assert!(shared
21330            .materializations
21331            .lock()
21332            .unwrap_or_else(|poisoned| poisoned.into_inner())
21333            .is_empty());
21334        drop(shared);
21335        drop(runtime);
21336        std::fs::remove_dir_all(root).unwrap();
21337    }
21338
21339    #[test]
21340    fn record_materialization_revalidation_failure_persists_recovery_required() {
21341        let (mut shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21342        let state_path = root.join("node-state-v6.json");
21343        shared.state_path = Some(state_path.clone());
21344        let (_address, _record_id, materialization_id, materialization_root) =
21345            bind_record_owned_materialization_fixture(&shared, &receipt, 205);
21346        std::fs::write(
21347            materialization_root.join(".gate4agent-materialization-owner"),
21348            b"tampered",
21349        )
21350        .unwrap();
21351
21352        let error = match shared.resolve_record_materialization(
21353            &_record_id,
21354            &agent("claude"),
21355            SessionMode::Pty,
21356            Some(&receipt),
21357            None,
21358            None,
21359        ) {
21360            Ok(_) => panic!("tampered owner marker unexpectedly revalidated"),
21361            Err(error) => error,
21362        };
21363        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21364        assert_eq!(
21365            shared
21366                .materializations
21367                .lock()
21368                .unwrap_or_else(|poisoned| poisoned.into_inner())
21369                .get(&materialization_id)
21370                .unwrap()
21371                .state(),
21372            MaterializationState::RecoveryRequired,
21373        );
21374        let loaded = session_registry::load(Some(&state_path), &shared.node_id).unwrap();
21375        assert_eq!(loaded.materializations.len(), 1);
21376        assert_eq!(
21377            loaded.materializations[0].state(),
21378            MaterializationState::RecoveryRequired,
21379        );
21380        drop(shared);
21381        drop(runtime);
21382        std::fs::remove_dir_all(root).unwrap();
21383    }
21384
21385    #[test]
21386    fn transient_record_materialization_resolution_failure_remains_ready() {
21387        let (shared, runtime, receipt, root, deny) = materialization_test_shared(false);
21388        let (_address, record_id, materialization_id, _materialization_root) =
21389            bind_record_owned_materialization_fixture(&shared, &receipt, 206);
21390        deny.store(true, Ordering::Release);
21391
21392        let error = match shared.resolve_record_materialization(
21393            &record_id,
21394            &agent("claude"),
21395            SessionMode::Pty,
21396            Some(&receipt),
21397            None,
21398            None,
21399        ) {
21400            Ok(_) => panic!("denied transient resolver unexpectedly succeeded"),
21401            Err(error) => error,
21402        };
21403        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21404        assert_eq!(
21405            shared
21406                .materializations
21407                .lock()
21408                .unwrap_or_else(|poisoned| poisoned.into_inner())
21409                .get(&materialization_id)
21410                .unwrap()
21411                .state(),
21412            MaterializationState::Ready,
21413        );
21414        drop(shared);
21415        drop(runtime);
21416        std::fs::remove_dir_all(root).unwrap();
21417    }
21418
21419    #[test]
21420    fn startup_missing_record_materialization_is_not_resumable() {
21421        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21422        let record_id = SessionRecordId::new("sr-missing-materialization").unwrap();
21423        shared
21424            .insert_record(ManagedSessionRecord {
21425                record_id: record_id.clone(),
21426                display_name: "missing materialization".to_owned(),
21427                provider: agent("claude"),
21428                mode: SessionMode::Pty,
21429                state: ManagedSessionState::Dormant,
21430                workspace_id: WorkspaceId::new("primary").unwrap(),
21431                canonical_root: opaque_windows_path(
21432                    std::env::current_dir().unwrap().to_string_lossy().into_owned(),
21433                ),
21434                provider_session: Some(gate4agent_types::ProviderSessionIdentity {
21435                    key: gate4agent_types::ProviderSessionKey::SessionId,
21436                    id: "provider-session-missing-materialization".to_owned(),
21437                    transcript_path: None,
21438                }),
21439                active_session: None,
21440                environment_profile: Some(receipt),
21441                bundle: None,
21442                context_id: None,
21443                context: None,
21444                exported_context: None,
21445                task_binding: None,
21446                created_at_unix_ms: 1,
21447                updated_at_unix_ms: 1,
21448                last_error: None,
21449            })
21450            .unwrap();
21451
21452        shared.reconcile_materializations();
21453        let record = shared.record(&record_id).unwrap();
21454        assert_eq!(record.state, ManagedSessionState::Unavailable);
21455        assert!(record.provider_session.is_none());
21456        assert_eq!(
21457            record.last_error.as_deref(),
21458            Some("environment-profile-unavailable"),
21459        );
21460        drop(shared);
21461        drop(runtime);
21462        std::fs::remove_dir_all(root).unwrap();
21463    }
21464
21465    #[tokio::test]
21466    async fn managed_worktree_cleanup_waits_for_session_environment_cleanup() {
21467        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21468        let lease = install_managed_lease(&shared);
21469        let address = SessionAddress {
21470            workspace_id: WorkspaceId::new("primary").unwrap(),
21471            session: SessionKey {
21472                instance_id: AgentInstanceId(204),
21473                generation: SessionGeneration(1),
21474            },
21475        };
21476        let (overlay, materialization_guard) = shared
21477            .prepare_session_materialization(
21478                &address,
21479                &agent("claude"),
21480                SessionMode::Pty,
21481                Some(&receipt),
21482                None,
21483                None,
21484                Some(lease.lease_id.clone()),
21485            )
21486            .unwrap()
21487            .unwrap();
21488        let materialization_id = materialization_guard.id().unwrap().clone();
21489        let materialization_root = shared
21490            .materializations
21491            .lock()
21492            .unwrap_or_else(|poisoned| poisoned.into_inner())
21493            .get(&materialization_id)
21494            .unwrap()
21495            .root()
21496            .to_path_buf();
21497        let selection = shared
21498            .select_environment_profile(
21499                address.session.instance_id,
21500                &agent("claude"),
21501                SessionMode::Pty,
21502                Some(&receipt),
21503            )
21504            .unwrap()
21505            .unwrap();
21506        assert!(shared
21507            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21508            .unwrap()
21509            .is_none());
21510        shared.bind_session_with_materialization(
21511            &address,
21512            ProviderRuntimePolicy::raw_pty(),
21513            Some(receipt),
21514            None,
21515            None,
21516            Some(materialization_id),
21517        );
21518        selection.retain();
21519        materialization_guard.retain();
21520
21521        let blocked = shared
21522            .cleanup_managed_worktree(&lease.lease_id, false)
21523            .await
21524            .unwrap_err();
21525        assert_eq!(blocked.code, NodeFailureCode::ManagedWorktreeRecoveryRequired);
21526        assert!(materialization_root.is_dir());
21527        let removed = shared.remove_binding(&address).unwrap();
21528        shared
21529            .cleanup_session_owned_materialization(&address, &removed)
21530            .unwrap();
21531        assert!(!materialization_root.exists());
21532        drop(shared);
21533        drop(runtime);
21534        std::fs::remove_dir_all(root).unwrap();
21535    }
21536
21537    #[tokio::test]
21538    async fn raw_managed_record_is_inventory_only_and_does_not_block_session_cleanup() {
21539        let git_root = temporary_workspace_root("raw-managed-inventory");
21540        let source = git_root.join("source");
21541        let allocation = git_root.join("allocation");
21542        std::fs::create_dir_all(&source).unwrap();
21543        std::fs::create_dir_all(&allocation).unwrap();
21544        run_test_git(&source, &["init"]);
21545        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
21546        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
21547        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
21548        run_test_git(&source, &["add", "seed.txt"]);
21549        run_test_git(&source, &["commit", "-m", "seed"]);
21550        let profile = ManagedWorktreeProfile::new(
21551            WorktreeProfileId::new("default").unwrap(),
21552            crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
21553            &allocation,
21554            "gate4agent",
21555            "HEAD",
21556            ManagedWorktreeRetention::RemoveWhenReleased,
21557        )
21558        .unwrap();
21559        let workspace = WorkspaceConfig::new(
21560            WorkspaceId::new("primary").unwrap(),
21561            &source,
21562        )
21563        .unwrap()
21564        .with_worktree_service_mode(WorktreeServiceMode::Managed)
21565        .with_managed_worktree_profile(profile.clone())
21566        .unwrap();
21567        let (shared, mut runtime, receipt, materialization_fixture_root, _deny) =
21568            materialization_test_shared_with_workspace(false, workspace);
21569        let target = PathBuf::from(profile.allocation_root()).join("mw-raw-inventory");
21570        let source_text = source.to_string_lossy().into_owned();
21571        let target_text = target.to_string_lossy().into_owned();
21572        let created = create_git_worktree(
21573            &source_text,
21574            &target_text,
21575            "gate4agent/mw-raw-inventory",
21576            Some("HEAD"),
21577        )
21578        .await
21579        .unwrap();
21580        let lease = ManagedWorktreeLeaseRecord {
21581            lease_id: ManagedWorktreeLeaseId::new("mw-raw-inventory").unwrap(),
21582            source_workspace_id: WorkspaceId::new("primary").unwrap(),
21583            workspace_id: WorkspaceId::new("managed-raw-inventory").unwrap(),
21584            profile_id: profile.profile_id().clone(),
21585            profile_revision: profile.revision().clone(),
21586            target_root: created.path.clone(),
21587            branch: "gate4agent/mw-raw-inventory".to_owned(),
21588            base_commit: created.head.clone(),
21589            expected_head: Some(created.head.clone()),
21590            retention: ManagedWorktreeRetention::RemoveWhenReleased,
21591            state: ManagedWorktreeLeaseState::Ready,
21592            session_holders: Vec::new(),
21593            record_holders: Vec::new(),
21594            cleanup_failure: None,
21595            created_at_unix_ms: 1,
21596            updated_at_unix_ms: 1,
21597        };
21598        shared
21599            .workspaces
21600            .write()
21601            .unwrap_or_else(|poisoned| poisoned.into_inner())
21602            .insert(lease.workspace_id.clone(), created.path.clone());
21603        *shared
21604            .managed_worktrees
21605            .lock()
21606            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21607            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
21608        let address = SessionAddress {
21609            workspace_id: lease.workspace_id.clone(),
21610            session: SessionKey {
21611                instance_id: AgentInstanceId(205),
21612                generation: SessionGeneration(1),
21613            },
21614        };
21615        let (overlay, materialization_guard) = shared
21616            .prepare_session_materialization(
21617                &address,
21618                &agent("claude"),
21619                SessionMode::Pty,
21620                Some(&receipt),
21621                None,
21622                None,
21623                Some(lease.lease_id.clone()),
21624            )
21625            .unwrap()
21626            .unwrap();
21627        let materialization_id = materialization_guard.id().unwrap().clone();
21628        let materialization_root = shared
21629            .materializations
21630            .lock()
21631            .unwrap_or_else(|poisoned| poisoned.into_inner())
21632            .get(&materialization_id)
21633            .unwrap()
21634            .root()
21635            .to_path_buf();
21636        let selection = shared
21637            .select_environment_profile(
21638                address.session.instance_id,
21639                &agent("claude"),
21640                SessionMode::Pty,
21641                Some(&receipt),
21642            )
21643            .unwrap()
21644            .unwrap();
21645        assert!(shared
21646            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21647            .unwrap()
21648            .is_none());
21649        let record_id = shared
21650            .bind_spawn_session_with_materialization(
21651                &address,
21652                agent("claude"),
21653                SessionMode::Pty,
21654                ProviderRuntimePolicy::raw_pty(),
21655                SpawnRecordPolicy::Always,
21656                Some(receipt.clone()),
21657                None,
21658                None,
21659                Some(materialization_id.clone()),
21660            )
21661            .unwrap()
21662            .unwrap();
21663        selection.retain();
21664        materialization_guard.retain();
21665        let record = shared.record(&record_id).unwrap();
21666        assert_eq!(record.state, ManagedSessionState::Live);
21667        assert!(record.provider_session.is_none());
21668        assert!(record.environment_profile.is_none());
21669        assert!(record.bundle.is_none());
21670        assert!(record.context_id.is_none());
21671        assert!(record.context.is_none());
21672        assert_eq!(record.active_session.as_ref(), Some(&address));
21673        assert_eq!(
21674            shared
21675                .materializations
21676                .lock()
21677                .unwrap_or_else(|poisoned| poisoned.into_inner())
21678                .get(&materialization_id)
21679                .unwrap()
21680                .owner(),
21681            &MaterializationOwner::Session {
21682                incarnation_id: shared.incarnation_id,
21683                instance_id: address.session.instance_id,
21684                generation: address.session.generation,
21685            },
21686        );
21687        {
21688            let mut bindings = shared
21689                .session_bindings
21690                .lock()
21691                .unwrap_or_else(|poisoned| poisoned.into_inner());
21692            bindings
21693                .get_mut(&address.session.instance_id)
21694                .unwrap()
21695                .managed_worktree_lease_id = Some(lease.lease_id.clone());
21696        }
21697        let lease_snapshot = shared
21698            .managed_worktrees
21699            .lock()
21700            .unwrap_or_else(|poisoned| poisoned.into_inner())
21701            .bind_session(
21702                &lease.lease_id,
21703                ManagedWorktreeSessionHolder {
21704                    incarnation_id: shared.incarnation_id,
21705                    instance_id: address.session.instance_id,
21706                    generation: address.session.generation,
21707                },
21708                None,
21709                unix_time_ms(),
21710            )
21711            .unwrap();
21712        assert_eq!(lease_snapshot.active_session_count, 1);
21713        assert_eq!(lease_snapshot.managed_record_count, 0);
21714        shared.persist_state().unwrap();
21715        assert!(shared
21716            .managed_worktrees
21717            .lock()
21718            .unwrap_or_else(|poisoned| poisoned.into_inner())
21719            .get(&lease.lease_id)
21720            .unwrap()
21721            .record_holders
21722            .is_empty());
21723        shared
21724            .handle
21725            .dispatch(shared.prepare_command(ControlCommand::Register {
21726                instance_id: address.session.instance_id,
21727                agent_id: agent("claude"),
21728                transport: TransportKind::Pty,
21729            }))
21730            .unwrap();
21731        runtime.tick().await;
21732        shared.reconcile_managed_record(
21733            &record_id,
21734            &address,
21735            &ControlEvent {
21736                sequence: 1,
21737                command_id: None,
21738                instance_id: address.session.instance_id,
21739                generation: address.session.generation,
21740                event: ControlEventKind::Removed,
21741            },
21742            false,
21743            false,
21744        );
21745        let remove = shared.remove_session(&address);
21746        let drive_runtime = async {
21747            runtime.tick().await;
21748        };
21749        let (removed, ()) = tokio::join!(remove, drive_runtime);
21750        removed.unwrap();
21751
21752        assert!(!materialization_root.exists());
21753        assert!(!Path::new(&created.path).exists());
21754        assert_eq!(
21755            shared
21756                .managed_worktrees
21757                .lock()
21758                .unwrap_or_else(|poisoned| poisoned.into_inner())
21759                .get(&lease.lease_id)
21760                .unwrap()
21761                .state,
21762            ManagedWorktreeLeaseState::Removed,
21763        );
21764        let renamed = shared
21765            .rename_session_record(&record_id, "raw managed inventory".to_owned())
21766            .unwrap();
21767        assert_eq!(renamed.record_id, record_id);
21768        assert_eq!(renamed.display_name, "raw managed inventory");
21769        assert_eq!(renamed.state, ManagedSessionState::Unavailable);
21770        assert!(renamed.active_session.is_none());
21771
21772        drop(shared);
21773        drop(runtime);
21774        std::fs::remove_dir_all(materialization_fixture_root).unwrap();
21775        std::fs::remove_dir_all(git_root).unwrap();
21776    }
21777
21778    fn install_managed_lease(shared: &NodeShared) -> ManagedWorktreeLeaseRecord {
21779        let root = std::env::current_dir().unwrap()
21780            .join("managed-test-target")
21781            .to_string_lossy()
21782            .into_owned();
21783        let lease = ManagedWorktreeLeaseRecord {
21784            lease_id: ManagedWorktreeLeaseId::new("mw-test").unwrap(),
21785            source_workspace_id: WorkspaceId::new("primary").unwrap(),
21786            workspace_id: WorkspaceId::new("managed-test").unwrap(),
21787            profile_id: WorktreeProfileId::new("default").unwrap(),
21788            profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
21789            target_root: root.clone(),
21790            branch: "gate4agent/mw-test".to_owned(),
21791            base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
21792            expected_head: Some("0123456789abcdef0123456789abcdef01234567".to_owned()),
21793            retention: ManagedWorktreeRetention::RemoveWhenReleased,
21794            state: ManagedWorktreeLeaseState::Ready,
21795            session_holders: Vec::new(),
21796            record_holders: Vec::new(),
21797            cleanup_failure: None,
21798            created_at_unix_ms: 1,
21799            updated_at_unix_ms: 1,
21800        };
21801        shared.workspaces.write()
21802            .unwrap_or_else(|poisoned| poisoned.into_inner())
21803            .insert(lease.workspace_id.clone(), root);
21804        *shared.managed_worktrees.lock()
21805            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21806            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
21807        lease
21808    }
21809
21810    #[cfg(feature = "fixture")]
21811    #[test]
21812    fn monitoring_fixture_managed_spawn_retains_effective_record_holder_and_converges_live() {
21813        let mut shared = terminal_test_shared();
21814        shared.fixture_semantic_hook_policy = true;
21815        let lease = install_managed_lease(&shared);
21816        let address = SessionAddress {
21817            workspace_id: lease.workspace_id.clone(),
21818            session: SessionKey {
21819                instance_id: AgentInstanceId(206),
21820                generation: SessionGeneration(1),
21821            },
21822        };
21823        let pre_admission_policy = ProviderRuntimePolicy::raw_pty();
21824        let effective_runtime_policy =
21825            shared.effective_spawn_runtime_policy(pre_admission_policy);
21826        assert_ne!(effective_runtime_policy, pre_admission_policy);
21827        assert!(effective_runtime_policy.provider_session_identity);
21828
21829        let record_id = shared
21830            .bind_spawn_session(
21831                &address,
21832                agent("claude"),
21833                SessionMode::Pty,
21834                effective_runtime_policy,
21835                SpawnRecordPolicy::Always,
21836                None,
21837            )
21838            .unwrap()
21839            .unwrap();
21840        assert_eq!(
21841            shared.record(&record_id).unwrap().state,
21842            ManagedSessionState::IdentityPending,
21843        );
21844        let (record_holder, raw_inventory_record_id) = {
21845            let mut bindings = shared
21846                .session_bindings
21847                .lock()
21848                .unwrap_or_else(|poisoned| poisoned.into_inner());
21849            let binding = bindings.get_mut(&address.session.instance_id).unwrap();
21850            binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
21851            managed_spawn_record_ownership(binding, effective_runtime_policy)
21852        };
21853        assert_eq!(record_holder.as_ref(), Some(&record_id));
21854        assert!(raw_inventory_record_id.is_none());
21855        let snapshot = shared
21856            .managed_worktrees
21857            .lock()
21858            .unwrap_or_else(|poisoned| poisoned.into_inner())
21859            .bind_session(
21860                &lease.lease_id,
21861                ManagedWorktreeSessionHolder {
21862                    incarnation_id: shared.incarnation_id,
21863                    instance_id: address.session.instance_id,
21864                    generation: address.session.generation,
21865                },
21866                record_holder,
21867                unix_time_ms(),
21868            )
21869            .unwrap();
21870        assert_eq!(snapshot.managed_record_count, 1);
21871        assert_eq!(
21872            shared
21873                .managed_worktrees
21874                .lock()
21875                .unwrap_or_else(|poisoned| poisoned.into_inner())
21876                .get(&lease.lease_id)
21877                .unwrap()
21878                .record_holders,
21879            vec![record_id.clone()],
21880        );
21881
21882        let identity = ProviderSessionIdentity {
21883            key: ProviderSessionKey::SessionId,
21884            id: "monitoring-fixture-managed-session".to_owned(),
21885            transcript_path: None,
21886        };
21887        shared.publish_control(ControlEvent {
21888            sequence: 1,
21889            command_id: None,
21890            instance_id: address.session.instance_id,
21891            generation: address.session.generation,
21892            event: ControlEventKind::ProviderEvent {
21893                sequence: 1,
21894                source: gate4agent_types::ProviderSource {
21895                    family: AdapterFamily::Hook,
21896                    binding: AdapterBinding::new(
21897                        gate4agent_types::AdapterId::new("claude-code").unwrap(),
21898                        "fixture/v1",
21899                        gate4agent_types::AdapterVerification::SyntheticFixture,
21900                    )
21901                    .unwrap(),
21902                },
21903                source_sequence: 1,
21904                event: ProviderEvent::SessionIdentityObserved {
21905                    identity: identity.clone(),
21906                },
21907            },
21908        });
21909
21910        let live = shared.record(&record_id).unwrap();
21911        assert_eq!(live.state, ManagedSessionState::Live);
21912        assert_eq!(live.provider_session.as_ref(), Some(&identity));
21913        assert_eq!(live.active_session.as_ref(), Some(&address));
21914        assert_eq!(
21915            shared
21916                .managed_worktrees
21917                .lock()
21918                .unwrap_or_else(|poisoned| poisoned.into_inner())
21919                .get(&lease.lease_id)
21920                .unwrap()
21921                .record_holders,
21922            vec![record_id],
21923        );
21924    }
21925
21926    fn managed_v2_test_shared(
21927        allocation_root: &Path,
21928        state_path: Option<PathBuf>,
21929        profile_revision: &str,
21930    ) -> NodeShared {
21931        let profile = ManagedWorktreeProfile::new(
21932            WorktreeProfileId::new("default").unwrap(),
21933            WorktreeProfileRevision::new(profile_revision).unwrap(),
21934            allocation_root,
21935            "gate4agent",
21936            "HEAD",
21937            ManagedWorktreeRetention::RemoveWhenReleased,
21938        )
21939        .unwrap();
21940        let workspace = WorkspaceConfig::new(
21941            WorkspaceId::new("primary").unwrap(),
21942            std::env::current_dir().unwrap(),
21943        )
21944        .unwrap()
21945        .with_worktree_service_mode(WorktreeServiceMode::Managed)
21946        .with_managed_worktree_profile(profile)
21947        .unwrap();
21948        let catalog = active_registry().unwrap();
21949        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
21950        let mut shared = NodeShared::new(
21951            handle,
21952            "fixture-token".to_owned(),
21953            NodeId::new("node-terminal-test").unwrap(),
21954            vec![workspace],
21955            vec![agent("claude")],
21956        );
21957        shared.state_path = state_path;
21958        shared
21959    }
21960
21961    fn managed_v2_request(idempotency_key: &str) -> ManagedWorktreeSpawnRequestV2 {
21962        let mut spawn_spec = spawn_spec_fixture();
21963        spawn_spec.idempotency_key = SpawnIdempotencyKey::new(idempotency_key).unwrap();
21964        ManagedWorktreeSpawnRequestV2 {
21965            spawn_spec,
21966            worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
21967            expected_profile_revision: WorktreeProfileRevision::new("v1").unwrap(),
21968        }
21969    }
21970
21971    #[test]
21972    fn managed_spawn_receipt_rebinds_allocated_worktree_and_roundtrips_wire() {
21973        let shared = terminal_test_shared();
21974        let mut lease = install_managed_lease(&shared);
21975        let session = SessionAddress {
21976            workspace_id: lease.workspace_id.clone(),
21977            session: SessionKey {
21978                instance_id: AgentInstanceId(73),
21979                generation: SessionGeneration(4),
21980            },
21981        };
21982        lease.state = ManagedWorktreeLeaseState::InUse;
21983        lease.session_holders.push(ManagedWorktreeSessionHolder {
21984            incarnation_id: shared.incarnation_id,
21985            instance_id: session.session.instance_id,
21986            generation: session.session.generation,
21987        });
21988        let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
21989        assert!(resolved.target.worktree_id.is_none());
21990
21991        let receipt = managed_spawn_receipt(
21992            &resolved,
21993            shared.incarnation_id,
21994            session.clone(),
21995            lease.snapshot(),
21996            None,
21997            None,
21998            None,
21999        );
22000        assert!(resolved.target.worktree_id.is_none());
22001        assert_eq!(receipt.spawn.target.workspace_id, lease.source_workspace_id);
22002        assert_eq!(receipt.spawn.target.worktree_id.as_ref(), Some(&lease.workspace_id));
22003        assert_eq!(receipt.spawn.session, session);
22004        assert_eq!(receipt.lease.workspace_id, lease.workspace_id);
22005        let encoded = serde_json::to_vec(&receipt).unwrap();
22006        let decoded: ManagedWorktreeSpawnReceipt = serde_json::from_slice(&encoded).unwrap();
22007        assert_eq!(decoded, receipt);
22008    }
22009
22010    #[tokio::test]
22011    async fn managed_spawn_v2_rejects_profile_revision_before_allocation() {
22012        let root = temporary_workspace_root("managed-v2-revision");
22013        let allocation = root.join("allocation");
22014        std::fs::create_dir_all(&allocation).unwrap();
22015        let shared = managed_v2_test_shared(
22016            &allocation,
22017            Some(root.join("node-state.json")),
22018            "v1",
22019        );
22020        let mut request = managed_v2_request("managed-v2-revision");
22021        request.expected_profile_revision = WorktreeProfileRevision::new("v2").unwrap();
22022
22023        let failure = shared.spawn_managed_worktree_v2(request).await.unwrap_err();
22024
22025        assert_eq!(
22026            failure.code,
22027            NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22028        );
22029        assert!(shared
22030            .managed_worktrees
22031            .lock()
22032            .unwrap_or_else(|poisoned| poisoned.into_inner())
22033            .records()
22034            .is_empty());
22035        assert!(shared
22036            .managed_spawn_replays
22037            .lock()
22038            .unwrap_or_else(|poisoned| poisoned.into_inner())
22039            .is_empty());
22040        std::fs::remove_dir_all(root).unwrap();
22041    }
22042
22043    #[test]
22044    fn managed_spawn_v2_concurrent_same_key_has_one_durable_reservation() {
22045        let root = temporary_workspace_root("managed-v2-concurrent");
22046        let allocation = root.join("allocation");
22047        std::fs::create_dir_all(&allocation).unwrap();
22048        let shared = Arc::new(managed_v2_test_shared(
22049            &allocation,
22050            Some(root.join("node-state.json")),
22051            "v1",
22052        ));
22053        let request = managed_v2_request("managed-v2-concurrent");
22054        let barrier = Arc::new(std::sync::Barrier::new(3));
22055        let mut joins = Vec::new();
22056        for _ in 0..2 {
22057            let shared = Arc::clone(&shared);
22058            let request = request.clone();
22059            let barrier = Arc::clone(&barrier);
22060            joins.push(std::thread::spawn(move || {
22061                let digest = managed_spawn_request_digest_v2(&request).unwrap();
22062                barrier.wait();
22063                shared.acquire_durable_managed_spawn_v2(&request, digest)
22064            }));
22065        }
22066        barrier.wait();
22067        let results = joins
22068            .into_iter()
22069            .map(|join| join.join().unwrap())
22070            .collect::<Vec<_>>();
22071        assert_eq!(
22072            results
22073                .iter()
22074                .filter(|result| {
22075                    matches!(result, Ok(DurableManagedSpawnReplayDecision::Reserved))
22076                })
22077                .count(),
22078            1,
22079        );
22080        assert_eq!(
22081            results
22082                .iter()
22083                .filter(|result| {
22084                    matches!(result, Err(error) if error.code == NodeFailureCode::BackendBusy)
22085                })
22086                .count(),
22087            1,
22088        );
22089        assert_eq!(
22090            shared
22091                .managed_spawn_replays
22092                .lock()
22093                .unwrap_or_else(|poisoned| poisoned.into_inner())
22094                .len(),
22095            1,
22096        );
22097        assert!(shared
22098            .managed_worktrees
22099            .lock()
22100            .unwrap_or_else(|poisoned| poisoned.into_inner())
22101            .records()
22102            .is_empty());
22103        drop(shared);
22104        std::fs::remove_dir_all(root).unwrap();
22105    }
22106
22107    #[tokio::test]
22108    async fn managed_spawn_v2_linked_pending_reopen_fails_closed_without_reallocation() {
22109        let root = temporary_workspace_root("managed-v2-linked-pending");
22110        let allocation = root.join("allocation");
22111        std::fs::create_dir_all(&allocation).unwrap();
22112        let state_path = root.join("node-state.json");
22113        let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22114        let lease = install_managed_lease(&shared);
22115        let request = managed_v2_request("managed-v2-linked-pending");
22116        assert!(matches!(
22117            shared
22118                .acquire_durable_managed_spawn_v2(
22119                    &request,
22120                    managed_spawn_request_digest_v2(&request).unwrap(),
22121                )
22122                .unwrap(),
22123            DurableManagedSpawnReplayDecision::Reserved,
22124        ));
22125        {
22126            let _transaction = shared
22127                .state_transaction
22128                .lock()
22129                .unwrap_or_else(|poisoned| poisoned.into_inner());
22130            shared
22131                .link_durable_managed_spawn_v2_lease_locked(
22132                    &request.spawn_spec.idempotency_key,
22133                    &lease.lease_id,
22134                )
22135                .unwrap();
22136            shared.persist_state_locked().unwrap();
22137        }
22138        drop(shared);
22139
22140        let mut loaded = session_registry::load(
22141            Some(&state_path),
22142            &NodeId::new("node-terminal-test").unwrap(),
22143        )
22144        .unwrap();
22145        let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22146        *reopened
22147            .managed_worktrees
22148            .lock()
22149            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22150            ManagedWorktreeRegistry::from_records(
22151                std::mem::take(&mut loaded.managed_worktrees),
22152                std::mem::take(&mut loaded.managed_worktree_tombstones),
22153            )
22154            .unwrap();
22155        *reopened
22156            .managed_spawn_replays
22157            .lock()
22158            .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22159            .managed_spawn_replays
22160            .into_iter()
22161            .map(|record| (record.idempotency_key.clone(), record))
22162            .collect();
22163        let lease_count = reopened
22164            .managed_worktrees
22165            .lock()
22166            .unwrap_or_else(|poisoned| poisoned.into_inner())
22167            .records()
22168            .len();
22169
22170        let failure = reopened
22171            .spawn_managed_worktree_v2(request)
22172            .await
22173            .unwrap_err();
22174
22175        assert_eq!(
22176            failure.code,
22177            NodeFailureCode::ManagedWorktreeRecoveryRequired,
22178        );
22179        assert_eq!(
22180            reopened
22181                .managed_worktrees
22182                .lock()
22183                .unwrap_or_else(|poisoned| poisoned.into_inner())
22184                .records()
22185                .len(),
22186            lease_count,
22187        );
22188        std::fs::remove_dir_all(root).unwrap();
22189    }
22190
22191    #[tokio::test]
22192    async fn managed_spawn_v2_replays_exact_receipt_after_durable_reopen_and_conflicts() {
22193        let root = temporary_workspace_root("managed-v2-reopen");
22194        let allocation = root.join("allocation");
22195        std::fs::create_dir_all(&allocation).unwrap();
22196        let state_path = root.join("node-state.json");
22197        let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22198        let lease = install_managed_lease(&shared);
22199        let request = managed_v2_request("managed-v2-reopen");
22200        let session = SessionAddress {
22201            workspace_id: lease.workspace_id.clone(),
22202            session: SessionKey {
22203                instance_id: AgentInstanceId(91),
22204                generation: SessionGeneration(1),
22205            },
22206        };
22207        let snapshot = {
22208            let mut registry = shared
22209                .managed_worktrees
22210                .lock()
22211                .unwrap_or_else(|poisoned| poisoned.into_inner());
22212            let current = registry.get_mut(&lease.lease_id).unwrap();
22213            current.state = ManagedWorktreeLeaseState::InUse;
22214            current.session_holders.push(ManagedWorktreeSessionHolder {
22215                incarnation_id: shared.incarnation_id,
22216                instance_id: session.session.instance_id,
22217                generation: session.session.generation,
22218            });
22219            current.snapshot()
22220        };
22221        let resolved = shared.resolve_spawn_spec(&request.spawn_spec).unwrap();
22222        let receipt = managed_spawn_receipt(
22223            &resolved,
22224            shared.incarnation_id,
22225            session,
22226            snapshot,
22227            None,
22228            None,
22229            None,
22230        );
22231        assert!(matches!(
22232            shared
22233                .acquire_durable_managed_spawn_v2(
22234                    &request,
22235                    managed_spawn_request_digest_v2(&request).unwrap(),
22236                )
22237                .unwrap(),
22238            DurableManagedSpawnReplayDecision::Reserved,
22239        ));
22240        {
22241            let _transaction = shared
22242                .state_transaction
22243                .lock()
22244                .unwrap_or_else(|poisoned| poisoned.into_inner());
22245            shared
22246                .link_durable_managed_spawn_v2_lease_locked(
22247                    &request.spawn_spec.idempotency_key,
22248                    &lease.lease_id,
22249                )
22250                .unwrap();
22251            shared.persist_state_locked().unwrap();
22252        }
22253        shared
22254            .commit_durable_managed_spawn_v2(&request, receipt.clone())
22255            .unwrap();
22256        let legacy_same_key = ManagedWorktreeSpawnRequest {
22257            spawn_spec: request.spawn_spec.clone(),
22258            worktree_profile_id: request.worktree_profile_id.clone(),
22259        };
22260        shared.remember_managed_spawn_attempt(
22261            Some(&request.spawn_spec.idempotency_key),
22262            legacy_same_key.clone(),
22263            Ok(receipt.clone()),
22264            Instant::now(),
22265        );
22266        assert!(shared
22267            .replay_managed_spawn(&legacy_same_key, Instant::now())
22268            .unwrap()
22269            .is_none());
22270        drop(shared);
22271
22272        let mut loaded = session_registry::load(
22273            Some(&state_path),
22274            &NodeId::new("node-terminal-test").unwrap(),
22275        )
22276        .unwrap();
22277        assert_eq!(loaded.managed_spawn_replays.len(), 1);
22278        let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22279        *reopened
22280            .managed_worktrees
22281            .lock()
22282            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22283            ManagedWorktreeRegistry::from_records(
22284                std::mem::take(&mut loaded.managed_worktrees),
22285                std::mem::take(&mut loaded.managed_worktree_tombstones),
22286            )
22287            .unwrap();
22288        *reopened
22289            .managed_spawn_replays
22290            .lock()
22291            .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22292            .managed_spawn_replays
22293            .into_iter()
22294            .map(|record| (record.idempotency_key.clone(), record))
22295            .collect();
22296        let lease_count_before = reopened
22297            .managed_worktrees
22298            .lock()
22299            .unwrap_or_else(|poisoned| poisoned.into_inner())
22300            .records()
22301            .len();
22302
22303        assert_eq!(
22304            reopened
22305                .spawn_managed_worktree_v2(request.clone())
22306                .await
22307                .unwrap(),
22308            receipt,
22309        );
22310        assert_eq!(
22311            reopened
22312                .managed_worktrees
22313                .lock()
22314                .unwrap_or_else(|poisoned| poisoned.into_inner())
22315                .records()
22316                .len(),
22317            lease_count_before,
22318        );
22319        let mut changed = request;
22320        changed.spawn_spec.deadline_ms = crate::protocol::SpawnDeadlineMs::new(29_000).unwrap();
22321        let failure = reopened
22322            .spawn_managed_worktree_v2(changed)
22323            .await
22324            .unwrap_err();
22325        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22326        assert_eq!(
22327            reopened
22328                .managed_worktrees
22329                .lock()
22330                .unwrap_or_else(|poisoned| poisoned.into_inner())
22331                .records()
22332                .len(),
22333            lease_count_before,
22334        );
22335        let mut new_key = managed_v2_request("managed-v2-new-after-profile-change");
22336        new_key.expected_profile_revision = WorktreeProfileRevision::new("v1").unwrap();
22337        let failure = reopened
22338            .spawn_managed_worktree_v2(new_key)
22339            .await
22340            .unwrap_err();
22341        assert_eq!(
22342            failure.code,
22343            NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22344        );
22345        assert_eq!(
22346            reopened
22347                .managed_worktrees
22348                .lock()
22349                .unwrap_or_else(|poisoned| poisoned.into_inner())
22350                .records()
22351                .len(),
22352            lease_count_before,
22353        );
22354        assert_eq!(
22355            reopened
22356                .managed_spawn_replays
22357                .lock()
22358                .unwrap_or_else(|poisoned| poisoned.into_inner())
22359                .len(),
22360            1,
22361        );
22362        std::fs::remove_dir_all(root).unwrap();
22363    }
22364
22365    #[test]
22366    fn legacy_managed_spawn_idempotency_remains_transient_and_exact() {
22367        let shared = terminal_test_shared();
22368        let request = ManagedWorktreeSpawnRequest {
22369            spawn_spec: spawn_spec_fixture(),
22370            worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22371        };
22372        let expected = failure(NodeFailureCode::BackendBusy, "legacy fixture failure");
22373        shared.remember_managed_spawn(
22374            request.clone(),
22375            Err(expected.clone()),
22376            Instant::now(),
22377        );
22378
22379        assert_eq!(
22380            shared
22381                .replay_managed_spawn(&request, Instant::now())
22382                .unwrap()
22383                .unwrap(),
22384            Err(expected),
22385        );
22386        assert!(shared
22387            .managed_spawn_replays
22388            .lock()
22389            .unwrap_or_else(|poisoned| poisoned.into_inner())
22390            .is_empty());
22391    }
22392
22393    #[tokio::test]
22394    async fn ordinary_spawn_and_spawn_spec_cannot_bypass_managed_lease_holder_fence() {
22395        let shared = terminal_test_shared();
22396        let lease = install_managed_lease(&shared);
22397        let raw = shared.spawn_session(
22398            lease.workspace_id.clone(),
22399            agent("claude"),
22400            SessionMode::Pty,
22401            gate4agent_types::TerminalSize { rows: 24, columns: 80 },
22402            None,
22403        ).await.unwrap_err();
22404        assert_eq!(raw.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22405        let mut spec = spawn_spec_fixture();
22406        spec.target.workspace_id = lease.workspace_id.clone();
22407        let specified = shared.spawn_from_spec(spec).await.unwrap_err();
22408        assert_eq!(specified.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22409        let mut selected = spawn_spec_fixture();
22410        selected.idempotency_key = SpawnIdempotencyKey::new("managed-selected-bypass").unwrap();
22411        selected.target.worktree_id = Some(lease.workspace_id);
22412        let selected = shared.spawn_from_spec(selected).await.unwrap_err();
22413        assert_eq!(selected.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22414        assert!(shared.handle.snapshot().sessions.is_empty());
22415    }
22416
22417    #[test]
22418    fn legacy_projection_omits_managed_snapshot_events_and_path_bearing_diagnostics() {
22419        let shared = terminal_test_shared();
22420        let lease = install_managed_lease(&shared);
22421        let snapshot = snapshot_for_wire(
22422            &shared, true, true, true, false, true, true, false, false, true,
22423        );
22424        assert!(snapshot.managed_worktrees.is_empty());
22425        assert!(snapshot.workspaces.iter().all(|workspace| {
22426            workspace.managed_worktree_profiles.is_none()
22427        }));
22428        let mut reply = ResponseEnvelope {
22429            request_id: 1,
22430            result: Ok(NodeResponse::Resync {
22431                event_sequence: 1,
22432                oldest_available_sequence: 1,
22433                snapshot: shared.snapshot(),
22434                events: vec![NodeEventEnvelope {
22435                    sequence: 1,
22436                    event: NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() },
22437                }],
22438            }),
22439        };
22440        project_response_without_managed_worktrees(&mut reply);
22441        let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
22442            panic!("projection changed the response kind");
22443        };
22444        assert!(snapshot.managed_worktrees.is_empty());
22445        assert!(events.is_empty());
22446        let mut git = git_snapshot_for_parser();
22447        git.branch = Some(lease.branch.clone());
22448        git.managed_worktree = Some(crate::protocol::ManagedWorktreeGitScope {
22449            lease_id: lease.lease_id.clone(),
22450            source_workspace_id: lease.source_workspace_id.clone(),
22451            branch: lease.branch.clone(),
22452            base_commit: GitObjectId::new(lease.base_commit.clone()).unwrap(),
22453            active_session_count: 1,
22454            managed_record_count: 0,
22455        });
22456        let mut inspection_reply = ResponseEnvelope {
22457            request_id: 2,
22458            result: Ok(NodeResponse::WorkspaceInspected {
22459                inspection: WorkspaceInspection {
22460                    workspace_id: lease.workspace_id.clone(),
22461                    entries: Vec::new(),
22462                    tree_truncated: false,
22463                    git,
22464                    truncation: None,
22465                },
22466            }),
22467        };
22468        project_response_without_managed_worktrees(&mut inspection_reply);
22469        let Ok(NodeResponse::WorkspaceInspected { inspection }) = inspection_reply.result else {
22470            panic!("projection changed the inspection response kind");
22471        };
22472        assert!(inspection.git.managed_worktree.is_none());
22473        let failure = managed_git_worktree_failure(GitWorktreeError {
22474            kind: GitWorktreeErrorKind::Failed,
22475            message: r"git failed at C:\private\managed-target".to_owned(),
22476        });
22477        assert!(!failure.message.contains("private"));
22478        assert!(!failure.message.contains("managed-target"));
22479    }
22480
22481    #[tokio::test]
22482    async fn restart_reconciliation_retains_exact_owned_worktree_and_explicit_cleanup_removes_it() {
22483        let root = temporary_workspace_root("managed-restart");
22484        let source = root.join("source");
22485        let allocation = root.join("allocation");
22486        std::fs::create_dir_all(&source).unwrap();
22487        std::fs::create_dir_all(&allocation).unwrap();
22488        run_test_git(&source, &["init"]);
22489        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
22490        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
22491        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22492        run_test_git(&source, &["add", "seed.txt"]);
22493        run_test_git(&source, &["commit", "-m", "seed"]);
22494        let profile = ManagedWorktreeProfile::new(
22495            WorktreeProfileId::new("default").unwrap(),
22496            crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22497            &allocation,
22498            "gate4agent",
22499            "HEAD",
22500            ManagedWorktreeRetention::Retain,
22501        ).unwrap();
22502        let workspace = WorkspaceConfig::new(
22503            WorkspaceId::new("primary").unwrap(),
22504            &source,
22505        ).unwrap()
22506            .with_worktree_service_mode(WorktreeServiceMode::Managed)
22507            .with_managed_worktree_profile(profile.clone()).unwrap();
22508        let target = PathBuf::from(profile.allocation_root()).join("mw-restart");
22509        let source_text = workspace.canonical_root().to_owned();
22510        let target_text = target.to_string_lossy().into_owned();
22511        let created = create_git_worktree(
22512            &source_text,
22513            &target_text,
22514            "gate4agent/mw-restart",
22515            Some("HEAD"),
22516        ).await.unwrap();
22517        let lease = ManagedWorktreeLeaseRecord {
22518            lease_id: ManagedWorktreeLeaseId::new("mw-restart").unwrap(),
22519            source_workspace_id: WorkspaceId::new("primary").unwrap(),
22520            workspace_id: WorkspaceId::new("managed-restart").unwrap(),
22521            profile_id: profile.profile_id().clone(),
22522            profile_revision: profile.revision().clone(),
22523            target_root: created.path.clone(),
22524            branch: "gate4agent/mw-restart".to_owned(),
22525            base_commit: created.head.clone(),
22526            expected_head: None,
22527            retention: ManagedWorktreeRetention::Retain,
22528            state: ManagedWorktreeLeaseState::Allocating,
22529            session_holders: Vec::new(),
22530            record_holders: Vec::new(),
22531            cleanup_failure: None,
22532            created_at_unix_ms: 1,
22533            updated_at_unix_ms: 1,
22534        };
22535        let catalog = active_registry().unwrap();
22536        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22537        let shared = NodeShared::new_with_incarnation(
22538            handle,
22539            "fixture-token".to_owned(),
22540            NodeId::new("node-managed-restart").unwrap(),
22541            NodeIncarnationId::from_bytes([7; crate::protocol::NODE_INCARNATION_ID_BYTES]),
22542            vec![workspace],
22543            vec![agent("claude")],
22544            ProviderRuntimeStatuses::default(),
22545            None,
22546            Vec::new(),
22547            Vec::new(),
22548            SpawnProfileRegistry::default(),
22549            None,
22550            None,
22551            None,
22552            None,
22553            Vec::new(),
22554            vec![lease.clone()],
22555            Vec::new(),
22556            Vec::new(),
22557            None,
22558            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
22559        );
22560        shared.reconcile_managed_worktrees().await;
22561        let retained = shared.managed_worktrees.lock()
22562            .unwrap_or_else(|poisoned| poisoned.into_inner())
22563            .get(&lease.lease_id).unwrap().clone();
22564        assert_eq!(retained.state, ManagedWorktreeLeaseState::Retained);
22565        assert!(shared.workspaces.read()
22566            .unwrap_or_else(|poisoned| poisoned.into_inner())
22567            .contains_key(&lease.workspace_id));
22568        let removed = shared.cleanup_managed_worktree(&lease.lease_id, true).await.unwrap();
22569        assert_eq!(removed.state, ManagedWorktreeLeaseState::Removed);
22570        assert!(!Path::new(&created.path).exists());
22571        std::fs::remove_dir_all(root).unwrap();
22572    }
22573
22574    fn run_test_git(root: &Path, arguments: &[&str]) {
22575        let output = std::process::Command::new("git")
22576            .arg("-C").arg(root).args(arguments).output().unwrap();
22577        assert!(
22578            output.status.success(),
22579            "git {:?} failed: {}",
22580            arguments,
22581            String::from_utf8_lossy(&output.stderr),
22582        );
22583    }
22584
22585    #[tokio::test]
22586    async fn spawn_spec_validation_fails_before_session_mutation() {
22587        let shared = terminal_test_shared();
22588        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22589            capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
22590        }));
22591        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22592            capability.as_str() == NODE_SPAWN_PROFILE_REVISION_CAPABILITY
22593        }));
22594        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22595            capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
22596        }));
22597
22598        let mut target_mismatch = spawn_spec_fixture();
22599        target_mismatch.target.node_id = NodeId::new("other-node").unwrap();
22600        let failure = shared.spawn_from_spec(target_mismatch).await.unwrap_err();
22601        assert_eq!(failure.code, NodeFailureCode::SpawnTargetMismatch);
22602
22603        let mut unknown_profile = spawn_spec_fixture();
22604        unknown_profile.idempotency_key = SpawnIdempotencyKey::new("unknown-profile").unwrap();
22605        unknown_profile.profile_id = crate::protocol::SpawnProfileId::new("missing").unwrap();
22606        let failure = shared.spawn_from_spec(unknown_profile).await.unwrap_err();
22607        assert_eq!(failure.code, NodeFailureCode::UnknownSpawnProfile);
22608
22609        let mut revision_mismatch = spawn_spec_fixture();
22610        revision_mismatch.idempotency_key =
22611            SpawnIdempotencyKey::new("profile-revision-mismatch").unwrap();
22612        revision_mismatch.expected_profile_revision =
22613            crate::protocol::SpawnProfileRevision::new("test-r2").unwrap();
22614        let failure = shared.spawn_from_spec(revision_mismatch).await.unwrap_err();
22615        assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
22616
22617        let mut unsupported_materializer = spawn_spec_fixture();
22618        unsupported_materializer.idempotency_key =
22619            SpawnIdempotencyKey::new("unsupported-materializer").unwrap();
22620        unsupported_materializer.overrides.bundle_id = crate::protocol::SpawnOverride::Set {
22621            value: crate::protocol::SpawnBundleId::new("bundle-v1").unwrap(),
22622        };
22623        let failure = shared
22624            .spawn_from_spec(unsupported_materializer)
22625            .await
22626            .unwrap_err();
22627        assert_eq!(failure.code, NodeFailureCode::UnknownBundle);
22628
22629        let failure = shared
22630            .spawn_session_with_deadline(
22631                WorkspaceId::new("primary").unwrap(),
22632                agent("claude"),
22633                SessionMode::Pty,
22634                gate4agent_types::TerminalSize {
22635                    rows: 24,
22636                    columns: 80,
22637                },
22638                None,
22639                None,
22640                None,
22641                None,
22642                None,
22643                None,
22644                SpawnRecordPolicy::ProviderIdentityOnly,
22645                Some(Instant::now()),
22646                &[],
22647                None,
22648                ApprovalLevel::default(),
22649                None,
22650                None,
22651            )
22652            .await
22653            .unwrap_err();
22654        assert_eq!(failure.code, NodeFailureCode::SpawnDeadlineExceeded);
22655        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22656        assert!(shared.handle.snapshot().sessions.is_empty());
22657    }
22658
22659    #[test]
22660    fn worktree_selection_requires_its_separate_negotiated_capability() {
22661        let mut spec = spawn_spec_fixture();
22662        spec.target.worktree_id = Some(WorkspaceId::new("selected-worktree").unwrap());
22663        let request = NodeRequest::SpawnSpec { spec };
22664        let spawn_spec_capability =
22665            CapabilityId::new(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY).unwrap();
22666        assert!(request_uses_unnegotiated_capability(
22667            &request,
22668            &[spawn_spec_capability.clone()],
22669        ));
22670        let worktree_capability =
22671            CapabilityId::new(NODE_WORKTREE_SELECTION_CAPABILITY).unwrap();
22672        assert!(request_uses_unnegotiated_capability(
22673            &request,
22674            &[spawn_spec_capability.clone(), worktree_capability.clone()],
22675        ));
22676        let profile_revision_capability =
22677            CapabilityId::new(NODE_SPAWN_PROFILE_REVISION_CAPABILITY).unwrap();
22678        assert!(request_uses_unnegotiated_capability(
22679            &request,
22680            &[
22681                spawn_spec_capability.clone(),
22682                worktree_capability.clone(),
22683                profile_revision_capability.clone(),
22684            ],
22685        ));
22686        // Default `SpawnOverrides` leaves `bundle_id`/`context_id` at
22687        // `Inherit`, not `Clear` — the profile's own defaults might still
22688        // materialize a bundle or a context pack, so both capabilities stay
22689        // conservatively required until negotiated (see
22690        // `requires_session_bundle_materialization_capability` /
22691        // `requires_history_context_pack_capability`).
22692        let bundle_materialization_capability =
22693            CapabilityId::new(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY).unwrap();
22694        assert!(request_uses_unnegotiated_capability(
22695            &request,
22696            &[
22697                spawn_spec_capability.clone(),
22698                worktree_capability.clone(),
22699                profile_revision_capability.clone(),
22700                bundle_materialization_capability.clone(),
22701            ],
22702        ));
22703        let history_context_pack_capability =
22704            CapabilityId::new(NODE_HISTORY_CONTEXT_PACK_CAPABILITY).unwrap();
22705        assert!(!request_uses_unnegotiated_capability(
22706            &request,
22707            &[
22708                spawn_spec_capability,
22709                worktree_capability,
22710                profile_revision_capability,
22711                bundle_materialization_capability,
22712                history_context_pack_capability,
22713            ],
22714        ));
22715    }
22716
22717    #[tokio::test]
22718    async fn spawn_spec_idempotency_replays_receipt_and_conflicts_before_session_mutation() {
22719        let mut shared = terminal_test_shared();
22720        let spec = spawn_spec_fixture();
22721        let receipt = shared
22722            .resolve_spawn_spec(&spec)
22723            .unwrap()
22724            .receipt(shared.incarnation_id, terminal_address(1));
22725        shared.remember_spawn_spec(spec.clone(), Ok(receipt.clone()), Instant::now());
22726
22727        let mut revised = shared.spawn_profiles.get(&spec.profile_id).unwrap().clone();
22728        revised.revision = crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
22729        shared.spawn_profiles = SpawnProfileRegistry::new([revised]).unwrap();
22730
22731        assert_eq!(shared.spawn_from_spec(spec.clone()).await.unwrap(), receipt);
22732
22733        let mut revision_conflicting = spec.clone();
22734        revision_conflicting.expected_profile_revision =
22735            crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
22736        let failure = shared.spawn_from_spec(revision_conflicting).await.unwrap_err();
22737        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22738
22739        let mut stale_cache_miss = spec.clone();
22740        stale_cache_miss.idempotency_key =
22741            SpawnIdempotencyKey::new("stale-profile-cache-miss").unwrap();
22742        let failure = shared.spawn_from_spec(stale_cache_miss).await.unwrap_err();
22743        assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
22744
22745        let mut conflicting = spec;
22746        conflicting.deadline_ms = crate::protocol::SpawnDeadlineMs::new(31_000).unwrap();
22747        let failure = shared.spawn_from_spec(conflicting).await.unwrap_err();
22748        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22749        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22750        assert!(shared.handle.snapshot().sessions.is_empty());
22751    }
22752
22753    #[tokio::test]
22754    async fn off_worktree_service_rejects_selection_and_mutation_before_git() {
22755        let shared = terminal_test_shared_with_mode(WorktreeServiceMode::Off);
22756        let mut spec = spawn_spec_fixture();
22757        spec.target.worktree_id = Some(WorkspaceId::new("unregistered-worktree").unwrap());
22758        let failure = shared.spawn_from_spec(spec).await.unwrap_err();
22759        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
22760
22761        let failure = shared
22762            .create_worktree(
22763                WorkspaceId::new("primary").unwrap(),
22764                WorkspaceId::new("new-worktree").unwrap(),
22765                "not-an-absolute-path".to_owned(),
22766                "invalid branch".to_owned(),
22767                None,
22768            )
22769            .await
22770            .unwrap_err();
22771        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
22772
22773        let failure = shared
22774            .remove_worktree(
22775                WorkspaceId::new("primary").unwrap(),
22776                "not-an-absolute-path".to_owned(),
22777            )
22778            .await
22779            .unwrap_err();
22780        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
22781        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22782        assert!(shared.handle.snapshot().sessions.is_empty());
22783    }
22784
22785    #[tokio::test]
22786    async fn manual_worktree_registration_failure_removes_the_created_worktree() {
22787        let root = temporary_workspace_root("manual-worktree-compensation");
22788        let source = root.join("source");
22789        let target = root.join("target");
22790        let invalid_state_parent = root.join("state-parent-is-a-file");
22791        let invalid_state_path = invalid_state_parent.join("state.json");
22792        std::fs::create_dir_all(&source).unwrap();
22793        std::fs::write(&invalid_state_parent, "not a directory").unwrap();
22794        run_test_git(&source, &["init"]);
22795        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
22796        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
22797        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22798        run_test_git(&source, &["add", "seed.txt"]);
22799        run_test_git(&source, &["commit", "-m", "seed"]);
22800
22801        let catalog = active_registry().unwrap();
22802        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22803        let workspace = WorkspaceConfig::new(
22804            WorkspaceId::new("primary").unwrap(),
22805            &source,
22806        ).unwrap();
22807        let mut shared = NodeShared::new(
22808            handle,
22809            "fixture-token".to_owned(),
22810            NodeId::new("node-worktree-compensation").unwrap(),
22811            vec![workspace],
22812            vec![agent("claude")],
22813        );
22814        shared.state_path = Some(invalid_state_path);
22815
22816        let error = shared.create_worktree(
22817            WorkspaceId::new("primary").unwrap(),
22818            WorkspaceId::new("compensated").unwrap(),
22819            target.to_string_lossy().into_owned(),
22820            "gate4agent/compensated".to_owned(),
22821            Some("HEAD".to_owned()),
22822        ).await.unwrap_err();
22823
22824        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
22825        assert!(!target.exists());
22826        assert!(list_git_worktrees(source.to_str().unwrap()).await.unwrap()
22827            .iter()
22828            .all(|worktree| !worktree_paths_equal(&worktree.path, &target.to_string_lossy())));
22829        std::fs::remove_dir_all(root).unwrap();
22830    }
22831
22832    #[test]
22833    fn selected_worktree_requires_authoritative_non_main_non_bare_entry() {
22834        fn worktree(path: &str, is_main: bool, is_bare: bool) -> NativeGitWorktreeSnapshot {
22835            NativeGitWorktreeSnapshot {
22836                path: path.to_owned(),
22837                head: "0123456789abcdef".to_owned(),
22838                branch: Some("refs/heads/test".to_owned()),
22839                is_bare,
22840                is_main,
22841                locked: false,
22842                lock_reason: None,
22843                prunable: false,
22844                prunable_reason: None,
22845                workspace_id: None,
22846            }
22847        }
22848
22849        let source = r"C:\repo";
22850        let selected = r"C:\repo-worktree";
22851        let listed = vec![
22852            worktree(source, true, false),
22853            worktree(selected, false, false),
22854        ];
22855        assert!(validate_selected_worktree(source, selected, &listed).is_ok());
22856
22857        let main_failure = validate_selected_worktree(source, source, &listed).unwrap_err();
22858        assert_eq!(main_failure.code, NodeFailureCode::WorktreeProtected);
22859
22860        let bare = vec![
22861            worktree(source, true, false),
22862            worktree(selected, false, true),
22863        ];
22864        let bare_failure = validate_selected_worktree(source, selected, &bare).unwrap_err();
22865        assert_eq!(bare_failure.code, NodeFailureCode::WorktreeProtected);
22866
22867        let foreign_failure =
22868            validate_selected_worktree(source, r"C:\foreign", &listed).unwrap_err();
22869        assert_eq!(foreign_failure.code, NodeFailureCode::WorktreeProtected);
22870    }
22871
22872    fn terminal_frame(sequence: u64) -> TerminalFrame {
22873        TerminalFrame {
22874            sequence,
22875            size: gate4agent_types::TerminalSize {
22876                rows: 24,
22877                columns: 80,
22878            },
22879            cursor_row: 2,
22880            cursor_column: 3,
22881            contents: format!("frame-{sequence}"),
22882            formatted: vec![1, 2, 3],
22883            scrollback_formatted: Vec::new(),
22884            alternate_screen: false,
22885            mouse_protocol_enabled: false,
22886            mouse_protocol_encoding: Default::default(),
22887            produced_at_unix_ms: 0,
22888            screen_state: gate4agent_types::PtyScreenState::default(),
22889            bracketed_paste: None,
22890        }
22891    }
22892
22893    #[test]
22894    fn terminal_frame_watermark_coalesces_and_resets_for_exact_session_address() {
22895        let shared = terminal_test_shared();
22896        let mut receiver = shared.terminal_event_tx.subscribe();
22897        let original = terminal_address(1);
22898
22899        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
22900        let first = receiver.try_recv().unwrap();
22901        assert_eq!(first.len(), 1);
22902        assert!(matches!(
22903            &first[0].event,
22904            NodeEvent::TerminalFrame { address, frame }
22905                if address == &original && frame.sequence == 7
22906        ));
22907
22908        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
22909        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(6))]);
22910        assert!(matches!(
22911            receiver.try_recv(),
22912            Err(broadcast::error::TryRecvError::Empty)
22913        ));
22914
22915        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(8))]);
22916        let advanced = receiver.try_recv().unwrap();
22917        assert_eq!(advanced[0].sequence, 2);
22918
22919        let resumed = terminal_address(2);
22920        shared.publish_terminal_frame_candidates(vec![(resumed.clone(), terminal_frame(1))]);
22921        let reset = receiver.try_recv().unwrap();
22922        assert_eq!(reset[0].sequence, 3);
22923        assert!(matches!(
22924            &reset[0].event,
22925            NodeEvent::TerminalFrame { address, frame }
22926                if address == &resumed && frame.sequence == 1
22927        ));
22928        shared.clear_terminal_frame_watermark(&resumed);
22929        assert!(!shared
22930            .terminal_frame_watermarks
22931            .lock()
22932            .unwrap_or_else(|poisoned| poisoned.into_inner())
22933            .contains_key(&resumed.session.instance_id));
22934    }
22935
22936    #[test]
22937    fn terminal_frame_batches_replace_without_entering_node_event_history() {
22938        let shared = terminal_test_shared();
22939        let mut receiver = shared.terminal_event_tx.subscribe();
22940        let address = terminal_address(1);
22941        let count = NODE_EVENT_HISTORY_MAX as u64 + 17;
22942
22943        for sequence in 1..=count {
22944            shared.publish_terminal_frame_candidates(vec![(
22945                address.clone(),
22946                terminal_frame(sequence),
22947            )]);
22948        }
22949
22950        let history = shared
22951            .history
22952            .lock()
22953            .unwrap_or_else(|poisoned| poisoned.into_inner());
22954        assert!(history.events.is_empty());
22955        assert_eq!(history.last_sequence, count);
22956        assert_eq!(history.replay_floor_sequence, 1);
22957        drop(history);
22958        let NodeResponse::Resync {
22959            event_sequence,
22960            oldest_available_sequence,
22961            events,
22962            ..
22963        } = shared.resync(0) else {
22964            unreachable!("resync helper returned another response")
22965        };
22966        assert_eq!(event_sequence, count);
22967        assert_eq!(oldest_available_sequence, 1);
22968        assert!(events.is_empty());
22969        assert!(matches!(
22970            resync_required_event(&shared).event,
22971            NodeEvent::ResyncRequired {
22972                oldest_available_sequence: 1,
22973            }
22974        ));
22975        assert!(matches!(
22976            receiver.try_recv(),
22977            Err(broadcast::error::TryRecvError::Lagged(skipped)) if skipped == count - 1
22978        ));
22979        let latest = receiver.try_recv().unwrap();
22980        assert_eq!(latest.len(), 1);
22981        assert_eq!(latest[0].sequence, count);
22982    }
22983
22984    #[test]
22985    fn real_durable_eviction_advances_authoritative_replay_floor() {
22986        let shared = terminal_test_shared();
22987        for _ in 0..=NODE_EVENT_HISTORY_MAX {
22988            shared.publish(NodeEvent::ControllerChanged { controller: None });
22989        }
22990
22991        let NodeResponse::Resync {
22992            event_sequence,
22993            oldest_available_sequence,
22994            events,
22995            ..
22996        } = shared.resync(0) else {
22997            unreachable!("resync helper returned another response")
22998        };
22999        assert_eq!(event_sequence, NODE_EVENT_HISTORY_MAX as u64 + 1);
23000        assert_eq!(oldest_available_sequence, 2);
23001        assert_eq!(events.first().map(|event| event.sequence), Some(2));
23002        assert_eq!(events.len(), NODE_EVENT_HISTORY_MAX);
23003        assert!(matches!(
23004            resync_required_event(&shared).event,
23005            NodeEvent::ResyncRequired {
23006                oldest_available_sequence: 2,
23007            }
23008        ));
23009    }
23010
23011    #[test]
23012    fn legacy_terminal_frame_connection_has_no_live_subscription() {
23013        let shared = terminal_test_shared();
23014        let without_capability = terminal_event_subscription(&shared, false);
23015        let with_capability = terminal_event_subscription(&shared, true);
23016        assert!(without_capability.is_none());
23017        assert!(with_capability.is_some());
23018        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23019            capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
23020        }));
23021    }
23022
23023    #[test]
23024    fn continuous_terminal_output_leaves_request_service_after_bounded_event_burst() {
23025        let mut pending = Vec::new();
23026        let mut event_sequence = 0_u64;
23027        for frame_sequence in 1..=4 {
23028            for instance in 1..=(NODE_CONNECTION_EVENT_BURST_MAX as u64 * 2) {
23029                event_sequence += 1;
23030                let mut address = terminal_address(1);
23031                address.session.instance_id = AgentInstanceId(instance);
23032                queue_connection_event(
23033                    &mut pending,
23034                    NodeEventEnvelope {
23035                        sequence: event_sequence,
23036                        event: NodeEvent::TerminalFrame {
23037                            address,
23038                            frame: terminal_frame(frame_sequence),
23039                        },
23040                    },
23041                    0,
23042                );
23043            }
23044        }
23045        assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX * 2);
23046        pending.sort_unstable_by_key(|event| event.sequence);
23047
23048        let mut uninterrupted = pending.clone();
23049        let first_burst = connection_event_burst_len(uninterrupted.len());
23050        uninterrupted.drain(..first_burst);
23051        let second_burst = connection_event_burst_len(uninterrupted.len());
23052        uninterrupted.drain(..second_burst);
23053        assert!(uninterrupted.is_empty());
23054
23055        let (frame_tx, mut frame_rx) =
23056            mpsc::channel::<Result<ClientFrame, FrameError>>(1);
23057        frame_tx
23058            .try_send(Ok(ClientFrame::Request(RequestEnvelope {
23059                request_id: 77,
23060                request: NodeRequest::Snapshot,
23061            })))
23062            .unwrap();
23063        let burst_len = connection_event_burst_len(pending.len());
23064        assert_eq!(burst_len, NODE_CONNECTION_EVENT_BURST_MAX);
23065        pending.drain(..burst_len);
23066        assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX);
23067        assert!(matches!(
23068            frame_rx.try_recv(),
23069            Ok(Ok(ClientFrame::Request(RequestEnvelope {
23070                request_id: 77,
23071                request: NodeRequest::Snapshot,
23072            })))
23073        ));
23074    }
23075
23076    #[test]
23077    fn terminal_lag_watermark_discards_retained_and_replayed_batches() {
23078        let mut pending = Vec::new();
23079        for sequence in [7_u64, 8] {
23080            let mut address = terminal_address(1);
23081            address.session.instance_id = AgentInstanceId(sequence);
23082            queue_connection_event(
23083                &mut pending,
23084                NodeEventEnvelope {
23085                    sequence,
23086                    event: NodeEvent::TerminalFrame {
23087                        address,
23088                        frame: terminal_frame(sequence),
23089                    },
23090                },
23091                0,
23092            );
23093        }
23094        let mut discard_through = 0;
23095        apply_connection_resync_watermark(&mut pending, &mut discard_through, 8);
23096        assert!(pending.is_empty());
23097
23098        let replayed = NodeEventEnvelope {
23099            sequence: 8,
23100            event: NodeEvent::TerminalFrame {
23101                address: terminal_address(1),
23102                frame: terminal_frame(8),
23103            },
23104        };
23105        let advanced = NodeEventEnvelope {
23106            sequence: 9,
23107            event: NodeEvent::TerminalFrame {
23108                address: terminal_address(2),
23109                frame: terminal_frame(1),
23110            },
23111        };
23112        queue_connection_event_batch(
23113            &mut pending,
23114            &[replayed, advanced.clone()],
23115            discard_through,
23116        );
23117        assert_eq!(pending, vec![advanced]);
23118    }
23119
23120    fn record(provider: &str, record_id: &str) -> ManagedSessionRecord {
23121        ManagedSessionRecord {
23122            record_id: SessionRecordId::new(record_id).unwrap(),
23123            display_name: format!("{provider} record"),
23124            provider: agent(provider),
23125            mode: SessionMode::Pty,
23126            state: ManagedSessionState::Unavailable,
23127            workspace_id: WorkspaceId::new("primary").unwrap(),
23128            canonical_root: opaque_windows_path(
23129                std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23130            ),
23131            provider_session: None,
23132            active_session: None,
23133            environment_profile: None,
23134            bundle: None,
23135            context_id: None,
23136            context: None,
23137            exported_context: None,
23138            task_binding: None,
23139            created_at_unix_ms: 1,
23140            updated_at_unix_ms: 1,
23141            last_error: None,
23142        }
23143    }
23144
23145    fn retention_test_record(
23146        workspace_id: &str,
23147        record_id: &str,
23148        state: ManagedSessionState,
23149        updated_at_unix_ms: u64,
23150    ) -> ManagedSessionRecord {
23151        ManagedSessionRecord {
23152            record_id: SessionRecordId::new(record_id).unwrap(),
23153            display_name: format!("{record_id} display"),
23154            provider: agent("claude"),
23155            mode: SessionMode::Pty,
23156            state,
23157            workspace_id: WorkspaceId::new(workspace_id).unwrap(),
23158            canonical_root: opaque_windows_path(
23159                std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23160            ),
23161            provider_session: None,
23162            active_session: None,
23163            environment_profile: None,
23164            bundle: None,
23165            context_id: None,
23166            context: None,
23167            exported_context: None,
23168            task_binding: None,
23169            created_at_unix_ms: updated_at_unix_ms,
23170            updated_at_unix_ms,
23171            last_error: None,
23172        }
23173    }
23174
23175    /// Two workspaces of three `Unavailable` records each, staggered by
23176    /// age, plus one never-touchable decoy per non-`Unavailable` state in
23177    /// `workspace-a` (all as old as that workspace's oldest real record --
23178    /// if the sweep's state gate ever leaked, these would be the first
23179    /// thing it wrongly retired). Ages, oldest to newest:
23180    /// `workspace-a`: a1 (500s), a2 (300s), a3 (100s).
23181    /// `workspace-b`: b1 (400s), b2 (200s), b3 (50s).
23182    fn retention_fixture_records(now: u64) -> Vec<ManagedSessionRecord> {
23183        vec![
23184            retention_test_record("workspace-a", "a1", ManagedSessionState::Unavailable, now - 500_000),
23185            retention_test_record("workspace-a", "a2", ManagedSessionState::Unavailable, now - 300_000),
23186            retention_test_record("workspace-a", "a3", ManagedSessionState::Unavailable, now - 100_000),
23187            retention_test_record("workspace-b", "b1", ManagedSessionState::Unavailable, now - 400_000),
23188            retention_test_record("workspace-b", "b2", ManagedSessionState::Unavailable, now - 200_000),
23189            retention_test_record("workspace-b", "b3", ManagedSessionState::Unavailable, now - 50_000),
23190            retention_test_record("workspace-a", "decoy-live", ManagedSessionState::Live, now - 500_000),
23191            retention_test_record(
23192                "workspace-a",
23193                "decoy-pending",
23194                ManagedSessionState::IdentityPending,
23195                now - 500_000,
23196            ),
23197            retention_test_record(
23198                "workspace-a",
23199                "decoy-dormant",
23200                ManagedSessionState::Dormant,
23201                now - 500_000,
23202            ),
23203        ]
23204    }
23205
23206    fn retention_fixture_shared() -> (NodeShared, NativeRuntime, PathBuf, u64) {
23207        let root = temporary_workspace_root("session-record-retention");
23208        std::fs::create_dir_all(&root).unwrap();
23209        let catalog = active_registry().unwrap();
23210        let (handle, runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23211        let workspace_a =
23212            WorkspaceConfig::new(WorkspaceId::new("workspace-a").unwrap(), &root).unwrap();
23213        let workspace_b =
23214            WorkspaceConfig::new(WorkspaceId::new("workspace-b").unwrap(), &root).unwrap();
23215        let mut shared = NodeShared::new(
23216            handle,
23217            "fixture-token".to_owned(),
23218            NodeId::new("node-retention-test").unwrap(),
23219            vec![workspace_a, workspace_b],
23220            vec![agent("claude")],
23221        );
23222        shared.state_path = Some(root.join("node-state-v10.json"));
23223        let now = unix_time_ms();
23224        for record in retention_fixture_records(now) {
23225            shared.insert_record(record).unwrap();
23226        }
23227        shared
23228            .persist_state_locked()
23229            .expect("fixture-only, empty-history persist against a fresh temp state path");
23230        (shared, runtime, root, now)
23231    }
23232
23233    fn retention_removed_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23234        shared
23235            .history
23236            .lock()
23237            .unwrap_or_else(|poisoned| poisoned.into_inner())
23238            .events
23239            .iter()
23240            .filter_map(|envelope| match &envelope.event {
23241                NodeEvent::SessionRecordRemoved { record_id } => {
23242                    Some(record_id.as_str().to_owned())
23243                }
23244                _ => None,
23245            })
23246            .collect()
23247    }
23248
23249    fn retention_remaining_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23250        shared
23251            .session_records
23252            .lock()
23253            .unwrap_or_else(|poisoned| poisoned.into_inner())
23254            .records
23255            .keys()
23256            .map(|record_id| record_id.as_str().to_owned())
23257            .collect()
23258    }
23259
23260    fn owned(values: &[&str]) -> std::collections::BTreeSet<String> {
23261        values.iter().map(|value| (*value).to_owned()).collect()
23262    }
23263
23264    #[tokio::test]
23265    async fn session_record_retention_keep_n_only_keeps_the_newest_per_workspace() {
23266        let (mut shared, runtime, root, _now) = retention_fixture_shared();
23267        shared.session_record_retention = SessionRecordRetentionConfig {
23268            age_ms: 0,
23269            keep_per_workspace: 1,
23270        };
23271        shared.retire_unavailable_session_records().await;
23272
23273        assert_eq!(
23274            retention_remaining_record_ids(&shared),
23275            owned(&["a3", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23276        );
23277        assert_eq!(
23278            retention_removed_record_ids(&shared),
23279            owned(&["a1", "a2", "b1", "b2"]),
23280        );
23281        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 4);
23282
23283        drop(shared);
23284        drop(runtime);
23285        std::fs::remove_dir_all(root).unwrap();
23286    }
23287
23288    /// Age-only would retire {a1, a2, b1}; keep-N-only would retire
23289    /// {a1, a2, b1, b2}. With both flags set the sweep must retire exactly
23290    /// the intersection {a1, a2, b1} -- `b2` fails the age test (it is only
23291    /// 200s old against a 250s threshold) even though it fails the keep-N
23292    /// test too, proving retirement needs BOTH, not either.
23293    #[tokio::test]
23294    async fn session_record_retention_both_set_retires_only_the_intersection() {
23295        let (mut shared, runtime, root, _now) = retention_fixture_shared();
23296        shared.session_record_retention = SessionRecordRetentionConfig {
23297            age_ms: 250_000,
23298            keep_per_workspace: 1,
23299        };
23300        shared.retire_unavailable_session_records().await;
23301
23302        assert_eq!(
23303            retention_remaining_record_ids(&shared),
23304            owned(&["a3", "b2", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23305        );
23306        assert_eq!(retention_removed_record_ids(&shared), owned(&["a1", "a2", "b1"]));
23307        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 3);
23308
23309        drop(shared);
23310        drop(runtime);
23311        std::fs::remove_dir_all(root).unwrap();
23312    }
23313
23314    #[tokio::test]
23315    async fn session_record_retention_both_disabled_is_a_no_op() {
23316        let (mut shared, runtime, root, _now) = retention_fixture_shared();
23317        shared.session_record_retention = SessionRecordRetentionConfig::default();
23318        shared.retire_unavailable_session_records().await;
23319
23320        assert_eq!(
23321            retention_remaining_record_ids(&shared),
23322            owned(&[
23323                "a1", "a2", "a3", "b1", "b2", "b3",
23324                "decoy-live", "decoy-pending", "decoy-dormant",
23325            ]),
23326        );
23327        assert!(retention_removed_record_ids(&shared).is_empty());
23328        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 0);
23329
23330        drop(shared);
23331        drop(runtime);
23332        std::fs::remove_dir_all(root).unwrap();
23333    }
23334
23335    #[test]
23336    fn session_record_context_export_selects_one_exact_history_identity() {
23337        let candidates = vec![
23338            HistoryCandidateSummary {
23339                id: "candidate-other".to_owned(),
23340                session_id_hint: "provider-other".to_owned(),
23341                modified_at_unix_ms: Some(1),
23342            },
23343            HistoryCandidateSummary {
23344                id: "candidate-exact".to_owned(),
23345                session_id_hint: "provider-exact".to_owned(),
23346                modified_at_unix_ms: Some(2),
23347            },
23348        ];
23349        assert_eq!(
23350            select_history_candidate(&candidates, Some("provider-exact")).unwrap(),
23351            "candidate-exact",
23352        );
23353    }
23354
23355    #[test]
23356    fn session_record_context_export_rejects_missing_and_ambiguous_history_identity() {
23357        let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23358            id: id.to_owned(),
23359            session_id_hint: hint.to_owned(),
23360            modified_at_unix_ms: None,
23361        };
23362        let missing = select_history_candidate(
23363            &[candidate("candidate-other", "provider-other")],
23364            Some("provider-exact"),
23365        )
23366        .unwrap_err();
23367        assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23368        let ambiguous = select_history_candidate(
23369            &[
23370                candidate("candidate-a", "provider-exact"),
23371                candidate("candidate-b", "provider-exact"),
23372            ],
23373            Some("provider-exact"),
23374        )
23375        .unwrap_err();
23376        assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23377    }
23378
23379    /// The PTY-sidecar-observation shape (no fleet provider today, but
23380    /// capability-keyed rather than vendor-locked) never carries a verified
23381    /// `provider_session`; its record-bound export instead falls back to
23382    /// "exactly one discovered candidate" (see
23383    /// `export_context_pack_for_session_record_inner`). This is the same
23384    /// ambiguity refusal as the identity-filtered path, just without an
23385    /// identity to filter by.
23386    #[test]
23387    fn session_record_context_export_selects_sole_history_candidate_without_identity() {
23388        let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23389            id: id.to_owned(),
23390            session_id_hint: hint.to_owned(),
23391            modified_at_unix_ms: None,
23392        };
23393        assert_eq!(
23394            select_history_candidate(&[candidate("candidate-sole", "sidecar-session")], None)
23395                .unwrap(),
23396            "candidate-sole",
23397        );
23398        let missing = select_history_candidate(&[], None).unwrap_err();
23399        assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23400        let ambiguous = select_history_candidate(
23401            &[
23402                candidate("candidate-a", "sidecar-session-a"),
23403                candidate("candidate-b", "sidecar-session-b"),
23404            ],
23405            None,
23406        )
23407        .unwrap_err();
23408        assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23409    }
23410
23411    #[test]
23412    fn session_record_context_export_revalidation_rejects_record_change() {
23413        let session = terminal_address(1);
23414        let identity = ProviderSessionIdentity {
23415            key: ProviderSessionKey::SessionId,
23416            id: "provider-exact".to_owned(),
23417            transcript_path: Some(r"C:\private\provider.jsonl".to_owned()),
23418        };
23419        let mut expected = record("claude", "record-context-export");
23420        expected.state = ManagedSessionState::Live;
23421        expected.provider_session = Some(identity.clone());
23422        expected.active_session = Some(session.clone());
23423        let binding = SessionBinding {
23424            workspace_id: session.workspace_id.clone(),
23425            generation: session.session.generation,
23426            runtime_policy: ProviderRuntimePolicy::raw_pty(),
23427            pending_resume: None,
23428            record_id: Some(expected.record_id.clone()),
23429            managed_worktree_lease_id: None,
23430            environment_profile: None,
23431            bundle: None,
23432            context: None,
23433            materialization_id: None,
23434        };
23435        let current_root = windows_path_text(&expected.canonical_root).to_owned();
23436        assert!(session_record_context_export_binding_is_exact(
23437            &expected,
23438            &expected,
23439            Some(&identity),
23440            &session,
23441            &binding,
23442            &expected.provider,
23443            &current_root,
23444            false,
23445        ));
23446        let mut changed = expected.clone();
23447        changed.updated_at_unix_ms += 1;
23448        assert!(!session_record_context_export_binding_is_exact(
23449            &expected,
23450            &changed,
23451            Some(&identity),
23452            &session,
23453            &binding,
23454            &expected.provider,
23455            &current_root,
23456            false,
23457        ));
23458        // A same-instant timestamp bump alone (state and active_session
23459        // otherwise unchanged) is not the clean-detachment transition
23460        // either, even with the reactive flag on: only the exact Live ->
23461        // Dormant + active_session cleared shape qualifies.
23462        assert!(!session_record_context_export_binding_is_exact(
23463            &expected,
23464            &changed,
23465            Some(&identity),
23466            &session,
23467            &binding,
23468            &expected.provider,
23469            &current_root,
23470            true,
23471        ));
23472        let mut detached = expected.clone();
23473        detached.state = ManagedSessionState::Dormant;
23474        detached.active_session = None;
23475        detached.updated_at_unix_ms += 1;
23476        assert!(!session_record_context_export_binding_is_exact(
23477            &expected,
23478            &detached,
23479            Some(&identity),
23480            &session,
23481            &binding,
23482            &expected.provider,
23483            &current_root,
23484            false,
23485        ));
23486        assert!(session_record_context_export_binding_is_exact(
23487            &expected,
23488            &detached,
23489            Some(&identity),
23490            &session,
23491            &binding,
23492            &expected.provider,
23493            &current_root,
23494            true,
23495        ));
23496    }
23497
23498    /// Mirrors `session_record_context_export_revalidation_rejects_record_change`
23499    /// for a record whose provider never carries a verified identity (the
23500    /// PTY-sidecar-observation shape -- capability-keyed, no fleet provider
23501    /// declares one today): `identity` is `None` throughout, and the
23502    /// exactness check falls back to comparing `provider_session` itself,
23503    /// which must still be `None` on both sides.
23504    #[test]
23505    fn session_record_context_export_binding_is_exact_without_identity() {
23506        let session = terminal_address(1);
23507        let mut expected = record("pty-sidecar-fixture", "record-context-export-no-identity");
23508        expected.state = ManagedSessionState::Live;
23509        expected.active_session = Some(session.clone());
23510        let binding = SessionBinding {
23511            workspace_id: session.workspace_id.clone(),
23512            generation: session.session.generation,
23513            runtime_policy: ProviderRuntimePolicy::raw_pty(),
23514            pending_resume: None,
23515            record_id: Some(expected.record_id.clone()),
23516            managed_worktree_lease_id: None,
23517            environment_profile: None,
23518            bundle: None,
23519            context: None,
23520            materialization_id: None,
23521        };
23522        let current_root = windows_path_text(&expected.canonical_root).to_owned();
23523        assert!(session_record_context_export_binding_is_exact(
23524            &expected,
23525            &expected,
23526            None,
23527            &session,
23528            &binding,
23529            &expected.provider,
23530            &current_root,
23531            false,
23532        ));
23533        // A record that gained an identity between the initial lookup and
23534        // this recheck no longer matches the `None` this shape expects to
23535        // keep seeing -- treated the same as any other mid-export drift.
23536        let mut drifted = expected.clone();
23537        drifted.provider_session = Some(ProviderSessionIdentity {
23538            key: ProviderSessionKey::SessionId,
23539            id: "unexpected-identity".to_owned(),
23540            transcript_path: None,
23541        });
23542        assert!(!session_record_context_export_binding_is_exact(
23543            &expected,
23544            &drifted,
23545            None,
23546            &session,
23547            &binding,
23548            &expected.provider,
23549            &current_root,
23550            false,
23551        ));
23552    }
23553
23554    #[test]
23555    fn session_record_context_export_preflight_requires_live_usable_source() {
23556        let mut source = record("claude", "record-context-export-preflight");
23557        source.state = ManagedSessionState::Live;
23558        assert!(session_record_context_export_source_is_usable(
23559            &source,
23560            &SessionStatus::Running,
23561            false,
23562        ));
23563        assert!(session_record_context_export_source_is_usable(
23564            &source,
23565            &SessionStatus::Exited { exit_code: Some(0) },
23566            false,
23567        ));
23568
23569        source.state = ManagedSessionState::Dormant;
23570        assert!(!session_record_context_export_source_is_usable(
23571            &source,
23572            &SessionStatus::Running,
23573            false,
23574        ));
23575        // The reactive auto-export-at-exit path (allow_clean_detachment) is
23576        // the only caller that may treat a Dormant, cleanly-exited record as
23577        // still usable — the on-demand path above never does.
23578        assert!(session_record_context_export_source_is_usable(
23579            &source,
23580            &SessionStatus::Exited { exit_code: Some(0) },
23581            true,
23582        ));
23583        assert!(!session_record_context_export_source_is_usable(
23584            &source,
23585            &SessionStatus::Exited { exit_code: Some(0) },
23586            false,
23587        ));
23588        source.state = ManagedSessionState::Live;
23589        assert!(!session_record_context_export_source_is_usable(
23590            &source,
23591            &SessionStatus::Starting,
23592            false,
23593        ));
23594    }
23595
23596    #[test]
23597    fn session_record_context_export_final_conflict_leaves_catalog_empty() {
23598        let catalog = active_registry().unwrap();
23599        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23600        let workspace = WorkspaceConfig::new(
23601            WorkspaceId::new("primary").unwrap(),
23602            std::env::current_dir().unwrap(),
23603        )
23604        .unwrap();
23605        let shared = NodeShared::new(
23606            handle,
23607            "fixture-token".to_owned(),
23608            NodeId::new("node-context-export-conflict").unwrap(),
23609            vec![workspace],
23610            vec![agent("claude")],
23611        );
23612        let history = HistorySessionRecord {
23613            session_id: "provider-exact".to_owned(),
23614            title: None,
23615            cwd: None,
23616            model: None,
23617            message_count: 1,
23618            completed_turn_count: None,
23619            total_tokens: 0,
23620            messages: vec![gate4agent_types::HistoryMessageRecord {
23621                role: gate4agent_types::HistoryMessageRole::User,
23622                text: "bounded context".to_owned(),
23623            }],
23624        };
23625        let pack = NodeContextPack::export(
23626            ContextPackLineageReceipt {
23627                source_node_id: shared.node_id.clone(),
23628                source_session: terminal_address(1),
23629                source_provider: agent("claude"),
23630            },
23631            &history,
23632        )
23633        .unwrap();
23634        let context_id = pack.receipt().id.clone();
23635        let session = terminal_address(1);
23636        let identity = ProviderSessionIdentity {
23637            key: ProviderSessionKey::SessionId,
23638            id: history.session_id.clone(),
23639            transcript_path: None,
23640        };
23641        let mut expected = record("claude", "record-context-export-final-conflict");
23642        expected.state = ManagedSessionState::Live;
23643        expected.provider_session = Some(identity.clone());
23644        expected.active_session = Some(session.clone());
23645        let mut changed = expected.clone();
23646        changed.updated_at_unix_ms += 1;
23647        shared.insert_record(changed).unwrap();
23648
23649        let error = shared
23650            .commit_context_pack_for_session_record(
23651                &expected,
23652                Some(&identity),
23653                &session,
23654                "candidate-exact",
23655                &history,
23656                pack,
23657                false,
23658            )
23659            .unwrap_err();
23660
23661        assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
23662        assert!(shared
23663            .context_catalog
23664            .read()
23665            .unwrap_or_else(|poisoned| poisoned.into_inner())
23666            .get(&context_id)
23667            .is_none());
23668    }
23669
23670    #[tokio::test]
23671    async fn session_task_binding_mints_assigns_clears_with_cas_and_persists_v9() {
23672        let root = temporary_workspace_root("session-task-binding");
23673        std::fs::create_dir_all(&root).unwrap();
23674        let state_path = root.join("node-state.json");
23675        let catalog = active_registry().unwrap();
23676        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23677        let workspace = WorkspaceConfig::new(WorkspaceId::new("primary").unwrap(), &root).unwrap();
23678        let node_id = NodeId::new("node-1").unwrap();
23679        let mut shared = NodeShared::new(
23680            handle,
23681            "fixture-token".to_owned(),
23682            node_id.clone(),
23683            vec![workspace],
23684            vec![agent("claude")],
23685        );
23686        shared.state_path = Some(state_path.clone());
23687        let record = record("claude", "sr-task");
23688        let record_id = record.record_id.clone();
23689        shared.insert_record(record).unwrap();
23690        shared.persist_state().unwrap();
23691        shared
23692            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
23693            .unwrap();
23694
23695        let minted = process_request_inner(
23696            &shared,
23697            77,
23698            ClientRole::Operator,
23699            NodeRequest::SetSessionTask {
23700                record_id: record_id.clone(),
23701                expected_revision: 0,
23702                target: SessionTaskTargetV1::New,
23703            },
23704        )
23705        .await
23706        .unwrap();
23707        let NodeResponse::SessionRecordUpdated { record: minted } = minted else {
23708            panic!("session task mutation returned another response");
23709        };
23710        let minted_binding = minted.task_binding.clone().unwrap();
23711        let minted_task_id = minted_binding.task_id.clone().unwrap();
23712        assert_eq!(minted_binding.revision, 1);
23713        assert!(minted_task_id.as_str().starts_with("task-"));
23714        assert_eq!(minted_task_id.as_str().len(), 29);
23715
23716        let event_count = shared.current_sequence();
23717        let repeated = process_request_inner(
23718            &shared,
23719            77,
23720            ClientRole::Operator,
23721            NodeRequest::SetSessionTask {
23722                record_id: record_id.clone(),
23723                expected_revision: 1,
23724                target: SessionTaskTargetV1::Existing {
23725                    task_id: minted_task_id.clone(),
23726                },
23727            },
23728        )
23729        .await
23730        .unwrap();
23731        assert_eq!(repeated, NodeResponse::SessionRecordUpdated { record: minted.clone() });
23732        assert_eq!(shared.current_sequence(), event_count);
23733
23734        let stale = process_request_inner(
23735            &shared,
23736            77,
23737            ClientRole::Operator,
23738            NodeRequest::SetSessionTask {
23739                record_id: record_id.clone(),
23740                expected_revision: 0,
23741                target: SessionTaskTargetV1::Existing {
23742                    task_id: minted_task_id,
23743                },
23744            },
23745        )
23746        .await
23747        .unwrap_err();
23748        assert_eq!(stale.code, NodeFailureCode::SessionRecordConflict);
23749
23750        let assigned_id: TaskId = "task-ffeeddccbbaa998877665544".parse().unwrap();
23751        let assigned = process_request_inner(
23752            &shared,
23753            77,
23754            ClientRole::Operator,
23755            NodeRequest::SetSessionTask {
23756                record_id: record_id.clone(),
23757                expected_revision: 1,
23758                target: SessionTaskTargetV1::Existing {
23759                    task_id: assigned_id.clone(),
23760                },
23761            },
23762        )
23763        .await
23764        .unwrap();
23765        let NodeResponse::SessionRecordUpdated { record: assigned } = assigned else {
23766            panic!("session task assignment returned another response");
23767        };
23768        assert_eq!(assigned.task_binding.as_ref().unwrap().revision, 2);
23769        assert_eq!(assigned.task_binding.as_ref().unwrap().task_id.as_ref(), Some(&assigned_id));
23770
23771        let cleared = process_request_inner(
23772            &shared,
23773            77,
23774            ClientRole::Operator,
23775            NodeRequest::SetSessionTask {
23776                record_id: record_id.clone(),
23777                expected_revision: 2,
23778                target: SessionTaskTargetV1::Clear,
23779            },
23780        )
23781        .await
23782        .unwrap();
23783        let NodeResponse::SessionRecordUpdated { record: cleared } = cleared else {
23784            panic!("session task clear returned another response");
23785        };
23786        assert_eq!(cleared.task_binding.as_ref().unwrap().revision, 3);
23787        assert!(cleared.task_binding.as_ref().unwrap().task_id.is_none());
23788        assert!(cleared.task_binding.as_ref().unwrap().changed_at_unix_ms > 0);
23789        assert_eq!(
23790            cleared.task_binding.as_ref().unwrap().changed_at_unix_ms,
23791            cleared.updated_at_unix_ms,
23792        );
23793
23794        let persisted = std::fs::read(&state_path).unwrap();
23795        assert_eq!(serde_json::from_slice::<serde_json::Value>(&persisted).unwrap()["version"], NODE_STATE_SCHEMA_V10);
23796        let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
23797        assert_eq!(loaded.records[0].task_binding, cleared.task_binding);
23798        std::fs::remove_dir_all(root).unwrap();
23799    }
23800
23801    #[test]
23802    fn session_task_capability_strips_snapshots_events_and_replies() {
23803        let mut bound = record("claude", "sr-task-projection");
23804        bound.updated_at_unix_ms = 2;
23805        bound.task_binding = Some(SessionTaskBindingV1 {
23806            revision: 1,
23807            task_id: Some("task-00112233445566778899aaff".parse().unwrap()),
23808            changed_at_unix_ms: 2,
23809        });
23810        let request = NodeRequest::SetSessionTask {
23811            record_id: bound.record_id.clone(),
23812            expected_revision: 1,
23813            target: SessionTaskTargetV1::Clear,
23814        };
23815        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23816            capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
23817        }));
23818        assert!(!request_uses_unnegotiated_capability(
23819            &request,
23820            &baseline_capabilities().unwrap(),
23821        ));
23822        assert!(request_uses_unnegotiated_capability(&request, &[]));
23823
23824        let mut snapshot = terminal_test_shared().snapshot();
23825        snapshot.session_records.push(bound.clone());
23826        let mut reply = ResponseEnvelope {
23827            request_id: 1,
23828            result: Ok(NodeResponse::Resync {
23829                event_sequence: 1,
23830                oldest_available_sequence: 1,
23831                snapshot,
23832                events: vec![NodeEventEnvelope {
23833                    sequence: 1,
23834                    event: NodeEvent::SessionRecordUpserted {
23835                        record: bound.clone(),
23836                    },
23837                }],
23838            }),
23839        };
23840        project_response_without_session_task_binding(&mut reply);
23841        let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
23842            panic!("task projection changed the response kind");
23843        };
23844        assert!(snapshot.session_records[0].task_binding.is_none());
23845        let NodeEvent::SessionRecordUpserted { record } = &events[0].event else {
23846            panic!("task projection changed the event kind");
23847        };
23848        assert!(record.task_binding.is_none());
23849
23850        let projected = project_event_without_session_task_binding(NodeEventEnvelope {
23851            sequence: 2,
23852            event: NodeEvent::SessionRecordUpserted { record: bound.clone() },
23853        });
23854        let NodeEvent::SessionRecordUpserted { record } = projected.event else {
23855            panic!("task event projection changed the event kind");
23856        };
23857        assert!(record.task_binding.is_none());
23858
23859        let mut direct = ResponseEnvelope {
23860            request_id: 2,
23861            result: Ok(NodeResponse::SessionRecordUpdated { record: bound }),
23862        };
23863        project_response_without_session_task_binding(&mut direct);
23864        let Ok(NodeResponse::SessionRecordUpdated { record }) = direct.result else {
23865            panic!("task reply projection changed the response kind");
23866        };
23867        assert!(record.task_binding.is_none());
23868    }
23869
23870    #[test]
23871    fn windows_runtime_default_node_endpoint_is_exact_and_valid() {
23872        assert_eq!(DEFAULT_NODE_ENDPOINT, r"\\.\pipe\gate4agent-node");
23873        let workspace = WorkspaceConfig::new(
23874            WorkspaceId::new("primary").unwrap(),
23875            std::env::current_dir().unwrap(),
23876        )
23877        .unwrap();
23878        let config = NodeServerConfig::new(
23879            DEFAULT_NODE_ENDPOINT,
23880            "fixture-token",
23881            NodeId::new("node-1").unwrap(),
23882            [workspace],
23883        )
23884        .unwrap();
23885        assert_eq!(config.endpoint, DEFAULT_NODE_ENDPOINT);
23886    }
23887
23888    #[cfg(all(feature = "fixture", windows))]
23889    #[test]
23890    fn provider_bundle_argv_hold_fixture_is_parent_bounded_and_waits_after_proof() {
23891        let root = temporary_workspace_root("provider-bundle-argv-hold");
23892        let outside = temporary_workspace_root("provider-bundle-argv-hold-outside");
23893        std::fs::create_dir_all(&root).unwrap();
23894        std::fs::create_dir_all(&outside).unwrap();
23895        let proof_path = root.join("provider.proof");
23896        let release_signal = root.join("release.signal");
23897        let config = NodeServerConfig::new(
23898            format!(
23899                r"\\.\pipe\gate4agent-provider-bundle-hold-{}",
23900                std::process::id(),
23901            ),
23902            "fixture-token",
23903            NodeId::new("provider-bundle-hold-node").unwrap(),
23904            [WorkspaceConfig::new(
23905                WorkspaceId::new("primary").unwrap(),
23906                std::env::current_dir().unwrap(),
23907            )
23908            .unwrap()],
23909        )
23910        .unwrap()
23911        .with_spawn_profiles(SpawnProfileRegistry::new([
23912            crate::protocol::SpawnProfileDefaults {
23913                profile_id: crate::protocol::SpawnProfileId::new("kimi-bundle-hold").unwrap(),
23914                revision: crate::protocol::SpawnProfileRevision::new("kimi-bundle-hold-r1")
23915                    .unwrap(),
23916                provider: agent("kimi"),
23917                mode: SessionMode::Pty,
23918                terminal_size: gate4agent_types::TerminalSize {
23919                    rows: 24,
23920                    columns: 80,
23921                },
23922                prompt: None,
23923                bundle_id: None,
23924                context_id: None,
23925                environment_profile_id: None,
23926            },
23927        ])
23928        .unwrap());
23929        let server = NodeServer::new_provider_bundle_argv_hold_fixture(
23930            config,
23931            agent("kimi"),
23932            proof_path.clone(),
23933            release_signal.clone(),
23934        )
23935        .unwrap();
23936        assert_eq!(server.shared.enabled_providers, [agent("kimi")]);
23937
23938        let spec = NodeServer::provider_bundle_argv_fixture_spec(
23939            agent("kimi"),
23940            proof_path.clone(),
23941            Some(release_signal.clone()),
23942        )
23943        .unwrap();
23944        let wrapper = &spec.launch.fixed_args[spec.launch.fixed_args.len() - 3];
23945        let proof_write = wrapper.find("WriteAllLines($proofPath").unwrap();
23946        let release_wait = wrapper
23947            .find("while (-not (Test-Path -LiteralPath $releaseSignal -PathType Leaf))")
23948            .unwrap();
23949        let fixture_ready = wrapper.find("fixture-ready>").unwrap();
23950        assert!(proof_write < release_wait);
23951        assert!(release_wait < fixture_ready);
23952        assert!(wrapper.contains("[DateTime]::UtcNow.AddSeconds(45)"));
23953        assert!(wrapper.contains("Start-Sleep -Milliseconds 20"));
23954        assert_eq!(
23955            &spec.launch.fixed_args[spec.launch.fixed_args.len() - 2..],
23956            &[
23957                proof_path.to_string_lossy().into_owned(),
23958                release_signal.to_string_lossy().into_owned(),
23959            ],
23960        );
23961
23962        let error = NodeServer::provider_bundle_argv_fixture_spec(
23963            agent("kimi"),
23964            proof_path,
23965            Some(outside.join("release.signal")),
23966        )
23967        .unwrap_err();
23968        assert!(matches!(
23969            error,
23970            NodeServerError::Registry(message)
23971                if message == "provider bundle argv release path must share the proof parent"
23972        ));
23973        drop(server);
23974        std::fs::remove_dir_all(root).unwrap();
23975        std::fs::remove_dir_all(outside).unwrap();
23976    }
23977
23978    #[cfg(feature = "fixture")]
23979    #[test]
23980    fn four_provider_context_fixture_registry_is_unambiguous_and_history_bound() {
23981        let root = temporary_workspace_root("context-fixture-registry");
23982        std::fs::create_dir_all(&root).unwrap();
23983        let proof_path = root.join("context.proof");
23984        let config = NodeServerConfig::new(
23985            format!(
23986                r"\\.\pipe\gate4agent-context-fixture-registry-{}",
23987                std::process::id(),
23988            ),
23989            "fixture-token",
23990            NodeId::new("context-fixture-node").unwrap(),
23991            [WorkspaceConfig::new(
23992                WorkspaceId::new("primary").unwrap(),
23993                std::env::current_dir().unwrap(),
23994            )
23995            .unwrap()],
23996        )
23997        .unwrap();
23998        let server = NodeServer::new_context_pack_fixture(config, proof_path).unwrap();
23999        let mut enabled = server
24000            .shared
24001            .enabled_providers
24002            .iter()
24003            .map(AgentId::as_str)
24004            .collect::<Vec<_>>();
24005        enabled.sort_unstable();
24006        assert_eq!(enabled, ["claude", "codex", "grok", "kimi"]);
24007        for provider in &server.shared.enabled_providers {
24008            assert!(server
24009                .shared
24010                .provider_adapter_contracts
24011                .iter()
24012                .any(|contract| {
24013                    &contract.provider == provider
24014                        && contract.family == AdapterFamily::History
24015                }));
24016        }
24017        drop(server);
24018        std::fs::remove_dir_all(root).unwrap();
24019    }
24020
24021    #[cfg(feature = "fixture")]
24022    #[test]
24023    fn monitoring_context_fixture_combines_claude_and_codex_hooks_with_history() {
24024        let root = temporary_workspace_root("monitoring-context-fixture");
24025        std::fs::create_dir_all(&root).unwrap();
24026        let proof_path = root.join("context.proof");
24027        let standard = NodeServer::context_pack_fixture_catalog(
24028            proof_path.clone(),
24029            None,
24030            false,
24031        ).unwrap();
24032        let monitoring = NodeServer::context_pack_fixture_catalog(
24033            proof_path.clone(),
24034            None,
24035            true,
24036        ).unwrap();
24037        let claude = AgentId::new("claude").unwrap();
24038        let standard_claude = standard.get(&claude).unwrap();
24039        let monitoring_claude = monitoring.get(&claude).unwrap();
24040        // Lifecycle hooks are retired (owner ruling 2026-09-25): neither the
24041        // standard nor the monitoring fixture declares a hook adapter
24042        // anymore.
24043        assert!(monitoring_claude.capabilities.adapters.hook.is_none());
24044        assert_eq!(
24045            monitoring_claude.capabilities.adapters.history,
24046            standard_claude.capabilities.adapters.history,
24047        );
24048        assert_ne!(monitoring_claude.launch, standard_claude.launch);
24049        let codex = AgentId::new("codex").unwrap();
24050        let standard_codex = standard.get(&codex).unwrap();
24051        let monitoring_codex = monitoring.get(&codex).unwrap();
24052        assert!(monitoring_codex.capabilities.adapters.hook.is_none());
24053        assert_eq!(
24054            monitoring_codex.capabilities.adapters.history,
24055            standard_codex.capabilities.adapters.history,
24056        );
24057        assert_ne!(monitoring_codex.launch, standard_codex.launch);
24058        let codex_validation = monitoring_codex.launch.fixed_args.iter()
24059            .find(|argument| argument.contains("F7_CODEX_BUNDLE_CONTEXT_VALIDATED"))
24060            .unwrap();
24061        for required in [
24062            "$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')",
24063            "$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')",
24064            "@('role', 'text')",
24065            "g4a-private-provider-session-canary",
24066            "private-provider-login-identity",
24067            "$rawProof",
24068            "$env:GATE4AGENT_HOOK_URL",
24069            "g4a-private-provider-session-canary",
24070        ] {
24071            assert!(codex_validation.contains(required), "missing privacy validation: {required}");
24072        }
24073        for provider in ["grok", "kimi"] {
24074            let provider = AgentId::new(provider).unwrap();
24075            assert_eq!(monitoring.get(&provider), standard.get(&provider));
24076        }
24077
24078        let config = NodeServerConfig::new(
24079            format!(
24080                r"\\.\pipe\gate4agent-monitoring-context-fixture-{}",
24081                std::process::id(),
24082            ),
24083            "fixture-token",
24084            NodeId::new("monitoring-context-fixture-node").unwrap(),
24085            [WorkspaceConfig::new(
24086                WorkspaceId::new("primary").unwrap(),
24087                std::env::current_dir().unwrap(),
24088            )
24089            .unwrap()],
24090        )
24091        .unwrap();
24092        let server = NodeServer::new_monitoring_context_pack_fixture(config, proof_path).unwrap();
24093        assert!(server.shared.fixture_semantic_hook_policy);
24094        drop(server);
24095        std::fs::remove_dir_all(root).unwrap();
24096    }
24097
24098    #[cfg(feature = "fixture")]
24099    #[test]
24100    fn kimi_context_only_fixture_is_bounded_and_preserves_codex_validation() {
24101        let root = temporary_workspace_root("kimi-context-only-fixture");
24102        std::fs::create_dir_all(&root).unwrap();
24103        let proof_path = root.join("context.proof");
24104        let standard = NodeServer::context_pack_fixture_catalog(
24105            proof_path.clone(),
24106            None,
24107            false,
24108        ).unwrap();
24109        let kimi = AgentId::new("kimi").unwrap();
24110        let context_only = NodeServer::context_pack_fixture_catalog(
24111            proof_path,
24112            Some(&kimi),
24113            false,
24114        )
24115        .unwrap();
24116
24117        let codex = AgentId::new("codex").unwrap();
24118        assert_eq!(
24119            standard.get(&codex).unwrap().launch.fixed_args,
24120            context_only.get(&codex).unwrap().launch.fixed_args,
24121        );
24122        let script = context_only
24123            .get(&kimi)
24124            .unwrap()
24125            .launch
24126            .fixed_args
24127            .iter()
24128            .find(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED"))
24129            .expect("Kimi context-only validation script is missing");
24130        for required in [
24131            "$bundleArgs.Count -ne 0",
24132            "GATE4AGENT_CONTEXT_ROOT",
24133            "context-pack.json",
24134            "$entries.Count -ne 1",
24135            "g4a-context-pack-v1",
24136            "$document.PSObject.Properties['cwd']",
24137            "'claude', 'codex', 'grok', 'kimi'",
24138            "$hasUser",
24139            "$hasAssistant",
24140            "SHA256",
24141            "context-only",
24142        ] {
24143            assert!(script.contains(required), "missing validation: {required}");
24144        }
24145        for forbidden in ["CODEX_HOME", "USERPROFILE", "auth", "token", "global config"] {
24146            assert!(!script.contains(forbidden), "forbidden home/auth access: {forbidden}");
24147        }
24148        assert!(!standard
24149            .get(&kimi)
24150            .unwrap()
24151            .launch
24152            .fixed_args
24153            .iter()
24154            .any(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED")));
24155
24156        std::fs::remove_dir_all(root).unwrap();
24157    }
24158
24159    #[cfg(all(feature = "fixture", windows))]
24160    #[tokio::test]
24161    async fn kimi_context_only_fixture_spawns_catalogued_context_pack_through_spawn_spec() {
24162        let root = temporary_workspace_root("kimi-context-only-spawn");
24163        let workspace_root = root.join("workspace");
24164        std::fs::create_dir_all(&workspace_root).unwrap();
24165        let materialization_root = root.join("private-materializations");
24166        let proof_path = root.join("context.proof");
24167        let node_id = NodeId::new("node-kimi-context-only-spawn").unwrap();
24168        let workspace_id = WorkspaceId::new("primary").unwrap();
24169        let profile_id = crate::protocol::SpawnProfileId::new("target-kimi").unwrap();
24170        let endpoint = format!(
24171            r"\\.\pipe\gate4agent-kimi-context-only-spawn-{}-{}",
24172            std::process::id(),
24173            unix_time_ms(),
24174        );
24175        let spawn_profiles = SpawnProfileRegistry::new([
24176            crate::protocol::SpawnProfileDefaults {
24177                profile_id: profile_id.clone(),
24178                revision: crate::protocol::SpawnProfileRevision::new("target-kimi-r1").unwrap(),
24179                provider: agent("kimi"),
24180                mode: SessionMode::Pty,
24181                terminal_size: gate4agent_types::TerminalSize {
24182                    rows: 30,
24183                    columns: 120,
24184                },
24185                prompt: None,
24186                bundle_id: None,
24187                context_id: None,
24188                environment_profile_id: None,
24189            },
24190        ])
24191        .unwrap();
24192        let mut config = NodeServerConfig::new(
24193            &endpoint,
24194            "fixture-token",
24195            node_id.clone(),
24196            [WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap()],
24197        )
24198        .unwrap()
24199        .with_spawn_profiles(spawn_profiles)
24200        .with_session_environment_materialization(
24201            materialization_root,
24202            Arc::new(FixtureSecretResolver {
24203                deny: Arc::new(AtomicBool::new(false)),
24204            }),
24205        )
24206        .unwrap();
24207        config.fixture_raw_pty_runtime = true;
24208        let server = NodeServer::new_context_only_proof_fixture(
24209            config,
24210            agent("kimi"),
24211            proof_path.clone(),
24212        )
24213        .unwrap();
24214        let shared = Arc::clone(&server.shared);
24215        let shutdown = server.shutdown_handle();
24216
24217        let history = HistorySessionRecord {
24218            session_id: "context-only-source-history".to_owned(),
24219            title: None,
24220            cwd: None,
24221            model: None,
24222            message_count: 2,
24223            completed_turn_count: None,
24224            total_tokens: 0,
24225            messages: vec![
24226                gate4agent_types::HistoryMessageRecord {
24227                    role: gate4agent_types::HistoryMessageRole::User,
24228                    text: "continue the bounded parent run through native C2".to_owned(),
24229                },
24230                gate4agent_types::HistoryMessageRecord {
24231                    role: gate4agent_types::HistoryMessageRole::Assistant,
24232                    text: "the exact parent context is ready for the child run".to_owned(),
24233                },
24234            ],
24235        };
24236        let pack = NodeContextPack::export(
24237            ContextPackLineageReceipt {
24238                source_node_id: node_id.clone(),
24239                source_session: SessionAddress {
24240                    workspace_id: workspace_id.clone(),
24241                    session: SessionKey {
24242                        instance_id: AgentInstanceId(71),
24243                        generation: SessionGeneration(1),
24244                    },
24245                },
24246                source_provider: agent("codex"),
24247            },
24248            &history,
24249        )
24250        .unwrap();
24251        let expected_context_hash = digest(&SHA256, pack.bytes())
24252            .as_ref()
24253            .iter()
24254            .map(|byte| format!("{byte:02x}"))
24255            .collect::<String>();
24256        let context = shared
24257            .context_catalog
24258            .write()
24259            .unwrap_or_else(|poisoned| poisoned.into_inner())
24260            .insert(pack)
24261            .unwrap();
24262        let spec = SpawnSpec {
24263            target: crate::protocol::SpawnTarget {
24264                node_id,
24265                workspace_id: workspace_id.clone(),
24266                worktree_id: None,
24267            },
24268            profile_id,
24269            expected_profile_revision: crate::protocol::SpawnProfileRevision::new(
24270                "target-kimi-r1",
24271            ).unwrap(),
24272            overrides: crate::protocol::SpawnOverrides {
24273                context_id: crate::protocol::SpawnOverride::Set {
24274                    value: context.id.clone(),
24275                },
24276                ..crate::protocol::SpawnOverrides::default()
24277            },
24278            deadline_ms: crate::protocol::SpawnDeadlineMs::new(10_000).unwrap(),
24279            idempotency_key: SpawnIdempotencyKey::new("kimi-context-only-spawn").unwrap(),
24280            required_capabilities: SpawnRequiredCapabilities::default(),
24281        };
24282        let resolved = match shared.resolve_spawn_spec(&spec) {
24283            Ok(resolved) => resolved,
24284            Err(error) => panic!(
24285                "Kimi context-only resolve rejected: exact_code={:?} message={}",
24286                error.code,
24287                error.message,
24288            ),
24289        };
24290        let resolved_context = match shared.resolve_context(&resolved) {
24291            Ok(Some(resolved_context)) => resolved_context,
24292            Ok(None) => panic!("Kimi context-only resolve omitted the exact ContextPack"),
24293            Err(error) => panic!(
24294                "Kimi context catalog resolve rejected: exact_code={:?} message={}",
24295                error.code,
24296                error.message,
24297            ),
24298        };
24299        assert_eq!(resolved_context, context);
24300        let runtime_policy = match shared
24301            .admit_provider_runtime(&agent("kimi"), ProviderRuntimeRequirement::RawPty)
24302            .await
24303        {
24304            Ok(runtime_policy) => runtime_policy,
24305            Err(error) => panic!(
24306                "Kimi RawPty admission rejected: exact_code={:?} message={}",
24307                error.code,
24308                error.message,
24309            ),
24310        };
24311        assert!(runtime_policy.raw_pty_lifecycle);
24312
24313        let launch_control = shared.native_launch_profile_control.as_ref().unwrap();
24314        let generic_overlay = NativeLaunchEnvironmentOverlay::new(
24315            agent("kimi"),
24316            TransportKind::Pty,
24317            vec![EnvMutation {
24318                key: OsString::from("GATE4AGENT_UNPROFILED_GENERIC"),
24319                value: Some(OsString::from("must-remain-rejected")),
24320            }],
24321        )
24322        .unwrap();
24323        assert_eq!(
24324            launch_control
24325                .install_native_launch_environment_overlay(
24326                    AgentInstanceId(72),
24327                    generic_overlay,
24328                )
24329                .unwrap_err(),
24330            gate4agent_runtime_native::NativeLaunchProfileError::EnvironmentOverlaySelectionMissing,
24331        );
24332
24333        let diagnostic_address = SessionAddress {
24334            workspace_id: workspace_id.clone(),
24335            session: SessionKey {
24336                instance_id: AgentInstanceId(73),
24337                generation: SessionGeneration(1),
24338            },
24339        };
24340        let (diagnostic_overlay, diagnostic_materialization) = match shared
24341            .prepare_session_materialization(
24342                &diagnostic_address,
24343                &agent("kimi"),
24344                SessionMode::Pty,
24345                None,
24346                None,
24347                Some(&resolved_context),
24348                None,
24349            )
24350        {
24351            Ok(Some(prepared)) => prepared,
24352            Ok(None) => panic!("Kimi context-only materialization produced no launch overlay"),
24353            Err(error) => panic!(
24354                "Kimi context-only materialization rejected: exact_code={:?} message={}",
24355                error.code,
24356                error.message,
24357            ),
24358        };
24359        let diagnostic_overlay = diagnostic_overlay
24360            .expect("Kimi context-only materialization omitted its environment overlay");
24361        assert!(matches!(
24362            &diagnostic_overlay,
24363            PreparedNativeLaunchOverlay::Environment(_),
24364        ));
24365        match shared.install_prepared_launch_overlay(
24366            diagnostic_address.session.instance_id,
24367            diagnostic_overlay,
24368        ) {
24369            Ok(None) => {}
24370            Ok(Some(_)) => panic!("Kimi context-only overlay unexpectedly retained argv authority"),
24371            Err(error) => panic!(
24372                "Kimi context-only overlay installation rejected: exact_code={:?} message={}",
24373                error.code,
24374                error.message,
24375            ),
24376        }
24377        assert!(launch_control.clear_native_instance_launch_overlay(
24378            diagnostic_address.session.instance_id,
24379        ));
24380        drop(diagnostic_materialization);
24381        assert!(shared
24382            .materializations
24383            .lock()
24384            .unwrap_or_else(|poisoned| poisoned.into_inner())
24385            .is_empty());
24386
24387        let node_task = tokio::spawn(server.run());
24388        let ready_client = timeout(Duration::from_secs(5), async {
24389            loop {
24390                match gate4agent_node_wire::LocalNodeClient::connect(
24391                    &endpoint,
24392                    &shared.node_id,
24393                    ClientRole::Observer,
24394                    "fixture-token",
24395                )
24396                .await
24397                {
24398                    Ok(client) => break client,
24399                    Err(_) if !node_task.is_finished() => sleep(Duration::from_millis(10)).await,
24400                    Err(error) => panic!("Kimi context-only Node stopped before readiness: {error}"),
24401                }
24402            }
24403        })
24404        .await
24405        .expect("Kimi context-only Node did not become ready");
24406        assert_eq!(
24407            ready_client
24408                .hello()
24409                .snapshot
24410                .provider_runtime_statuses
24411                .iter()
24412                .find(|status| status.provider().as_str() == "kimi")
24413                .expect("Kimi runtime status is missing")
24414                .mode(),
24415            crate::protocol::ProviderRuntimeMode::RawPassthrough,
24416        );
24417        drop(ready_client);
24418
24419        let receipt = match shared.spawn_from_spec(spec).await {
24420            Ok(receipt) => receipt,
24421            Err(error) => panic!(
24422                "Kimi context-only SpawnSpec rejected: exact_code={:?} message={}",
24423                error.code,
24424                error.message,
24425            ),
24426        };
24427        assert_eq!(receipt.provider, agent("kimi"));
24428        assert_eq!(receipt.context.as_ref(), Some(&context));
24429        assert!(receipt.context_binding_is_valid());
24430        timeout(Duration::from_secs(5), async {
24431            while !proof_path.is_file() {
24432                sleep(Duration::from_millis(10)).await;
24433            }
24434        })
24435        .await
24436        .expect("Kimi context-only fixture did not write its proof");
24437
24438        let proof = std::fs::read_to_string(&proof_path).unwrap();
24439        let lines = proof.lines().collect::<Vec<_>>();
24440        assert_eq!(lines.len(), 7);
24441        assert_eq!(lines[0], "context-only");
24442        assert!(Path::new(lines[1]).is_absolute());
24443        assert_ne!(
24444            std::fs::canonicalize(Path::new(lines[1])).unwrap(),
24445            std::fs::canonicalize(&workspace_root).unwrap(),
24446        );
24447        assert_eq!(
24448            std::fs::canonicalize(Path::new(lines[2])).unwrap(),
24449            std::fs::canonicalize(&workspace_root).unwrap(),
24450        );
24451        assert_eq!(lines[3], expected_context_hash);
24452        assert_eq!(lines[4], "g4a-context-pack-v1");
24453        assert_eq!(lines[5], "codex");
24454        assert_eq!(lines[6], "2");
24455        assert!(shared.handle.snapshot().sessions.iter().any(|session| {
24456            session.instance_id == receipt.session.session.instance_id
24457                && session.agent_id.as_str() == "kimi"
24458        }));
24459
24460        shutdown.request_shutdown().await.unwrap();
24461        timeout(Duration::from_secs(5), node_task)
24462            .await
24463            .unwrap()
24464            .unwrap()
24465            .unwrap();
24466        drop(shutdown);
24467        drop(shared);
24468        std::fs::remove_dir_all(root).unwrap();
24469    }
24470
24471    #[cfg(feature = "fixture")]
24472    #[test]
24473    fn clean_exit_fixture_constructor_is_fixture_scoped_and_path_bounded() {
24474        let root = temporary_workspace_root("clean-exit-constructor");
24475        std::fs::create_dir_all(&root).unwrap();
24476        let config = construction_test_config("clean-exit-constructor")
24477            .with_state_path(root.join("node-state.json"))
24478            .unwrap();
24479        let server = NodeServer::new_clean_exit_fixture(
24480            config,
24481            root.clone(),
24482            root.join("started.marker"),
24483            root.join("release.signal"),
24484        )
24485        .unwrap();
24486        assert_eq!(
24487            server
24488                .shared
24489                .enabled_providers
24490                .iter()
24491                .map(AgentId::as_str)
24492                .collect::<Vec<_>>(),
24493            ["claude"],
24494        );
24495        drop(server);
24496        std::fs::remove_dir_all(root).unwrap();
24497    }
24498
24499    #[test]
24500    fn production_registry_provider_contract_manifest_is_exact() {
24501        let registry = active_registry().unwrap();
24502        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
24503        assert_eq!(
24504            providers
24505                .iter()
24506                .map(|contract| (contract.provider.as_str(), contract.revision.as_str()))
24507                .collect::<Vec<_>>(),
24508            vec![
24509                ("claude", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24510                ("codex", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24511                ("grok", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24512                ("kimi", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24513            ]
24514        );
24515        assert_eq!(
24516            adapters
24517                .iter()
24518                .map(|contract| (
24519                    contract.provider.as_str(),
24520                    contract.family,
24521                    contract.adapter_id.as_str(),
24522                    contract.revision.as_str(),
24523                ))
24524                .collect::<Vec<_>>(),
24525            vec![
24526                ("claude", AdapterFamily::PtySemantic, "claude-code", "gate4agent-adapter/v1"),
24527                ("claude", AdapterFamily::Pipe, "claude-code", "gate4agent-adapter/v1"),
24528                ("claude", AdapterFamily::Acp, "claude-code", "gate4agent-adapter/v1"),
24529                ("claude", AdapterFamily::OneShot, "claude", "gate4agent-inline/claude-code-2.1/v1"),
24530                ("claude", AdapterFamily::History, "claude-code", "gate4agent-adapter/v1"),
24531                ("claude", AdapterFamily::Resume, "claude-code", "gate4agent-adapter/v1"),
24532                ("claude", AdapterFamily::SessionOptions, "claude-code", "gate4agent-session-options/orca-d8629c4/v1"),
24533                ("codex", AdapterFamily::PtySemantic, "codex", "gate4agent-adapter/v1"),
24534                ("codex", AdapterFamily::Pipe, "codex", "gate4agent-adapter/v1"),
24535                ("codex", AdapterFamily::Acp, "codex", "gate4agent-adapter/v1"),
24536                ("codex", AdapterFamily::OneShot, "codex", "gate4agent-inline/codex-cli-0.144/v1"),
24537                ("codex", AdapterFamily::History, "codex", "gate4agent-adapter/v1"),
24538                ("codex", AdapterFamily::Resume, "codex", "gate4agent-adapter/v1"),
24539                ("codex", AdapterFamily::SessionOptions, "codex", "gate4agent-session-options/orca-d8629c4/v1"),
24540                ("grok", AdapterFamily::Acp, "grok", "gate4agent-adapter/v1"),
24541                ("grok", AdapterFamily::History, "grok", "gate4agent-adapter/v1"),
24542                ("grok", AdapterFamily::Resume, "grok", "gate4agent-adapter/v1"),
24543                ("kimi", AdapterFamily::PtySemantic, "kimi", "gate4agent-adapter/v1"),
24544                ("kimi", AdapterFamily::Pipe, "kimi", "gate4agent-adapter/v1"),
24545                ("kimi", AdapterFamily::Acp, "kimi", "gate4agent-adapter/v1"),
24546                ("kimi", AdapterFamily::OneShot, "kimi", "gate4agent-inline/kimi-code-0.31/v1"),
24547                ("kimi", AdapterFamily::History, "kimi", "gate4agent-adapter/v1"),
24548                ("kimi", AdapterFamily::Resume, "kimi", "gate4agent-adapter/v1"),
24549            ]
24550        );
24551    }
24552
24553    fn construction_test_config(name: &str) -> NodeServerConfig {
24554        let workspace = WorkspaceConfig::new(
24555            WorkspaceId::new("primary").unwrap(),
24556            std::env::current_dir().unwrap(),
24557        )
24558        .unwrap();
24559        NodeServerConfig::new(
24560            format!(r"\\.\pipe\gate4agent-{name}"),
24561            "fixture-token",
24562            NodeId::new(name).unwrap(),
24563            [workspace],
24564        )
24565        .unwrap()
24566    }
24567
24568    fn maximum_size_provider_manifest_registry() -> AgentRegistry {
24569        let adapter = AdapterBinding::new(
24570            gate4agent_types::AdapterId::new("a".repeat(96)).unwrap(),
24571            "r".repeat(crate::protocol::MAX_ADAPTER_CONTRACT_REVISION_BYTES),
24572            gate4agent_types::AdapterVerification::SyntheticFixture,
24573        )
24574        .unwrap();
24575        let agent_ids = ["claude", "codex", "kimi"]
24576            .into_iter()
24577            .map(|agent_id| AgentId::new(agent_id).unwrap())
24578            .collect::<Vec<_>>();
24579        let adapters = gate4agent_catalog::AdapterRegistry::new(
24580            [
24581                AdapterFamily::PtySemantic,
24582                AdapterFamily::Pipe,
24583                AdapterFamily::OneShot,
24584                AdapterFamily::Acp,
24585                AdapterFamily::Hook,
24586                AdapterFamily::ManagedHook,
24587                AdapterFamily::History,
24588                AdapterFamily::Resume,
24589                AdapterFamily::SessionOptions,
24590                AdapterFamily::CapabilityProbe,
24591            ]
24592            .into_iter()
24593            .map(|family| gate4agent_catalog::AdapterDescriptor {
24594                family,
24595                binding: adapter.clone(),
24596                agents: agent_ids.clone(),
24597            }),
24598        )
24599        .unwrap();
24600        let specs = agent_ids
24601            .iter()
24602            .map(|agent_id| {
24603                let agent_id = agent_id.as_str();
24604                let mut spec = builtin_registry().get_by_id(agent_id).unwrap().clone();
24605                spec.revision =
24606                    "r".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES);
24607                spec.capabilities.transports.pty_adapter = Some(adapter.clone());
24608                spec.capabilities.transports.pipe = Some(gate4agent_types::PipeTransportSpec {
24609                    adapter: adapter.clone(),
24610                    protocol: gate4agent_types::PipeProtocol::SemanticNdjson,
24611                    launch_override: None,
24612                    prompt_delivery: gate4agent_types::PipePromptDelivery::None,
24613                });
24614                spec.capabilities.transports.acp = Some(gate4agent_types::AcpTransportSpec {
24615                    adapter: adapter.clone(),
24616                    launch_override: None,
24617                });
24618                spec.capabilities.adapters.hook = Some(adapter.clone());
24619                spec.capabilities.adapters.managed_hook = Some(adapter.clone());
24620                spec.capabilities.adapters.one_shot = Some(adapter.clone());
24621                spec.capabilities.adapters.history = Some(adapter.clone());
24622                spec.capabilities.adapters.resume = Some(adapter.clone());
24623                spec.capabilities.adapters.session_options = Some(adapter.clone());
24624                spec.capabilities.adapters.capability_probe = Some(adapter.clone());
24625                spec
24626            })
24627            .collect::<Vec<_>>();
24628        AgentRegistry::new_with_adapters(specs, &adapters).unwrap()
24629    }
24630
24631    /// The real fleet's manifest is accepted by a real server construction.
24632    ///
24633    /// The count is asserted against `active_registry` itself rather than a
24634    /// literal, so this stays a test that construction succeeds for whatever
24635    /// the fleet is -- which is the property worth guarding -- instead of a
24636    /// second place the fleet size is written down and has to be kept in
24637    /// step with the first.
24638    #[test]
24639    fn node_server_construction_accepts_the_production_manifest() {
24640        let registry = active_registry().unwrap();
24641        let (providers, _) = provider_contract_manifest(&registry).unwrap();
24642        assert_eq!(providers.len(), registry.iter().count());
24643        assert!(!providers.is_empty());
24644        let server = NodeServer::new_with_registry(
24645            construction_test_config("manifest-production-bound"),
24646            registry,
24647        )
24648        .unwrap();
24649        drop(server);
24650    }
24651
24652    #[test]
24653    fn node_server_construction_rejects_count_valid_manifest_over_handshake_capacity() {
24654        let registry = maximum_size_provider_manifest_registry();
24655        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
24656        assert_eq!(providers.len(), 3);
24657        assert_eq!(adapters.len(), 30);
24658        assert!(adapters.len() <= crate::protocol::MAX_PROVIDER_ADAPTER_CONTRACTS);
24659        let error = match NodeServer::new_with_registry(
24660            construction_test_config("manifest-handshake-overflow"),
24661            registry,
24662        ) {
24663            Ok(_) => panic!("oversized negotiated manifest was accepted"),
24664            Err(error) => error,
24665        };
24666        assert!(matches!(
24667            error,
24668            NodeServerError::ProviderContractManifest(message)
24669                if message.contains("authentication binding length")
24670                    && message.contains("8192-byte limit")
24671        ));
24672    }
24673
24674    #[test]
24675    fn provider_contract_manifest_rejects_wire_revision_overflow_before_startup() {
24676        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24677        spec.revision = "x".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES + 1);
24678        let registry = AgentRegistry::new([spec]).unwrap();
24679        assert!(matches!(
24680            provider_contract_manifest(&registry),
24681            Err(NodeServerError::ProviderContractManifest(message))
24682                if message.contains("exceeds the 128-byte limit")
24683        ));
24684    }
24685
24686    #[test]
24687    fn provider_contract_manifest_rejects_invalid_wire_revision_before_startup() {
24688        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24689        spec.revision = "invalid revision".to_owned();
24690        let registry = AgentRegistry::new([spec]).unwrap();
24691        assert!(matches!(
24692            provider_contract_manifest(&registry),
24693            Err(NodeServerError::ProviderContractManifest(message))
24694                if message.contains("bounded lowercase ASCII")
24695        ));
24696    }
24697
24698    #[test]
24699    fn provider_contract_manifest_rejects_conflicting_duplicate_family_before_startup() {
24700        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24701        let transport_binding = spec.capabilities.adapters.one_shot.clone().unwrap();
24702        let conflicting_binding = builtin_registry()
24703            .get_by_id("codex")
24704            .unwrap()
24705            .capabilities
24706            .adapters
24707            .one_shot
24708            .clone()
24709            .unwrap();
24710        let pipe = spec.capabilities.transports.pipe.as_mut().unwrap();
24711        pipe.protocol = gate4agent_types::PipeProtocol::OneShotText;
24712        pipe.adapter = transport_binding;
24713        spec.capabilities.adapters.one_shot = Some(conflicting_binding);
24714        let registry = AgentRegistry::new([spec]).unwrap();
24715        assert!(matches!(
24716            provider_contract_manifest(&registry),
24717            Err(NodeServerError::ProviderContractManifest(message))
24718                if message.contains("conflicting OneShot adapter bindings")
24719        ));
24720    }
24721
24722    #[test]
24723    fn public_node_failures_expose_only_fixed_categories() {
24724        let provider = failure(
24725            NodeFailureCode::BackendOperationFailed,
24726            r"provider bearer-secret failed at C:\private\session.jsonl",
24727        );
24728        assert_eq!(provider.message, "backend-operation-failed");
24729        assert!(!provider.message.contains("bearer-secret"));
24730        assert!(!provider.message.contains(r"C:\private"));
24731        let persistence = persistence_failure(io::Error::new(
24732            io::ErrorKind::Other,
24733            r"state write failed at C:\private\state-v1.json",
24734        ));
24735        assert_eq!(persistence.message, DURABLE_STATE_COMMIT_FAILED_ERROR);
24736        assert!(!persistence.message.contains(r"C:\private"));
24737
24738        for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::Unsupported] {
24739            let generic = durable_state_load_error(io::Error::new(kind, "host I/O failure"));
24740            assert_eq!(
24741                generic.to_string(),
24742                "node durable state failed: durable-state-load-failed",
24743            );
24744        }
24745    }
24746
24747    #[test]
24748    fn terminal_byte_sequences_are_bounded_before_dispatch() {
24749        let sequence = b"\x1b[1;5D".to_vec();
24750        assert_eq!(
24751            terminal_bytes_action(sequence.clone()).unwrap(),
24752            InputAction::TerminalBytes(sequence),
24753        );
24754        assert_eq!(
24755            terminal_bytes_action(Vec::new()).unwrap_err().code,
24756            NodeFailureCode::InvalidRequest,
24757        );
24758        assert_eq!(
24759            terminal_bytes_action(vec![0; MAX_NODE_TERMINAL_BYTES + 1])
24760                .unwrap_err()
24761                .code,
24762            NodeFailureCode::InvalidRequest,
24763        );
24764    }
24765
24766    #[test]
24767    fn node_server_rejects_a_second_owner_of_the_same_durable_state_path() {
24768        let root = temporary_workspace_root("exclusive-state-owner");
24769        std::fs::create_dir_all(&root).unwrap();
24770        let workspace = WorkspaceConfig::new(
24771            WorkspaceId::new("primary").unwrap(),
24772            &root,
24773        )
24774        .unwrap();
24775        let state_path = root.join("state-v1.json");
24776        let endpoint = format!(
24777            r"\\.\pipe\gate4agent-node-state-lock-{}-{}",
24778            std::process::id(),
24779            std::time::SystemTime::now()
24780                .duration_since(std::time::UNIX_EPOCH)
24781                .unwrap()
24782                .as_nanos(),
24783        );
24784        let config = NodeServerConfig::new(
24785            endpoint,
24786            "fixture-token",
24787            NodeId::new("node-1").unwrap(),
24788            [workspace],
24789        )
24790        .unwrap()
24791        .with_state_path(&state_path)
24792        .unwrap();
24793
24794        let first = NodeServer::new(config.clone()).unwrap();
24795        let error = match NodeServer::new(config.clone()) {
24796            Ok(_) => panic!("a second node server acquired the same durable state path"),
24797            Err(error) => error,
24798        };
24799        assert!(matches!(
24800            &error,
24801            NodeServerError::DurableState(error)
24802                if error.kind() == io::ErrorKind::WouldBlock
24803        ));
24804        assert_eq!(
24805            error.to_string(),
24806            "node durable state failed: durable-state-lock-failed",
24807        );
24808        assert!(!error.to_string().contains(&state_path.to_string_lossy().into_owned()));
24809        drop(first);
24810        let second = NodeServer::new(config).unwrap();
24811        drop(second);
24812        std::fs::remove_dir_all(root).unwrap();
24813    }
24814
24815    #[test]
24816    fn session_binding_is_workspace_scoped_and_removed_fail_closed() {
24817        let catalog = active_registry().unwrap();
24818        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24819        let workspace_id = WorkspaceId::new("primary").unwrap();
24820        let workspace = WorkspaceConfig::new(
24821            workspace_id.clone(),
24822            std::env::current_dir().unwrap(),
24823        )
24824        .unwrap();
24825        let shared = NodeShared::new(
24826            handle,
24827            "fixture-token".to_owned(),
24828            NodeId::new("node-1").unwrap(),
24829            vec![workspace.clone()],
24830            vec![agent("claude"), agent("codex"), agent("kimi")],
24831        );
24832        let instance_id = AgentInstanceId(17);
24833        let generation = SessionGeneration(3);
24834        assert!(shared.address_for(instance_id, generation).is_none());
24835        shared.bind_session(instance_id, workspace_id.clone(), generation);
24836        let address = SessionAddress {
24837            workspace_id,
24838            session: SessionKey { instance_id, generation },
24839        };
24840        assert_eq!(
24841            shared.workspace_root(&address.workspace_id).unwrap(),
24842            workspace.canonical_root,
24843        );
24844        assert_eq!(shared.address_for(instance_id, generation), Some(address.clone()));
24845        shared.remove_binding(&address);
24846        assert!(shared.address_for(instance_id, generation).is_none());
24847    }
24848
24849    #[test]
24850    fn open_provider_request_gate_is_state_aware_and_fail_closed() {
24851        let address = SessionAddress {
24852            workspace_id: WorkspaceId::new("primary").unwrap(),
24853            session: SessionKey {
24854                instance_id: AgentInstanceId(7),
24855                generation: SessionGeneration(2),
24856            },
24857        };
24858        let record_id = SessionRecordId::new("record-a").unwrap();
24859        let size = gate4agent_types::TerminalSize {
24860            rows: 24,
24861            columns: 80,
24862        };
24863        let address_requests = [
24864            NodeRequest::Resume {
24865                session: address.clone(),
24866                terminal_size: size,
24867                initial_prompt: None,
24868            },
24869            NodeRequest::Prompt { session: address.clone(), text: "p".to_owned() },
24870            NodeRequest::Paste { session: address.clone(), text: "p".to_owned() },
24871            NodeRequest::Input { session: address.clone(), text: "p".to_owned() },
24872            NodeRequest::TerminalBytes { session: address.clone(), bytes: vec![1] },
24873            NodeRequest::TerminalControl {
24874                session: address.clone(),
24875                control: TerminalControl::Enter,
24876            },
24877            NodeRequest::Resize { session: address.clone(), size },
24878            NodeRequest::Interrupt { session: address.clone() },
24879            NodeRequest::Stop { session: address.clone(), force: false },
24880            NodeRequest::Remove { session: address },
24881        ];
24882        for request in &address_requests {
24883            assert!(request_requires_open_provider_ids_with(
24884                request,
24885                |_| Some(agent("third-party-agent")),
24886                |_| Some(agent("claude")),
24887            ));
24888            assert!(!request_requires_open_provider_ids_with(
24889                request,
24890                |_| Some(agent("claude")),
24891                |_| Some(agent("claude")),
24892            ));
24893            assert!(request_requires_open_provider_ids_with(
24894                request,
24895                |_| None,
24896                |_| Some(agent("claude")),
24897            ));
24898        }
24899
24900        let record_requests = [
24901            NodeRequest::RenameSessionRecord {
24902                record_id: record_id.clone(),
24903                display_name: "renamed".to_owned(),
24904            },
24905            NodeRequest::ResumeSessionRecord {
24906                record_id: record_id.clone(),
24907                terminal_size: size,
24908                initial_prompt: None,
24909            },
24910            NodeRequest::ForgetSessionRecord { record_id },
24911        ];
24912        for request in &record_requests {
24913            assert!(request_requires_open_provider_ids_with(
24914                request,
24915                |_| Some(agent("claude")),
24916                |_| Some(agent("grok")),
24917            ));
24918            assert!(!request_requires_open_provider_ids_with(
24919                request,
24920                |_| Some(agent("claude")),
24921                |_| Some(agent("kimi")),
24922            ));
24923            assert!(request_requires_open_provider_ids_with(
24924                request,
24925                |_| Some(agent("claude")),
24926                |_| None,
24927            ));
24928        }
24929
24930        assert!(request_requires_open_provider_ids_with(
24931            &NodeRequest::Spawn {
24932                workspace_id: WorkspaceId::new("primary").unwrap(),
24933                provider: agent("third-party-agent"),
24934                mode: SessionMode::Pty,
24935                terminal_size: size,
24936                initial_prompt: None,
24937            },
24938            |_| None,
24939            |_| None,
24940        ));
24941        assert!(!request_requires_open_provider_ids_with(
24942            &NodeRequest::Snapshot,
24943            |_| None,
24944            |_| None,
24945        ));
24946        assert!(request_requires_open_provider_ids_with(
24947            &NodeRequest::ForgetContextPack {
24948                context_id: SpawnContextId::new("ctx-forget").unwrap(),
24949            },
24950            |_| None,
24951            |_| None,
24952        ));
24953    }
24954
24955    #[test]
24956    fn legacy_projection_filters_open_record_replies_and_event_history() {
24957        let catalog = active_registry().unwrap();
24958        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24959        let workspace = WorkspaceConfig::new(
24960            WorkspaceId::new("primary").unwrap(),
24961            std::env::current_dir().unwrap(),
24962        )
24963        .unwrap();
24964        let shared = NodeShared::new(
24965            handle,
24966            "fixture-token".to_owned(),
24967            NodeId::new("node-1").unwrap(),
24968            vec![workspace],
24969            vec![agent("claude"), agent("grok")],
24970        );
24971        let legacy = record("claude", "legacy-record");
24972        let open = record("grok", "open-record");
24973        let legacy_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
24974            record: legacy.clone(),
24975        });
24976        let open_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
24977            record: open.clone(),
24978        });
24979        let legacy_removed = shared.publish(NodeEvent::SessionRecordRemoved {
24980            record_id: legacy.record_id.clone(),
24981        });
24982        let open_removed = shared.publish(NodeEvent::SessionRecordRemoved {
24983            record_id: open.record_id.clone(),
24984        });
24985        assert!(project_event_legacy_provider_ids(&shared, legacy_upsert.clone()).is_some());
24986        assert!(project_event_legacy_provider_ids(&shared, open_upsert.clone()).is_none());
24987        assert!(project_event_legacy_provider_ids(&shared, legacy_removed.clone()).is_some());
24988        assert!(project_event_legacy_provider_ids(&shared, open_removed.clone()).is_none());
24989
24990        let mut record_reply = ResponseEnvelope {
24991            request_id: 1,
24992            result: Ok(NodeResponse::SessionRecordUpdated { record: open }),
24993        };
24994        project_response_legacy_provider_ids(&shared, &mut record_reply);
24995        assert!(matches!(
24996            record_reply.result,
24997            Err(NodeFailure { code: NodeFailureCode::UnsupportedCapability, .. })
24998        ));
24999
25000        let mut resync = ResponseEnvelope {
25001            request_id: 2,
25002            result: Ok(NodeResponse::Resync {
25003                event_sequence: open_removed.sequence,
25004                oldest_available_sequence: 1,
25005                snapshot: shared.snapshot(),
25006                events: vec![legacy_upsert, open_upsert, legacy_removed, open_removed],
25007            }),
25008        };
25009        project_response_legacy_provider_ids(&shared, &mut resync);
25010        let Ok(NodeResponse::Resync { snapshot, events, .. }) = resync.result else {
25011            panic!("legacy projection changed the resync response kind");
25012        };
25013        assert!(snapshot.session_records.iter().all(|item| {
25014            provider_id_is_legacy(&item.provider)
25015        }));
25016        assert_eq!(events.len(), 2);
25017        assert!(events.iter().all(|event| matches!(
25018            &event.event,
25019            NodeEvent::SessionRecordUpserted { record }
25020                if provider_id_is_legacy(&record.provider)
25021        ) || matches!(&event.event, NodeEvent::SessionRecordRemoved { .. })));
25022    }
25023
25024    #[test]
25025    fn session_binding_capacity_is_bounded_by_the_control_session_limit() {
25026        let catalog = active_registry().unwrap();
25027        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25028        let workspace_id = WorkspaceId::new("primary").unwrap();
25029        let workspace = WorkspaceConfig::new(
25030            workspace_id.clone(),
25031            std::env::current_dir().unwrap(),
25032        )
25033        .unwrap();
25034        let shared = NodeShared::new(
25035            handle,
25036            "fixture-token".to_owned(),
25037            NodeId::new("node-1").unwrap(),
25038            vec![workspace],
25039            vec![agent("claude")],
25040        );
25041        for index in 0..CONTROL_SESSIONS_MAX {
25042            shared.bind_session(
25043                AgentInstanceId((index + 1) as u64),
25044                workspace_id.clone(),
25045                SessionGeneration(1),
25046            );
25047        }
25048        let error = shared.ensure_binding_capacity().unwrap_err();
25049        assert_eq!(error.code, NodeFailureCode::BackendBusy);
25050    }
25051
25052    #[tokio::test]
25053    async fn provider_spawn_admission_rejects_before_session_mutation() {
25054        let catalog = active_registry().unwrap();
25055        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25056        let workspace = WorkspaceConfig::new(
25057            WorkspaceId::new("primary").unwrap(),
25058            std::env::current_dir().unwrap(),
25059        )
25060        .unwrap();
25061        let mut shared = NodeShared::new(
25062            handle,
25063            "fixture-token".to_owned(),
25064            NodeId::new("node-provider-admission").unwrap(),
25065            vec![workspace],
25066            vec![agent("claude")],
25067        );
25068        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25069            crate::protocol::ProviderRuntimeStatus::unavailable(agent("claude")),
25070        ])
25071        .unwrap();
25072        let terminal_size = gate4agent_types::TerminalSize {
25073            rows: 24,
25074            columns: 80,
25075        };
25076
25077        let unavailable = shared
25078            .spawn_session(
25079                WorkspaceId::new("primary").unwrap(),
25080                agent("claude"),
25081                SessionMode::Pty,
25082                terminal_size,
25083                None,
25084            )
25085            .await
25086            .unwrap_err();
25087        assert_eq!(unavailable.code, NodeFailureCode::BackendOperationFailed);
25088        assert_eq!(unavailable.message, "backend-operation-failed");
25089
25090        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25091            crate::protocol::ProviderRuntimeStatus::raw_passthrough(
25092                agent("claude"),
25093                Some(crate::protocol::ProviderRuntimeVersion::new("999.0.0").unwrap()),
25094            ),
25095        ])
25096        .unwrap();
25097        let semantic = shared
25098            .spawn_session(
25099                WorkspaceId::new("primary").unwrap(),
25100                agent("claude"),
25101                SessionMode::Inline,
25102                terminal_size,
25103                None,
25104            )
25105            .await
25106            .unwrap_err();
25107        assert_eq!(semantic.code, NodeFailureCode::UnsupportedCapability);
25108        assert_eq!(semantic.message, "unsupported-capability");
25109
25110        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
25111        assert!(shared
25112            .session_bindings
25113            .lock()
25114            .unwrap_or_else(|poisoned| poisoned.into_inner())
25115            .is_empty());
25116        assert!(shared
25117            .session_records
25118            .lock()
25119            .unwrap_or_else(|poisoned| poisoned.into_inner())
25120            .records
25121            .is_empty());
25122        assert!(shared.handle.snapshot().sessions.is_empty());
25123    }
25124
25125    #[tokio::test]
25126    async fn raw_spawn_binding_is_visible_with_a_durable_live_record() {
25127        let catalog = active_registry().unwrap();
25128        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25129        let workspace_id = WorkspaceId::new("primary").unwrap();
25130        let workspace = WorkspaceConfig::new(
25131            workspace_id.clone(),
25132            std::env::current_dir().unwrap(),
25133        )
25134        .unwrap();
25135        let shared = NodeShared::new(
25136            handle,
25137            "fixture-token".to_owned(),
25138            NodeId::new("node-raw-spawn-record").unwrap(),
25139            vec![workspace],
25140            vec![agent("claude")],
25141        );
25142        let address = SessionAddress {
25143            workspace_id,
25144            session: SessionKey {
25145                instance_id: AgentInstanceId(71),
25146                generation: SessionGeneration::default(),
25147            },
25148        };
25149
25150        let record_id = shared
25151            .bind_spawn_session(
25152                &address,
25153                agent("claude"),
25154                SessionMode::Pty,
25155                ProviderRuntimePolicy::raw_pty(),
25156                SpawnRecordPolicy::Always,
25157                None,
25158            )
25159            .unwrap()
25160            .unwrap();
25161        shared
25162            .handle
25163            .dispatch(shared.prepare_command(ControlCommand::Register {
25164                instance_id: address.session.instance_id,
25165                agent_id: agent("claude"),
25166                transport: TransportKind::Pty,
25167            }))
25168            .unwrap();
25169        runtime.tick().await;
25170
25171        assert!(shared.handle.snapshot().sessions.iter().any(|session| {
25172            session.instance_id == address.session.instance_id
25173                && session.generation == address.session.generation
25174        }));
25175        let record = shared.record(&record_id).unwrap();
25176        assert_eq!(record.display_name, "claude #1");
25177        assert_eq!(record.state, ManagedSessionState::Live);
25178        assert_eq!(record.active_session.as_ref(), Some(&address));
25179        assert!(record.provider_session.is_none());
25180        let binding_record_id = shared
25181            .session_bindings
25182            .lock()
25183            .unwrap_or_else(|poisoned| poisoned.into_inner())
25184            .get(&address.session.instance_id)
25185            .and_then(|binding| binding.record_id.clone());
25186        assert_eq!(binding_record_id.as_ref(), Some(&record_id));
25187
25188        let renamed = shared
25189            .rename_session_record(&record_id, "raw review".to_owned())
25190            .unwrap();
25191        assert_eq!(renamed.record_id, record_id);
25192        assert_eq!(renamed.display_name, "raw review");
25193        shared.reconcile_managed_record(
25194            &record_id,
25195            &address,
25196            &ControlEvent {
25197                sequence: 1,
25198                command_id: None,
25199                instance_id: address.session.instance_id,
25200                generation: address.session.generation,
25201                event: ControlEventKind::Running { process_id: None },
25202            },
25203            false,
25204            false,
25205        );
25206        let running = shared.record(&record_id).unwrap();
25207        assert_eq!(running.state, ManagedSessionState::Live);
25208        assert_eq!(running.active_session.as_ref(), Some(&address));
25209        assert_eq!(running.display_name, "raw review");
25210
25211        shared.reconcile_managed_record(
25212            &record_id,
25213            &address,
25214            &ControlEvent {
25215                sequence: 2,
25216                command_id: None,
25217                instance_id: address.session.instance_id,
25218                generation: address.session.generation,
25219                event: ControlEventKind::Exited {
25220                    exit_code: Some(0),
25221                    forced: false,
25222                },
25223            },
25224            false,
25225            false,
25226        );
25227        let detached = shared.record(&record_id).unwrap();
25228        assert_eq!(detached.state, ManagedSessionState::Unavailable);
25229        assert!(detached.active_session.is_none());
25230        assert_eq!(detached.display_name, "raw review");
25231        let renamed_detached = shared
25232            .rename_session_record(&record_id, "raw review detached".to_owned())
25233            .unwrap();
25234        assert_eq!(renamed_detached.record_id, record_id);
25235        assert_eq!(renamed_detached.state, ManagedSessionState::Unavailable);
25236        assert!(renamed_detached.active_session.is_none());
25237        assert_eq!(renamed_detached.display_name, "raw review detached");
25238        assert_eq!(shared.snapshot().session_records, vec![renamed_detached]);
25239    }
25240
25241    #[test]
25242    fn legacy_raw_spawn_binding_remains_untracked() {
25243        let catalog = active_registry().unwrap();
25244        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25245        let workspace_id = WorkspaceId::new("primary").unwrap();
25246        let workspace = WorkspaceConfig::new(
25247            workspace_id.clone(),
25248            std::env::current_dir().unwrap(),
25249        )
25250        .unwrap();
25251        let shared = NodeShared::new(
25252            handle,
25253            "fixture-token".to_owned(),
25254            NodeId::new("node-legacy-raw-spawn").unwrap(),
25255            vec![workspace],
25256            vec![agent("claude")],
25257        );
25258        let address = SessionAddress {
25259            workspace_id,
25260            session: SessionKey {
25261                instance_id: AgentInstanceId(72),
25262                generation: SessionGeneration::default(),
25263            },
25264        };
25265
25266        let record_id = shared
25267            .bind_spawn_session(
25268                &address,
25269                agent("claude"),
25270                SessionMode::Pty,
25271                ProviderRuntimePolicy::raw_pty(),
25272                SpawnRecordPolicy::ProviderIdentityOnly,
25273                None,
25274            )
25275            .unwrap();
25276
25277        assert!(record_id.is_none());
25278        let binding_record_id = shared
25279            .session_bindings
25280            .lock()
25281            .unwrap_or_else(|poisoned| poisoned.into_inner())
25282            .get(&address.session.instance_id)
25283            .and_then(|binding| binding.record_id.clone());
25284        assert!(binding_record_id.is_none());
25285        assert!(shared.snapshot().session_records.is_empty());
25286    }
25287
25288    #[tokio::test]
25289    async fn raw_runtime_policy_rejects_prompt_paste_and_resume_before_mutation() {
25290        let catalog = active_registry().unwrap();
25291        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25292        let workspace_id = WorkspaceId::new("primary").unwrap();
25293        let workspace = WorkspaceConfig::new(
25294            workspace_id.clone(),
25295            std::env::current_dir().unwrap(),
25296        )
25297        .unwrap();
25298        let mut shared = NodeShared::new(
25299            handle,
25300            "fixture-token".to_owned(),
25301            NodeId::new("node-raw-semantic-gate").unwrap(),
25302            vec![workspace],
25303            vec![agent("claude")],
25304        );
25305        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25306            crate::protocol::ProviderRuntimeStatus::raw_passthrough(agent("claude"), None),
25307        ])
25308        .unwrap();
25309        shared
25310            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25311            .unwrap();
25312        let address = SessionAddress {
25313            workspace_id,
25314            session: SessionKey {
25315                instance_id: AgentInstanceId(72),
25316                generation: SessionGeneration::default(),
25317            },
25318        };
25319        shared.bind_session_with_policy(
25320            &address,
25321            ProviderRuntimePolicy::raw_pty(),
25322            None,
25323        );
25324        shared
25325            .handle
25326            .dispatch(shared.prepare_command(ControlCommand::Register {
25327                instance_id: address.session.instance_id,
25328                agent_id: agent("claude"),
25329                transport: TransportKind::Pty,
25330            }))
25331            .unwrap();
25332        runtime.tick().await;
25333        let command_id_before = shared.next_command_id.load(Ordering::Acquire);
25334        let revision_before = shared.handle.snapshot().revision;
25335        let history_sequence_before = shared.current_sequence();
25336        let terminal_size = gate4agent_types::TerminalSize {
25337            rows: 24,
25338            columns: 80,
25339        };
25340
25341        for request in [
25342            NodeRequest::Prompt {
25343                session: address.clone(),
25344                text: "prompt".to_owned(),
25345            },
25346            NodeRequest::Paste {
25347                session: address.clone(),
25348                text: "paste".to_owned(),
25349            },
25350            // `initial_prompt: Some(_)` selects `ResumeWithPrompt`, which needs
25351            // the same semantic capabilities as `Prompt`/`Paste`. A bare
25352            // (promptless) resume is a raw PTY relaunch and IS admitted under
25353            // `raw_pty()` — see `raw_pty_admits_provider_native_resume_without_prompt_only`
25354            // in `provider_runtime.rs`; it is deliberately not exercised here.
25355            NodeRequest::Resume {
25356                session: address.clone(),
25357                terminal_size,
25358                initial_prompt: Some("resume".to_owned()),
25359            },
25360        ] {
25361            let error = process_request_inner(
25362                &shared,
25363                77,
25364                ClientRole::Operator,
25365                request,
25366            )
25367            .await
25368            .unwrap_err();
25369            assert_eq!(error.code, NodeFailureCode::UnsupportedCapability);
25370        }
25371
25372        assert_eq!(shared.next_command_id.load(Ordering::Acquire), command_id_before);
25373        assert_eq!(shared.handle.snapshot().revision, revision_before);
25374        assert_eq!(shared.current_sequence(), history_sequence_before);
25375        let bindings = shared
25376            .session_bindings
25377            .lock()
25378            .unwrap_or_else(|poisoned| poisoned.into_inner());
25379        let binding = bindings.get(&address.session.instance_id).unwrap();
25380        assert_eq!(binding.generation, address.session.generation);
25381        assert!(binding.pending_resume.is_none());
25382        assert!(binding.record_id.is_none());
25383    }
25384
25385    #[tokio::test]
25386    async fn windows_rejects_unix_bytes_workspace_path_before_filesystem_access() {
25387        let catalog = active_registry().unwrap();
25388        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25389        let primary = WorkspaceConfig::new(
25390            WorkspaceId::new("primary").unwrap(),
25391            std::env::current_dir().unwrap(),
25392        )
25393        .unwrap();
25394        let shared = NodeShared::new(
25395            handle,
25396            "fixture-token".to_owned(),
25397            NodeId::new("node-1").unwrap(),
25398            vec![primary],
25399            vec![agent("claude")],
25400        );
25401        shared
25402            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25403            .unwrap();
25404        let before = shared.snapshot();
25405        let error = process_request_inner(
25406            &shared,
25407            77,
25408            ClientRole::Operator,
25409            NodeRequest::RegisterWorkspace {
25410                workspace_id: WorkspaceId::new("foreign").unwrap(),
25411                root: OpaqueHostPath::unix_bytes(b"/srv/repo".to_vec()).unwrap(),
25412            },
25413        )
25414        .await
25415        .unwrap_err();
25416        assert_eq!(error.code, NodeFailureCode::InvalidRequest);
25417        assert_eq!(error.message, "invalid-request");
25418        assert_eq!(shared.snapshot().workspaces, before.workspaces);
25419    }
25420
25421    #[tokio::test]
25422    async fn dynamic_workspaces_reject_duplicates_busy_and_last_then_publish_ordered_events() {
25423        let catalog = active_registry().unwrap();
25424        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25425        let primary_id = WorkspaceId::new("primary").unwrap();
25426        let primary = WorkspaceConfig::new(
25427            primary_id.clone(),
25428            std::env::current_dir().unwrap(),
25429        )
25430        .unwrap();
25431        let shared = NodeShared::new(
25432            handle,
25433            "fixture-token".to_owned(),
25434            NodeId::new("node-1").unwrap(),
25435            vec![primary.clone()],
25436            vec![agent("claude")],
25437        );
25438        let secondary_id = WorkspaceId::new("secondary").unwrap();
25439        let secondary = shared
25440            .register_workspace(
25441                secondary_id.clone(),
25442                std::env::temp_dir().to_string_lossy().into_owned(),
25443            )
25444            .await
25445            .unwrap();
25446        assert_eq!(secondary.workspace_id, secondary_id);
25447        assert!(secondary.sessions.is_empty());
25448        assert_eq!(shared.snapshot().workspaces.len(), 2);
25449
25450        let duplicate_id = shared
25451            .register_workspace(
25452                secondary_id.clone(),
25453                primary.canonical_root().to_owned(),
25454            )
25455            .await
25456            .unwrap_err();
25457        assert_eq!(duplicate_id.code, NodeFailureCode::DuplicateWorkspaceId);
25458        let duplicate_root = shared
25459            .register_workspace(
25460                WorkspaceId::new("duplicate-root").unwrap(),
25461                windows_path_text(&secondary.canonical_root).to_owned(),
25462            )
25463            .await
25464            .unwrap_err();
25465        assert_eq!(duplicate_root.code, NodeFailureCode::DuplicateWorkspaceRoot);
25466
25467        let address = SessionAddress {
25468            workspace_id: secondary_id.clone(),
25469            session: SessionKey {
25470                instance_id: AgentInstanceId(41),
25471                generation: SessionGeneration(1),
25472            },
25473        };
25474        shared.bind_session(
25475            address.session.instance_id,
25476            secondary_id.clone(),
25477            address.session.generation,
25478        );
25479        let busy = shared.unregister_workspace(&secondary_id).unwrap_err();
25480        assert_eq!(busy.code, NodeFailureCode::WorkspaceBusy);
25481        shared.remove_binding(&address);
25482        shared.unregister_workspace(&secondary_id).unwrap();
25483        assert_eq!(shared.snapshot().workspaces.len(), 1);
25484
25485        let last = shared.unregister_workspace(&primary_id).unwrap_err();
25486        assert_eq!(last.code, NodeFailureCode::LastWorkspace);
25487        let history = shared
25488            .history
25489            .lock()
25490            .unwrap_or_else(|poisoned| poisoned.into_inner());
25491        assert!(matches!(
25492            &history.events[0],
25493            NodeEventEnvelope {
25494                sequence: 1,
25495                event: NodeEvent::WorkspaceAdded { workspace },
25496            } if workspace.workspace_id == secondary_id
25497        ));
25498        assert!(matches!(
25499            &history.events[1],
25500            NodeEventEnvelope {
25501                sequence: 2,
25502                event: NodeEvent::WorkspaceRemoved { workspace_id },
25503            } if workspace_id == &secondary_id
25504        ));
25505    }
25506
25507    #[tokio::test]
25508    async fn provider_session_reference_index_is_controller_gated_and_idempotent() {
25509        let secondary_root = temporary_workspace_root("provider-session-reference-index");
25510        std::fs::create_dir_all(&secondary_root).unwrap();
25511        let catalog = active_registry().unwrap();
25512        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25513        let primary_id = WorkspaceId::new("primary").unwrap();
25514        let secondary_id = WorkspaceId::new("secondary").unwrap();
25515        let shared = NodeShared::new(
25516            handle,
25517            "fixture-token".to_owned(),
25518            NodeId::new("node-session-reference-index").unwrap(),
25519            vec![
25520                WorkspaceConfig::new(primary_id.clone(), std::env::current_dir().unwrap())
25521                    .unwrap(),
25522                WorkspaceConfig::new(secondary_id.clone(), &secondary_root).unwrap(),
25523            ],
25524            vec![agent("claude"), agent("codex")],
25525        );
25526        let identity = ProviderSessionIdentity {
25527            key: gate4agent_types::ProviderSessionKey::SessionId,
25528            id: "provider-session-42".to_owned(),
25529            transcript_path: None,
25530        };
25531        let index_request = |workspace_id: WorkspaceId,
25532                             provider: AgentId,
25533                             identity: ProviderSessionIdentity,
25534                             display_name: &str| {
25535            NodeRequest::IndexProviderSession {
25536                workspace_id,
25537                provider,
25538                identity,
25539                display_name: display_name.to_owned(),
25540            }
25541        };
25542        let request = index_request(
25543            primary_id.clone(),
25544            agent("claude"),
25545            identity.clone(),
25546            "release shepherd",
25547        );
25548        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
25549            capability.as_str() == NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY
25550        }));
25551        let observer = process_request_inner(
25552            &shared,
25553            77,
25554            ClientRole::Observer,
25555            request.clone(),
25556        )
25557        .await
25558        .unwrap_err();
25559        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
25560        let uncontrolled = process_request_inner(
25561            &shared,
25562            77,
25563            ClientRole::Operator,
25564            request.clone(),
25565        )
25566        .await
25567        .unwrap_err();
25568        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
25569        shared
25570            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25571            .unwrap();
25572
25573        let indexed = process_request_inner(
25574            &shared,
25575            77,
25576            ClientRole::Operator,
25577            request.clone(),
25578        )
25579        .await
25580        .unwrap();
25581        let NodeResponse::ProviderSessionIndexed { record } = indexed else {
25582            panic!("provider session reference index returned another response");
25583        };
25584        assert_eq!(record.state, ManagedSessionState::Dormant);
25585        assert_eq!(record.mode, SessionMode::Pty);
25586        assert_eq!(record.workspace_id, primary_id);
25587        assert_eq!(record.provider_session.as_ref(), Some(&identity));
25588        assert!(record.active_session.is_none());
25589        assert!(record.environment_profile.is_none());
25590        assert!(record.bundle.is_none());
25591        assert!(record.context.is_none());
25592
25593        let repeated = process_request_inner(
25594            &shared,
25595            77,
25596            ClientRole::Operator,
25597            index_request(
25598                primary_id.clone(),
25599                agent("claude"),
25600                identity.clone(),
25601                "ignored replacement",
25602            ),
25603        )
25604        .await
25605        .unwrap();
25606        assert!(matches!(
25607            repeated,
25608            NodeResponse::ProviderSessionIndexed { record: ref existing }
25609                if existing.record_id == record.record_id
25610                    && existing.display_name == "release shepherd"
25611        ));
25612        let conflicting_workspace = process_request_inner(
25613            &shared,
25614            77,
25615            ClientRole::Operator,
25616            index_request(
25617                secondary_id,
25618                agent("claude"),
25619                identity.clone(),
25620                "release shepherd",
25621            ),
25622        )
25623        .await
25624        .unwrap_err();
25625        assert_eq!(conflicting_workspace.code, NodeFailureCode::SessionRecordConflict);
25626        let conflicting_provider = process_request_inner(
25627            &shared,
25628            77,
25629            ClientRole::Operator,
25630            index_request(
25631                primary_id.clone(),
25632                agent("codex"),
25633                identity.clone(),
25634                "release shepherd",
25635            ),
25636        )
25637        .await
25638        .unwrap_err();
25639        assert_eq!(conflicting_provider.code, NodeFailureCode::SessionRecordConflict);
25640        let transcript_rejected = process_request_inner(
25641            &shared,
25642            77,
25643            ClientRole::Operator,
25644            index_request(
25645                primary_id,
25646                agent("claude"),
25647                ProviderSessionIdentity {
25648                    transcript_path: Some("C:/provider/transcript.jsonl".to_owned()),
25649                    ..identity
25650                },
25651                "release shepherd",
25652            ),
25653        )
25654        .await
25655        .unwrap_err();
25656        assert_eq!(transcript_rejected.code, NodeFailureCode::InvalidRequest);
25657        let upserts = shared
25658            .history
25659            .lock()
25660            .unwrap_or_else(|poisoned| poisoned.into_inner())
25661            .events
25662            .iter()
25663            .filter(|event| matches!(
25664                &event.event,
25665                NodeEvent::SessionRecordUpserted { record: published }
25666                    if published.record_id == record.record_id
25667            ))
25668            .count();
25669        assert_eq!(upserts, 1);
25670        std::fs::remove_dir_all(secondary_root).unwrap();
25671    }
25672
25673    #[tokio::test]
25674    async fn standalone_workspace_is_independent_registered_and_controller_gated() {
25675        let fixture_root = temporary_workspace_root("standalone-independent");
25676        let target = fixture_root.join("independent");
25677        std::fs::create_dir_all(&fixture_root).unwrap();
25678        let catalog = active_registry().unwrap();
25679        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25680        let selected = WorkspaceConfig::new(
25681            WorkspaceId::new("primary").unwrap(),
25682            std::env::current_dir().unwrap(),
25683        )
25684        .unwrap();
25685        let selected_root = selected.canonical_root().to_owned();
25686        let shared = NodeShared::new(
25687            handle,
25688            "fixture-token".to_owned(),
25689            NodeId::new("node-standalone").unwrap(),
25690            vec![selected],
25691            vec![agent("claude")],
25692        );
25693        let workspace_id = WorkspaceId::new("independent").unwrap();
25694        let request = NodeRequest::CreateStandaloneWorkspace {
25695            workspace_id: workspace_id.clone(),
25696            root: opaque_windows_path(target.to_string_lossy().into_owned()),
25697            initial_branch: Some("main".to_owned()),
25698        };
25699        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
25700            capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
25701        }));
25702        assert!(!request_uses_unnegotiated_capability(
25703            &request,
25704            &baseline_capabilities().unwrap(),
25705        ));
25706        assert!(request_uses_unnegotiated_capability(&request, &[]));
25707
25708        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
25709            .await
25710            .unwrap_err();
25711        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
25712        assert!(!target.exists());
25713        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
25714            .await
25715            .unwrap_err();
25716        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
25717        assert!(!target.exists());
25718        shared
25719            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25720            .unwrap();
25721
25722        let response = process_request_inner(&shared, 77, ClientRole::Operator, request)
25723            .await
25724            .unwrap();
25725        let NodeResponse::StandaloneWorkspaceCreated { workspace } = response else {
25726            panic!("standalone workspace creation returned another response");
25727        };
25728        assert_eq!(workspace.workspace_id, workspace_id);
25729        assert!(target.join(".git").is_dir());
25730        let branch = run_git_bounded(
25731            target.to_str().unwrap(),
25732            &["branch", "--show-current"],
25733            GIT_OUTPUT_MAX_BYTES,
25734        )
25735        .await
25736        .unwrap();
25737        assert!(branch.success);
25738        assert_eq!(String::from_utf8(branch.stdout).unwrap().trim(), "main");
25739        let worktrees = list_git_worktrees(target.to_str().unwrap()).await.unwrap();
25740        assert_eq!(worktrees.len(), 1);
25741        let listed_root = WorkspaceConfig::new(
25742            WorkspaceId::new("listed-independent").unwrap(),
25743            &worktrees[0].path,
25744        )
25745        .unwrap();
25746        assert!(platform::roots_equal(
25747            listed_root.canonical_root(),
25748            windows_path_text(&workspace.canonical_root),
25749        ));
25750        assert!(!platform::roots_equal(listed_root.canonical_root(), &selected_root));
25751        assert!(shared.snapshot().workspaces.iter().any(|snapshot| {
25752            snapshot.workspace_id == workspace_id
25753                && snapshot.canonical_root == workspace.canonical_root
25754        }));
25755        assert!(shared
25756            .history
25757            .lock()
25758            .unwrap_or_else(|poisoned| poisoned.into_inner())
25759            .events
25760            .iter()
25761            .any(|event| matches!(
25762                &event.event,
25763                NodeEvent::WorkspaceAdded { workspace }
25764                    if workspace.workspace_id == workspace_id
25765            )));
25766        std::fs::remove_dir_all(fixture_root).unwrap();
25767    }
25768
25769    #[tokio::test]
25770    async fn standalone_workspace_registration_failure_preserves_guarded_repository_for_recovery() {
25771        let fixture_root = temporary_workspace_root("standalone-compensation");
25772        let created_target = fixture_root.join("created-target");
25773        let existing_target = fixture_root.join("existing-target");
25774        let invalid_state_parent = fixture_root.join("state-parent-is-a-file");
25775        let invalid_state_path = invalid_state_parent.join("state.json");
25776        std::fs::create_dir_all(&existing_target).unwrap();
25777        std::fs::write(&invalid_state_parent, "not a directory").unwrap();
25778        let catalog = active_registry().unwrap();
25779        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25780        let primary = WorkspaceConfig::new(
25781            WorkspaceId::new("primary").unwrap(),
25782            std::env::current_dir().unwrap(),
25783        )
25784        .unwrap();
25785        let mut shared = NodeShared::new(
25786            handle,
25787            "fixture-token".to_owned(),
25788            NodeId::new("node-standalone-compensation").unwrap(),
25789            vec![primary],
25790            vec![agent("claude")],
25791        );
25792        shared.state_path = Some(invalid_state_path);
25793
25794        let created_recovery = shared
25795            .create_standalone_workspace(
25796                WorkspaceId::new("compensated").unwrap(),
25797                created_target.to_string_lossy().into_owned(),
25798                Some("main".to_owned()),
25799            )
25800            .await
25801            .unwrap_err();
25802        assert_eq!(
25803            created_recovery.code,
25804            NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
25805        );
25806        assert!(created_target.join(".git").is_dir());
25807        assert!(shared.snapshot().workspaces.iter().all(|workspace| {
25808            workspace.workspace_id.as_str() != "compensated"
25809        }));
25810
25811        let existing_recovery = shared
25812            .create_standalone_workspace(
25813                WorkspaceId::new("restored").unwrap(),
25814                existing_target.to_string_lossy().into_owned(),
25815                None,
25816            )
25817            .await
25818            .unwrap_err();
25819        assert_eq!(
25820            existing_recovery.code,
25821            NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
25822        );
25823        assert!(existing_target.is_dir());
25824        assert!(existing_target.join(".git").is_dir());
25825        assert!(shared.snapshot().workspaces.iter().all(|workspace| {
25826            workspace.workspace_id.as_str() != "restored"
25827        }));
25828
25829        let registered_empty = fixture_root.join("registered-empty");
25830        std::fs::create_dir(&registered_empty).unwrap();
25831        let catalog = active_registry().unwrap();
25832        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25833        let registered = WorkspaceConfig::new(
25834            WorkspaceId::new("registered-empty").unwrap(),
25835            &registered_empty,
25836        )
25837        .unwrap();
25838        let duplicate_shared = NodeShared::new(
25839            handle,
25840            "fixture-token".to_owned(),
25841            NodeId::new("node-standalone-duplicate-root").unwrap(),
25842            vec![registered],
25843            vec![agent("claude")],
25844        );
25845        let duplicate = duplicate_shared
25846            .create_standalone_workspace(
25847                WorkspaceId::new("duplicate-root").unwrap(),
25848                registered_empty.to_string_lossy().into_owned(),
25849                None,
25850            )
25851            .await
25852            .unwrap_err();
25853        assert_eq!(duplicate.code, NodeFailureCode::DuplicateWorkspaceRoot);
25854        assert!(std::fs::read_dir(&registered_empty).unwrap().next().is_none());
25855        std::fs::remove_dir_all(fixture_root).unwrap();
25856    }
25857
25858    #[test]
25859    fn managed_record_id_collision_preserves_the_existing_record() {
25860        let catalog = active_registry().unwrap();
25861        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25862        let workspace = WorkspaceConfig::new(
25863            WorkspaceId::new("primary").unwrap(),
25864            std::env::current_dir().unwrap(),
25865        )
25866        .unwrap();
25867        let shared = NodeShared::new(
25868            handle,
25869            "fixture-token".to_owned(),
25870            NodeId::new("node-1").unwrap(),
25871            vec![workspace.clone()],
25872            vec![agent("claude")],
25873        );
25874        let record_id = SessionRecordId::new("sr-collision").unwrap();
25875        let original = ManagedSessionRecord {
25876            record_id: record_id.clone(),
25877            display_name: "original".to_owned(),
25878            provider: agent("claude"),
25879            mode: SessionMode::Pty,
25880            state: ManagedSessionState::Dormant,
25881            workspace_id: workspace.workspace_id,
25882            canonical_root: opaque_windows_path(workspace.canonical_root),
25883            provider_session: None,
25884            active_session: None,
25885            environment_profile: None,
25886            bundle: None,
25887            context_id: None,
25888            context: None,
25889            exported_context: None,
25890            task_binding: None,
25891            created_at_unix_ms: 1,
25892            updated_at_unix_ms: 1,
25893            last_error: None,
25894        };
25895        shared.insert_record(original.clone()).unwrap();
25896        let mut collision = original.clone();
25897        collision.display_name = "replacement".to_owned();
25898        let error = shared.insert_record(collision).unwrap_err();
25899        assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
25900        assert_eq!(shared.record(&record_id).unwrap(), original);
25901    }
25902
25903    #[test]
25904    fn managed_record_runtime_errors_are_exposed_only_as_allowlisted_categories() {
25905        let catalog = active_registry().unwrap();
25906        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25907        let workspace = WorkspaceConfig::new(
25908            WorkspaceId::new("primary").unwrap(),
25909            std::env::current_dir().unwrap(),
25910        )
25911        .unwrap();
25912        let shared = NodeShared::new(
25913            handle,
25914            "fixture-token".to_owned(),
25915            NodeId::new("node-1").unwrap(),
25916            vec![workspace.clone()],
25917            vec![agent("claude")],
25918        );
25919        let record_id = SessionRecordId::new("sr-runtime-error").unwrap();
25920        shared
25921            .insert_record(ManagedSessionRecord {
25922                record_id: record_id.clone(),
25923                display_name: "runtime error".to_owned(),
25924                provider: agent("claude"),
25925                mode: SessionMode::Pty,
25926                state: ManagedSessionState::IdentityPending,
25927                workspace_id: workspace.workspace_id,
25928                canonical_root: opaque_windows_path(workspace.canonical_root),
25929                provider_session: None,
25930                active_session: None,
25931                environment_profile: None,
25932                bundle: None,
25933                context_id: None,
25934                context: None,
25935                exported_context: None,
25936                task_binding: None,
25937                created_at_unix_ms: 1,
25938                updated_at_unix_ms: 1,
25939                last_error: None,
25940            })
25941            .unwrap();
25942
25943        shared
25944            .mark_record_error(
25945                &record_id,
25946                r"provider failed with bearer-secret-123 at C:\private\session.jsonl",
25947            )
25948            .unwrap();
25949        let record = shared.record(&record_id).unwrap();
25950        assert_eq!(record.state, ManagedSessionState::Unavailable);
25951        assert_eq!(
25952            record.last_error.as_deref(),
25953            Some("provider-runtime-failed"),
25954        );
25955        let snapshot = shared.snapshot();
25956        assert_eq!(snapshot.session_records, vec![record]);
25957    }
25958
25959    /// Regression coverage for the "operator sees `deadline` instead of the
25960    /// kernel's own named rejection" defect: a spawn-dispatch waiter must be
25961    /// able to learn a command's asynchronous kernel rejection (here,
25962    /// specifically classified as `UnsupportedTransport`) exactly once, and
25963    /// must not still find it after that -- proving the fast path this
25964    /// mechanism exists for (`spawn_session_with_deadline`'s commit-wait
25965    /// loop) drains the reason instead of leaving it to be rediscovered (or
25966    /// missed) on a later poll.
25967    #[test]
25968    fn rejected_command_reason_is_classified_and_consumed_exactly_once() {
25969        let catalog = active_registry().unwrap();
25970        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25971        let workspace = WorkspaceConfig::new(
25972            WorkspaceId::new("primary").unwrap(),
25973            std::env::current_dir().unwrap(),
25974        )
25975        .unwrap();
25976        let shared = NodeShared::new(
25977            handle,
25978            "fixture-token".to_owned(),
25979            NodeId::new("node-1").unwrap(),
25980            vec![workspace],
25981            vec![agent("claude")],
25982        );
25983
25984        assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
25985
25986        shared.record_rejected_command(
25987            CommandId(1),
25988            "agent 'claude' does not support transport Acp".to_owned(),
25989            true,
25990        );
25991        // An unrelated command_id must not see another command's reason.
25992        assert!(shared.take_rejected_command_reason(CommandId(2)).is_none());
25993
25994        let reason = shared.take_rejected_command_reason(CommandId(1)).unwrap();
25995        assert_eq!(reason.message, "agent 'claude' does not support transport Acp");
25996        assert!(reason.unsupported_transport);
25997
25998        // Consumed: a second read for the same command_id finds nothing.
25999        assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
26000
26001        // A rejection unrelated to transport support keeps its own
26002        // classification distinct, so the reader can choose the generic
26003        // `BackendOperationFailed` code instead of misreporting
26004        // `UnsupportedTransport`.
26005        shared.record_rejected_command(
26006            CommandId(3),
26007            "backend busy".to_owned(),
26008            false,
26009        );
26010        let unclassified = shared.take_rejected_command_reason(CommandId(3)).unwrap();
26011        assert!(!unclassified.unsupported_transport);
26012    }
26013
26014    #[test]
26015    fn provider_identity_update_is_ignored_without_exact_session_policy() {
26016        let catalog = active_registry().unwrap();
26017        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26018        let workspace = WorkspaceConfig::new(
26019            WorkspaceId::new("primary").unwrap(),
26020            std::env::current_dir().unwrap(),
26021        )
26022        .unwrap();
26023        let shared = NodeShared::new(
26024            handle,
26025            "fixture-token".to_owned(),
26026            NodeId::new("node-identity-policy").unwrap(),
26027            vec![workspace.clone()],
26028            vec![agent("claude")],
26029        );
26030        let address = SessionAddress {
26031            workspace_id: workspace.workspace_id.clone(),
26032            session: SessionKey {
26033                instance_id: AgentInstanceId(90),
26034                generation: SessionGeneration(1),
26035            },
26036        };
26037        let record_id = SessionRecordId::new("sr-policy-denied").unwrap();
26038        shared
26039            .insert_record(ManagedSessionRecord {
26040                record_id: record_id.clone(),
26041                display_name: "policy denied".to_owned(),
26042                provider: agent("claude"),
26043                mode: SessionMode::Pty,
26044                state: ManagedSessionState::IdentityPending,
26045                workspace_id: workspace.workspace_id,
26046                canonical_root: opaque_windows_path(workspace.canonical_root),
26047                provider_session: None,
26048                active_session: Some(address.clone()),
26049                environment_profile: None,
26050                bundle: None,
26051                context_id: None,
26052                context: None,
26053                exported_context: None,
26054                task_binding: None,
26055                created_at_unix_ms: 1,
26056                updated_at_unix_ms: 1,
26057                last_error: None,
26058            })
26059            .unwrap();
26060        let event = ControlEvent {
26061            sequence: 1,
26062            command_id: None,
26063            instance_id: address.session.instance_id,
26064            generation: address.session.generation,
26065            event: ControlEventKind::ProviderEvent {
26066                sequence: 1,
26067                source: gate4agent_types::ProviderSource {
26068                    family: AdapterFamily::PtySemantic,
26069                    binding: AdapterBinding::new(
26070                        gate4agent_types::AdapterId::new("claude").unwrap(),
26071                        "fixture/v1",
26072                        gate4agent_types::AdapterVerification::SyntheticFixture,
26073                    )
26074                    .unwrap(),
26075                },
26076                source_sequence: 1,
26077                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26078                    identity: gate4agent_types::ProviderSessionIdentity {
26079                        key: gate4agent_types::ProviderSessionKey::SessionId,
26080                        id: "provider-session".to_owned(),
26081                        transcript_path: None,
26082                    },
26083                },
26084            },
26085        };
26086
26087        shared.reconcile_managed_record(&record_id, &address, &event, false, false);
26088
26089        let record = shared.record(&record_id).unwrap();
26090        assert!(record.provider_session.is_none());
26091        assert_eq!(record.state, ManagedSessionState::IdentityPending);
26092        assert_eq!(record.active_session.as_ref(), Some(&address));
26093    }
26094
26095    #[test]
26096    fn provider_identity_cannot_rebind_a_record_across_workspaces() {
26097        let secondary_root = temporary_workspace_root("identity-scope");
26098        std::fs::create_dir_all(&secondary_root).unwrap();
26099        let catalog = active_registry().unwrap();
26100        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26101        let primary = WorkspaceConfig::new(
26102            WorkspaceId::new("primary").unwrap(),
26103            std::env::current_dir().unwrap(),
26104        )
26105        .unwrap();
26106        let secondary = WorkspaceConfig::new(
26107            WorkspaceId::new("secondary").unwrap(),
26108            &secondary_root,
26109        )
26110        .unwrap();
26111        let shared = NodeShared::new(
26112            handle,
26113            "fixture-token".to_owned(),
26114            NodeId::new("node-1").unwrap(),
26115            vec![primary.clone(), secondary.clone()],
26116            vec![agent("claude")],
26117        );
26118        let identity = gate4agent_types::ProviderSessionIdentity {
26119            key: gate4agent_types::ProviderSessionKey::SessionId,
26120            id: "provider-session".to_owned(),
26121            transcript_path: None,
26122        };
26123        let dormant_id = SessionRecordId::new("sr-dormant").unwrap();
26124        shared
26125            .insert_record(ManagedSessionRecord {
26126                record_id: dormant_id.clone(),
26127                display_name: "dormant".to_owned(),
26128                provider: agent("claude"),
26129                mode: SessionMode::Pty,
26130                state: ManagedSessionState::Dormant,
26131                workspace_id: primary.workspace_id,
26132                canonical_root: opaque_windows_path(primary.canonical_root),
26133                provider_session: Some(identity.clone()),
26134                active_session: None,
26135                environment_profile: None,
26136                bundle: None,
26137                context_id: None,
26138                context: None,
26139                exported_context: None,
26140                task_binding: None,
26141                created_at_unix_ms: 1,
26142                updated_at_unix_ms: 1,
26143                last_error: None,
26144            })
26145            .unwrap();
26146        let address = SessionAddress {
26147            workspace_id: secondary.workspace_id.clone(),
26148            session: SessionKey {
26149                instance_id: AgentInstanceId(91),
26150                generation: SessionGeneration(1),
26151            },
26152        };
26153        let current_id = SessionRecordId::new("sr-current").unwrap();
26154        shared
26155            .insert_record(ManagedSessionRecord {
26156                record_id: current_id.clone(),
26157                display_name: "current".to_owned(),
26158                provider: agent("claude"),
26159                mode: SessionMode::Pty,
26160                state: ManagedSessionState::IdentityPending,
26161                workspace_id: secondary.workspace_id,
26162                canonical_root: opaque_windows_path(secondary.canonical_root),
26163                provider_session: None,
26164                active_session: Some(address.clone()),
26165                environment_profile: None,
26166                bundle: None,
26167                context_id: None,
26168                context: None,
26169                exported_context: None,
26170                task_binding: None,
26171                created_at_unix_ms: 2,
26172                updated_at_unix_ms: 2,
26173                last_error: None,
26174            })
26175            .unwrap();
26176        shared.bind_managed_session(
26177            &address,
26178            current_id.clone(),
26179            ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26180            None,
26181        );
26182        let event = ControlEvent {
26183            sequence: 1,
26184            command_id: None,
26185            instance_id: address.session.instance_id,
26186            generation: address.session.generation,
26187            event: ControlEventKind::ProviderEvent {
26188                sequence: 1,
26189                source: gate4agent_types::ProviderSource {
26190                    family: gate4agent_types::AdapterFamily::PtySemantic,
26191                    binding: gate4agent_types::AdapterBinding::new(
26192                        gate4agent_types::AdapterId::new("claude").unwrap(),
26193                        "fixture/v1",
26194                        gate4agent_types::AdapterVerification::SyntheticFixture,
26195                    )
26196                    .unwrap(),
26197                },
26198                source_sequence: 1,
26199                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26200                    identity,
26201                },
26202            },
26203        };
26204        shared.reconcile_managed_record(&current_id, &address, &event, true, false);
26205
26206        let binding = shared
26207            .session_bindings
26208            .lock()
26209            .unwrap_or_else(|poisoned| poisoned.into_inner())
26210            .get(&address.session.instance_id)
26211            .cloned()
26212            .unwrap();
26213        assert_eq!(binding.record_id.as_ref(), Some(&current_id));
26214        assert_eq!(shared.record(&dormant_id).unwrap().state, ManagedSessionState::Dormant);
26215        let current = shared.record(&current_id).unwrap();
26216        assert_eq!(current.state, ManagedSessionState::Unavailable);
26217        assert_eq!(current.active_session.as_ref(), Some(&address));
26218        assert_eq!(
26219            current.last_error.as_deref(),
26220            Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR),
26221        );
26222        std::fs::remove_dir_all(secondary_root).unwrap();
26223    }
26224
26225    #[tokio::test]
26226    async fn semantic_identity_with_transcript_path_reuses_record_and_survives_reload() {
26227        let root = temporary_workspace_root("semantic-provider-session");
26228        std::fs::create_dir_all(&root).unwrap();
26229        let state_path = root.join("state-v1.json");
26230        let catalog = active_registry().unwrap();
26231        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26232        let workspace = WorkspaceConfig::new(
26233            WorkspaceId::new("primary").unwrap(),
26234            &root,
26235        )
26236        .unwrap();
26237        let node_id = NodeId::new("node-semantic-identity").unwrap();
26238        let shared = NodeShared::new_with_incarnation(
26239            handle,
26240            "fixture-token".to_owned(),
26241            node_id.clone(),
26242            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26243            vec![workspace.clone()],
26244            vec![agent("claude")],
26245            ProviderRuntimeStatuses::default(),
26246            None,
26247            Vec::new(),
26248            Vec::new(),
26249            SpawnProfileRegistry::default(),
26250            None,
26251            None,
26252            None,
26253            Some(state_path.clone()),
26254            Vec::new(),
26255            Vec::new(),
26256            Vec::new(),
26257            Vec::new(),
26258            None,
26259            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
26260        );
26261        let persisted_identity = gate4agent_types::ProviderSessionIdentity {
26262            key: gate4agent_types::ProviderSessionKey::SessionId,
26263            id: "provider-session".to_owned(),
26264            transcript_path: None,
26265        };
26266        let durable_id = SessionRecordId::new("sr-durable").unwrap();
26267        shared
26268            .insert_record(ManagedSessionRecord {
26269                record_id: durable_id.clone(),
26270                display_name: "durable".to_owned(),
26271                provider: agent("claude"),
26272                mode: SessionMode::Pty,
26273                state: ManagedSessionState::Dormant,
26274                workspace_id: workspace.workspace_id.clone(),
26275                canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26276                provider_session: Some(persisted_identity.clone()),
26277                active_session: None,
26278                environment_profile: None,
26279                bundle: None,
26280                context_id: None,
26281                context: None,
26282                exported_context: None,
26283                task_binding: None,
26284                created_at_unix_ms: 1,
26285                updated_at_unix_ms: 1,
26286                last_error: None,
26287            })
26288            .unwrap();
26289        let address = SessionAddress {
26290            workspace_id: workspace.workspace_id.clone(),
26291            session: SessionKey {
26292                instance_id: AgentInstanceId(92),
26293                generation: SessionGeneration(2),
26294            },
26295        };
26296        let pending_id = SessionRecordId::new("sr-pending").unwrap();
26297        shared
26298            .insert_record(ManagedSessionRecord {
26299                record_id: pending_id.clone(),
26300                display_name: "pending".to_owned(),
26301                provider: agent("claude"),
26302                mode: SessionMode::Pty,
26303                state: ManagedSessionState::IdentityPending,
26304                workspace_id: workspace.workspace_id.clone(),
26305                canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26306                provider_session: None,
26307                active_session: Some(address.clone()),
26308                environment_profile: None,
26309                bundle: None,
26310                context_id: None,
26311                context: None,
26312                exported_context: None,
26313                task_binding: None,
26314                created_at_unix_ms: 2,
26315                updated_at_unix_ms: 2,
26316                last_error: None,
26317            })
26318            .unwrap();
26319        shared.bind_managed_session(
26320            &address,
26321            pending_id.clone(),
26322            ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26323            None,
26324        );
26325        let observed_identity = gate4agent_types::ProviderSessionIdentity {
26326            transcript_path: Some(r"C:\private\provider-transcript.jsonl".to_owned()),
26327            ..persisted_identity
26328        };
26329        let event = ControlEvent {
26330            sequence: 1,
26331            command_id: None,
26332            instance_id: address.session.instance_id,
26333            generation: address.session.generation,
26334            event: ControlEventKind::ProviderEvent {
26335                sequence: 1,
26336                source: gate4agent_types::ProviderSource {
26337                    family: gate4agent_types::AdapterFamily::PtySemantic,
26338                    binding: gate4agent_types::AdapterBinding::new(
26339                        gate4agent_types::AdapterId::new("claude").unwrap(),
26340                        "fixture/v1",
26341                        gate4agent_types::AdapterVerification::SyntheticFixture,
26342                    )
26343                    .unwrap(),
26344                },
26345                source_sequence: 1,
26346                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26347                    identity: observed_identity.clone(),
26348                },
26349            },
26350        };
26351        shared.reconcile_managed_record(&pending_id, &address, &event, true, false);
26352
26353        let snapshot = shared.snapshot();
26354        assert_eq!(snapshot.session_records.len(), 1);
26355        let live = &snapshot.session_records[0];
26356        assert_eq!(live.record_id, durable_id);
26357        assert_eq!(live.state, ManagedSessionState::Live);
26358        assert_eq!(live.active_session.as_ref(), Some(&address));
26359        assert_eq!(live.provider_session.as_ref(), Some(&observed_identity));
26360        let binding = shared
26361            .session_bindings
26362            .lock()
26363            .unwrap_or_else(|poisoned| poisoned.into_inner())
26364            .get(&address.session.instance_id)
26365            .cloned()
26366            .unwrap();
26367        assert_eq!(binding.record_id.as_ref(), Some(&durable_id));
26368
26369        let mut duplicate = live.clone();
26370        duplicate.record_id = SessionRecordId::new("sr-duplicate").unwrap();
26371        duplicate.display_name = "duplicate".to_owned();
26372        duplicate.state = ManagedSessionState::Dormant;
26373        duplicate.active_session = None;
26374        duplicate.provider_session.as_mut().unwrap().transcript_path = None;
26375        let duplicate_error = shared.insert_record(duplicate).unwrap_err();
26376        assert_eq!(duplicate_error.code, NodeFailureCode::SessionRecordConflict);
26377        assert_eq!(shared.snapshot().session_records.len(), 1);
26378
26379        let terminal_size = gate4agent_types::TerminalSize {
26380            rows: 24,
26381            columns: 80,
26382        };
26383        let (first_busy, second_busy) = tokio::join!(
26384            shared.resume_session_record(&durable_id, terminal_size, None),
26385            shared.resume_session_record(&durable_id, terminal_size, None),
26386        );
26387        for busy in [first_busy.unwrap_err(), second_busy.unwrap_err()] {
26388            assert_eq!(busy.code, NodeFailureCode::SessionRecordBusy);
26389            assert_eq!(busy.message, "session-record-busy");
26390        }
26391
26392        let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
26393        assert_eq!(loaded.records.len(), 1);
26394        assert_eq!(loaded.records[0].record_id, durable_id);
26395        assert_eq!(loaded.records[0].state, ManagedSessionState::Dormant);
26396        assert_eq!(
26397            loaded.records[0]
26398                .provider_session
26399                .as_ref()
26400                .unwrap()
26401                .transcript_path,
26402            None,
26403        );
26404        let catalog = active_registry().unwrap();
26405        let (reloaded_handle, _reloaded_runtime) =
26406            NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26407        let reloaded = NodeShared::new_with_incarnation(
26408            reloaded_handle,
26409            "fixture-token".to_owned(),
26410            node_id,
26411            NodeIncarnationId::from_bytes([1; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26412            vec![workspace],
26413            vec![agent("claude")],
26414            ProviderRuntimeStatuses::default(),
26415            None,
26416            Vec::new(),
26417            Vec::new(),
26418            SpawnProfileRegistry::default(),
26419            None,
26420            None,
26421            None,
26422            None,
26423            loaded.records,
26424            loaded.managed_worktrees,
26425            loaded.managed_worktree_tombstones,
26426            loaded.materializations,
26427            loaded.warning,
26428            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
26429        );
26430        assert_eq!(reloaded.snapshot().session_records.len(), 1);
26431        assert_eq!(reloaded.snapshot().session_records[0].record_id, durable_id);
26432        std::fs::remove_dir_all(root).unwrap();
26433    }
26434
26435    #[tokio::test]
26436    async fn workspace_reregistration_requires_the_recorded_root_and_restores_records() {
26437        let secondary_root = temporary_workspace_root("workspace-restore");
26438        let other_root = temporary_workspace_root("workspace-conflict");
26439        std::fs::create_dir_all(&secondary_root).unwrap();
26440        std::fs::create_dir_all(&other_root).unwrap();
26441        let catalog = active_registry().unwrap();
26442        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26443        let primary = WorkspaceConfig::new(
26444            WorkspaceId::new("primary").unwrap(),
26445            std::env::current_dir().unwrap(),
26446        )
26447        .unwrap();
26448        let secondary = WorkspaceConfig::new(
26449            WorkspaceId::new("secondary").unwrap(),
26450            &secondary_root,
26451        )
26452        .unwrap();
26453        let shared = NodeShared::new(
26454            handle,
26455            "fixture-token".to_owned(),
26456            NodeId::new("node-1").unwrap(),
26457            vec![primary, secondary.clone()],
26458            vec![agent("claude")],
26459        );
26460        let record_id = SessionRecordId::new("sr-workspace").unwrap();
26461        shared
26462            .insert_record(ManagedSessionRecord {
26463                record_id: record_id.clone(),
26464                display_name: "workspace record".to_owned(),
26465                provider: agent("claude"),
26466                mode: SessionMode::Pty,
26467                state: ManagedSessionState::Dormant,
26468                workspace_id: secondary.workspace_id.clone(),
26469                canonical_root: opaque_windows_path(secondary.canonical_root.clone()),
26470                provider_session: Some(gate4agent_types::ProviderSessionIdentity {
26471                    key: gate4agent_types::ProviderSessionKey::SessionId,
26472                    id: "provider-session".to_owned(),
26473                    transcript_path: None,
26474                }),
26475                active_session: None,
26476                environment_profile: None,
26477                bundle: None,
26478                context_id: None,
26479                context: None,
26480                exported_context: None,
26481                task_binding: None,
26482                created_at_unix_ms: 1,
26483                updated_at_unix_ms: 1,
26484                last_error: None,
26485            })
26486            .unwrap();
26487        shared.unregister_workspace(&secondary.workspace_id).unwrap();
26488        let unavailable = shared.record(&record_id).unwrap();
26489        assert_eq!(unavailable.state, ManagedSessionState::Unavailable);
26490        assert_eq!(windows_path_text(&unavailable.canonical_root), secondary.canonical_root);
26491
26492        let conflict = shared
26493            .register_workspace(
26494                secondary.workspace_id.clone(),
26495                other_root.to_string_lossy().into_owned(),
26496            )
26497            .await
26498            .unwrap_err();
26499        assert_eq!(conflict.code, NodeFailureCode::SessionRecordConflict);
26500        shared
26501            .register_workspace(
26502                secondary.workspace_id,
26503                secondary_root.to_string_lossy().into_owned(),
26504            )
26505            .await
26506            .unwrap();
26507        let restored = shared.record(&record_id).unwrap();
26508        assert_eq!(restored.state, ManagedSessionState::Dormant);
26509        assert!(restored.last_error.is_none());
26510        std::fs::remove_dir_all(secondary_root).unwrap();
26511        std::fs::remove_dir_all(other_root).unwrap();
26512    }
26513
26514    #[test]
26515    fn dynamic_workspace_root_is_rejected_before_filesystem_canonicalization() {
26516        let error = validate_workspace_request_root(
26517            &"x".repeat(MAX_WORKSPACE_ROOT_BYTES + 1),
26518        )
26519        .unwrap_err();
26520        assert_eq!(error.code, NodeFailureCode::InvalidWorkspaceRoot);
26521        let control = validate_workspace_request_root("C:\\repo\nchild").unwrap_err();
26522        assert_eq!(control.code, NodeFailureCode::InvalidWorkspaceRoot);
26523    }
26524
26525    #[test]
26526    fn concurrent_publish_and_resync_never_acknowledge_an_unappended_event() {
26527        let catalog = active_registry().unwrap();
26528        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26529        let workspace = WorkspaceConfig::new(
26530            WorkspaceId::new("primary").unwrap(),
26531            std::env::current_dir().unwrap(),
26532        )
26533        .unwrap();
26534        let shared = NodeShared::new(
26535            handle,
26536            "fixture-token".to_owned(),
26537            NodeId::new("node-1").unwrap(),
26538            vec![workspace],
26539            vec![agent("claude")],
26540        );
26541        let publishers = 4_u64;
26542        let events_per_publisher = 256_u64;
26543        let start = std::sync::Barrier::new(publishers as usize + 1);
26544        let finished = AtomicU64::new(0);
26545
26546        std::thread::scope(|scope| {
26547            for _ in 0..publishers {
26548                scope.spawn(|| {
26549                    start.wait();
26550                    for _ in 0..events_per_publisher {
26551                        shared.publish(NodeEvent::ControllerChanged { controller: None });
26552                    }
26553                    finished.fetch_add(1, Ordering::Release);
26554                });
26555            }
26556            start.wait();
26557            while finished.load(Ordering::Acquire) != publishers {
26558                assert_resync_history_is_linearized(shared.resync(0));
26559                std::thread::yield_now();
26560            }
26561        });
26562        let response = shared.resync(0);
26563        assert_resync_history_is_linearized(response.clone());
26564        let NodeResponse::Resync { event_sequence, events, .. } = response else {
26565            unreachable!("resync helper returned another response");
26566        };
26567        assert_eq!(event_sequence, publishers * events_per_publisher);
26568        assert_eq!(events.len() as u64, event_sequence);
26569    }
26570
26571    fn assert_resync_history_is_linearized(response: NodeResponse) {
26572        let NodeResponse::Resync { event_sequence, events, .. } = response else {
26573            unreachable!("resync helper returned another response");
26574        };
26575        assert!(events.windows(2).all(|pair| {
26576            pair[0].sequence.checked_add(1) == Some(pair[1].sequence)
26577        }));
26578        assert_eq!(events.last().map_or(0, |event| event.sequence), event_sequence);
26579    }
26580
26581    #[test]
26582    fn canonical_workspace_roots_are_safe_for_cmd_exe() {
26583        assert_eq!(
26584            platform::normalize_canonical_root(r"\\?\C:\repo\worktree".to_owned()),
26585            r"C:\repo\worktree",
26586        );
26587        assert_eq!(
26588            platform::normalize_canonical_root(r"\\?\UNC\server\share\repo".to_owned()),
26589            r"\\server\share\repo",
26590        );
26591        assert!(validate_workspace_root(
26592            &WorkspaceId::new("network").unwrap(),
26593            r"\\server\share\repo",
26594        )
26595        .is_err());
26596        let workspace = WorkspaceConfig::new(
26597            WorkspaceId::new("primary").unwrap(),
26598            std::env::current_dir().unwrap(),
26599        )
26600        .unwrap();
26601        assert!(!workspace.canonical_root.starts_with(r"\\?\"));
26602    }
26603
26604    #[test]
26605    fn resume_rebinds_only_on_the_expected_authorized_generation() {
26606        let catalog = active_registry().unwrap();
26607        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26608        let workspace_id = WorkspaceId::new("primary").unwrap();
26609        let workspace = WorkspaceConfig::new(
26610            workspace_id.clone(),
26611            std::env::current_dir().unwrap(),
26612        )
26613        .unwrap();
26614        let shared = NodeShared::new(
26615            handle,
26616            "fixture-token".to_owned(),
26617            NodeId::new("node-1").unwrap(),
26618            vec![workspace],
26619            vec![agent("claude")],
26620        );
26621        let instance_id = AgentInstanceId(17);
26622        let original = SessionAddress {
26623            workspace_id,
26624            session: SessionKey {
26625                instance_id,
26626                generation: SessionGeneration(3),
26627            },
26628        };
26629        shared.bind_session(
26630            instance_id,
26631            original.workspace_id.clone(),
26632            original.session.generation,
26633        );
26634        shared
26635            .arm_resume(
26636                &original,
26637                CommandId(41),
26638                ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26639            )
26640            .unwrap();
26641
26642        shared.publish_control(ControlEvent {
26643            sequence: 1,
26644            command_id: None,
26645            instance_id,
26646            generation: SessionGeneration(4),
26647            event: ControlEventKind::Running { process_id: None },
26648        });
26649        assert_eq!(
26650            shared.address_for(instance_id, SessionGeneration(3)),
26651            Some(original.clone()),
26652        );
26653        assert!(shared.address_for(instance_id, SessionGeneration(4)).is_none());
26654
26655        shared.publish_control(ControlEvent {
26656            sequence: 2,
26657            command_id: Some(CommandId(42)),
26658            instance_id,
26659            generation: SessionGeneration(3),
26660            event: ControlEventKind::CommandRejected {
26661                message: "unrelated command".to_owned(),
26662            },
26663        });
26664
26665        shared.publish_control(ControlEvent {
26666            sequence: 3,
26667            command_id: None,
26668            instance_id,
26669            generation: SessionGeneration(4),
26670            event: ControlEventKind::ResumeAuthorized {
26671                session: gate4agent_types::ResumeSessionSummary {
26672                    key: gate4agent_types::ProviderSessionKey::SessionId,
26673                    id: "fixture-session".to_owned(),
26674                },
26675            },
26676        });
26677        assert!(shared.address_for(instance_id, SessionGeneration(3)).is_none());
26678        let rebound = shared.address_for(instance_id, SessionGeneration(4)).unwrap();
26679        assert_eq!(rebound.workspace_id, original.workspace_id);
26680        let history = shared
26681            .history
26682            .lock()
26683            .unwrap_or_else(|poisoned| poisoned.into_inner());
26684        assert!(history.events.iter().any(|envelope| matches!(
26685            &envelope.event,
26686            NodeEvent::Control { address, event }
26687                if address == &rebound
26688                    && matches!(&event.event, ControlEventKind::ResumeAuthorized { .. })
26689        )));
26690    }
26691
26692    #[test]
26693    fn all_node_pty_prompts_use_bracketed_paste_framing() {
26694        for agent in ["claude", "codex", "kimi"] {
26695            assert_eq!(
26696                prompt_framing(&AgentId::new(agent).unwrap()),
26697                PromptFraming::BracketedPaste
26698            );
26699        }
26700    }
26701
26702    fn generous_walk_deadline() -> Instant {
26703        Instant::now() + Duration::from_secs(30)
26704    }
26705
26706    #[test]
26707    fn workspace_tree_is_relative_and_skips_heavy_directories() {
26708        let root = temporary_workspace_root("tree");
26709        std::fs::create_dir_all(root.join("src")).unwrap();
26710        std::fs::create_dir_all(root.join(".git/objects")).unwrap();
26711        std::fs::create_dir_all(root.join("target/debug")).unwrap();
26712        std::fs::create_dir_all(root.join("node_modules/package")).unwrap();
26713        std::fs::write(root.join("src/lib.rs"), b"pub fn fixture() {}\n").unwrap();
26714        std::fs::write(root.join("README.md"), b"fixture\n").unwrap();
26715        // A renamed/agent-scoped build directory (`target-a2`-style, per the
26716        // real repository's own build layout) that carries the Cache
26717        // Directory Tagging Specification marker cargo writes into every
26718        // `--target-dir` it creates: must be skipped even though its name
26719        // does not exactly match `target`.
26720        std::fs::create_dir_all(root.join("target-a2/debug")).unwrap();
26721        std::fs::write(root.join("target-a2/CACHEDIR.TAG"), b"Signature: 8a477f597d28d172789f06886806bc55\n").unwrap();
26722        // A directory that merely *starts with* `target` but carries no
26723        // CACHEDIR.TAG: the new prefix-adjacent check must not false-match
26724        // on the name alone, so this stays in the response.
26725        std::fs::create_dir_all(root.join("target-notes")).unwrap();
26726        std::fs::write(root.join("target-notes/plan.md"), b"not a build directory\n").unwrap();
26727
26728        let (entries, truncated, _budget) =
26729            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
26730        assert!(!truncated);
26731        assert!(entries.iter().any(|entry| {
26732            entry.relative_path.as_utf8() == Some("src")
26733                && entry.kind == WorkspaceEntryKind::Directory
26734        }));
26735        assert!(entries.iter().any(|entry| {
26736            entry.relative_path.as_utf8() == Some("src/lib.rs")
26737                && entry.kind == WorkspaceEntryKind::File
26738        }));
26739        assert!(entries.iter().any(|entry| {
26740            entry.relative_path.as_utf8() == Some("target-notes")
26741                && entry.kind == WorkspaceEntryKind::Directory
26742        }));
26743        assert!(entries.iter().any(|entry| {
26744            entry.relative_path.as_utf8() == Some("target-notes/plan.md")
26745                && entry.kind == WorkspaceEntryKind::File
26746        }));
26747        assert!(entries.iter().all(|entry| {
26748            let path = entry.relative_path.as_utf8().unwrap();
26749            !path.starts_with(".git")
26750                && !(path == "target" || path.starts_with("target/"))
26751                && !(path == "target-a2" || path.starts_with("target-a2/"))
26752                && !path.starts_with("node_modules")
26753        }));
26754
26755        std::fs::remove_dir_all(&root).unwrap();
26756    }
26757
26758    #[test]
26759    fn workspace_walk_stops_at_its_entry_cap_and_marks_a_partial_result() {
26760        let root = temporary_workspace_root("tree-entry-cap");
26761        std::fs::create_dir_all(&root).unwrap();
26762        for index in 0..20 {
26763            std::fs::write(root.join(format!("file-{index:02}.txt")), b"fixture\n").unwrap();
26764        }
26765
26766        let (entries, truncated, budget) =
26767            collect_workspace_entries(&root, generous_walk_deadline(), 5);
26768        assert!(truncated);
26769        assert!(budget.entry_cap_exceeded);
26770        assert!(!budget.time_budget_exceeded);
26771        assert!(entries.len() <= 5);
26772
26773        let inspection_truncation = workspace_inspection_truncation(&budget, false, Duration::from_millis(1))
26774            .expect("an entry-cap-truncated walk must report a truncation marker");
26775        assert!(inspection_truncation.walk_entry_cap_exceeded);
26776        assert!(!inspection_truncation.walk_time_budget_exceeded);
26777        assert!(!inspection_truncation.git_time_budget_exceeded);
26778        assert!(inspection_truncation.entries_visited >= 5);
26779
26780        std::fs::remove_dir_all(&root).unwrap();
26781    }
26782
26783    #[test]
26784    fn workspace_walk_within_budget_reports_no_truncation() {
26785        let root = temporary_workspace_root("tree-no-truncation");
26786        std::fs::create_dir_all(&root).unwrap();
26787        std::fs::write(root.join("only-file.txt"), b"fixture\n").unwrap();
26788
26789        let (_entries, truncated, budget) =
26790            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
26791        assert!(!truncated);
26792        assert!(workspace_inspection_truncation(&budget, false, Duration::from_millis(1)).is_none());
26793
26794        std::fs::remove_dir_all(&root).unwrap();
26795    }
26796
26797    #[test]
26798    fn workspace_walk_emits_strict_global_byte_order_for_prefix_named_siblings() {
26799        let root = temporary_workspace_root("tree-prefix-siblings");
26800        std::fs::create_dir_all(root.join("gate4agent-c2/src")).unwrap();
26801        std::fs::write(root.join("gate4agent-c2/src/lib.rs"), b"fixture\n").unwrap();
26802        std::fs::create_dir_all(root.join("gate4agent-c2-client")).unwrap();
26803        std::fs::write(root.join("gate4agent-c2-client/lib.rs"), b"fixture\n").unwrap();
26804
26805        let (entries, truncated, _budget) =
26806            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
26807        assert!(!truncated);
26808        let paths = entries
26809            .iter()
26810            .map(|entry| entry.relative_path.as_utf8().unwrap().to_owned())
26811            .collect::<Vec<_>>();
26812        assert!(paths.windows(2).all(|pair| pair[0] < pair[1]));
26813        assert_eq!(
26814            paths,
26815            vec![
26816                "gate4agent-c2".to_owned(),
26817                "gate4agent-c2-client".to_owned(),
26818                "gate4agent-c2-client/lib.rs".to_owned(),
26819                "gate4agent-c2/src".to_owned(),
26820                "gate4agent-c2/src/lib.rs".to_owned(),
26821            ],
26822        );
26823
26824        std::fs::remove_dir_all(&root).unwrap();
26825    }
26826
26827    #[test]
26828    fn git_parsers_cap_status_and_commit_results() {
26829        let mut status_output = Vec::new();
26830        for index in 0..=GIT_STATUS_MAX_ENTRIES {
26831            status_output.extend_from_slice(format!(" M src/file-{index}.rs\0").as_bytes());
26832        }
26833        let mut snapshot = git_snapshot_for_parser();
26834        parse_git_status(&status_output, &mut snapshot);
26835        assert_eq!(snapshot.status.len(), GIT_STATUS_MAX_ENTRIES);
26836        assert!(snapshot.truncated);
26837        assert_eq!(snapshot.status[0].index_status, " ");
26838        assert_eq!(snapshot.status[0].worktree_status, "M");
26839
26840        snapshot.truncated = false;
26841        let commit_output = (0..=GIT_COMMIT_MAX_ENTRIES)
26842            .map(|index| format!("abc{index}\u{1f}commit {index}\n"))
26843            .collect::<String>();
26844        parse_git_commits(commit_output.as_bytes(), &mut snapshot);
26845        assert_eq!(snapshot.recent_commits.len(), GIT_COMMIT_MAX_ENTRIES);
26846        assert!(snapshot.truncated);
26847    }
26848
26849    #[test]
26850    fn porcelain_v1_z_parser_preserves_current_then_previous_rename_and_copy_order() {
26851        let mut snapshot = git_snapshot_for_parser();
26852        parse_git_status(
26853            b"R  src/current name.rs\0src/previous name.rs\0 C src/copy.rs\0src/original.rs\0?? src/line\nname.rs\0",
26854            &mut snapshot,
26855        );
26856
26857        assert!(!snapshot.truncated);
26858        assert_eq!(snapshot.status.len(), 3);
26859        assert_eq!(snapshot.status[0].index_status, "R");
26860        assert_eq!(snapshot.status[0].worktree_status, " ");
26861        assert_eq!(snapshot.status[0].path.as_utf8(), Some("src/current name.rs"));
26862        assert_eq!(
26863            snapshot.status[0]
26864                .previous_path
26865                .as_ref()
26866                .and_then(RepositoryPath::as_utf8),
26867            Some("src/previous name.rs"),
26868        );
26869        assert_eq!(snapshot.status[1].index_status, " ");
26870        assert_eq!(snapshot.status[1].worktree_status, "C");
26871        assert_eq!(snapshot.status[1].path.as_utf8(), Some("src/copy.rs"));
26872        assert_eq!(
26873            snapshot.status[1]
26874                .previous_path
26875                .as_ref()
26876                .and_then(RepositoryPath::as_utf8),
26877            Some("src/original.rs"),
26878        );
26879        assert_eq!(snapshot.status[2].path.as_utf8(), Some("src/line\nname.rs"));
26880        assert!(snapshot.status[2].previous_path.is_none());
26881    }
26882
26883    #[test]
26884    fn porcelain_v1_z_parser_never_invents_invalid_or_incomplete_path_identity() {
26885        let mut snapshot = git_snapshot_for_parser();
26886        parse_git_status(
26887            b"?? valid.rs\0?? invalid-\xff.rs\0?? invalid\\windows.rs\0R  current.rs\0",
26888            &mut snapshot,
26889        );
26890
26891        assert!(snapshot.truncated);
26892        assert_eq!(snapshot.status.len(), 1);
26893        assert_eq!(snapshot.status[0].path.as_utf8(), Some("valid.rs"));
26894        assert!(snapshot.status[0].previous_path.is_none());
26895    }
26896
26897    #[tokio::test]
26898    async fn observer_can_browse_host_directories_without_controller() {
26899        let root = temporary_workspace_root("host-directory-observer");
26900        std::fs::create_dir_all(root.join("directory-b")).unwrap();
26901        std::fs::create_dir_all(root.join("directory-a")).unwrap();
26902        std::fs::write(root.join("ordinary-file"), b"not a directory").unwrap();
26903        let workspace_id = WorkspaceId::new("primary").unwrap();
26904        let workspace = WorkspaceConfig::new(workspace_id, &root).unwrap();
26905        let catalog = active_registry().unwrap();
26906        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26907        let shared = NodeShared::new(
26908            handle,
26909            "fixture-token".to_owned(),
26910            NodeId::new("node-1").unwrap(),
26911            vec![workspace],
26912            vec![agent("claude")],
26913        );
26914        let request = NodeRequest::BrowseHostDirectories {
26915            directory: Some(OpaqueHostPath::utf8(root.to_string_lossy().into_owned()).unwrap()),
26916            after: None,
26917        };
26918        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
26919            capability.as_str() == CAPABILITY_HOST_DIRECTORY_BROWSE_V1
26920        }));
26921        assert!(!request_uses_unnegotiated_capability(
26922            &request,
26923            &baseline_capabilities().unwrap(),
26924        ));
26925        assert!(request_uses_unnegotiated_capability(&request, &[]));
26926
26927        let response = process_request_inner(&shared, 77, ClientRole::Observer, request)
26928            .await
26929            .unwrap();
26930        let NodeResponse::HostDirectoriesBrowsed { listing } = response else {
26931            panic!("host directory browse returned another response");
26932        };
26933        assert_eq!(
26934            listing
26935                .entries
26936                .iter()
26937                .map(|entry| entry.display_name.as_str())
26938                .collect::<Vec<_>>(),
26939            vec!["directory-a", "directory-b"],
26940        );
26941        assert!(listing.entries.iter().all(|entry| !entry.is_link));
26942        assert!(!listing.incomplete);
26943        std::fs::remove_dir_all(root).unwrap();
26944    }
26945
26946    #[tokio::test]
26947    async fn observer_can_inspect_only_a_registered_workspace() {
26948        let root = temporary_workspace_root("observer");
26949        std::fs::create_dir_all(root.join("src")).unwrap();
26950        std::fs::write(root.join("src/main.rs"), b"fn main() {}\n").unwrap();
26951        let workspace_id = WorkspaceId::new("primary").unwrap();
26952        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
26953        let catalog = active_registry().unwrap();
26954        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26955        let shared = NodeShared::new(
26956            handle,
26957            "fixture-token".to_owned(),
26958            NodeId::new("node-1").unwrap(),
26959            vec![workspace],
26960            vec![agent("claude")],
26961        );
26962
26963        let response = process_request_inner(
26964            &shared,
26965            77,
26966            ClientRole::Observer,
26967            NodeRequest::InspectWorkspace {
26968                workspace_id: workspace_id.clone(),
26969            },
26970        )
26971        .await
26972        .unwrap();
26973        let NodeResponse::WorkspaceInspected { inspection } = response else {
26974            panic!("workspace inspection returned another response");
26975        };
26976        assert_eq!(inspection.workspace_id, workspace_id);
26977        assert!(inspection.entries.iter().any(|entry| {
26978            entry.relative_path.as_utf8() == Some("src/main.rs")
26979        }));
26980
26981        let unknown = process_request_inner(
26982            &shared,
26983            77,
26984            ClientRole::Observer,
26985            NodeRequest::InspectWorkspace {
26986                workspace_id: WorkspaceId::new("unknown").unwrap(),
26987            },
26988        )
26989        .await
26990        .unwrap_err();
26991        assert_eq!(unknown.code, NodeFailureCode::UnknownWorkspace);
26992        std::fs::remove_dir_all(&root).unwrap();
26993    }
26994
26995    #[test]
26996    fn context_pack_source_accepts_only_running_or_successful_exit() {
26997        assert!(context_pack_source_status_is_usable(&SessionStatus::Running));
26998        assert!(context_pack_source_status_is_usable(&SessionStatus::Exited {
26999            exit_code: Some(0),
27000        }));
27001        assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27002            exit_code: Some(1),
27003        }));
27004        assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27005            exit_code: None,
27006        }));
27007        assert!(!context_pack_source_status_is_usable(&SessionStatus::Failed {
27008            message: "provider failed".to_owned(),
27009        }));
27010        assert!(!context_pack_source_status_is_usable(&SessionStatus::Registered));
27011        assert!(!context_pack_source_status_is_usable(&SessionStatus::Starting));
27012        assert!(!context_pack_source_status_is_usable(&SessionStatus::Stopping));
27013    }
27014
27015    #[test]
27016    fn context_pack_repository_rejects_changed_head_with_same_git_snapshot() {
27017        let git = git_snapshot_for_parser();
27018        let first = ContextPackRepositoryObservation {
27019            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27020            git: git.clone(),
27021            files: Vec::new(),
27022        };
27023        let second = ContextPackRepositoryObservation {
27024            head: ContextPackRepositoryHead::Commit("b".repeat(40)),
27025            git,
27026            files: Vec::new(),
27027        };
27028
27029        let error = stable_context_pack_repository(first, second).unwrap_err();
27030
27031        assert_eq!(
27032            error.code,
27033            NodeFailureCode::ContextPackMaterializationFailed,
27034        );
27035    }
27036
27037    #[test]
27038    fn context_pack_repository_rejects_changed_file_with_same_git_snapshot() {
27039        let git = git_snapshot_for_parser();
27040        let first = ContextPackRepositoryObservation {
27041            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27042            git: git.clone(),
27043            files: vec![ContextPackRepositoryFileSource::utf8(
27044                "README.md",
27045                "first\n".to_owned(),
27046                6,
27047            )],
27048        };
27049        let second = ContextPackRepositoryObservation {
27050            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27051            git,
27052            files: vec![ContextPackRepositoryFileSource::utf8(
27053                "README.md",
27054                "other\n".to_owned(),
27055                6,
27056            )],
27057        };
27058
27059        let error = stable_context_pack_repository(first, second).unwrap_err();
27060
27061        assert_eq!(
27062            error.code,
27063            NodeFailureCode::ContextPackMaterializationFailed,
27064        );
27065    }
27066
27067    #[tokio::test]
27068    async fn native_session_catalog_zero_active_session_fixture() {
27069        let workspace_root = temporary_workspace_root("native-catalog-workspace");
27070        let history_root = temporary_workspace_root("native-catalog-history");
27071        let outside_root = temporary_workspace_root("native-catalog-outside");
27072        std::fs::create_dir_all(&workspace_root).unwrap();
27073        std::fs::create_dir_all(&history_root).unwrap();
27074        std::fs::create_dir_all(&outside_root).unwrap();
27075        let transcript = |session_id: &str, cwd: &Path, title: &str| {
27076            [
27077                serde_json::json!({
27078                    "type": "user",
27079                    "sessionId": session_id,
27080                    "cwd": cwd.to_str().unwrap(),
27081                    "message": { "content": "raw user text must not cross the catalog wire" }
27082                }),
27083                serde_json::json!({
27084                    "type": "assistant",
27085                    "sessionId": session_id,
27086                    "cwd": cwd.to_str().unwrap(),
27087                    "message": { "model": "claude-sonnet", "content": "raw answer" }
27088                }),
27089                serde_json::json!({
27090                    "type": "ai-title",
27091                    "sessionId": session_id,
27092                    "aiTitle": title
27093                }),
27094            ]
27095            .into_iter()
27096            .map(|record| record.to_string())
27097            .collect::<Vec<_>>()
27098            .join("\n")
27099        };
27100        std::fs::write(
27101            history_root.join("stable-session.jsonl"),
27102            transcript("stable-session", &workspace_root, "Workspace review"),
27103        )
27104        .unwrap();
27105        std::fs::write(
27106            history_root.join("outside-session.jsonl"),
27107            transcript("outside-session", &outside_root, "Outside review"),
27108        )
27109        .unwrap();
27110        let history = NativeHistoryConfig::new(vec![
27111            NativeHistoryRoot::new(
27112                gate4agent_types::AdapterId::new("claude-code").unwrap(),
27113                HistorySourceLayout::SingleNdjson,
27114                history_root.clone(),
27115            )
27116            .unwrap(),
27117        ])
27118        .unwrap();
27119        let catalog = active_registry().unwrap();
27120        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27121        assert!(handle.snapshot().sessions.is_empty());
27122        let workspace_id = WorkspaceId::new("primary").unwrap();
27123        let workspace = WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap();
27124        let mut shared = NodeShared::new(
27125            handle,
27126            "fixture-token".to_owned(),
27127            NodeId::new("node-native-catalog").unwrap(),
27128            vec![workspace],
27129            vec![agent("claude")],
27130        );
27131        shared.native_session_catalog = Some(Arc::new(Mutex::new(
27132            NativeSessionCatalogAuthority::new(history),
27133        )));
27134        let route = NativeSessionCatalogRoute::workspace(
27135            workspace_id.clone(),
27136            agent("claude"),
27137        );
27138        let request = NodeRequest::CatalogNativeSessions {
27139            route: route.clone(),
27140            limit: 2,
27141        };
27142        let observer = process_request_inner(&shared, 7, ClientRole::Observer, request.clone())
27143            .await
27144            .unwrap_err();
27145        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27146        let response = process_request_inner(&shared, 7, ClientRole::Operator, request)
27147            .await
27148            .unwrap();
27149        let NodeResponse::NativeSessionsCataloged { entries, summary, .. } = response else {
27150            panic!("native catalog returned a different response");
27151        };
27152        let summary = summary.expect("node must emit native session catalog summary metadata");
27153        assert_eq!(entries.len(), 1);
27154        assert!(gate4agent_types::validate_candidate_id(&entries[0].selection_id).is_ok());
27155        assert_eq!(entries[0].external_group, None);
27156        assert_eq!(entries[0].record_id, None);
27157        assert_eq!(entries[0].title, None);
27158        assert_eq!(entries[0].model, None);
27159        assert_eq!(entries[0].message_count, 0);
27160        let encoded_entries = serde_json::to_string(&entries).unwrap();
27161        assert!(!encoded_entries.contains("stable-session"));
27162        assert!(!encoded_entries.contains(history_root.to_string_lossy().as_ref()));
27163        let page_request = NodeRequest::PageNativeSessions {
27164            route: route.clone(),
27165            window: crate::protocol::NativeSessionCatalogWindow::Recent,
27166            catalog_revision: summary.catalog_revision,
27167            recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27168            after_selection_id: None,
27169            limit: 2,
27170        };
27171        let observer = process_request_inner(
27172            &shared,
27173            7,
27174            ClientRole::Observer,
27175            page_request.clone(),
27176        )
27177        .await
27178        .unwrap_err();
27179        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27180        let page_response = process_request_inner(
27181            &shared,
27182            7,
27183            ClientRole::Operator,
27184            page_request.clone(),
27185        )
27186        .await
27187        .unwrap();
27188        assert!(matches!(
27189            page_response,
27190            NodeResponse::NativeSessionsPaged { page, .. }
27191                if page.entries.len() == 1 && !page.has_more && page.remaining_count == 0
27192        ));
27193        let stale = process_request_inner(
27194            &shared,
27195            7,
27196            ClientRole::Operator,
27197            NodeRequest::PageNativeSessions {
27198                route: route.clone(),
27199                window: crate::protocol::NativeSessionCatalogWindow::Recent,
27200                catalog_revision: summary.catalog_revision.wrapping_add(1),
27201                recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27202                after_selection_id: None,
27203                limit: 2,
27204            },
27205        )
27206        .await
27207        .unwrap_err();
27208        assert_eq!(stale.code, NodeFailureCode::StaleNativeSessionCatalog);
27209        let selection = NativeSessionSelection {
27210            route: route.clone(),
27211            catalog_revision: summary.catalog_revision,
27212            recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27213            selection_id: entries[0].selection_id.clone(),
27214        };
27215        let preview_response = process_request_inner(
27216            &shared,
27217            7,
27218            ClientRole::Operator,
27219            NodeRequest::PreviewNativeSession {
27220                selection: selection.clone(),
27221                message_limit: 1,
27222            },
27223        )
27224        .await
27225        .unwrap();
27226        let NodeResponse::NativeSessionPreviewed { preview, .. } = preview_response else {
27227            panic!("native preview returned a different response");
27228        };
27229        assert_eq!(preview.messages.len(), 1);
27230        assert!(preview.message_count_exact);
27231        assert_eq!(preview.messages[0].text, "raw answer");
27232        assert!(preview.truncated);
27233
27234        let index_request = NodeRequest::IndexNativeSession {
27235            selection: selection.clone(),
27236            display_name: "Saved Claude".to_owned(),
27237        };
27238        let observer = process_request_inner(
27239            &shared,
27240            7,
27241            ClientRole::Observer,
27242            index_request.clone(),
27243        )
27244        .await
27245        .unwrap_err();
27246        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27247        let without_controller = process_request_inner(
27248            &shared,
27249            7,
27250            ClientRole::Operator,
27251            index_request.clone(),
27252        )
27253        .await
27254        .unwrap_err();
27255        assert_eq!(without_controller.code, NodeFailureCode::ControllerRequired);
27256        let acquired = process_request_inner(
27257            &shared,
27258            7,
27259            ClientRole::Operator,
27260            NodeRequest::AcquireController { lease_ms: 5_000 },
27261        )
27262        .await
27263        .unwrap();
27264        assert!(matches!(acquired, NodeResponse::Controller { controller: Some(_) }));
27265        let indexed = process_request_inner(
27266            &shared,
27267            7,
27268            ClientRole::Operator,
27269            index_request,
27270        )
27271        .await
27272        .unwrap();
27273        assert!(indexed.native_session_index_contract_is_valid());
27274        let NodeResponse::NativeSessionIndexed {
27275            selection: echoed_selection,
27276            record,
27277        } = indexed else {
27278            panic!("native session index returned a different response");
27279        };
27280        assert_eq!(echoed_selection, selection);
27281        let recataloged = process_request_inner(
27282            &shared,
27283            7,
27284            ClientRole::Operator,
27285            NodeRequest::CatalogNativeSessions {
27286                route,
27287                limit: 2,
27288            },
27289        )
27290        .await
27291        .unwrap();
27292        let NodeResponse::NativeSessionsCataloged { entries, .. } = recataloged else {
27293            panic!("native catalog returned a different response after indexing");
27294        };
27295        assert_eq!(entries[0].record_id.as_ref(), Some(&record.record_id));
27296
27297        let unregistered_route = NativeSessionCatalogRoute::unregistered(agent("claude"));
27298        let external_catalog = process_request_inner(
27299            &shared,
27300            7,
27301            ClientRole::Operator,
27302            NodeRequest::CatalogNativeSessions {
27303                route: unregistered_route.clone(),
27304                limit: 2,
27305            },
27306        )
27307        .await
27308        .unwrap();
27309        let external_json = serde_json::to_string(&external_catalog).unwrap();
27310        assert!(!external_json.contains("outside-session"));
27311        assert!(!external_json.contains(outside_root.to_string_lossy().as_ref()));
27312        assert!(!external_json.contains(history_root.to_string_lossy().as_ref()));
27313        assert!(!external_json.contains("\"session_id\""));
27314        assert!(!external_json.contains("\"cwd\""));
27315        assert!(!external_json.contains("\"path\""));
27316        let NodeResponse::NativeSessionsCataloged {
27317            route: echoed_route,
27318            entries: external_entries,
27319            summary: Some(external_summary),
27320        } = external_catalog
27321        else {
27322            panic!("unregistered native catalog returned a different response");
27323        };
27324        assert_eq!(echoed_route, unregistered_route);
27325        assert_eq!(external_entries.len(), 1);
27326        assert_eq!(external_entries[0].record_id, None);
27327        let external_group = external_entries[0]
27328            .external_group
27329            .as_ref()
27330            .expect("unregistered session must have an external group");
27331        assert_eq!(
27332            external_group.kind,
27333            gate4agent_types::NativeSessionExternalGroupKind::Project,
27334        );
27335        assert!(external_group.group_id.starts_with("external-"));
27336        assert_eq!(
27337            external_group.display_name,
27338            outside_root.file_name().unwrap().to_string_lossy(),
27339        );
27340        let external_selection = NativeSessionSelection {
27341            route: unregistered_route,
27342            catalog_revision: external_summary.catalog_revision,
27343            recent_cutoff_unix_ms: external_summary.recent_cutoff_unix_ms,
27344            selection_id: external_entries[0].selection_id.clone(),
27345        };
27346        let external_preview = process_request_inner(
27347            &shared,
27348            7,
27349            ClientRole::Operator,
27350            NodeRequest::PreviewNativeSession {
27351                selection: external_selection.clone(),
27352                message_limit: 1,
27353            },
27354        )
27355        .await
27356        .unwrap();
27357        let external_preview_json = serde_json::to_string(&external_preview).unwrap();
27358        assert!(!external_preview_json.contains("outside-session"));
27359        assert!(!external_preview_json.contains(outside_root.to_string_lossy().as_ref()));
27360        assert!(!external_preview_json.contains("\"session_id\""));
27361        let NodeResponse::NativeSessionPreviewed { preview, .. } = external_preview else {
27362            panic!("unregistered native preview returned a different response");
27363        };
27364        assert_eq!(preview.messages.len(), 1);
27365        let external_index = process_request_inner(
27366            &shared,
27367            7,
27368            ClientRole::Operator,
27369            NodeRequest::IndexNativeSession {
27370                selection: external_selection,
27371                display_name: "External Claude".to_owned(),
27372            },
27373        )
27374        .await
27375        .unwrap_err();
27376        assert_eq!(
27377            external_index.code,
27378            NodeFailureCode::WorkspaceRegistrationRequired,
27379        );
27380        let record_response = process_request_inner(
27381            &shared,
27382            7,
27383            ClientRole::Operator,
27384            NodeRequest::PreviewSessionRecord {
27385                record_id: record.record_id.clone(),
27386                message_limit: 2,
27387            },
27388        )
27389        .await
27390        .unwrap();
27391        let NodeResponse::SessionRecordPreviewed { record_id, preview } = record_response else {
27392            panic!("record preview returned a different response");
27393        };
27394        assert_eq!(record_id, record.record_id);
27395        assert_eq!(preview.messages.len(), 2);
27396        let encoded = serde_json::to_string(&preview).unwrap();
27397        assert!(!encoded.contains("stable-session"));
27398        assert!(!encoded.contains(history_root.to_string_lossy().as_ref()));
27399        let NodeResponse::Resync { events, .. } = shared.resync(0) else {
27400            panic!("record preview history summary returned a different response");
27401        };
27402        let summaries = events
27403            .iter()
27404            .filter_map(|envelope| match &envelope.event {
27405                NodeEvent::SessionRecordHistorySummarized { record_id, summary }
27406                    if record_id == &record.record_id =>
27407                {
27408                    Some(*summary)
27409                }
27410                _ => None,
27411            })
27412            .collect::<Vec<_>>();
27413        assert_eq!(summaries, vec![SessionHistorySummaryV1::from(&preview)]);
27414        let summary_json = serde_json::to_string(&summaries).unwrap();
27415        for private in [
27416            "stable-session",
27417            "raw user text must not cross the catalog wire",
27418            "raw answer",
27419            "Workspace review",
27420            "claude-sonnet",
27421        ] {
27422            assert!(!summary_json.contains(private), "{private}");
27423        }
27424        let identity = record.provider_session.clone().unwrap();
27425        assert_eq!(
27426            shared.revalidate_session_record_preview(&record, &identity),
27427            Ok(())
27428        );
27429        shared.remove_record_memory(&record.record_id);
27430        assert_eq!(
27431            shared
27432                .revalidate_session_record_preview(&record, &identity)
27433                .unwrap_err()
27434                .code,
27435            NodeFailureCode::UnknownSessionRecord
27436        );
27437        let released = process_request_inner(
27438            &shared,
27439            7,
27440            ClientRole::Operator,
27441            NodeRequest::ReleaseController,
27442        )
27443        .await
27444        .unwrap();
27445        assert!(matches!(released, NodeResponse::Controller { controller: None }));
27446        assert!(shared.controller_state().is_none());
27447        std::fs::remove_dir_all(workspace_root).unwrap();
27448        std::fs::remove_dir_all(history_root).unwrap();
27449        std::fs::remove_dir_all(outside_root).unwrap();
27450    }
27451
27452    #[tokio::test]
27453    async fn native_session_catalog_concurrent_operation_fails_fast_without_waiting_on_mutex() {
27454        let history_root = temporary_workspace_root("native-catalog-concurrent-operation");
27455        std::fs::create_dir_all(&history_root).unwrap();
27456        let history = NativeHistoryConfig::new(vec![
27457            NativeHistoryRoot::new(
27458                gate4agent_types::AdapterId::new("claude-code").unwrap(),
27459                HistorySourceLayout::SingleNdjson,
27460                history_root.clone(),
27461            )
27462            .unwrap(),
27463        ])
27464        .unwrap();
27465        let catalog = Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)));
27466        let held = catalog.lock().unwrap();
27467        let result = timeout(
27468            Duration::from_secs(1),
27469            run_native_session_catalog_operation(
27470                Arc::clone(&catalog),
27471                "native session catalog",
27472                |_| Ok::<(), std::convert::Infallible>(()),
27473            ),
27474        )
27475        .await
27476        .expect("concurrent native catalog request must not wait for the operation deadline")
27477        .unwrap_err();
27478        assert_eq!(result.code, NodeFailureCode::BackendBusy);
27479        drop(held);
27480        std::fs::remove_dir_all(history_root).unwrap();
27481    }
27482
27483    fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
27484        let unique = std::time::SystemTime::now()
27485            .duration_since(std::time::UNIX_EPOCH)
27486            .unwrap()
27487            .as_nanos();
27488        std::env::temp_dir().join(format!(
27489            "gate4agent-node-{label}-{}-{unique}",
27490            std::process::id(),
27491        ))
27492    }
27493
27494    fn git_snapshot_for_parser() -> GitSnapshot {
27495        GitSnapshot {
27496            is_repository: true,
27497            branch: Some("main".to_owned()),
27498            status: Vec::new(),
27499            recent_commits: Vec::new(),
27500            worktrees: Vec::new(),
27501            managed_worktree: None,
27502            truncated: false,
27503            diagnostic: None,
27504        }
27505    }
27506
27507    #[tokio::test]
27508    async fn workspace_inspection_scopes_only_exact_active_managed_branch() {
27509        let root = temporary_workspace_root("managed-git-scope");
27510        std::fs::create_dir_all(&root).unwrap();
27511        run_test_git(&root, &["init"]);
27512        run_test_git(&root, &["config", "user.email", "gate4agent@example.invalid"]);
27513        run_test_git(&root, &["config", "user.name", "Gate4Agent Test"]);
27514        std::fs::write(root.join("seed.txt"), "seed\n").unwrap();
27515        run_test_git(&root, &["add", "seed.txt"]);
27516        run_test_git(&root, &["commit", "-m", "seed"]);
27517        run_test_git(&root, &["checkout", "-b", "gate4agent/a"]);
27518
27519        let workspace_id = WorkspaceId::new("managed-a").unwrap();
27520        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27521        let catalog = active_registry().unwrap();
27522        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27523        let shared = NodeShared::new(
27524            handle,
27525            "fixture-token".to_owned(),
27526            NodeId::new("node-1").unwrap(),
27527            vec![workspace],
27528            vec![agent("claude")],
27529        );
27530        let mut lease = ManagedWorktreeLeaseRecord {
27531            lease_id: ManagedWorktreeLeaseId::new("mw-a").unwrap(),
27532            source_workspace_id: WorkspaceId::new("primary").unwrap(),
27533            workspace_id: workspace_id.clone(),
27534            profile_id: WorktreeProfileId::new("default").unwrap(),
27535            profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
27536            target_root: root.to_string_lossy().into_owned(),
27537            branch: "gate4agent/a".to_owned(),
27538            base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
27539            expected_head: None,
27540            retention: ManagedWorktreeRetention::Retain,
27541            state: ManagedWorktreeLeaseState::InUse,
27542            session_holders: vec![ManagedWorktreeSessionHolder {
27543                incarnation_id: shared.incarnation_id,
27544                instance_id: AgentInstanceId(7),
27545                generation: SessionGeneration(1),
27546            }],
27547            record_holders: Vec::new(),
27548            cleanup_failure: None,
27549            created_at_unix_ms: 1,
27550            updated_at_unix_ms: 1,
27551        };
27552        *shared
27553            .managed_worktrees
27554            .lock()
27555            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
27556            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
27557
27558        let inspection = shared.inspect_workspace(workspace_id.clone()).await.unwrap();
27559        let scope = inspection.git.managed_worktree.unwrap();
27560        assert_eq!(scope.lease_id, lease.lease_id);
27561        assert_eq!(scope.branch, "gate4agent/a");
27562        assert_eq!(scope.active_session_count, 1);
27563
27564        lease.branch = "gate4agent/b".to_owned();
27565        *shared
27566            .managed_worktrees
27567            .lock()
27568            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
27569            ManagedWorktreeRegistry::from_records(vec![lease], Vec::new()).unwrap();
27570        let mismatch = shared.inspect_workspace(workspace_id).await.unwrap();
27571        assert!(mismatch.git.managed_worktree.is_none());
27572        std::fs::remove_dir_all(root).unwrap();
27573    }
27574
27575    #[tokio::test]
27576    async fn workspace_file_write_requires_controller_and_rejects_stale_revision() {
27577        let root = temporary_workspace_root("workspace-file-write-controller");
27578        std::fs::create_dir_all(root.join("src")).unwrap();
27579        std::fs::write(root.join("src/lib.rs"), b"old\n").unwrap();
27580        let workspace_id = WorkspaceId::new("primary").unwrap();
27581        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27582        let catalog = active_registry().unwrap();
27583        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27584        let shared = NodeShared::new(
27585            handle,
27586            "fixture-token".to_owned(),
27587            NodeId::new("node-1").unwrap(),
27588            vec![workspace],
27589            vec![agent("claude")],
27590        );
27591        let path = RepositoryPath::utf8("src/lib.rs".to_owned()).unwrap();
27592        let expected_revision = workspace_file_revision(b"old\n");
27593        let request = NodeRequest::WriteWorkspaceFile {
27594            workspace_id: workspace_id.clone(),
27595            path: path.clone(),
27596            expected_revision: expected_revision.clone(),
27597            text: "new\n".to_owned(),
27598        };
27599        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
27600            .await
27601            .unwrap_err();
27602        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27603        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27604            .await
27605            .unwrap_err();
27606        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
27607        shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
27608        let response = process_request_inner(&shared, 77, ClientRole::Operator, request).await.unwrap();
27609        let NodeResponse::WorkspaceFileWritten { file } = response else {
27610            panic!("workspace file write returned another response");
27611        };
27612        assert_eq!(file.revision, Some(workspace_file_revision(b"new\n")));
27613        assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
27614        let stale = process_request_inner(
27615            &shared,
27616            77,
27617            ClientRole::Operator,
27618            NodeRequest::WriteWorkspaceFile {
27619                workspace_id,
27620                path,
27621                expected_revision,
27622                text: "lost update\n".to_owned(),
27623            },
27624        )
27625        .await
27626        .unwrap_err();
27627        assert_eq!(stale.code, NodeFailureCode::RepositoryFileRevisionConflict);
27628        assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
27629        std::fs::remove_dir_all(root).unwrap();
27630    }
27631
27632    #[tokio::test]
27633    async fn workspace_entry_create_requires_controller_and_never_overwrites() {
27634        let root = temporary_workspace_root("workspace-entry-create-controller");
27635        std::fs::create_dir_all(root.join("src")).unwrap();
27636        let workspace_id = WorkspaceId::new("primary").unwrap();
27637        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27638        let catalog = active_registry().unwrap();
27639        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27640        let shared = NodeShared::new(
27641            handle,
27642            "fixture-token".to_owned(),
27643            NodeId::new("node-1").unwrap(),
27644            vec![workspace],
27645            vec![agent("claude")],
27646        );
27647        let path = RepositoryPath::utf8("src/new.rs".to_owned()).unwrap();
27648        let request = NodeRequest::CreateWorkspaceFile {
27649            workspace_id: workspace_id.clone(),
27650            path: path.clone(),
27651        };
27652        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
27653            capability.as_str() == NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY
27654        }));
27655        assert!(!request_uses_unnegotiated_capability(
27656            &request,
27657            &baseline_capabilities().unwrap(),
27658        ));
27659        assert!(request_uses_unnegotiated_capability(&request, &[]));
27660        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
27661            .await
27662            .unwrap_err();
27663        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27664        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27665            .await
27666            .unwrap_err();
27667        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
27668        shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
27669
27670        let response = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27671            .await
27672            .unwrap();
27673        let NodeResponse::WorkspaceFileCreated { file } = response else {
27674            panic!("workspace file create returned another response");
27675        };
27676        assert_eq!(file.workspace_id, workspace_id);
27677        assert_eq!(file.path, path);
27678        assert_eq!(file.revision, Some(workspace_file_revision(&[])));
27679        assert_eq!(std::fs::read(root.join("src/new.rs")).unwrap(), Vec::<u8>::new());
27680
27681        let duplicate = process_request_inner(&shared, 77, ClientRole::Operator, request)
27682            .await
27683            .unwrap_err();
27684        assert_eq!(duplicate.code, NodeFailureCode::RepositoryEntryAlreadyExists);
27685
27686        let directory_path = RepositoryPath::utf8("src/nested".to_owned()).unwrap();
27687        let response = process_request_inner(
27688            &shared,
27689            77,
27690            ClientRole::Operator,
27691            NodeRequest::CreateWorkspaceDirectory {
27692                workspace_id: workspace_id.clone(),
27693                path: directory_path.clone(),
27694            },
27695        )
27696        .await
27697        .unwrap();
27698        assert_eq!(
27699            response,
27700            NodeResponse::WorkspaceDirectoryCreated {
27701                workspace_id: workspace_id.clone(),
27702                entry: WorkspaceEntry {
27703                    relative_path: directory_path,
27704                    kind: WorkspaceEntryKind::Directory,
27705                },
27706            },
27707        );
27708        assert!(root.join("src/nested").is_dir());
27709
27710        let missing_parent = process_request_inner(
27711            &shared,
27712            77,
27713            ClientRole::Operator,
27714            NodeRequest::CreateWorkspaceFile {
27715                workspace_id: workspace_id.clone(),
27716                path: RepositoryPath::utf8("missing/new.rs".to_owned()).unwrap(),
27717            },
27718        )
27719        .await
27720        .unwrap_err();
27721        assert_eq!(missing_parent.code, NodeFailureCode::RepositoryParentNotFound);
27722        let non_directory_parent = process_request_inner(
27723            &shared,
27724            77,
27725            ClientRole::Operator,
27726            NodeRequest::CreateWorkspaceFile {
27727                workspace_id,
27728                path: RepositoryPath::utf8("src/new.rs/child".to_owned()).unwrap(),
27729            },
27730        )
27731        .await
27732        .unwrap_err();
27733        assert_eq!(
27734            non_directory_parent.code,
27735            NodeFailureCode::RepositoryParentNotDirectory,
27736        );
27737        std::fs::remove_dir_all(root).unwrap();
27738    }
27739
27740    #[tokio::test]
27741    async fn workspace_entry_create_timeout_cancels_before_commit_without_late_mutation() {
27742        let root = temporary_workspace_root("workspace-entry-create-timeout-cancel");
27743        std::fs::create_dir_all(root.join("src")).unwrap();
27744        #[cfg(windows)]
27745        let root = std::fs::canonicalize(root).unwrap();
27746        let path = RepositoryPath::utf8("src/late.rs".to_owned()).unwrap();
27747        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
27748        let barrier = Arc::new(std::sync::Barrier::new(2));
27749        let done = Arc::new(AtomicBool::new(false));
27750        let task_commit_state = Arc::clone(&commit_state);
27751        let task_barrier = Arc::clone(&barrier);
27752        let task_done = Arc::clone(&done);
27753        let task_root = root.clone();
27754        let task = tokio::task::spawn_blocking(move || {
27755            task_barrier.wait();
27756            let result = create_workspace_file_on_disk(
27757                &task_root,
27758                &path,
27759                &task_commit_state,
27760            );
27761            task_done.store(true, Ordering::Release);
27762            result
27763        });
27764
27765        let error = settle_workspace_entry_create(
27766            task,
27767            Arc::clone(&commit_state),
27768            Duration::from_millis(10),
27769            "fixture deadline",
27770            "fixture task failed",
27771        )
27772        .await
27773        .unwrap_err();
27774        assert_eq!(error.code, NodeFailureCode::RepositoryEntryCreateTimedOut);
27775        assert_eq!(
27776            commit_state.load(Ordering::Acquire),
27777            WORKSPACE_ENTRY_CREATE_CANCELED,
27778        );
27779
27780        barrier.wait();
27781        timeout(Duration::from_secs(1), async {
27782            while !done.load(Ordering::Acquire) {
27783                tokio::task::yield_now().await;
27784            }
27785        })
27786        .await
27787        .expect("canceled blocking create did not settle");
27788        assert!(!root.join("src/late.rs").exists());
27789        std::fs::remove_dir_all(root).unwrap();
27790    }
27791}
27792
27793#[cfg(test)]
27794mod provisional_spawn_tests {
27795    use super::*;
27796    use std::sync::Arc;
27797
27798    fn shared() -> NodeShared {
27799        let catalog = active_registry().unwrap();
27800        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27801        let workspace = WorkspaceConfig::new(
27802            WorkspaceId::new("primary").unwrap(),
27803            std::env::current_dir().unwrap(),
27804        )
27805        .unwrap()
27806        .with_worktree_service_mode(WorktreeServiceMode::Manual);
27807        NodeShared::new(
27808            handle,
27809            "fixture-token".to_owned(),
27810            NodeId::new("node-provisional-test").unwrap(),
27811            vec![workspace],
27812            vec![AgentId::new("claude").unwrap()],
27813        )
27814    }
27815
27816    /// Provisional draft map: sessionize by instance id only; 50 sequential
27817    /// mark/claim cycles never collide, and a claimed draft is never reaped.
27818    #[test]
27819    fn provisional_spawn_keys_are_unique_and_sessionize_prevents_reap() {
27820        let shared = shared();
27821        let ws = WorkspaceId::new("primary").unwrap();
27822        let mut ids = Vec::with_capacity(50);
27823        for i in 1u64..=50 {
27824            let id = AgentInstanceId(i);
27825            ids.push(id);
27826            let address = SessionAddress {
27827                workspace_id: ws.clone(),
27828                session: SessionKey {
27829                    instance_id: id,
27830                    generation: SessionGeneration(1),
27831                },
27832            };
27833            shared.mark_spawn_provisional(address);
27834        }
27835        assert_eq!(
27836            shared
27837                .provisional_spawns
27838                .lock()
27839                .unwrap_or_else(|p| p.into_inner())
27840                .len(),
27841            50
27842        );
27843        for id in ids.iter().step_by(2) {
27844            shared.sessionize_spawn(*id);
27845        }
27846        assert_eq!(
27847            shared
27848                .provisional_spawns
27849                .lock()
27850                .unwrap_or_else(|p| p.into_inner())
27851                .len(),
27852            25
27853        );
27854        assert!(shared.take_due_provisional_spawns().is_empty());
27855        {
27856            let mut map = shared
27857                .provisional_spawns
27858                .lock()
27859                .unwrap_or_else(|p| p.into_inner());
27860            let past = Instant::now() - Duration::from_secs(30);
27861            for (_, (_, at)) in map.iter_mut() {
27862                *at = past;
27863            }
27864        }
27865        let due = shared.take_due_provisional_spawns();
27866        assert_eq!(due.len(), 25);
27867        let due_ids: std::collections::BTreeSet<_> =
27868            due.iter().map(|a| a.session.instance_id.0).collect();
27869        let expected: std::collections::BTreeSet<_> =
27870            ids.iter().skip(1).step_by(2).map(|id| id.0).collect();
27871        assert_eq!(due_ids, expected);
27872        assert!(shared
27873            .provisional_spawns
27874            .lock()
27875            .unwrap_or_else(|p| p.into_inner())
27876            .is_empty());
27877    }
27878
27879    /// Twenty parallel provisional marks must each get their own instance key;
27880    /// killing/sessionizing one id must not touch the others.
27881    #[test]
27882    fn provisional_spawn_parallel_marks_isolate_kills_by_instance_id() {
27883        let shared = Arc::new(shared());
27884        let ws = WorkspaceId::new("primary").unwrap();
27885        let mut handles = Vec::new();
27886        for i in 1u64..=20 {
27887            let shared = Arc::clone(&shared);
27888            let ws = ws.clone();
27889            handles.push(std::thread::spawn(move || {
27890                let id = AgentInstanceId(i);
27891                let address = SessionAddress {
27892                    workspace_id: ws,
27893                    session: SessionKey {
27894                        instance_id: id,
27895                        generation: SessionGeneration(1),
27896                    },
27897                };
27898                shared.mark_spawn_provisional(address);
27899                id
27900            }));
27901        }
27902        let mut ids = Vec::new();
27903        for h in handles {
27904            ids.push(h.join().unwrap());
27905        }
27906        ids.sort_by_key(|id| id.0);
27907        assert_eq!(ids.len(), 20);
27908        ids.dedup();
27909        assert_eq!(ids.len(), 20, "parallel marks must not share instance keys");
27910        assert_eq!(
27911            shared
27912                .provisional_spawns
27913                .lock()
27914                .unwrap_or_else(|p| p.into_inner())
27915                .len(),
27916            20
27917        );
27918        shared.sessionize_spawn(AgentInstanceId(7));
27919        let left: Vec<_> = shared
27920            .provisional_spawns
27921            .lock()
27922            .unwrap_or_else(|p| p.into_inner())
27923            .keys()
27924            .map(|id| id.0)
27925            .collect();
27926        assert!(!left.contains(&7));
27927        assert_eq!(left.len(), 19);
27928    }
27929}
27930
27931
27932#[cfg(unix)]
27933extern "C" {
27934    #[link_name = "geteuid"]
27935    fn libc_geteuid() -> u32;
27936}