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
215/// Model of the spawn in progress (for the m4a auto-nick), if the request named one.
216pub fn spawn_model() -> Option<String> {
217    SPAWN_SESSION_OPTIONS.try_with(|o| o.as_ref().map(|o| o.model.clone())).ok().flatten().filter(|m| !m.is_empty())
218        .or_else(|| SPAWN_NICK_MODEL.try_with(|m| m.clone()).ok().flatten().filter(|m| !m.is_empty()))
219}
220
221tokio::task_local! {
222    /// Model named by the spawn request (any launch path, ACP included), for the m4a auto-nick
223    /// that is registered before the ACP options are applied.
224    static SPAWN_NICK_MODEL: Option<String>;
225    /// Launch options (model / effort) of a `SpawnWithOptions` request, scoped around that
226    /// spawn so the provider's start carries them (g4a maps them to the CLI's own flags).
227    static SPAWN_SESSION_OPTIONS: Option<gate4agent_types::SessionOptionSelection>;
228    /// Provider session id to resume (node-start restore of journalled sessions).
229    static RESUME_PROVIDER_SESSION: Option<String>;
230    /// Reserved journal slot; ordinary spawns keep using the monotonic allocator.
231    static RESTORE_INSTANCE_ID: AgentInstanceId;
232    /// Origin (`human`/`agent`) and optional parent signature for the node session registry.
233    static SPAWN_ORIGIN: Option<String>;
234    static SPAWN_PARENT: Option<String>;
235}
236
237/// `SpawnSessionOptions` -> g4a selection. Effort needs a model (g4a options hang off a model).
238fn spawn_selection(
239    options: &gate4agent_node_protocol::SpawnSessionOptions,
240) -> Result<Option<gate4agent_types::SessionOptionSelection>, NodeFailure> {
241    options.validate().map_err(|e| failure(NodeFailureCode::InvalidRequest, e))?;
242    match (&options.model, &options.effort) {
243        (None, None) => Ok(None),
244        (None, Some(_)) => Err(failure(NodeFailureCode::InvalidRequest, "spawn effort needs a model")),
245        (Some(model), effort) => {
246            let mut sel = gate4agent_types::SessionOptionSelection::new(model.clone());
247            if let Some(e) = effort {
248                sel = sel.with_value("effort", e.clone());
249            }
250            Ok(Some(sel))
251        }
252    }
253}
254
255mod browser_bridge;
256
257use gate4agent_node_core::drive_loop_profile;
258
259#[path = "bundle_delivery.rs"]
260mod bundle_delivery;
261
262#[path = "context_pack_store.rs"]
263mod context_pack_store;
264
265#[cfg(windows)]
266pub use crate::platform::DEFAULT_NODE_ENDPOINT;
267
268const NODE_EVENT_HISTORY_MAX: usize = 4_096;
269const NODE_BROADCAST_CAPACITY: usize = 1_024;
270const NODE_TERMINAL_BROADCAST_CAPACITY: usize = 1;
271/// Unlike `NODE_TERMINAL_BROADCAST_CAPACITY`'s single replaceable slot (a
272/// terminal frame is a full-screen snapshot; only the latest ever matters),
273/// an `AgentStreamChunkV1` is one delta in an ordered sequence -- collapsing
274/// two pending sends into one would silently drop content a subscriber can
275/// never recover. This needs headroom for a burst instead.
276const NODE_AGENT_STREAM_BROADCAST_CAPACITY: usize = 256;
277/// The node never has more than `MAX_HARNESS_MCP_PENDING_CALLS_PER_NODE`
278/// `NodeEvent::HarnessMcpReadCall`s in flight at once (`begin_call`'s own
279/// admission check), and `publish_transient` sends each exactly once as a
280/// single-envelope batch -- so this many slots covers the entire pending
281/// set without ever lagging the one subscriber that matters, the harness's
282/// own connection, the way sharing the durable `event_tx`'s bus could.
283const NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY: usize = MAX_HARNESS_MCP_PENDING_CALLS_PER_NODE;
284const NODE_CONNECTION_EVENT_BURST_MAX: usize = 16;
285const CONTROL_EVENT_SUBSCRIPTION_CAPACITY: usize = 1_024;
286const MAX_PREAUTH_CONNECTIONS: usize = 32;
287const MAX_AUTHENTICATED_CONNECTIONS: usize = 16;
288const AUTH_FRAME_TIMEOUT_MS: u64 = 5_000;
289const FRAME_BODY_TIMEOUT_MS: u64 = 5_000;
290const CONNECTION_SHUTDOWN_GRACE_MS: u64 = 250;
291const SPAWN_DISPATCH_TIMEOUT_MS: u64 = 2_000;
292/// Fresh Spawn stays provisional until HQ successfully attaches (first Resize /
293/// TerminalBytes). This is an *attach-never-arrived* timeout from spawn time —
294/// not the disconnect reconnect grace (HQ owns that, starting only on a real
295/// client WS close). A provider conversation id is a session; a PTY with no
296/// attached HQ view is still a draft and is reaped here if attach never lands.
297const PROVISIONAL_SPAWN_GRACE_MS: u64 = 90_000;
298/// How often `drive_runtime_until_shutdown`'s loop re-evaluates the session
299/// record retention policy (`NodeServerConfig::session_record_retention`).
300/// A sweep cadence, not a policy value -- the policy itself lives entirely
301/// in the operator-configured age/keep-N flags; this just bounds how stale
302/// the in-memory view of dead `Unavailable` records can get between sweeps
303/// on an otherwise-idle node, mirroring the once-per-startup sweep that
304/// also runs alongside `reconcile_managed_worktrees`.
305const SESSION_RECORD_RETENTION_SWEEP_PERIOD: Duration = Duration::from_secs(5 * 60);
306/// TTL for `NodeShared::rejected_command_reasons` entries -- see that
307/// field's own doc for why a spawn waiter needs this at all. Must outlive
308/// the longest a waiter can still be polling for this exact command's
309/// outcome: `gate4agent_node_protocol::MAX_SPAWN_DEADLINE_MS` (120s) is the
310/// largest `deadline_ms` any caller may request, and the margin on top
311/// absorbs the waiter's own poll cadence plus clock skew between the drive
312/// loop (writer) and the waiter (reader).
313const REJECTED_COMMAND_REASON_TTL_MS: u64 = 150_000;
314/// Bounds `NodeShared::rejected_command_reasons` against an unbounded
315/// backlog of asynchronously-rejected commands nobody ever collects (a
316/// waiter that stops polling, or a burst of always-rejected commands from a
317/// misbehaving caller): once at capacity, the oldest entry is evicted to
318/// make room for the newest, mirroring the eviction rule already used by
319/// `spawn_idempotency`.
320const REJECTED_COMMAND_REASON_MAX_ENTRIES: usize = 256;
321/// How long `wait_until_removed` waits between two `Remove` re-dispatches
322/// for the same session. The presence poll runs far faster (2ms); this
323/// only paces the sending, so an accepted `Remove` still in flight is not
324/// piled on by a dozen more that land on an already-dropped instance.
325const REMOVE_REDISPATCH_INTERVAL_MS: u64 = 100;
326const SPAWN_IDEMPOTENCY_MAX_ENTRIES: usize = 256;
327const SPAWN_IDEMPOTENCY_TTL_MS: u64 = 15 * 60 * 1_000;
328const PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS: u64 = 2_500;
329const MUTATION_SETTLE_TIMEOUT_MS: u64 = 5_000;
330const READINESS_SETTLE_HEADROOM_MS: u64 = 2_000;
331const MANAGED_RESUME_SETTLE_TIMEOUT_MS: u64 = 30_000;
332const WORKSPACE_INSPECTION_MAX_CONCURRENCY: usize = 4;
333const WORKSPACE_FILE_READ_TIMEOUT_MS: u64 = 2_000;
334const HOST_DIRECTORY_BROWSE_TIMEOUT_MS: u64 = 2_000;
335const NATIVE_SESSION_CATALOG_TIMEOUT_MS: u64 = 30_000;
336const WORKSPACE_TREE_MAX_DEPTH: usize = 6;
337const WORKSPACE_TREE_MAX_ENTRIES: usize = 512;
338/// Default total wall-clock budget for `inspect_workspace`'s walk+git
339/// chain, overridable via `GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS`.
340const WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT: u64 = 8_000;
341/// The harness relay enforces its own flat response deadline
342/// (`HOST_RUN_READ_RESPONSE_DEADLINE`, `hatchery-harness-service::runtime`,
343/// 12s) above this node-side budget. The two constants live in different
344/// crates with no shared definition, so clamp any override here comfortably
345/// below that external ceiling — a misconfigured value above it would
346/// reopen the exact deadline race this budget exists to close.
347const WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX: u64 = 11_000;
348/// Default cap on directory entries visited (not just returned) by one
349/// walk, overridable via `GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP`.
350const WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT: usize = 50_000;
351const WORKSPACE_INSPECTION_ENTRY_CAP_MAX: usize = 1_000_000;
352const GIT_STATUS_MAX_ENTRIES: usize = 128;
353const GIT_COMMIT_MAX_ENTRIES: usize = 12;
354const GIT_OUTPUT_MAX_BYTES: usize = 64 * 1_024;
355const GIT_DIAGNOSTIC_MAX_BYTES: usize = 1_024;
356const WORKSPACE_FILE_WRITE_TIMEOUT_MS: u64 = 5_000;
357const WORKSPACE_ENTRY_CREATE_TIMEOUT_MS: u64 = 5_000;
358const GIT_COMMAND_TIMEOUT_MS: u64 = 1_500;
359const WORKSPACE_UNAVAILABLE_ERROR: &str = "workspace-unavailable";
360const PROVIDER_SESSION_SCOPE_CONFLICT_ERROR: &str = "provider-session-scope-conflict";
361const PROVIDER_SESSION_LIVE_CONFLICT_ERROR: &str = "provider-session-live-conflict";
362const MANAGED_SESSION_CAPACITY_ERROR: &str = "managed-session-capacity";
363const PROVIDER_IDENTITY_ALLOCATION_ERROR: &str =
364    "provider-session-identity-allocation-failed";
365const PROVIDER_RESUME_REJECTED_ERROR: &str = "provider-resume-rejected";
366const DURABLE_STATE_COMMIT_FAILED_ERROR: &str = "durable-state-commit-failed";
367const DURABLE_STATE_LOCK_FAILED_ERROR: &str = "durable-state-lock-failed";
368const DURABLE_STATE_LOAD_FAILED_ERROR: &str = "durable-state-load-failed";
369const DURABLE_STATE_CONFLICT_ERROR: &str = "durable-state-conflict";
370const DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR: &str = "durable-state-schema-unsupported";
371const DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR: &str =
372    "durable-state-path-semantics-unsupported";
373
374fn opaque_windows_path(value: String) -> OpaqueHostPath {
375    OpaqueHostPath::utf8(value).expect("validated Windows node path must remain wire-safe")
376}
377
378fn agent_progress_from_provider_snapshot(
379    address: SessionAddress,
380    provider: &ProviderSnapshot,
381) -> Option<SessionAgentProgress> {
382    let mut truncated = false;
383    let active_tool_count = bounded_progress_count(provider.active_tools.len(), &mut truncated);
384    let mut active_tool_labels = Vec::with_capacity(
385        provider
386            .active_tools
387            .len()
388            .min(MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS),
389    );
390    for tool in &provider.active_tools {
391        if active_tool_labels.len() == MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS {
392            truncated = true;
393            break;
394        }
395        if let Some(label) = sanitize_progress_tool_label(&tool.name, &mut truncated) {
396            active_tool_labels.push(label);
397        }
398    }
399    let attention = provider
400        .interactions
401        .iter()
402        .find(|interaction| {
403            matches!(
404                interaction.status,
405                ProviderInteractionStatus::Pending | ProviderInteractionStatus::Resolving { .. }
406            )
407        })
408        .map(|interaction| AgentProgressAttentionV1 {
409            kind: match interaction.interaction_kind {
410                ProviderInteractionKind::Approval => AgentProgressAttentionKindV1::Approval,
411                ProviderInteractionKind::Question => AgentProgressAttentionKindV1::Question,
412            },
413            tool_label: sanitize_progress_tool_label(&interaction.tool_name, &mut truncated),
414        });
415    let subagent_count = bounded_progress_count(provider.subagents.len(), &mut truncated);
416    let usage = (provider.completed_turns > 0
417        || provider.usage.input_tokens > 0
418        || provider.usage.output_tokens > 0
419        || provider.usage.cache_read_tokens > 0
420        || provider.usage.cache_write_tokens > 0
421        || provider.usage.reasoning_tokens > 0)
422        .then_some(AgentProgressUsageV1 {
423            input_tokens: provider.usage.input_tokens,
424            output_tokens: provider.usage.output_tokens,
425            cache_read_tokens: provider.usage.cache_read_tokens,
426            cache_write_tokens: provider.usage.cache_write_tokens,
427            reasoning_tokens: provider.usage.reasoning_tokens,
428        });
429    let progress = AgentProgressV1 {
430        provider_sequence: provider.sequence,
431        activity: provider.activity,
432        completed_turns: provider.completed_turns,
433        usage,
434        current: AgentProgressCurrentV1::from(provider.activity),
435        active_tool_labels,
436        active_tool_count,
437        attention,
438        subagent_count,
439        last_event_kind: provider.last_event.as_ref().and_then(agent_progress_event_kind),
440        gap_count: provider.gap_count,
441        stale: provider.stale,
442        truncated,
443    };
444    let entry = SessionAgentProgress { address, progress };
445    serde_json::to_vec(&entry)
446        .ok()
447        .filter(|encoded| encoded.len() <= MAX_AGENT_PROGRESS_ENTRY_BYTES)
448        .map(|_| entry)
449}
450
451fn bounded_progress_count(count: usize, truncated: &mut bool) -> u32 {
452    match u32::try_from(count) {
453        Ok(count) => count,
454        Err(_) => {
455            *truncated = true;
456            u32::MAX
457        }
458    }
459}
460
461fn agent_progress_event_kind(event: &ProviderEvent) -> Option<AgentProgressEventKindV1> {
462    if matches!(event, ProviderEvent::ContextWindowUsage { .. }) {
463        return None;
464    }
465    Some(match event {
466        ProviderEvent::SessionStarted { .. } => AgentProgressEventKindV1::SessionStarted,
467        ProviderEvent::SessionIdentityObserved { .. } => {
468            AgentProgressEventKindV1::SessionIdentityObserved
469        }
470        ProviderEvent::TurnStarted { .. } => AgentProgressEventKindV1::TurnStarted,
471        ProviderEvent::WorkingObserved => AgentProgressEventKindV1::WorkingObserved,
472        ProviderEvent::Text { .. } => AgentProgressEventKindV1::Text,
473        ProviderEvent::Thinking { .. } => AgentProgressEventKindV1::Thinking,
474        ProviderEvent::ToolStarted { .. } => AgentProgressEventKindV1::ToolStarted,
475        ProviderEvent::ToolCompleted { .. } => AgentProgressEventKindV1::ToolCompleted,
476        ProviderEvent::TurnCompleted { .. } => AgentProgressEventKindV1::TurnCompleted,
477        ProviderEvent::TurnInterrupted => AgentProgressEventKindV1::TurnInterrupted,
478        ProviderEvent::SessionEnded { .. } => AgentProgressEventKindV1::SessionEnded,
479        ProviderEvent::Error { .. } => AgentProgressEventKindV1::Error,
480        ProviderEvent::Ready => AgentProgressEventKindV1::Ready,
481        ProviderEvent::InteractionRequested { .. } => {
482            AgentProgressEventKindV1::InteractionRequested
483        }
484        ProviderEvent::InteractionResolved { .. } => AgentProgressEventKindV1::InteractionResolved,
485        ProviderEvent::SubagentStarted { .. } => AgentProgressEventKindV1::SubagentStarted,
486        ProviderEvent::SubagentStopped { .. } => AgentProgressEventKindV1::SubagentStopped,
487        ProviderEvent::RateLimited { .. } => AgentProgressEventKindV1::RateLimited,
488        ProviderEvent::HostRequestObserved { .. } => AgentProgressEventKindV1::HostRequestObserved,
489        ProviderEvent::UnrecognizedNotification { .. } => {
490            AgentProgressEventKindV1::UnrecognizedNotification
491        }
492        ProviderEvent::ContextWindowUsage { .. } => unreachable!("handled above"),
493        // ACP session/update coverage beyond text/tool/turn streaming
494        // (`plan`, `available_commands_update`, `current_mode_update`,
495        // `session_info_update`, `usage_update`, `config_option_update`,
496        // `user_message_chunk`). `AgentProgressEventKindV1` is a versioned
497        // wire enum without a `#[serde(other)]` fallback; minting new
498        // variants for it is a deliberate wire-contract decision for
499        // whoever owns this protocol, not a side effect of parsing more of
500        // ACP's own wire -- until that decision is made, these fold into
501        // the same "we saw something, no dedicated kind for it yet" bucket
502        // as `UnrecognizedNotification`.
503        ProviderEvent::UserMessage { .. }
504        | ProviderEvent::Plan { .. }
505        | ProviderEvent::AvailableCommandsUpdated { .. }
506        | ProviderEvent::ModeChanged { .. }
507        | ProviderEvent::SessionInfoUpdated { .. }
508        | ProviderEvent::UsageUpdated { .. }
509        | ProviderEvent::ConfigOptionsUpdated { .. } => {
510            AgentProgressEventKindV1::UnrecognizedNotification
511        }
512    })
513}
514
515/// Maps this harness's own `HostDecisionAuthority` onto the wider
516/// Maps a host request decider onto the `BlockAuthorityV1` a `Blocked` stream
517/// chunk carries -- the four authorities a `Denied` decision is ever made
518/// under, one-for-one. The remaining `BlockAuthorityV1` variants
519/// (`ProviderClassifier`, `ProviderPermissionRule`, `ProviderSandbox`,
520/// `ProviderRefusal`, `UserRejected`, `ProviderQuota`, `Unknown`) describe a
521/// block the PROVIDER side of the wire reported -- this function never
522/// produces one, because `ProviderHostDecisionAuthority` has no vocabulary for
523/// them; the provider-side blocks below mint those directly from typed fields.
524/// Whether a provider source is a live session transport -- the only kind
525/// whose events reach the agent stream. History, resume, session-option and
526/// capability-probe sources are not sessions and stream nothing.
527fn provider_family_is_live(family: AdapterFamily) -> bool {
528    match family {
529        AdapterFamily::PtySemantic
530        | AdapterFamily::Hook
531        | AdapterFamily::ManagedHook
532        | AdapterFamily::Pipe
533        | AdapterFamily::OneShot
534        | AdapterFamily::Acp => true,
535        AdapterFamily::History
536        | AdapterFamily::Resume
537        | AdapterFamily::SessionOptions
538        | AdapterFamily::CapabilityProbe => false,
539    }
540}
541
542fn block_authority(by: ProviderHostDecisionAuthority) -> BlockAuthorityV1 {
543    match by {
544        ProviderHostDecisionAuthority::Gate => BlockAuthorityV1::HostGate,
545        ProviderHostDecisionAuthority::Policy => BlockAuthorityV1::HostPolicy,
546        ProviderHostDecisionAuthority::Operator => BlockAuthorityV1::Operator,
547        ProviderHostDecisionAuthority::DeadlinePolicy => BlockAuthorityV1::HostDeadline,
548    }
549}
550
551/// The `Blocked::reason` for a `Denied` decision this build's own
552/// `AgentEvent::RpcIncomingRequest::reason` did not attach text to --
553/// reachable today for `Operator`/`HostDeadline`: a deferred `session/
554/// request_permission` request resolved by `AcpSession::
555/// resolve_pending_request`/`expire_deadlines` (`src/acp/session.rs`),
556/// neither of which computes free text (an operator answers approve/reject,
557/// never a comment; an unattended deadline asks nobody anything). `reason`
558/// is a REQUIRED field on the wire (see `AgentStreamChunkKindV1::Blocked`)
559/// -- a categorical fallback naming the authority is the
560/// honest minimum, never a placeholder standing in for detail nobody
561/// reported.
562fn action_blocked_fallback_reason(authority: BlockAuthorityV1) -> &'static str {
563    match authority {
564        BlockAuthorityV1::HostGate => "denied by the dangerous-command gate",
565        BlockAuthorityV1::HostPolicy => "denied by host policy",
566        BlockAuthorityV1::HostDeadline => {
567            "denied by host policy after the operator deadline expired"
568        }
569        BlockAuthorityV1::Operator => "declined by the operator",
570        BlockAuthorityV1::ProviderClassifier => "denied by the provider's classifier",
571        BlockAuthorityV1::ProviderPermissionRule => "denied by a provider permission rule",
572        BlockAuthorityV1::ProviderSandbox => "denied by the provider's sandbox",
573        BlockAuthorityV1::ProviderRefusal => "refused by the provider",
574        BlockAuthorityV1::UserRejected => "rejected by the user",
575        BlockAuthorityV1::ProviderQuota => "the provider's quota or rate limit was exhausted",
576        BlockAuthorityV1::Unknown => "blocked; no reason was reported",
577    }
578}
579
580/// Computes `AgentStreamChunkKindV1::Blocked`'s `tool_class`/`authority`/`reason`
581/// for a `HostRequestObserved` `Denied` decision.
582fn host_request_denied_block(
583    method: &str,
584    by: ProviderHostDecisionAuthority,
585    reason: Option<&str>,
586) -> (String, BlockAuthorityV1, String) {
587    let authority = block_authority(by);
588    let reason_source = reason.unwrap_or_else(|| action_blocked_fallback_reason(authority));
589    // `AgentStreamChunkKindV1::validate` REJECTS an over-long reason outright
590    // rather than truncating -- this cut happens here, at the producer, before
591    // the validator ever sees the text.
592    let (reason_text, _) =
593        truncate_text(reason_source, MAX_ACP_BLOCKED_REASON_BYTES);
594    (tool_class_label(method), authority, reason_text)
595}
596
597// ---------------------------------------------------------------------------
598// K2 -- provider-side blocks read off ACP `tool_call_update._meta.
599// nonExecutionKind` (`ProviderEvent::ToolCompleted::non_execution_kind`) and
600// `SessionEnded::stop_reason` (`stopReason: "refusal"`, or a `session/
601// prompt` RPC error whose vendor code names a quota/limit/rate
602// exhaustion). See
603// `docs/gate4agent/plans/gate4agent-blocked-action-event-2026-09-02.md`
604// §3 slice K2.
605// ---------------------------------------------------------------------------
606
607/// Classifies ACP's `nonExecutionKind` into the typed authority a block
608/// this build mints under, or `None` when the tool truly did not run for a
609/// reason that is NOT a block -- `"interrupted"`/`"cancelled"` mean the
610/// TURN stopped (session closed, the operator cancelled), not that a
611/// policy/classifier/rule refused the call, so those (and any unrecognized
612/// future value) stay a plain `ToolCompleted { success: false }` only, per
613/// this plan's own rule that a tool failure is never inflated into a block
614/// this build cannot actually attribute.
615fn provider_tool_block_authority(kind: Option<&str>) -> Option<(BlockAuthorityV1, &'static str)> {
616    match kind? {
617        "user-rejected" => Some((BlockAuthorityV1::UserRejected, "user-rejected")),
618        "permission-rule" => Some((BlockAuthorityV1::ProviderPermissionRule, "permission-rule")),
619        _ => None,
620    }
621}
622
623/// Splits a provider's own denial sentence into `reason`/`help`, when it
624/// matches the fixed shape Claude Code's classifier/permission-rule denial
625/// uses -- verified live, the owner's sample fixture
626/// (`docs/gate4agent/research/gate4agent-blocked-action-signals-2026-09-02.md`
627/// appendix): "...denied by <authority>. Reason: <reason>. <help...>".
628/// `authority` is NEVER derived from this text (see this module's own rule
629/// that authority comes only from a typed field) -- only `reason`/`help`
630/// are. Falls back to the full text as `reason` with no `help` when the
631/// shape does not match (a provider whose text does not carry this exact
632/// wording) -- the honest minimum: the tool truly did not run, so SOME
633/// reason is always reported, never fabricated detail beyond what arrived.
634fn split_provider_denial_text(text: &str) -> (String, Option<String>) {
635    let trimmed = text.trim();
636    let Some((_, after_reason)) = trimmed.split_once("Reason:") else {
637        return (trimmed.to_owned(), None);
638    };
639    let tail = after_reason.trim_start();
640    let Some((reason, help)) = tail.split_once(". ") else {
641        // "Reason:" present but no ". " to split help off -- the whole
642        // tail is the reason, e.g. "Reason: Blocked by classifier." with
643        // no help text following on some providers/versions.
644        let reason = tail.strip_suffix('.').unwrap_or(tail).trim();
645        return (reason.to_owned(), None);
646    };
647    let help = help.trim();
648    if help.is_empty() {
649        (reason.trim().to_owned(), None)
650    } else {
651        (reason.trim().to_owned(), Some(help.to_owned()))
652    }
653}
654
655/// `tool_class` for a provider-side `ActionBlocked` this build has no typed
656/// tool name for -- `ProviderEvent::ToolCompleted` carries only `id`, never
657/// `name` (see `ProviderEvent::ToolStarted`, which does, but a tool's own
658/// name is never threaded through to its completion). Reads the class off
659/// the guidance tail's own "add a <Class> permission rule" sentence when
660/// present (the shape the owner's sample fixture uses) -- text matching
661/// fills `tool_class` here, same as `reason`/`help`, NEVER `authority` (see
662/// this module's own rule). Falls back to the same generic `"Tool"` class
663/// `ToolCompleted`'s own observation already uses when nothing names one.
664fn tool_class_from_denial_help(help: Option<&str>) -> String {
665    help.and_then(|text| text.split("add a ").nth(1))
666        .and_then(|tail| tail.split(' ').next())
667        .filter(|word| !word.is_empty())
668        .map(str::to_owned)
669        .unwrap_or_else(|| "Tool".to_owned())
670}
671
672/// Whether a `session/prompt` RPC error's vendor code (or, absent one, its
673/// message) names a quota/rate-limit exhaustion -- Codex's
674/// `data.codexErrorInfo: "usageLimitExceeded"` (measured live 2026-09-05)
675/// contains "limit", the word this build actually keys on; "quota"/"rate"
676/// are kept for whichever OTHER vendor's own code names one of those
677/// instead, never confirmed live, but the honest generalization of the
678/// one confirmed sample.
679fn provider_error_is_quota(vendor_code: Option<&str>, message: &str) -> bool {
680    fn names_quota(s: &str) -> bool {
681        let lower = s.to_ascii_lowercase();
682        lower.contains("limit") || lower.contains("quota") || lower.contains("rate")
683    }
684    vendor_code.is_some_and(names_quota) || names_quota(message)
685}
686
687/// The `Blocked` chunk this build mints when a turn stopped via a
688/// typed provider fact rather than a tool call -- `stopReason: "refusal"`
689/// (`authority: ProviderRefusal`) or a `session/prompt` RPC error whose
690/// vendor code (or message) names a quota/limit/rate exhaustion
691/// (`authority: ProviderQuota`, `reason` = the error message verbatim,
692/// `reason_kind` = the vendor code). `None` for every other stop --
693/// `end_turn`, `max_tokens`, `max_turn_requests`, `cancelled`, an untagged
694/// `Other`, or an RPC error whose vendor code/message names none of the
695/// three words: `SessionEnded` stays a plain `Exited`/turn-end fact for
696/// those, never inflated into a block this build cannot actually
697/// attribute. `ProviderRefusal`'s `reason` has no source text to read on
698/// `SessionEnded` itself (ACP's `refusal` stopReason carries none) -- the
699/// honest minimum names the fact that happened, never invented detail; a
700/// later slice that threads the turn's last agent text chunk through can
701/// replace it without changing this function's contract.
702fn provider_session_end_block(
703    stop_reason: &ProviderStopReason,
704) -> Option<(BlockAuthorityV1, String, String)> {
705    match stop_reason {
706        ProviderStopReason::Refusal => Some((
707            BlockAuthorityV1::ProviderRefusal,
708            "refusal".to_owned(),
709            "the provider ended the turn with stopReason: refusal".to_owned(),
710        )),
711        ProviderStopReason::ProviderError { message, vendor_code, .. }
712            if provider_error_is_quota(vendor_code.as_deref(), message) =>
713        {
714            let reason_kind = vendor_code.clone().unwrap_or_else(|| "provider-error".to_owned());
715            Some((BlockAuthorityV1::ProviderQuota, reason_kind, message.clone()))
716        }
717        _ => None,
718    }
719}
720
721/// Builds the `AgentStreamChunkV1` `event`'s `ProviderEvent` carries onto the
722/// `agent-stream-events-v1` channel -- see
723/// `docs/gate4agent/plans/gate4agent-acp-control-plane-on-the-wire-2026-09-02.md`
724/// §5. Two invariants keep the chunk correlatable with the `NodeEvent::Control`
725/// event it derives from, so a client joining the two sees one id and one
726/// ordering:
727///
728/// - `source_sequence` on the chunk is the `source_sequence` the provider
729///   event itself carries.
730/// - `InteractionPrompt::correlation_id` is minted by
731///   `correlation::interaction_correlation` with the
732///   `(instance_id, generation, provider_sequence)` triple -- never the
733///   provider's own `request_id`, whose opacity on the wire is pinned by a
734///   dedicated test.
735///
736/// Returns `None` for every `ControlEventKind` other than `ProviderEvent`
737/// (nothing else carries stream content), for a source that is not a live
738/// session transport, and for the `ProviderEvent` variants below with no
739/// declared chunk kind to carry them, or whose declared chunk kind this build
740/// has no source for yet:
741///
742/// - `ModelCatalog { current, available }` has no `ProviderEvent` source at
743///   all -- grok's vendor model switch is not bridged into `ProviderEvent`
744///   yet, the same gap the plan names explicitly.
745/// - `UserMessage { text, is_delta }` is an echo of the OPERATOR's own
746///   prompt, replayed on session resume -- `Text`/`Thinking` are documented
747///   as "what the agent is saying", so mapping the operator's words onto
748///   `Text` would misrepresent whose content it is. No dedicated
749///   user-message kind is declared, so this stays unproduced rather than
750///   mislabeled.
751///
752/// `HostRequestObserved { .. }` is its own arm below: a `Granted`/`Deferred`
753/// decision returns `None` (nothing for the operator to react to), a `Denied`
754/// decision returns `Some(Blocked { .. })`.
755///
756/// `ToolCompleted { .. }`/`SessionEnded { .. }` are likewise their own arms
757/// below (K2): each returns `None` unless
758/// `provider_tool_block_authority`/`provider_session_end_block` classifies the
759/// event as an actual block (ACP's `_meta.nonExecutionKind`, or `stopReason`/an
760/// RPC error naming a quota).
761fn agent_stream_chunk(event: &ControlEvent) -> Option<AgentStreamChunkV1> {
762    let ControlEventKind::ProviderEvent {
763        sequence: provider_sequence,
764        source,
765        source_sequence,
766        event: provider_event,
767    } = &event.event
768    else {
769        return None;
770    };
771    if !provider_family_is_live(source.family) {
772        return None;
773    }
774    let kind = match provider_event {
775        ProviderEvent::Text { text, is_delta } => AgentStreamChunkKindV1::Text {
776            text: text.clone(),
777            is_delta: *is_delta,
778        },
779        ProviderEvent::Thinking { text } => {
780            AgentStreamChunkKindV1::Thinking { text: text.clone() }
781        }
782        ProviderEvent::InteractionRequested {
783            interaction_kind,
784            tool_name,
785            title,
786            prompt,
787            options,
788            ..
789        } => AgentStreamChunkKindV1::InteractionPrompt {
790            correlation_id: interaction_correlation(
791                event.instance_id,
792                event.generation,
793                *provider_sequence,
794            ),
795            interaction_kind: *interaction_kind,
796            tool_name: tool_name.clone(),
797            // `title`/`options` are carried straight through from
798            // `ProviderEvent::InteractionRequested`, as the agent itself
799            // gave them on `session/request_permission`'s `PermissionTool
800            // Call.title`/`options` (see `gate4agent-shell-native`'s
801            // `provider_event`, the only ACP-transport producer). A PTY- or
802            // hook-sourced interaction carries `title: None` and
803            // `options: []` from its own producer because that source has
804            // none to offer, never because this mapping dropped one.
805            title: title.clone(),
806            prompt: prompt.clone(),
807            options: options
808                .iter()
809                .map(|option| AgentStreamInteractionOptionV1 {
810                    option_id: option.option_id.clone(),
811                    name: option.name.clone(),
812                    kind: option.kind.clone(),
813                })
814                .collect(),
815        },
816        ProviderEvent::ConfigOptionsUpdated { options } => {
817            AgentStreamChunkKindV1::ConfigOptions { options: options.clone() }
818        }
819        // `available` is the mode catalogue `AcpSession::available_modes()`
820        // read back at `session/new` and threaded through by
821        // `gate4agent-shell-native`'s `provider_event` (see that crate's
822        // `ProviderEvent::ModeChanged` arm) -- an empty list here is the
823        // agent's own honest "no modes announced", never "not read": ACP
824        // orders `session/new` before any `session/update`, and the
825        // catalogue is fixed at handshake, so a `ModeChanged` event cannot
826        // exist before the same session object's catalogue is already
827        // populated (see `gate4agent_types::ProviderEvent::ModeChanged`'s
828        // own doc comment for the ordering argument in full).
829        ProviderEvent::ModeChanged { mode_id, available } => AgentStreamChunkKindV1::ModeCatalog {
830            current: Some(mode_id.clone()),
831            available: available
832                .iter()
833                .map(|mode| AgentStreamNamedIdV1 {
834                    id: mode.id.clone(),
835                    name: mode.name.clone(),
836                    description: mode.description.clone(),
837                })
838                .collect(),
839        },
840        // A denied host request is a block -- see this function's own doc
841        // comment for why `HostRequestObserved` is not in the exclusion list
842        // below.
843        ProviderEvent::HostRequestObserved { method, decision, reason, .. } => {
844            let ProviderHostRequestDecision::Denied { by } = decision else {
845                return None;
846            };
847            let (tool_class, authority, reason_text) =
848                host_request_denied_block(method, *by, reason.as_deref());
849            AgentStreamChunkKindV1::Blocked {
850                correlation_id: None,
851                tool_class,
852                authority,
853                reason_kind: None,
854                reason: reason_text,
855                help: None,
856            }
857        }
858        // Same relationship as `HostRequestObserved` above, driven by ACP's
859        // own `_meta.nonExecutionKind` instead of the host's
860        // `HostRequestDecision`.
861        ProviderEvent::ToolCompleted { id, output, non_execution_kind, .. } => {
862            let (authority, reason_kind) =
863                provider_tool_block_authority(non_execution_kind.as_deref())?;
864            let (reason, help) = split_provider_denial_text(output);
865            let (reason, _) =
866                truncate_text(&reason, MAX_ACP_BLOCKED_REASON_BYTES);
867            let help = help.map(|help| {
868                truncate_text(&help, MAX_ACP_BLOCKED_HELP_BYTES).0
869            });
870            AgentStreamChunkKindV1::Blocked {
871                correlation_id: Some(tool_correlation(event, source, id)),
872                tool_class: tool_class_from_denial_help(help.as_deref()),
873                authority,
874                reason_kind: Some(reason_kind.to_owned()),
875                reason,
876                help,
877            }
878        }
879        // Same relationship, driven by `stopReason: "refusal"` or a
880        // `session/prompt` RPC error naming a quota/limit/rate exhaustion
881        // instead of a tool-level fact.
882        ProviderEvent::SessionEnded { stop_reason, .. } => {
883            let (authority, reason_kind, reason) =
884                stop_reason.as_ref().and_then(provider_session_end_block)?;
885            let (reason, _) =
886                truncate_text(&reason, MAX_ACP_BLOCKED_REASON_BYTES);
887            AgentStreamChunkKindV1::Blocked {
888                correlation_id: None,
889                tool_class: "Session".to_owned(),
890                authority,
891                reason_kind: Some(reason_kind),
892                reason,
893                help: None,
894            }
895        }
896        ProviderEvent::SessionStarted { .. }
897        | ProviderEvent::SessionIdentityObserved { .. }
898        | ProviderEvent::TurnStarted { .. }
899        | ProviderEvent::WorkingObserved
900        | ProviderEvent::ToolStarted { .. }
901        | ProviderEvent::TurnCompleted { .. }
902        | ProviderEvent::ContextWindowUsage { .. }
903        | ProviderEvent::TurnInterrupted
904        | ProviderEvent::Error { .. }
905        | ProviderEvent::Ready
906        | ProviderEvent::InteractionResolved { .. }
907        | ProviderEvent::SubagentStarted { .. }
908        | ProviderEvent::SubagentStopped { .. }
909        | ProviderEvent::RateLimited { .. }
910        | ProviderEvent::UnrecognizedNotification { .. }
911        | ProviderEvent::UserMessage { .. }
912        | ProviderEvent::Plan { .. }
913        | ProviderEvent::AvailableCommandsUpdated { .. }
914        | ProviderEvent::SessionInfoUpdated { .. }
915        | ProviderEvent::UsageUpdated { .. } => return None,
916    };
917    Some(AgentStreamChunkV1 { source_sequence: *source_sequence, kind })
918}
919
920async fn run_native_session_catalog_operation<T, E, F>(
921    catalog: Arc<Mutex<NativeSessionCatalogAuthority>>,
922    operation: &'static str,
923    execute: F,
924) -> Result<T, NodeFailure>
925where
926    T: Send + 'static,
927    E: std::fmt::Display + Send + 'static,
928    F: FnOnce(&mut NativeSessionCatalogAuthority) -> Result<T, E> + Send + 'static,
929{
930    let task = tokio::task::spawn_blocking(move || {
931        let mut catalog = match catalog.try_lock() {
932            Ok(catalog) => catalog,
933            Err(std::sync::TryLockError::Poisoned(poisoned)) => poisoned.into_inner(),
934            Err(std::sync::TryLockError::WouldBlock) => {
935                return Err(failure(
936                    NodeFailureCode::BackendBusy,
937                    "native session catalog operation is already running",
938                ));
939            }
940        };
941        execute(&mut catalog).map_err(|error| {
942            failure(
943                NodeFailureCode::BackendOperationFailed,
944                &format!("{operation} failed: {error}"),
945            )
946        })
947    });
948    timeout(Duration::from_millis(NATIVE_SESSION_CATALOG_TIMEOUT_MS), task)
949        .await
950        .map_err(|_| {
951            failure(
952                NodeFailureCode::BackendBusy,
953                &format!("{operation} exceeded its bounded deadline"),
954            )
955        })?
956        .map_err(|_| {
957            failure(
958                NodeFailureCode::BackendOperationFailed,
959                &format!("{operation} task failed"),
960            )
961        })?
962}
963
964fn windows_path_text(path: &OpaqueHostPath) -> &str {
965    path.as_utf8()
966        .expect("Windows node only creates UTF-8 opaque host paths")
967}
968
969fn require_windows_path(path: OpaqueHostPath) -> Result<String, NodeFailure> {
970    path.as_utf8()
971        .map(str::to_owned)
972        .ok_or_else(|| failure(
973            NodeFailureCode::InvalidRequest,
974            "this node requires UTF-8 host paths",
975        ))
976}
977
978fn protocol_worktree(mut worktree: NativeGitWorktreeSnapshot) -> GitWorktreeSnapshot {
979    GitWorktreeSnapshot {
980        path: opaque_windows_path(worktree.path),
981        head: worktree.head,
982        branch: worktree.branch,
983        is_bare: worktree.is_bare,
984        is_main: worktree.is_main,
985        locked: worktree.locked,
986        lock_reason: worktree.lock_reason,
987        prunable: worktree.prunable,
988        prunable_reason: worktree.prunable_reason,
989        workspace_id: worktree.workspace_id.take(),
990    }
991}
992
993#[derive(Clone, Debug, Eq, PartialEq)]
994pub struct WorkspaceConfig {
995    workspace_id: WorkspaceId,
996    canonical_root: String,
997    worktree_service_mode: WorktreeServiceMode,
998    managed_worktree_profiles: BTreeMap<WorktreeProfileId, ManagedWorktreeProfile>,
999}
1000
1001impl WorkspaceConfig {
1002    pub fn new(
1003        workspace_id: WorkspaceId,
1004        root: impl AsRef<Path>,
1005    ) -> Result<Self, NodeServerError> {
1006        let root = root.as_ref();
1007        if !root.is_absolute() {
1008            return Err(NodeServerError::InvalidWorkspaceRoot {
1009                workspace_id,
1010                path: root.to_string_lossy().into_owned(),
1011                message: "path is not absolute".to_owned(),
1012            });
1013        }
1014        let canonical = std::fs::canonicalize(root).map_err(|error| {
1015            NodeServerError::InvalidWorkspaceRoot {
1016                workspace_id: workspace_id.clone(),
1017                path: root.to_string_lossy().into_owned(),
1018                message: error.to_string(),
1019            }
1020        })?;
1021        if !canonical.is_dir() {
1022            return Err(NodeServerError::InvalidWorkspaceRoot {
1023                workspace_id,
1024                path: root.to_string_lossy().into_owned(),
1025                message: "path is not a directory".to_owned(),
1026            });
1027        }
1028        let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
1029            NodeServerError::InvalidWorkspaceRoot {
1030                workspace_id: workspace_id.clone(),
1031                path: path.to_string_lossy().into_owned(),
1032                message: "canonical path is not valid Unicode".to_owned(),
1033            }
1034        })?;
1035        let canonical_root = platform::normalize_canonical_root(canonical_root);
1036        validate_workspace_root(&workspace_id, &canonical_root)?;
1037        Ok(Self {
1038            workspace_id,
1039            canonical_root,
1040            worktree_service_mode: WorktreeServiceMode::Manual,
1041            managed_worktree_profiles: BTreeMap::new(),
1042        })
1043    }
1044
1045    pub fn workspace_id(&self) -> &WorkspaceId {
1046        &self.workspace_id
1047    }
1048
1049    pub fn canonical_root(&self) -> &str {
1050        &self.canonical_root
1051    }
1052
1053    pub fn with_worktree_service_mode(mut self, mode: WorktreeServiceMode) -> Self {
1054        self.worktree_service_mode = mode;
1055        self
1056    }
1057
1058    pub fn with_managed_worktree_profile(
1059        mut self,
1060        profile: ManagedWorktreeProfile,
1061    ) -> Result<Self, NodeServerError> {
1062        profile.validate_for_workspace(&self.canonical_root).map_err(|message| {
1063            NodeServerError::InvalidManagedWorktreeProfile {
1064                workspace_id: self.workspace_id.clone(),
1065                profile_id: profile.profile_id().clone(),
1066                message,
1067            }
1068        })?;
1069        if self.managed_worktree_profiles.contains_key(profile.profile_id()) {
1070            return Err(NodeServerError::DuplicateManagedWorktreeProfile {
1071                workspace_id: self.workspace_id,
1072                profile_id: profile.profile_id().clone(),
1073            });
1074        }
1075        if self.managed_worktree_profiles.len()
1076            == MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE
1077        {
1078            return Err(NodeServerError::ManagedWorktreeProfileCapacity {
1079                workspace_id: self.workspace_id,
1080                max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
1081            });
1082        }
1083        self.managed_worktree_profiles
1084            .insert(profile.profile_id().clone(), profile);
1085        Ok(self)
1086    }
1087}
1088
1089#[derive(Clone)]
1090pub struct NodeServerConfig {
1091    pub endpoint: String,
1092    api_listen: Option<std::net::SocketAddr>,
1093    /// Opt-in node-envelope HTTP+WS browser bridge (separate from `--api-listen`
1094    /// ops HTTP and from framed C2). Loopback only; default `None` = off.
1095    bridge_listen: Option<std::net::SocketAddr>,
1096    /// Optional bridge shared secret (`GATE4AGENT_BRIDGE_TOKEN`). Distinct from
1097    /// NODE_TOKEN; never logged. `None` = loopback bind is the only gate.
1098    bridge_token: Option<String>,
1099    /// Tip 6: optional mesh underlay UDP bind that relays to local `--bridge-listen`.
1100    /// Requires bridge_listen + bridge_token. HQ never accepts (NodePeer).
1101    bridge_underlay: Option<browser_bridge::BridgeUnderlayAccept>,
1102    /// A relay to CALL, rather than wait to be called by. `None` keeps the
1103    /// node purely a listener, which is what it has always been.
1104    call_home: Option<std::net::SocketAddr>,
1105    /// When set, call-home authenticates by signing the relay's challenge with this
1106    /// identity (enrolled node) and falls back to the token handshake against a relay
1107    /// that sends no challenge.
1108    call_home_identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
1109    pub node_id: NodeId,
1110    pub workspaces: Vec<WorkspaceConfig>,
1111    access_token: String,
1112    pub runtime: NativeRuntimeConfig,
1113    session_record_retention: SessionRecordRetentionConfig,
1114    state_path: Option<PathBuf>,
1115    spawn_profiles: SpawnProfileRegistry,
1116    session_environment: Option<NodeSessionEnvironmentConfig>,
1117    history: Option<NativeHistoryConfig>,
1118    harness_mcp_helper: Option<ReviewedHarnessMcpProgram>,
1119    /// Station network allowlist catalog (opaque ids). Empty default.
1120    /// Loaded at startup from `--network-allowlist-catalog` / env
1121    /// `GATE4AGENT_NODE_NETWORK_ALLOWLIST_CATALOG` file. Never secrets.
1122    network_allowlist_catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog,
1123    #[cfg(feature = "fixture")]
1124    fixture_raw_pty_runtime: bool,
1125}
1126
1127#[derive(Clone)]
1128struct NodeSessionEnvironmentConfig {
1129    root: PathBuf,
1130    resolver: Arc<dyn NodeSecretResolver>,
1131}
1132
1133impl NodeServerConfig {
1134    pub fn new(
1135        endpoint: impl Into<String>,
1136        access_token: impl Into<String>,
1137        node_id: NodeId,
1138        workspaces: impl IntoIterator<Item = WorkspaceConfig>,
1139    ) -> Result<Self, NodeServerError> {
1140        let endpoint = endpoint.into();
1141        let access_token = access_token.into();
1142        if !platform::validate_endpoint(&endpoint) {
1143            return Err(NodeServerError::InvalidEndpoint);
1144        }
1145        if access_token.is_empty() || access_token.len() > 4_096 {
1146            return Err(NodeServerError::InvalidAccessToken);
1147        }
1148        let workspaces = workspaces.into_iter().collect::<Vec<_>>();
1149        if workspaces.is_empty() {
1150            return Err(NodeServerError::NoWorkspaces);
1151        }
1152        let mut workspace_ids = BTreeMap::new();
1153        let mut workspace_roots = BTreeMap::new();
1154        for workspace in &workspaces {
1155            if workspace.worktree_service_mode == WorktreeServiceMode::Managed
1156                && workspace.managed_worktree_profiles.is_empty()
1157            {
1158                return Err(NodeServerError::ManagedWorktreeProfileRequired(
1159                    workspace.workspace_id.clone(),
1160                ));
1161            }
1162            if workspace.worktree_service_mode != WorktreeServiceMode::Managed
1163                && !workspace.managed_worktree_profiles.is_empty()
1164            {
1165                return Err(NodeServerError::ManagedWorktreeProfileModeMismatch(
1166                    workspace.workspace_id.clone(),
1167                ));
1168            }
1169            if workspace_ids
1170                .insert(workspace.workspace_id.clone(), ())
1171                .is_some()
1172            {
1173                return Err(NodeServerError::DuplicateWorkspaceId(
1174                    workspace.workspace_id.clone(),
1175                ));
1176            }
1177            let root_key = platform::root_identity(&workspace.canonical_root);
1178            if let Some(existing) = workspace_roots.insert(
1179                root_key,
1180                workspace.workspace_id.clone(),
1181            ) {
1182                return Err(NodeServerError::DuplicateWorkspaceRoot {
1183                    first: existing,
1184                    second: workspace.workspace_id.clone(),
1185                    root: workspace.canonical_root.clone(),
1186                });
1187            }
1188        }
1189        Ok(Self {
1190            endpoint,
1191            api_listen: None,
1192            bridge_listen: None,
1193            bridge_token: None,
1194            bridge_underlay: None,
1195            call_home: None,
1196            call_home_identity: None,
1197            node_id,
1198            workspaces,
1199            access_token,
1200            // PTY output is polled by the runtime's effect worker; the upstream
1201            // default (20ms) added ~10-20ms to every keystroke echo.
1202            runtime: NativeRuntimeConfig {
1203                worker_poll_interval_ms: 4,
1204                ..NativeRuntimeConfig::default()
1205            },
1206            session_record_retention: SessionRecordRetentionConfig::default(),
1207            state_path: None,
1208            spawn_profiles: SpawnProfileRegistry::default(),
1209            session_environment: None,
1210            history: None,
1211            harness_mcp_helper: None,
1212            network_allowlist_catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog::new(),
1213            #[cfg(feature = "fixture")]
1214            fixture_raw_pty_runtime: false,
1215        })
1216    }
1217
1218    pub fn with_api_listen(
1219        mut self,
1220        api_listen: std::net::SocketAddr,
1221    ) -> Result<Self, NodeServerError> {
1222        if !api_listen.ip().is_loopback() {
1223            return Err(NodeServerError::InvalidApiListen(api_listen));
1224        }
1225        self.api_listen = Some(api_listen);
1226        Ok(self)
1227    }
1228
1229    /// Opt-in node-envelope HTTP+WS bridge listener. Loopback only (same spirit
1230    /// as `with_api_listen`). Does not enable by default — libraries and the
1231    /// binary leave this `None` until `--bridge-listen` / `with_bridge_listen`.
1232    pub fn with_bridge_listen(
1233        mut self,
1234        bridge_listen: std::net::SocketAddr,
1235    ) -> Result<Self, NodeServerError> {
1236        if !bridge_listen.ip().is_loopback() {
1237            return Err(NodeServerError::InvalidBridgeListen(bridge_listen));
1238        }
1239        self.bridge_listen = Some(bridge_listen);
1240        Ok(self)
1241    }
1242
1243    /// Optional bridge shared secret, distinct from the C2/node access token.
1244    /// Never log the value. Empty / oversize refused.
1245    pub fn with_bridge_token(
1246        mut self,
1247        bridge_token: impl Into<String>,
1248    ) -> Result<Self, NodeServerError> {
1249        let bridge_token = bridge_token.into();
1250        if bridge_token.is_empty() || bridge_token.len() > 4_096 {
1251            return Err(NodeServerError::InvalidBridgeToken);
1252        }
1253        self.bridge_token = Some(bridge_token);
1254        Ok(self)
1255    }
1256
1257    /// Tip 6: point bridge reachability over mesh underlay (UDP+AEAD relay to
1258    /// local loopback `--bridge-listen`). Requires bridge listen + bridge token
1259    /// already configured. Transport key is 32 raw bytes (never logged).
1260    /// Underlay auth token is distinct from BRIDGE_TOKEN (crypto ≠ auth).
1261    pub fn with_bridge_underlay(
1262        mut self,
1263        bind: std::net::SocketAddr,
1264        transport_key: [u8; 32],
1265        underlay_token: impl Into<String>,
1266    ) -> Result<Self, NodeServerError> {
1267        if self.bridge_listen.is_none() {
1268            return Err(NodeServerError::BridgeUnderlayRequiresBridgeListen);
1269        }
1270        if self.bridge_token.is_none() {
1271            return Err(NodeServerError::BridgeUnderlayRequiresBridgeToken);
1272        }
1273        let underlay_token = underlay_token.into();
1274        if underlay_token.is_empty() || underlay_token.len() > 4_096 {
1275            return Err(NodeServerError::InvalidBridgeUnderlayToken);
1276        }
1277        // Port 0 is allowed (ephemeral). Role lock (no HQ accept) lives in underlay.
1278        self.bridge_underlay = Some(browser_bridge::BridgeUnderlayAccept {
1279            bind,
1280            transport_key,
1281            underlay_token,
1282        });
1283        Ok(self)
1284    }
1285
1286    /// Also dial `relay` and serve this wire over the connection, in
1287    /// addition to accepting on the local endpoint.
1288    ///
1289    /// Who opens the socket and who is the protocol's server are separate
1290    /// questions, and this is the node's half of separating them. The node
1291    /// stays the server either way -- it answers `NodeRequest`s and emits
1292    /// `NodeEvent`s exactly as it does for a relay that dialled it. All
1293    /// that changes is that a node with no address anyone can reach makes
1294    /// the connection itself, and names itself once it arrives (see
1295    /// `gate4agent_node_wire::write_call_home_announce`).
1296    ///
1297    /// Loopback only, and that is not an oversight to be flagged and
1298    /// worked around. This wire carries terminal contents, keystrokes and
1299    /// file bytes as plaintext JSON; its mutual challenge-response
1300    /// authenticates both ends and encrypts nothing. Every listener in
1301    /// this stack is loopback-guarded for that reason, and a dialler must
1302    /// hold the same line -- a node calling a public relay would put the
1303    /// whole control plane on the wire in the clear. Reaching a relay on
1304    /// another host is a transport question, and it stays unanswered here
1305    /// on purpose: this change is about direction, not distance.
1306    pub fn with_call_home(
1307        mut self,
1308        relay: std::net::SocketAddr,
1309    ) -> Result<Self, NodeServerError> {
1310        // Loopback, or the concrete C2 WireGuard tunnel address. Unspecified
1311        // and port 0 refused — plaintext wire must not dial every interface.
1312        if relay.port() == 0 || relay.ip().is_unspecified() {
1313            return Err(NodeServerError::InvalidCallHome(relay));
1314        }
1315        self.call_home = Some(relay);
1316        Ok(self)
1317    }
1318
1319    /// Authenticate call-home with a node-held identity instead of a standing token.
1320    pub fn with_call_home_identity(mut self, identity: Arc<hatchery_enroll::NodeIdentity>) -> Self {
1321        self.call_home_identity = Some(identity);
1322        self
1323    }
1324
1325    pub fn with_state_path(
1326        mut self,
1327        state_path: impl Into<PathBuf>,
1328    ) -> Result<Self, NodeServerError> {
1329        let state_path = state_path.into();
1330        if !state_path.is_absolute() || state_path.file_name().is_none() {
1331            return Err(NodeServerError::InvalidStatePath(
1332                state_path.to_string_lossy().into_owned(),
1333            ));
1334        }
1335        self.state_path = Some(state_path);
1336        Ok(self)
1337    }
1338
1339    pub fn with_spawn_profiles(mut self, spawn_profiles: SpawnProfileRegistry) -> Self {
1340        self.spawn_profiles = spawn_profiles;
1341        self
1342    }
1343
1344    /// Sets the age/keep-N policy the drive loop's retention sweep uses to
1345    /// retire dead `ManagedSessionState::Unavailable` records. Both fields
1346    /// default to `0` (disabled) via `SessionRecordRetentionConfig::default`
1347    /// when this is never called -- a node never deletes a durable record
1348    /// until an operator opts in with a real value.
1349    pub fn with_session_record_retention(
1350        mut self,
1351        retention: SessionRecordRetentionConfig,
1352    ) -> Self {
1353        self.session_record_retention = retention;
1354        self
1355    }
1356
1357    pub fn with_session_environment_materialization(
1358        mut self,
1359        root: impl Into<PathBuf>,
1360        resolver: Arc<dyn NodeSecretResolver>,
1361    ) -> Result<Self, NodeServerError> {
1362        let root = root.into();
1363        if !root.is_absolute() {
1364            return Err(NodeServerError::InvalidSessionEnvironmentRoot);
1365        }
1366        self.session_environment = Some(NodeSessionEnvironmentConfig { root, resolver });
1367        Ok(self)
1368    }
1369
1370    /// Enables bounded provider-history discovery and loading from explicit
1371    /// host-owned roots. Ambient vendor homes are never inferred here.
1372    pub fn with_history(mut self, history: NativeHistoryConfig) -> Self {
1373        self.history = Some(history);
1374        self
1375    }
1376
1377    /// Enables the H3B read proxy with one exact operator-reviewed helper file.
1378    /// Linux/macOS use owner-only UDS; Windows uses owner-only named pipes.
1379    /// Both platforms review the helper the same way (absolute regular file +
1380    /// identity hash) — the old `cfg(not(windows))` hard-refuse was a leftover
1381    /// gate after Unix listener/peer-cred support landed.
1382    pub fn with_harness_mcp_helper(
1383        mut self,
1384        helper_program: impl Into<PathBuf>,
1385    ) -> Result<Self, NodeServerError> {
1386        self.harness_mcp_helper = Some(
1387            ReviewedHarnessMcpProgram::review(helper_program.into())
1388                .map_err(|_| NodeServerError::InvalidHarnessMcpHelper)?,
1389        );
1390        Ok(self)
1391    }
1392
1393    /// Install an in-memory station network allowlist catalog (opaque ids +
1394    /// optional node-local permits / provider-native mapping). Empty remains
1395    /// the default — unknown spawn ids refuse at resolve. Never cookies /
1396    /// OAuth / proxy credentials. C2 / inventory still list ids only.
1397    pub fn with_network_allowlist_catalog(
1398        mut self,
1399        catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog,
1400    ) -> Result<Self, NodeServerError> {
1401        if catalog.len() > crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES {
1402            return Err(NodeServerError::NetworkAllowlistCatalogCapacity {
1403                max: crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES,
1404            });
1405        }
1406        self.network_allowlist_catalog = catalog;
1407        Ok(self)
1408    }
1409
1410    /// Load station network allowlist catalog from an absolute regular file
1411    /// (v1 id-list or v2 JSON schema). Unset path is not used here — callers
1412    /// that want env fallback use `resolve_network_allowlist_catalog`.
1413    pub fn with_network_allowlist_catalog_file(
1414        self,
1415        path: impl AsRef<std::path::Path>,
1416    ) -> Result<Self, NodeServerError> {
1417        let catalog = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(path)
1418            .map_err(NodeServerError::from)?;
1419        self.with_network_allowlist_catalog(catalog)
1420    }
1421}
1422
1423pub fn default_state_path(node_id: &NodeId) -> Result<PathBuf, NodeServerError> {
1424    platform::default_state_path(node_id.as_str())
1425        .ok_or(NodeServerError::LocalStateDirectoryUnavailable)
1426}
1427
1428pub fn default_node_endpoint() -> Result<PathBuf, NodeServerError> {
1429    platform::default_node_endpoint().ok_or(NodeServerError::LocalRuntimeDirectoryUnavailable)
1430}
1431
1432fn delivery_store_root_for_state_path(state_path: &Path) -> Option<PathBuf> {
1433    let parent = state_path.parent()?;
1434    let mut name = state_path.file_name()?.to_os_string();
1435    name.push(".delivery-store");
1436    Some(parent.join(name))
1437}
1438
1439fn context_pack_store_root_for_state_path(state_path: &Path) -> Option<PathBuf> {
1440    let parent = state_path.parent()?;
1441    let mut name = state_path.file_name()?.to_os_string();
1442    name.push(".context-pack-store");
1443    Some(parent.join(name))
1444}
1445
1446pub struct NodeServer {
1447    config: NodeServerConfig,
1448    runtime: NativeRuntime,
1449    shared: Arc<NodeShared>,
1450    events: EventSubscription,
1451    state_path_lock: Option<session_registry::StatePathLock>,
1452}
1453
1454#[cfg(feature = "fixture")]
1455#[derive(Clone, Default)]
1456pub struct SpawnManagedWorktreeV2FailureProbe {
1457    latest_code: Arc<Mutex<Option<NodeFailureCode>>>,
1458}
1459
1460#[cfg(feature = "fixture")]
1461impl SpawnManagedWorktreeV2FailureProbe {
1462    pub fn latest_code(&self) -> Option<NodeFailureCode> {
1463        *self
1464            .latest_code
1465            .lock()
1466            .unwrap_or_else(|poisoned| poisoned.into_inner())
1467    }
1468
1469    fn record(&self, code: Option<NodeFailureCode>) {
1470        *self
1471            .latest_code
1472            .lock()
1473            .unwrap_or_else(|poisoned| poisoned.into_inner()) = code;
1474    }
1475}
1476
1477#[cfg(all(feature = "fixture", windows))]
1478struct IsolatedCodexHomeFixtureEnvironment;
1479
1480#[cfg(all(feature = "fixture", windows))]
1481impl gate4agent_runtime_native::NativeChildEnvironmentResolver
1482    for IsolatedCodexHomeFixtureEnvironment
1483{
1484    fn resolve_child_environment(
1485        &self,
1486    ) -> Result<
1487        Vec<gate4agent_catalog::EnvMutation>,
1488        gate4agent_runtime_native::NativeChildEnvironmentResolveError,
1489    > {
1490        Ok(vec![gate4agent_catalog::EnvMutation {
1491            key: OsString::from("USERPROFILE"),
1492            value: None,
1493        }])
1494    }
1495}
1496
1497impl NodeServer {
1498    /// The letter-wake handle for the door, when m4a accounts are on. `token` is what callers
1499    /// must present.
1500    /// Turn on the m4a signer proxy for PTY spawns (replaces `M4A_SESSION_CAP`).
1501    #[cfg(unix)]
1502    pub fn set_signer_proxy(&self, proxy: Arc<crate::keystore_proxy::SignerProxy>) -> bool {
1503        self.shared.signer_proxy.set(proxy).is_ok()
1504    }
1505
1506    pub fn wake_handle(&self, token: String, policy: Arc<crate::wake_pipeline::WakePolicy>) -> Option<node_wake::NodeWake> {
1507        self.shared.m4a_accounts.as_ref()?;
1508        Some(node_wake::NodeWake::new(Arc::clone(&self.shared), token, policy))
1509    }
1510
1511    pub fn new(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1512        let catalog = active_registry()?;
1513        Self::new_with_registry(config, catalog)
1514    }
1515
1516    #[cfg(feature = "fixture")]
1517    pub fn new_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1518        let mut spec = gate4agent_testkit::interactive_agent_spec();
1519        spec.id = AgentId::new("claude").map_err(|error| NodeServerError::Registry(error.to_string()))?;
1520        spec.display_name = "Controlled Claude PTY fixture".to_owned();
1521        Self::new_fixture_with_spec(config, spec)
1522    }
1523
1524    #[cfg(feature = "fixture")]
1525    pub fn new_clean_exit_fixture(
1526        config: NodeServerConfig,
1527        fixture_root: PathBuf,
1528        started_marker: PathBuf,
1529        release_signal: PathBuf,
1530    ) -> Result<Self, NodeServerError> {
1531        let mut spec = gate4agent_testkit::controlled_clean_exit_agent_spec(
1532            &fixture_root,
1533            &started_marker,
1534            &release_signal,
1535        )
1536        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1537        spec.id = AgentId::new("claude")
1538            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1539        spec.display_name = "Controlled Claude clean-exit fixture".to_owned();
1540        Self::new_fixture_with_spec(config, spec)
1541    }
1542
1543    /// A `claude` source whose session establishes a verified provider
1544    /// identity via an early `SessionStart` Hook post (so it is eligible for
1545    /// auto-export-at-exit and its native history is independently
1546    /// loadable), waits for `release_signal`, then exits cleanly — paired
1547    /// on the same Node with a plain `codex` target that never validates
1548    /// anything about what it receives. Built for A2 durable ContextPack
1549    /// E2E coverage: the test itself inspects materialized files and
1550    /// managed-session-record fields directly, rather than having the
1551    /// target script self-validate.
1552    #[cfg(all(feature = "fixture", windows))]
1553    pub fn new_durable_context_pack_clean_exit_fixture(
1554        config: NodeServerConfig,
1555        fixture_root: PathBuf,
1556        started_marker: PathBuf,
1557        release_signal: PathBuf,
1558        session_id: &str,
1559    ) -> Result<Self, NodeServerError> {
1560        let mut source_spec = gate4agent_testkit::identified_clean_exit_agent_spec(
1561            &fixture_root,
1562            &started_marker,
1563            &release_signal,
1564            session_id,
1565        )
1566        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1567        let source_id = AgentId::new("claude")
1568            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1569        let source_provider = builtin_registry().get(&source_id).ok_or_else(|| {
1570            NodeServerError::Registry(
1571                "durable context pack clean-exit fixture source adapter is unavailable"
1572                    .to_owned(),
1573            )
1574        })?;
1575        source_spec.capabilities.adapters.history =
1576            source_provider.capabilities.adapters.history.clone();
1577        source_spec.id = source_id;
1578        source_spec.display_name =
1579            "Controlled Claude durable context-pack clean-exit fixture".to_owned();
1580        // Both specs launch via `powershell.exe`; the registry's ambiguity
1581        // check keys on `detection.command` (+ aliases), not `launch.program`
1582        // -- give each a distinct detection identity, same precedent as
1583        // `context_pack_fixture_catalog`'s per-provider
1584        // `gate4agent-{provider}-context-fixture.cmd` commands.
1585        source_spec.detection.command =
1586            "gate4agent-claude-durable-context-pack-fixture.cmd".to_owned();
1587        source_spec.detection.aliases.clear();
1588
1589        let mut target_spec = gate4agent_testkit::interactive_agent_spec();
1590        target_spec.id = AgentId::new("codex")
1591            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1592        target_spec.display_name =
1593            "Controlled Codex durable context-pack target fixture".to_owned();
1594        target_spec.detection.command =
1595            "gate4agent-codex-durable-context-pack-fixture.cmd".to_owned();
1596        target_spec.detection.aliases.clear();
1597
1598        let catalog = AgentRegistry::new([source_spec, target_spec])
1599            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1600        let mut server = Self::new_with_registry(config, catalog)?;
1601        Arc::get_mut(&mut server.shared)
1602            .expect("fixture Node shared state must remain uniquely owned before run")
1603            .fixture_semantic_hook_policy = true;
1604        Ok(server)
1605    }
1606
1607    #[cfg(feature = "fixture")]
1608    pub fn new_monitoring_hook_fixture(
1609        config: NodeServerConfig,
1610    ) -> Result<Self, NodeServerError> {
1611        let mut spec = gate4agent_testkit::monitoring_hook_agent_spec();
1612        spec.id = AgentId::new("claude")
1613            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1614        let mut server = Self::new_fixture_with_spec(config, spec)?;
1615        Arc::get_mut(&mut server.shared)
1616            .expect("fixture Node shared state must remain uniquely owned before run")
1617            .fixture_semantic_hook_policy = true;
1618        Ok(server)
1619    }
1620
1621    #[cfg(all(feature = "fixture", windows))]
1622    pub fn new_provider_bundle_argv_fixture(
1623        config: NodeServerConfig,
1624        agent_id: AgentId,
1625        proof_path: PathBuf,
1626    ) -> Result<Self, NodeServerError> {
1627        let spec = Self::provider_bundle_argv_fixture_spec(agent_id, proof_path, None)?;
1628        Self::new_fixture_with_spec(config, spec)
1629    }
1630
1631    #[cfg(all(feature = "fixture", windows))]
1632    pub fn new_provider_bundle_argv_hold_fixture(
1633        config: NodeServerConfig,
1634        agent_id: AgentId,
1635        proof_path: PathBuf,
1636        release_signal: PathBuf,
1637    ) -> Result<Self, NodeServerError> {
1638        let spec = Self::provider_bundle_argv_fixture_spec(
1639            agent_id,
1640            proof_path,
1641            Some(release_signal),
1642        )?;
1643        Self::new_fixture_with_spec(config, spec)
1644    }
1645
1646    #[cfg(all(feature = "fixture", windows))]
1647    fn provider_bundle_argv_fixture_spec(
1648        agent_id: AgentId,
1649        proof_path: PathBuf,
1650        release_signal: Option<PathBuf>,
1651    ) -> Result<gate4agent_types::AgentSpec, NodeServerError> {
1652        if !matches!(agent_id.as_str(), "claude" | "codex" | "kimi") {
1653            return Err(NodeServerError::Registry(
1654                "provider bundle argv fixture requires Claude, Codex, or Kimi".to_owned(),
1655            ));
1656        }
1657        let mut spec = gate4agent_testkit::interactive_agent_spec();
1658        spec.id = agent_id.clone();
1659        spec.display_name = format!("Controlled {agent_id} bundle argv fixture");
1660        let provider = builtin_registry()
1661            .get(&agent_id)
1662            .ok_or_else(|| {
1663                NodeServerError::Registry(
1664                    "provider bundle argv fixture adapter is unavailable".to_owned(),
1665                )
1666            })?;
1667        spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1668        if !proof_path.is_absolute()
1669            || !proof_path
1670                .parent()
1671                .is_some_and(|parent| parent.is_dir())
1672        {
1673            return Err(NodeServerError::Registry(
1674                "provider bundle argv proof path requires an existing absolute parent"
1675                    .to_owned(),
1676            ));
1677        }
1678        if let Some(release_signal) = release_signal.as_ref() {
1679            if !release_signal.is_absolute()
1680                || !release_signal
1681                    .parent()
1682                    .is_some_and(|parent| parent.is_dir())
1683            {
1684                return Err(NodeServerError::Registry(
1685                    "provider bundle argv release path requires an existing absolute parent"
1686                        .to_owned(),
1687                ));
1688            }
1689            if release_signal.exists() {
1690                return Err(NodeServerError::Registry(
1691                    "provider bundle argv release path must not exist".to_owned(),
1692                ));
1693            }
1694            let proof_parent = std::fs::canonicalize(
1695                proof_path
1696                    .parent()
1697                    .expect("validated provider bundle proof parent"),
1698            )
1699            .map_err(|_| {
1700                NodeServerError::Registry(
1701                    "provider bundle argv proof parent is unavailable".to_owned(),
1702                )
1703            })?;
1704            let release_parent = std::fs::canonicalize(
1705                release_signal
1706                    .parent()
1707                    .expect("validated provider bundle release parent"),
1708            )
1709            .map_err(|_| {
1710                NodeServerError::Registry(
1711                    "provider bundle argv release parent is unavailable".to_owned(),
1712                )
1713            })?;
1714            if proof_parent != release_parent {
1715                return Err(NodeServerError::Registry(
1716                    "provider bundle argv release path must share the proof parent".to_owned(),
1717                ));
1718            }
1719        }
1720        let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1721            NodeServerError::Registry(
1722                "provider bundle argv proof path is not valid Unicode".to_owned(),
1723            )
1724        })?;
1725        let release_signal = release_signal.map(|release_signal| {
1726            release_signal.into_os_string().into_string().map_err(|_| {
1727                NodeServerError::Registry(
1728                    "provider bundle argv release path is not valid Unicode".to_owned(),
1729                )
1730            })
1731        }).transpose()?;
1732        let script = spec
1733            .launch
1734            .fixed_args
1735            .pop()
1736            .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
1737        let bundle_validation = if agent_id.as_str() == "codex" {
1738            "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') }"
1739        } else if agent_id.as_str() == "claude" {
1740            "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 }"
1741        } else {
1742            "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 }"
1743        };
1744        if let Some(release_signal) = release_signal {
1745            spec.launch.fixed_args.push(format!(
1746                "& {{ 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} }}",
1747            ));
1748            spec.launch.fixed_args.push(proof_path);
1749            spec.launch.fixed_args.push(release_signal);
1750        } else {
1751            spec.launch.fixed_args.push(format!(
1752                "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {bundle_validation}; {script} }}",
1753            ));
1754            spec.launch.fixed_args.push(proof_path);
1755        }
1756        Ok(spec)
1757    }
1758
1759    #[cfg(all(feature = "fixture", windows))]
1760    pub fn new_context_pack_fixture(
1761        config: NodeServerConfig,
1762        proof_path: PathBuf,
1763    ) -> Result<Self, NodeServerError> {
1764        let catalog = Self::context_pack_fixture_catalog(proof_path, None, false)?;
1765        Self::new_with_registry(config, catalog)
1766    }
1767
1768    #[cfg(all(feature = "fixture", windows))]
1769    pub fn new_monitoring_context_pack_fixture(
1770        config: NodeServerConfig,
1771        proof_path: PathBuf,
1772    ) -> Result<Self, NodeServerError> {
1773        let catalog = Self::context_pack_fixture_catalog(proof_path, None, true)?;
1774        let mut server = Self::new_with_registry(config, catalog)?;
1775        Arc::get_mut(&mut server.shared)
1776            .expect("fixture Node shared state must remain uniquely owned before run")
1777            .fixture_semantic_hook_policy = true;
1778        Ok(server)
1779    }
1780
1781    #[cfg(all(feature = "fixture", windows))]
1782    pub fn install_isolated_codex_home_environment_fixture(
1783        &mut self,
1784        profile_id: SpawnEnvironmentProfileId,
1785        profile_revision: crate::protocol::SpawnEnvironmentProfileRevision,
1786    ) -> Result<(), NodeServerError> {
1787        let provider = AgentId::new("codex")
1788            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1789        let native_profile = gate4agent_runtime_native::NativeLaunchProfile::new(
1790            gate4agent_runtime_native::NativeLaunchProfileId::new(format!(
1791                "{}-pty",
1792                profile_id.as_str(),
1793            ))
1794            .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1795            provider.clone(),
1796            TransportKind::Pty,
1797            vec![OsString::from("USERPROFILE")],
1798            Arc::new(IsolatedCodexHomeFixtureEnvironment),
1799        )
1800        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1801        let materialization = NodeSessionMaterializationProfile::new(
1802            Vec::new(),
1803            vec![crate::session_environment::NodeSessionPathBinding::new(
1804                "CODEX_HOME",
1805                crate::session_environment::NodeSessionPathClass::ProviderHome,
1806            )
1807            .map_err(|error| NodeServerError::Registry(error.to_string()))?],
1808            Vec::new(),
1809        )
1810        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1811        self.install_environment_profile(
1812            NodeEnvironmentProfile::new_with_materialization(
1813                profile_id,
1814                profile_revision,
1815                provider,
1816                [native_profile],
1817                Some(materialization),
1818            )
1819            .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1820        )
1821    }
1822
1823    #[cfg(all(feature = "fixture", windows))]
1824    pub fn new_context_only_proof_fixture(
1825        config: NodeServerConfig,
1826        target_provider: AgentId,
1827        proof_path: PathBuf,
1828    ) -> Result<Self, NodeServerError> {
1829        if target_provider.as_str() != "kimi" {
1830            return Err(NodeServerError::Registry(
1831                "context-only proof fixture requires Kimi".to_owned(),
1832            ));
1833        }
1834        let catalog = Self::context_pack_fixture_catalog(
1835            proof_path,
1836            Some(&target_provider),
1837            false,
1838        )?;
1839        Self::new_with_registry(config, catalog)
1840    }
1841
1842    #[cfg(all(feature = "fixture", windows))]
1843    fn context_pack_fixture_catalog(
1844        proof_path: PathBuf,
1845        context_only_target: Option<&AgentId>,
1846        monitoring_claude_source: bool,
1847    ) -> Result<AgentRegistry, NodeServerError> {
1848        if !proof_path.is_absolute()
1849            || !proof_path
1850                .parent()
1851                .is_some_and(|parent| parent.is_dir())
1852        {
1853            return Err(NodeServerError::Registry(
1854                "context pack proof path requires an existing absolute parent".to_owned(),
1855            ));
1856        }
1857        let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1858            NodeServerError::Registry(
1859                "context pack proof path is not valid Unicode".to_owned(),
1860            )
1861        })?;
1862        let providers = ["claude", "codex", "grok", "kimi"];
1863        let builtins = builtin_registry();
1864        let mut specs = Vec::with_capacity(providers.len());
1865        for provider_id in providers {
1866            let agent_id = AgentId::new(provider_id)
1867                .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1868            let provider = builtins.get(&agent_id).ok_or_else(|| {
1869                NodeServerError::Registry(
1870                    "context pack fixture provider adapter is unavailable".to_owned(),
1871                )
1872            })?;
1873            let monitored_provider =
1874                monitoring_claude_source && matches!(provider_id, "claude" | "codex");
1875            let mut spec = if monitored_provider {
1876                gate4agent_testkit::monitoring_hook_agent_spec()
1877            } else {
1878                gate4agent_testkit::interactive_agent_spec()
1879            };
1880            spec.id = agent_id.clone();
1881            spec.display_name = format!("Controlled {agent_id} context pack fixture");
1882            spec.detection.command = format!("gate4agent-{provider_id}-context-fixture.cmd");
1883            spec.detection.aliases.clear();
1884            spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1885            if provider_id == "codex" {
1886                let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1887                    NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1888                })?;
1889                let validation = r#"
1890$bundleArgs = @($bundleArgs)
1891if ($bundleArgs.Count -ne 0) { exit 91 }
1892$codexHome = [Environment]::GetEnvironmentVariable('CODEX_HOME', 'Process')
1893$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1894$cwd = (Get-Location).ProviderPath
1895if ([string]::IsNullOrEmpty($codexHome) -or -not [IO.Path]::IsPathRooted($codexHome) -or -not (Test-Path -LiteralPath $codexHome -PathType Container)) { exit 92 }
1896$skillPath = Join-Path $codexHome 'skills/review-code/SKILL.md'
1897if (-not (Test-Path -LiteralPath $skillPath -PathType Leaf)) { exit 93 }
1898$homeFiles = @(Get-ChildItem -LiteralPath $codexHome -Recurse -Force -File)
1899if ($homeFiles.Count -ne 1 -or [IO.Path]::GetFullPath($homeFiles[0].FullName) -ine [IO.Path]::GetFullPath($skillPath)) { exit 94 }
1900$sha = [Security.Cryptography.SHA256]::Create()
1901try { $skillHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($skillPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1902if ($skillHash -cne 'd78fe03be106b673fcf5415c8359e99f0298b5071cd11822c58ebcb27e52a68d') { exit 95 }
1903if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 96 }
1904$contextPath = Join-Path $contextRoot 'context-pack.json'
1905if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 97 }
1906$contextFiles = @(Get-ChildItem -LiteralPath $contextRoot -Recurse -Force -File)
1907if ($contextFiles.Count -ne 1 -or [IO.Path]::GetFullPath($contextFiles[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 98 }
1908$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1909$codexFull = [IO.Path]::GetFullPath($codexHome).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1910$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1911$separator = [IO.Path]::DirectorySeparatorChar
1912if ($contextFull.Equals($codexFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($codexFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 99 }
1913if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 100 }
1914$rawContext = [IO.File]::ReadAllText($contextPath)
1915if ($rawContext.Contains('g4a-private-provider-session-canary') -or $rawContext.Contains('private-provider-login-identity')) { exit 101 }
1916try { $document = $rawContext | ConvertFrom-Json -ErrorAction Stop } catch { exit 102 }
1917$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')
1918$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')
1919foreach ($property in @($document.PSObject.Properties.Name)) {
1920    if ($allowedTopLevel -cnotcontains [string]$property) { exit 103 }
1921}
1922foreach ($property in $requiredTopLevel) {
1923    if ($null -eq $document.PSObject.Properties[$property]) { exit 104 }
1924}
1925if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 105 }
1926if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 106 }
1927$messages = @($document.retained_messages)
1928if ($messages.Count -eq 0) { exit 107 }
1929foreach ($message in $messages) {
1930    $messageProperties = @($message.PSObject.Properties.Name)
1931    foreach ($property in $messageProperties) {
1932        if (@('role', 'text') -cnotcontains [string]$property) { exit 108 }
1933    }
1934    if ($messageProperties.Count -ne 2 -or @('user', 'assistant') -cnotcontains [string]$message.role -or [string]::IsNullOrEmpty([string]$message.text)) { exit 109 }
1935}
1936$sha = [Security.Cryptography.SHA256]::Create()
1937try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1938[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))
1939$rawProof = [IO.File]::ReadAllText($proofPath)
1940if ($rawProof.Contains('g4a-private-provider-session-canary') -or $rawProof.Contains('private-provider-login-identity')) { exit 110 }
1941[Console]::Out.WriteLine('F7_CODEX_BUNDLE_CONTEXT_VALIDATED')
1942"#;
1943                spec.launch.fixed_args.push(format!(
1944                    "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1945                ));
1946                spec.launch.fixed_args.push(proof_path.clone());
1947            } else if context_only_target.map(AgentId::as_str) == Some(provider_id) {
1948                let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1949                    NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1950                })?;
1951                let validation = r#"
1952$bundleArgs = @($bundleArgs)
1953if ($bundleArgs.Count -ne 0) { exit 91 }
1954$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1955$cwd = (Get-Location).ProviderPath
1956if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 92 }
1957$contextPath = Join-Path $contextRoot 'context-pack.json'
1958if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 93 }
1959$entries = @(Get-ChildItem -LiteralPath $contextRoot -Force)
1960if ($entries.Count -ne 1 -or $entries[0] -isnot [IO.FileInfo] -or [IO.Path]::GetFullPath($entries[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 94 }
1961$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1962$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1963$separator = [IO.Path]::DirectorySeparatorChar
1964if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase) -or $cwdFull.StartsWith($contextFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 95 }
1965try { $document = [IO.File]::ReadAllText($contextPath) | ConvertFrom-Json -ErrorAction Stop } catch { exit 96 }
1966if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 97 }
1967if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 98 }
1968$messages = @($document.retained_messages)
1969if ($messages.Count -eq 0) { exit 99 }
1970$hasUser = $false
1971$hasAssistant = $false
1972foreach ($message in $messages) {
1973    if ([string]::IsNullOrWhiteSpace([string]$message.text)) { exit 100 }
1974    if ([string]$message.role -ceq 'user') { $hasUser = $true }
1975    elseif ([string]$message.role -ceq 'assistant') { $hasAssistant = $true }
1976    else { exit 101 }
1977}
1978if (-not $hasUser -or -not $hasAssistant) { exit 102 }
1979$sha = [Security.Cryptography.SHA256]::Create()
1980try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1981[IO.File]::WriteAllLines($proofPath, [string[]]@('context-only', $contextRoot, $cwd, $contextHash, [string]$document.schema, [string]$document.source_provider, [string]$messages.Count))
1982[Console]::Out.WriteLine('F7_KIMI_CONTEXT_ONLY_VALIDATED')
1983"#;
1984                spec.launch.fixed_args.push(format!(
1985                    "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1986                ));
1987                spec.launch.fixed_args.push(proof_path.clone());
1988            }
1989            specs.push(spec);
1990        }
1991        let catalog = AgentRegistry::new(specs)
1992            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1993        Ok(catalog)
1994    }
1995
1996    #[cfg(feature = "fixture")]
1997    pub fn new_exact_launcher_fixture(
1998        config: NodeServerConfig,
1999        agent_id: AgentId,
2000        launcher: String,
2001    ) -> Result<Self, NodeServerError> {
2002        if !matches!(agent_id.as_str(), "claude" | "codex" | "grok" | "kimi") {
2003            return Err(NodeServerError::Registry(
2004                "exact launcher fixture requires a supported provider".to_owned(),
2005            ));
2006        }
2007        if launcher.contains('\0') {
2008            return Err(NodeServerError::Registry(
2009                "exact launcher fixture path contains NUL".to_owned(),
2010            ));
2011        }
2012        let launcher_path = Path::new(&launcher);
2013        if !launcher_path.is_absolute() {
2014            return Err(NodeServerError::Registry(
2015                "exact launcher fixture path must be absolute".to_owned(),
2016            ));
2017        }
2018        let metadata = std::fs::metadata(launcher_path).map_err(|_| {
2019            NodeServerError::Registry("exact launcher fixture path is unavailable".to_owned())
2020        })?;
2021        if !metadata.is_file() {
2022            return Err(NodeServerError::Registry(
2023                "exact launcher fixture path must be a regular file".to_owned(),
2024            ));
2025        }
2026        let mut spec = builtin_registry()
2027            .get(&agent_id)
2028            .ok_or_else(|| {
2029                NodeServerError::Registry(
2030                    "exact launcher fixture provider is unavailable".to_owned(),
2031                )
2032            })?
2033            .clone();
2034        spec.launch.program = launcher;
2035        Self::new_fixture_with_spec(config, spec)
2036    }
2037
2038    /// Shared H3B validation and spec construction for
2039    /// `new_harness_mcp_proxy_fixture` and
2040    /// `new_harness_mcp_acp_launcher_fixture`: an absolute, exact-regular-
2041    /// file provider launcher plus at most 32 Unicode, NUL-free fixed
2042    /// arguments (65,536 bytes total), replacing `spec.launch` on a clone of
2043    /// the named builtin catalog entry -- detection command and expected
2044    /// process matcher rebased on the launcher's own basename, exactly the
2045    /// same shape `new_harness_mcp_proxy_fixture` always built.
2046    #[cfg(feature = "fixture")]
2047    fn h3b_provider_spec_with_program_and_args(
2048        agent_id: AgentId,
2049        provider_program: PathBuf,
2050        fixed_args: Vec<OsString>,
2051    ) -> Result<AgentSpec, NodeServerError> {
2052        if !provider_program.is_absolute() || fixed_args.len() > 32 {
2053            return Err(NodeServerError::Registry(
2054                "H3B fixture requires an absolute provider and at most 32 arguments".to_owned(),
2055            ));
2056        }
2057        let metadata = std::fs::symlink_metadata(&provider_program).map_err(|_| {
2058            NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
2059        })?;
2060        if !metadata.file_type().is_file() || metadata.file_type().is_symlink() {
2061            return Err(NodeServerError::Registry(
2062                "H3B fixture provider must be an exact regular file".to_owned(),
2063            ));
2064        }
2065        let program = provider_program.into_os_string().into_string().map_err(|_| {
2066            NodeServerError::Registry("H3B fixture provider path is not Unicode".to_owned())
2067        })?;
2068        let fixed_args = fixed_args.into_iter().map(|argument| {
2069            argument.into_string().map_err(|_| NodeServerError::Registry(
2070                "H3B fixture argument is not Unicode".to_owned(),
2071            ))
2072        }).collect::<Result<Vec<_>, _>>()?;
2073        let argument_bytes = fixed_args.iter()
2074            .try_fold(0usize, |total, argument| total.checked_add(argument.len()));
2075        if program.contains('\0')
2076            || fixed_args.iter().any(|argument| argument.contains('\0'))
2077            || !matches!(argument_bytes, Some(total) if total <= 65_536)
2078        {
2079            return Err(NodeServerError::Registry(
2080                "H3B fixture provider or arguments are invalid".to_owned(),
2081            ));
2082        }
2083        let mut spec = builtin_registry().get(&agent_id).ok_or_else(|| {
2084            NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
2085        })?.clone();
2086        let process_name = Path::new(&program).file_name().and_then(|name| name.to_str())
2087            .ok_or_else(|| NodeServerError::Registry(
2088                "H3B fixture provider basename is unavailable".to_owned(),
2089            ))?.to_owned();
2090        spec.detection.command = process_name.clone();
2091        spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
2092            name: process_name,
2093        }];
2094        spec.launch.program = program;
2095        spec.launch.fixed_args = fixed_args;
2096        Ok(spec)
2097    }
2098
2099    #[cfg(feature = "fixture")]
2100    pub fn new_harness_mcp_proxy_fixture(
2101        config: NodeServerConfig,
2102        provider_program: PathBuf,
2103        fixed_args: Vec<OsString>,
2104        harness_mcp_program: PathBuf,
2105    ) -> Result<Self, NodeServerError> {
2106        let mut config = config.with_harness_mcp_helper(harness_mcp_program)?;
2107        let agent_id = AgentId::new("codex")
2108            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2109        let mut spec =
2110            Self::h3b_provider_spec_with_program_and_args(agent_id, provider_program, fixed_args)?;
2111        spec.display_name = "Controlled H3B MCP proxy fixture".to_owned();
2112        config.fixture_raw_pty_runtime = true;
2113        Self::new_fixture_with_spec(config, spec)
2114    }
2115
2116    /// The ACP-mode sibling of `new_harness_mcp_proxy_fixture`: the same
2117    /// `--harness-mcp-helper` wiring and the same absolute-program-plus-
2118    /// fixed-args launcher replacement as `new_exact_launcher_fixture`, but
2119    /// admitting `ProviderRuntimeRequirement::Acp` the honest way instead of
2120    /// hardcoding it. `new_harness_mcp_proxy_fixture` forces `config.
2121    /// fixture_raw_pty_runtime = true`, which tears out the live
2122    /// `ProviderRuntimeMonitor` entirely (`NodeServer::new_with_registry`)
2123    /// and makes `NodeShared::admit_provider_runtime`'s no-monitor branch
2124    /// hardcode `acp_transport = false` for every provider, refusing
2125    /// `ProviderRuntimeRequirement::Acp` unconditionally regardless of what
2126    /// the catalog declares. This constructor leaves `fixture_raw_pty_
2127    /// runtime` at its default `false`, so a real `ProviderRuntimeMonitor`
2128    /// is built from the (cloned, launcher-replaced) catalog spec, and
2129    /// `ProviderRuntimeMonitor::supports_acp_transport` reads the honest,
2130    /// unmodified catalog fact: every builtin provider this accepts
2131    /// (`claude`, `codex`, `grok`, `kimi`) already declares `capabilities.
2132    /// transports.acp` in `gate4agent-catalog::builtin`, so no capability is
2133    /// invented here -- this constructor only removes the fixture-only
2134    /// bypass standing in its way. `provider_runtime::policy_for_transport`'s
2135    /// `TransportKind::Acp` arm (applied unconditionally at spawn-prep time
2136    /// for every ACP session, fixture or not) is what actually grants the
2137    /// ACP-shaped `ProviderRuntimePolicy` downstream of that admission --
2138    /// this constructor never calls it directly, and does not need to.
2139    #[cfg(feature = "fixture")]
2140    pub fn new_harness_mcp_acp_launcher_fixture(
2141        config: NodeServerConfig,
2142        helper_program: PathBuf,
2143        provider: AgentId,
2144        program: PathBuf,
2145        args: Vec<OsString>,
2146    ) -> Result<Self, NodeServerError> {
2147        if !matches!(provider.as_str(), "claude" | "codex" | "grok" | "kimi") {
2148            return Err(NodeServerError::Registry(
2149                "ACP harness-MCP launcher fixture requires a supported provider".to_owned(),
2150            ));
2151        }
2152        let config = config.with_harness_mcp_helper(helper_program)?;
2153        let mut spec = Self::h3b_provider_spec_with_program_and_args(provider, program, args)?;
2154        let launch = spec.launch.clone();
2155        let acp = spec.capabilities.transports.acp.as_mut().ok_or_else(|| {
2156            NodeServerError::Registry(
2157                "ACP harness-MCP launcher fixture provider does not declare an ACP transport"
2158                    .to_owned(),
2159            )
2160        })?;
2161        // Routes the ACP spawn itself through the exact replaced launcher
2162        // (`AcpSession::spawn_with_launch` -> a bare `Command::new(&launch.
2163        // program)`, `gate4agent`'s `src/acp/spawn.rs`) instead of the
2164        // catalog's own vendor-npm resolution (`acp_command`, same file) --
2165        // without this the ACP transport ignores `spec.launch` entirely and
2166        // tries to spawn the real vendor CLI (e.g. `npx -y @agentclientprotocol/
2167        // codex-acp@1.10.0`), which this fixture must never reach.
2168        acp.launch_override = Some(launch);
2169        spec.display_name = "Controlled ACP harness-MCP launcher fixture".to_owned();
2170        Self::new_fixture_with_spec(config, spec)
2171    }
2172
2173    #[cfg(feature = "fixture")]
2174    pub fn new_resume_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
2175        let mut spec = gate4agent_testkit::interactive_agent_spec();
2176        let claude_id = AgentId::new("claude")
2177            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2178        let claude = builtin_registry()
2179            .get(&claude_id)
2180            .ok_or_else(|| NodeServerError::Registry("Claude fixture adapter is unavailable".to_owned()))?;
2181        spec.id = claude_id;
2182        spec.display_name = "Resumable Claude PTY fixture".to_owned();
2183        spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
2184            name: "claude".to_owned(),
2185        }];
2186        spec.capabilities.transports.pty_adapter = claude
2187            .capabilities
2188            .transports
2189            .pty_adapter
2190            .clone();
2191        spec.capabilities.adapters.resume = claude
2192            .capabilities
2193            .adapters
2194            .resume
2195            .clone();
2196        let script = spec
2197            .launch
2198            .fixed_args
2199            .pop()
2200            .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
2201        spec.launch.fixed_args.push(format!(
2202            "& {{ param([Parameter(ValueFromRemainingArguments=$true)][string[]]$ignored) {script} }}",
2203        ));
2204        Self::new_fixture_with_spec(config, spec)
2205    }
2206
2207    #[cfg(feature = "fixture")]
2208    pub fn new_cmd_cwd_fixture(
2209        config: NodeServerConfig,
2210        command: String,
2211    ) -> Result<Self, NodeServerError> {
2212        let mut spec = gate4agent_testkit::interactive_agent_spec();
2213        spec.id = AgentId::new("claude")
2214            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2215        spec.display_name = "Controlled cmd cwd fixture".to_owned();
2216        spec.detection.command = "gate4agent-cwd-fixture.cmd".to_owned();
2217        spec.launch.program = command;
2218        spec.launch.fixed_args.clear();
2219        Self::new_fixture_with_spec(config, spec)
2220    }
2221
2222    #[cfg(feature = "fixture")]
2223    fn new_fixture_with_spec(
2224        config: NodeServerConfig,
2225        spec: gate4agent_types::AgentSpec,
2226    ) -> Result<Self, NodeServerError> {
2227        let catalog = AgentRegistry::new([spec])
2228            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2229        Self::new_with_registry(config, catalog)
2230    }
2231
2232    fn new_with_registry(
2233        config: NodeServerConfig,
2234        catalog: AgentRegistry,
2235    ) -> Result<Self, NodeServerError> {
2236        for profile in config.spawn_profiles.iter() {
2237            if catalog.get(&profile.provider).is_none() {
2238                return Err(NodeServerError::Registry(
2239                    "spawn profile references an unavailable provider".to_owned(),
2240                ));
2241            }
2242        }
2243        let input_settle_timeout_ms = catalog
2244            .iter()
2245            .map(|spec| spec.readiness.timeout_ms)
2246            .max()
2247            .unwrap_or(MUTATION_SETTLE_TIMEOUT_MS)
2248            .saturating_add(READINESS_SETTLE_HEADROOM_MS)
2249            .max(MUTATION_SETTLE_TIMEOUT_MS);
2250        let (provider_contracts, provider_adapter_contracts) =
2251            provider_contract_manifest(&catalog)?;
2252        let compatibility_support = node_compatibility_support_for_manifest(
2253            &provider_contracts,
2254            &provider_adapter_contracts,
2255        )?;
2256        validate_node_negotiated_handshake_capacity(&compatibility_support)
2257            .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2258        let enabled_providers = provider_contracts
2259            .iter()
2260            .map(|contract| contract.provider.clone())
2261            .collect();
2262        #[cfg(feature = "fixture")]
2263        let fixture_raw_pty_runtime = config.fixture_raw_pty_runtime;
2264        #[cfg(not(feature = "fixture"))]
2265        let fixture_raw_pty_runtime = false;
2266        let (provider_runtime_monitor, provider_runtime_statuses) = if fixture_raw_pty_runtime {
2267            let statuses = ProviderRuntimeStatuses::new(catalog.iter().map(|spec| {
2268                crate::protocol::ProviderRuntimeStatus::raw_passthrough(spec.id.clone(), None)
2269            })).expect("fixture provider catalog remains bounded");
2270            (None, statuses)
2271        } else {
2272            let monitor = Arc::new(ProviderRuntimeMonitor::new(&catalog));
2273            let statuses = monitor.collect();
2274            (Some(monitor), statuses)
2275        };
2276        let state_path_lock =
2277            session_registry::StatePathLock::acquire(config.state_path.as_deref())
2278                .map_err(|error| {
2279                    durable_state_server_error(error, DURABLE_STATE_LOCK_FAILED_ERROR)
2280                })?;
2281        let (delivery_store, delivered_bundles) = match config.state_path.as_ref() {
2282            Some(state_path) => {
2283                let delivery_root = delivery_store_root_for_state_path(state_path)
2284                    .ok_or_else(|| {
2285                        NodeServerError::DeliveryStore(
2286                            "delivery store root could not be derived from state path".to_owned(),
2287                        )
2288                    })?;
2289                let (store, bundles) = DeliveryStore::open(delivery_root).map_err(|error| {
2290                    let detail = match &error {
2291                        DeliveryStoreError::Storage(io_error) => {
2292                            format!("{error}: {io_error}")
2293                        }
2294                        _ => error.to_string(),
2295                    };
2296                    NodeServerError::DeliveryStore(detail)
2297                })?;
2298                (Some(store), bundles)
2299            }
2300            None => (None, Vec::new()),
2301        };
2302        let delivered_catalog = BundleCatalog::new(delivered_bundles).map_err(|error| {
2303            NodeServerError::DeliveryStore(format!("bundle catalog: {error}"))
2304        })?;
2305        let (context_pack_store, durable_context_packs) = match config.state_path.as_ref() {
2306            Some(state_path) => {
2307                let context_pack_root = context_pack_store_root_for_state_path(state_path)
2308                    .ok_or(NodeServerError::ContextPackStore)?;
2309                let (store, packs) = ContextPackStore::open(context_pack_root)
2310                    .map_err(|_| NodeServerError::ContextPackStore)?;
2311                (Some(store), packs)
2312            }
2313            None => (None, Vec::new()),
2314        };
2315        let mut durable_context_catalog = ContextPackCatalog::default();
2316        for pack in durable_context_packs {
2317            durable_context_catalog
2318                .insert(pack)
2319                .map_err(|_| NodeServerError::ContextPackStore)?;
2320        }
2321        let session_environment_materializer = config
2322            .session_environment
2323            .as_ref()
2324            .map(|environment| {
2325                SessionEnvironmentMaterializer::new(
2326                    environment.root.clone(),
2327                    Arc::clone(&environment.resolver),
2328                )
2329                .map_err(|_| NodeServerError::SessionEnvironmentMaterializer)
2330            })
2331            .transpose()?;
2332        // Session launchers go through the unprivileged `exec-as` wrapper when run-as is set
2333        // (the provider version probe above already used the original catalog).
2334        #[cfg(unix)]
2335        let catalog = crate::isolation::wrap_registry(catalog).map_err(NodeServerError::Registry)?;
2336        let native_session_catalog = config.history.clone().map(|history| {
2337            Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)))
2338        });
2339        let (handle, runtime) = match config.history.clone() {
2340            Some(history) => NativeRuntime::new_with_history(catalog, config.runtime, history),
2341            None => NativeRuntime::new(catalog, config.runtime),
2342        };
2343        let native_launch_profile_control = runtime.native_launch_profile_control();
2344        let harness_mcp_registry = config
2345            .harness_mcp_helper
2346            .clone()
2347            .map(HarnessMcpProxyRegistry::new);
2348        let events = handle.subscribe(CONTROL_EVENT_SUBSCRIPTION_CAPACITY);
2349        let incarnation_id = random_incarnation_id()
2350            .map_err(NodeServerError::IncarnationIdentity)?;
2351        let mut loaded = session_registry::load(config.state_path.as_deref(), &config.node_id)
2352            .map_err(durable_state_load_error)?;
2353        let managed_worktrees = std::mem::take(&mut loaded.managed_worktrees);
2354        let managed_worktree_tombstones =
2355            std::mem::take(&mut loaded.managed_worktree_tombstones);
2356        let materializations = std::mem::take(&mut loaded.materializations);
2357        let managed_spawn_replays = std::mem::take(&mut loaded.managed_spawn_replays);
2358        let (workspaces, records, persistence_warning) =
2359            merge_durable_state(&config.workspaces, loaded)?;
2360        let mut shared = NodeShared::new_with_incarnation(
2361            handle,
2362            config.access_token.clone(),
2363            config.node_id.clone(),
2364            incarnation_id,
2365            workspaces,
2366            enabled_providers,
2367            provider_runtime_statuses,
2368            provider_runtime_monitor,
2369            provider_contracts,
2370            provider_adapter_contracts,
2371            config.spawn_profiles.clone(),
2372            Some(native_launch_profile_control),
2373            native_session_catalog,
2374            session_environment_materializer,
2375            config.state_path.clone(),
2376            records,
2377            managed_worktrees,
2378            managed_worktree_tombstones,
2379            materializations,
2380            persistence_warning,
2381            input_settle_timeout_ms,
2382        );
2383        shared.harness_mcp_registry = harness_mcp_registry.clone();
2384        shared.session_record_retention = config.session_record_retention;
2385        shared.network_allowlist_catalog =
2386            RwLock::new(config.network_allowlist_catalog.clone());
2387        shared.bundle_catalog = RwLock::new(delivered_catalog);
2388        shared.delivery_store = Mutex::new(delivery_store);
2389        shared.context_catalog = RwLock::new(durable_context_catalog);
2390        shared.context_pack_store = Mutex::new(context_pack_store);
2391        shared.managed_spawn_replays = Mutex::new(
2392            managed_spawn_replays
2393                .into_iter()
2394                .map(|record| (record.idempotency_key.clone(), record))
2395                .collect(),
2396        );
2397        let shared = Arc::new(shared);
2398        if let Some(registry) = harness_mcp_registry {
2399            let weak = Arc::downgrade(&shared);
2400            registry.set_event_sink(Arc::new(move |event| match weak.upgrade() {
2401                Some(shared) => shared.publish_transient(event),
2402                None => false,
2403            }));
2404        }
2405        if shared.persistence_error().is_none() {
2406            shared.persist_state().map_err(|error| {
2407                durable_state_server_error(error, DURABLE_STATE_COMMIT_FAILED_ERROR)
2408            })?;
2409        }
2410        Ok(Self {
2411            config,
2412            runtime,
2413            shared,
2414            events,
2415            state_path_lock,
2416        })
2417    }
2418
2419    pub fn shutdown_handle(&self) -> NodeShutdownHandle {
2420        NodeShutdownHandle {
2421            shared: Arc::clone(&self.shared),
2422        }
2423    }
2424
2425    #[cfg(feature = "fixture")]
2426    pub fn spawn_managed_worktree_v2_failure_probe_fixture(
2427        &self,
2428    ) -> SpawnManagedWorktreeV2FailureProbe {
2429        self.shared
2430            .fixture_spawn_managed_worktree_v2_failure_probe
2431            .clone()
2432    }
2433
2434    /// Installs one immutable host-local environment profile before the server runs.
2435    ///
2436    /// Native resolvers remain inside the runtime; the shared node registry keeps
2437    /// only public IDs, revisions, provider bindings, and opaque native profile IDs.
2438    pub fn install_environment_profile(
2439        &mut self,
2440        profile: NodeEnvironmentProfile,
2441    ) -> Result<(), NodeServerError> {
2442        if !self.shared.enabled_providers.contains(profile.provider()) {
2443            return Err(NodeServerError::EnvironmentProfileProviderUnavailable(
2444                profile.provider().clone(),
2445            ));
2446        }
2447        let (binding, native_profiles, materialization) = profile.into_parts();
2448        if materialization.is_some() && self.shared.session_environment_materializer.is_none() {
2449            return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2450        }
2451        let mut profiles = self
2452            .shared
2453            .environment_profiles
2454            .write()
2455            .unwrap_or_else(|poisoned| poisoned.into_inner());
2456        if profiles.contains_key(&binding.id) {
2457            return Err(NodeServerError::DuplicateEnvironmentProfile(
2458                binding.id.clone(),
2459            ));
2460        }
2461        if profiles.len() >= MAX_NODE_ENVIRONMENT_PROFILES {
2462            return Err(NodeServerError::EnvironmentProfileCapacity {
2463                max: MAX_NODE_ENVIRONMENT_PROFILES,
2464            });
2465        }
2466        for native_id in binding.native_profile_ids() {
2467            if profiles.values().any(|current| {
2468                current.native_profile_ids().any(|current_id| current_id == native_id)
2469            }) {
2470                return Err(NodeServerError::DuplicateNativeEnvironmentProfile(
2471                    native_id.to_string(),
2472                ));
2473            }
2474        }
2475
2476        let mut installed = Vec::with_capacity(native_profiles.len());
2477        for native_profile in native_profiles {
2478            let native_id = native_profile.id().clone();
2479            if let Err(error) = self.runtime.upsert_native_launch_profile(native_profile) {
2480                for installed_id in installed.into_iter().rev() {
2481                    let _ = self.runtime.remove_native_launch_profile(&installed_id);
2482                }
2483                return Err(NodeServerError::NativeEnvironmentProfile(error.to_string()));
2484            }
2485            installed.push(native_id);
2486        }
2487        if let Some(materialization) = materialization {
2488            self.shared
2489                .environment_materialization_profiles
2490                .write()
2491                .unwrap_or_else(|poisoned| poisoned.into_inner())
2492                .insert(binding.id.clone(), materialization);
2493        }
2494        profiles.insert(binding.id.clone(), binding);
2495        Ok(())
2496    }
2497
2498    /// Installs one immutable host-local bundle before the server runs.
2499    pub fn install_bundle(&mut self, bundle: NodeBundle) -> Result<(), NodeServerError> {
2500        if self.shared.session_environment_materializer.is_none() {
2501            return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2502        }
2503        let mut catalog = self
2504            .shared
2505            .bundle_catalog
2506            .write()
2507            .unwrap_or_else(|poisoned| poisoned.into_inner());
2508        catalog
2509            .insert_idempotent(bundle)
2510            .map_err(|error| NodeServerError::BundleCatalog(error.to_string()))?;
2511        Ok(())
2512    }
2513
2514    pub async fn run_until_ctrl_signal(self) -> Result<(), NodeServerError> {
2515        let shutdown = self.shutdown_handle();
2516        let run = self.run();
2517        tokio::pin!(run);
2518        tokio::select! {
2519            result = &mut run => result,
2520            signal = wait_for_ctrl_signal() => {
2521                signal?;
2522                let shutdown_result = shutdown.request_shutdown().await;
2523                let run_result = run.await;
2524                shutdown_result.and(run_result)
2525            }
2526        }
2527    }
2528
2529    pub async fn run(self) -> Result<(), NodeServerError> {
2530        let NodeServer {
2531            config,
2532            mut runtime,
2533            shared,
2534            events,
2535            state_path_lock: _state_path_lock,
2536        } = self;
2537        shared.reconcile_materializations();
2538        shared.reconcile_managed_worktrees().await;
2539        shared.retire_unavailable_session_records().await;
2540        shared.reconcile_context_pack_exports().await;
2541        shared.reserve_journalled_instance_ids();
2542        {
2543            let restore = Arc::clone(&shared);
2544            tokio::spawn(async move {
2545                sleep(Duration::from_secs(3)).await;
2546                restore.restore_journalled_sessions().await;
2547                loop {
2548                    restore.refresh_session_journal();
2549                    if restore.shutdown.load(Ordering::Acquire) { break; }
2550                    sleep(Duration::from_secs(10)).await;
2551                }
2552            });
2553        }
2554        // Lifecycle hooks are retired (owner ruling 2026-09-25): the node
2555        // observes sessions through ACP where a provider has it; there is no
2556        // hook ingress.
2557        let endpoint = config.endpoint.clone();
2558        let call_home = config.call_home;
2559        let call_home_identity = config.call_home_identity.clone();
2560        let api_listen = config.api_listen;
2561        let bridge_listen = config.bridge_listen;
2562        let bridge_underlay = config.bridge_underlay;
2563        let bridge_auth = browser_bridge::BridgeAuth::from_optional(config.bridge_token)
2564            .map_err(|_| NodeServerError::InvalidBridgeToken)?;
2565        let accept_shared = Arc::clone(&shared);
2566        let api_shared = Arc::clone(&shared);
2567        let bridge_shared = Arc::clone(&shared);
2568        let shutdown_shared = Arc::clone(&shared);
2569        let shutdown_timeout = Duration::from_millis(
2570            config.runtime.provider_shutdown_timeout_ms.max(1),
2571        );
2572        let result = {
2573            let runtime_loop = drive_runtime_until_shutdown(
2574                &mut runtime,
2575                events,
2576                Arc::clone(&shared),
2577                shutdown_timeout,
2578            );
2579            let accept_loop = accept_connections(&endpoint, call_home, call_home_identity, accept_shared);
2580            let api_loop = http_api::run(api_listen, api_shared);
2581            let bridge_loop = browser_bridge::run(bridge_listen, bridge_auth, bridge_shared, bridge_underlay);
2582            tokio::pin!(runtime_loop);
2583            tokio::pin!(accept_loop);
2584            tokio::pin!(api_loop);
2585            tokio::pin!(bridge_loop);
2586            tokio::select! {
2587                runtime_result = &mut runtime_loop => {
2588                    let shutdown_result = shutdown_shared
2589                        .begin_shutdown()
2590                        .await
2591                        .map_err(NodeServerError::ShutdownDispatch);
2592                    let (accept_result, api_result, bridge_result) =
2593                        tokio::join!(&mut accept_loop, &mut api_loop, &mut bridge_loop);
2594                    match runtime_result {
2595                        Err(error) => Err(error),
2596                        Ok(()) => shutdown_result
2597                            .and(accept_result)
2598                            .and(api_result.map_err(NodeServerError::HttpApi))
2599                            .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2600                    }
2601                }
2602                accept_result = &mut accept_loop => {
2603                    let shutdown_result = shutdown_shared
2604                        .begin_shutdown()
2605                        .await
2606                        .map_err(NodeServerError::ShutdownDispatch);
2607                    let (runtime_result, api_result, bridge_result) =
2608                        tokio::join!(&mut runtime_loop, &mut api_loop, &mut bridge_loop);
2609                    match accept_result {
2610                        Err(error) => Err(error),
2611                        Ok(()) => shutdown_result
2612                            .and(runtime_result)
2613                            .and(api_result.map_err(NodeServerError::HttpApi))
2614                            .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2615                    }
2616                }
2617                api_result = &mut api_loop => {
2618                    let shutdown_result = shutdown_shared
2619                        .begin_shutdown()
2620                        .await
2621                        .map_err(NodeServerError::ShutdownDispatch);
2622                    let (runtime_result, accept_result, bridge_result) =
2623                        tokio::join!(&mut runtime_loop, &mut accept_loop, &mut bridge_loop);
2624                    match api_result {
2625                        Err(error) => Err(NodeServerError::HttpApi(error)),
2626                        Ok(()) => shutdown_result
2627                            .and(runtime_result)
2628                            .and(accept_result)
2629                            .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2630                    }
2631                }
2632                bridge_result = &mut bridge_loop => {
2633                    let shutdown_result = shutdown_shared
2634                        .begin_shutdown()
2635                        .await
2636                        .map_err(NodeServerError::ShutdownDispatch);
2637                    let (runtime_result, accept_result, api_result) =
2638                        tokio::join!(&mut runtime_loop, &mut accept_loop, &mut api_loop);
2639                    match bridge_result {
2640                        Err(error) => Err(NodeServerError::BridgeApi(error)),
2641                        Ok(()) => shutdown_result
2642                            .and(runtime_result)
2643                            .and(accept_result)
2644                            .and(api_result.map_err(NodeServerError::HttpApi)),
2645                    }
2646                }
2647            }
2648        };
2649        // Persistence gap fix: durable DeliveryStore / ContextPackStore hold
2650        // exclusive flock (Unix) / share-mode locks (Windows) inside NodeShared.
2651        // NodeShutdownHandle also clones that Arc, so without an explicit release
2652        // here a supervisor that keeps the shutdown handle cannot reopen the same
2653        // state path after run() returns (EAGAIN / Resource temporarily unavailable).
2654        // StatePathLock already drops with this stack frame (`_state_path_lock`).
2655        if let Ok(mut guard) = shared.delivery_store.lock() {
2656            *guard = None;
2657        }
2658        if let Ok(mut guard) = shared.context_pack_store.lock() {
2659            *guard = None;
2660        }
2661        result
2662    }
2663}
2664
2665async fn drive_runtime_until_shutdown(
2666    runtime: &mut NativeRuntime,
2667    events: EventSubscription,
2668    shared: Arc<NodeShared>,
2669    shutdown_timeout: Duration,
2670) -> Result<(), NodeServerError> {
2671    // Published once, never per iteration. The worker loops inside
2672    // `NativeRuntime` fold into this profile continuously; turning it into
2673    // percentiles is what costs, and that is `GET /metrics`'s bill to pay.
2674    let _ = shared
2675        .shell_efficiency_profile
2676        .set(runtime.shell_efficiency_profile());
2677    let mut shutdown_started = None;
2678    let mut last_retention_sweep = Instant::now();
2679    let mut last_provisional_reap = Instant::now();
2680    loop {
2681        // Every phase below is timed unconditionally -- see
2682        // `drive_loop_profile`'s own doc comment for why an always-on,
2683        // allocation-free ring is cheap enough to never need a feature
2684        // flag. This is the only place these numbers can be taken: the
2685        // phases are calls made directly from this loop, not something a
2686        // callee could time on our behalf.
2687        let runtime_tick_start = Instant::now();
2688        let tick_result = runtime.tick().await;
2689        let runtime_tick_elapsed = runtime_tick_start.elapsed();
2690
2691        let event_drain_start = Instant::now();
2692        // Every command the kernel refused, named. `NativeRuntimeTick`
2693        // has carried these outcomes all along and nothing read them, so a
2694        // rejected command -- a hook event denied by runtime policy, a
2695        // stale generation, an unsupported capability -- vanished at the
2696        // last step of the loop that produced it. A refusal the operator
2697        // cannot see is indistinguishable from an event that never arrived,
2698        // and telling those two apart is most of debugging this stack.
2699        for outcome in &tick_result.command_outcomes {
2700            if let Err(error) = &outcome.result {
2701                shared.rejected_commands_total.fetch_add(1, Ordering::Relaxed);
2702                tracing::warn!(
2703                    command_id = outcome.command_id.0,
2704                    cause = %error,
2705                    "control command rejected"
2706                );
2707                shared.record_rejected_command(
2708                    outcome.command_id,
2709                    error.to_string(),
2710                    error.is_unsupported_transport(),
2711                );
2712            }
2713        }
2714        let mut events_drained: u64 = 0;
2715        while let Ok(event) = events.try_recv() {
2716            events_drained += 1;
2717            let clean_exit = matches!(
2718                event.event,
2719                ControlEventKind::Exited { exit_code: Some(0), forced: false },
2720            );
2721            let instance_id = event.instance_id;
2722            let generation = event.generation;
2723            // Resolve and spawn before publish_control: publish_control's own
2724            // managed-record bookkeeping (reconcile_managed_record) downgrades
2725            // this exact record from Live to Dormant and clears
2726            // active_session for every Exited event, inside the same
2727            // synchronous call. Capturing (record_id, session) here — before
2728            // that runs — is the only way to observe them still Live and
2729            // still populated; the export itself must still run on its own
2730            // detached task (never awaited inline on this loop) because it
2731            // needs NativeRuntime::tick() to keep running to ever settle, and
2732            // tick() is only ever driven by this same loop. See
2733            // reconcile_context_pack_export_for_record's doc comment.
2734            if clean_exit {
2735                if let Some((record_id, session)) =
2736                    shared.managed_record_export_target_for_instance(instance_id, generation)
2737                {
2738                    let export_shared = Arc::clone(&shared);
2739                    tokio::spawn(async move {
2740                        export_shared
2741                            .reconcile_context_pack_export_for_record(&record_id, session)
2742                            .await;
2743                    });
2744                }
2745            }
2746            shared.publish_control(event);
2747        }
2748        let event_drain_elapsed = event_drain_start.elapsed();
2749
2750        let publish_terminal_frames_start = Instant::now();
2751        shared.publish_terminal_frames();
2752        shared.run_due_provider_retries();
2753        let publish_terminal_frames_elapsed = publish_terminal_frames_start.elapsed();
2754
2755        // Physical PTY count and the runtime's own per-phase tick timings
2756        // both live inside `NativeRuntime`, which `GET /metrics` (reading
2757        // only `NodeShared`) cannot reach directly -- this loop is the one
2758        // place holding `&mut NativeRuntime`, so it is the only place that
2759        // can copy either out.
2760        shared
2761            .native_session_gauge
2762            .store(runtime.active_native_sessions(), Ordering::Relaxed);
2763        *shared
2764            .runtime_tick_profile
2765            .lock()
2766            .unwrap_or_else(|poisoned| poisoned.into_inner()) = runtime.tick_profile_snapshot();
2767
2768        let remainder_start = Instant::now();
2769        if shared.shutdown.load(Ordering::Acquire) {
2770            let started = *shutdown_started.get_or_insert_with(Instant::now);
2771            let snapshot = shared.handle.snapshot();
2772            let sessions_settled = snapshot.sessions.iter().all(|session| {
2773                !matches!(
2774                    session.status,
2775                    gate4agent_types::SessionStatus::Starting
2776                        | gate4agent_types::SessionStatus::Running
2777                        | gate4agent_types::SessionStatus::Stopping
2778                )
2779            });
2780            if runtime.active_native_sessions() == 0 && sessions_settled {
2781                return Ok(());
2782            }
2783            if started.elapsed() >= shutdown_timeout {
2784                return Err(NodeServerError::ShutdownTimedOut {
2785                    active_native_sessions: runtime.active_native_sessions(),
2786                });
2787            }
2788        }
2789        let remainder_elapsed = remainder_start.elapsed();
2790
2791        {
2792            let mut profile = shared
2793                .drive_loop_profile
2794                .lock()
2795                .unwrap_or_else(|poisoned| poisoned.into_inner());
2796            profile.record_runtime_tick(runtime_tick_elapsed);
2797            profile.record_event_drain(event_drain_elapsed);
2798            profile.record_publish_terminal_frames(publish_terminal_frames_elapsed);
2799            profile.record_remainder(remainder_elapsed);
2800            profile.note_iteration(Instant::now());
2801        }
2802
2803        // "Produced nothing" means this iteration's `NativeRuntime::tick`
2804        // applied zero observations (`tick_result.observations_applied`,
2805        // which already folds in any terminal frames collected -- see
2806        // `NativeEffectDispatcher::drain_observations`) AND the
2807        // control-event subscription drained above forwarded zero events.
2808        // Command ingress and dispatched effects are deliberately NOT part
2809        // of this definition: an idle node still runs `step_control_plane`
2810        // and every provider supervisor's `tick` regardless, every 10ms --
2811        // so "nothing" here means nothing OBSERVABLE left this iteration,
2812        // matching what a reader would assume from `drive_loop_iterations`'s
2813        // own field name, not that the tick itself was free of work.
2814        shared.drive_loop_iterations_total.fetch_add(1, Ordering::Relaxed);
2815        if tick_result.observations_applied == 0 && events_drained == 0 {
2816            shared.drive_loop_iterations_idle.fetch_add(1, Ordering::Relaxed);
2817        }
2818
2819        // A sweep cadence, not a policy value -- see
2820        // `SESSION_RECORD_RETENTION_SWEEP_PERIOD`'s own doc comment. The
2821        // policy itself (whether this does anything at all) lives entirely
2822        // in `NodeShared::session_record_retention`.
2823        if last_retention_sweep.elapsed() >= SESSION_RECORD_RETENTION_SWEEP_PERIOD {
2824            shared.retire_unavailable_session_records().await;
2825            last_retention_sweep = Instant::now();
2826        }
2827        // Provisional Spawn grace: kill PTYs that never got an HQ attach.
2828        if last_provisional_reap.elapsed() >= Duration::from_secs(2) {
2829            for address in shared.take_due_provisional_spawns() {
2830                shared.reap_provisional_spawn(address).await;
2831            }
2832            last_provisional_reap = Instant::now();
2833        }
2834
2835        // Fast-poll for 300ms after PTY input so its echo is read and published
2836        // within ~1ms; otherwise nap 10ms but wake at once on new input.
2837        let hot = shared
2838            .pty_input_marks
2839            .lock()
2840            .unwrap_or_else(|poisoned| poisoned.into_inner())
2841            .values()
2842            .any(|(at, _)| at.elapsed() < Duration::from_millis(300));
2843        if hot {
2844            sleep(Duration::from_millis(1)).await;
2845        } else {
2846            tokio::select! {
2847                _ = sleep(Duration::from_millis(10)) => {}
2848                _ = shared.pty_input_wake.notified() => {}
2849            }
2850        }
2851    }
2852}
2853
2854#[derive(Clone)]
2855pub struct NodeShutdownHandle {
2856    shared: Arc<NodeShared>,
2857}
2858
2859impl NodeShutdownHandle {
2860    pub async fn request_shutdown(&self) -> Result<(), NodeServerError> {
2861        self.shared
2862            .begin_shutdown()
2863            .await
2864            .map_err(NodeServerError::ShutdownDispatch)
2865    }
2866}
2867
2868async fn wait_for_ctrl_signal() -> Result<(), NodeServerError> {
2869    #[cfg(windows)]
2870    {
2871    let mut ctrl_c = tokio::signal::windows::ctrl_c()?;
2872    let mut ctrl_break = tokio::signal::windows::ctrl_break()?;
2873    return tokio::select! {
2874        signal = ctrl_c.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2875        signal = ctrl_break.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2876    };
2877    }
2878    #[cfg(unix)]
2879    {
2880        let mut interrupt = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())?;
2881        let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
2882        tokio::select! {
2883            signal = interrupt.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2884            signal = terminate.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2885        }
2886    }
2887}
2888
2889/// The providers this node actually runs.
2890///
2891/// `builtin_registry()` is a reference catalog of everything the project has
2892/// ever described; the fleet is this list, and it is deliberately short. A
2893/// provider only belongs here if it can be exercised for real -- there is a
2894/// subscription behind it and a failure in it can be reproduced and
2895/// debugged. Carrying one that cannot is carrying code nobody can verify,
2896/// which is worse than not supporting it: it looks supported.
2897///
2898/// A provider dropped from the fleet by this rule is removed outright, not
2899/// carried dark: no catalog entry, no adapter, no dead code claiming support
2900/// for something nobody can verify runs.
2901/// Host interactive shell for HQ terminal attach (no provider CLI, no network).
2902fn shell_host_spec() -> AgentSpec {
2903    // Windows nodes (incl. MSYS-hosted) have no `/bin/bash` for Win32 Path::is_file;
2904    // fleet inventory on op-box resolves `bash` to the WSL stub at
2905    // `/c/Windows/system32/bash`, which is not a host shell. Use ComSpec/cmd.
2906    #[cfg(windows)]
2907    let (detect_command, program, fixed_args, process_name) = {
2908        let program = std::env::var("ComSpec").unwrap_or_else(|_| {
2909            r"C:\Windows\System32\cmd.exe".to_owned()
2910        });
2911        ("cmd".to_owned(), program, Vec::<String>::new(), "cmd".to_owned())
2912    };
2913    #[cfg(not(windows))]
2914    let (detect_command, program, fixed_args, process_name) = (
2915        "bash".to_owned(),
2916        "/bin/bash".to_owned(),
2917        vec!["-i".to_owned()],
2918        "bash".to_owned(),
2919    );
2920    AgentSpec {
2921        id: AgentId::new("shell").expect("shell agent id"),
2922        revision: "hatchery:shell-pty-1".to_owned(),
2923        display_name: "Host shell".to_owned(),
2924        detection: DetectionSpec {
2925            command: detect_command,
2926            aliases: vec!["sh".to_owned(), "shell".to_owned(), "cmd".to_owned()],
2927            required_commands: Vec::new(),
2928            unsupported_platforms: Vec::new(),
2929        },
2930        launch: LaunchSpec {
2931            program,
2932            fixed_args,
2933        },
2934        expected_processes: vec![ProcessMatcher::Exact {
2935            name: process_name,
2936        }],
2937        prompt: PromptSpec {
2938            initial: InitialPromptMode::None,
2939            native_draft: None,
2940        },
2941        readiness: AgentReadinessSpec::default(),
2942        capabilities: AgentCapabilities {
2943            agent_commands: None,
2944            transports: AgentTransportCapabilities {
2945                pty: true,
2946                pty_adapter: None,
2947                pipe: None,
2948                acp: None,
2949            },
2950            adapters: AgentAdapterCapabilities::default(),
2951        },
2952        verification: SpecVerification::Gate4AgentVerified,
2953    }
2954}
2955
2956fn active_registry() -> Result<AgentRegistry, NodeServerError> {
2957    let mut specs = vec![shell_host_spec()];
2958    specs.extend(
2959        builtin_registry()
2960            .iter()
2961            .filter(|spec| matches!(spec.id.as_str(), "claude" | "codex" | "grok" | "kimi"))
2962            .cloned(),
2963    );
2964    AgentRegistry::new(specs).map_err(|error| NodeServerError::Registry(error.to_string()))
2965}
2966
2967fn provider_contract_manifest(
2968    catalog: &AgentRegistry,
2969) -> Result<
2970    (
2971        Vec<ProviderContractSupport>,
2972        Vec<ProviderAdapterContractSupport>,
2973    ),
2974    NodeServerError,
2975> {
2976    let mut provider_contracts = Vec::new();
2977    let mut provider_adapter_contracts = Vec::new();
2978    for spec in catalog.iter() {
2979        let provider = spec.id.clone();
2980        let revision = ProviderContractRevision::new(spec.revision.clone())
2981            .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2982        provider_contracts.push(ProviderContractSupport {
2983            provider: provider.clone(),
2984            revision,
2985        });
2986        for (family, binding) in declared_adapter_bindings(spec)? {
2987            let revision = AdapterContractRevision::new(binding.revision.clone())
2988                .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2989            provider_adapter_contracts.push(ProviderAdapterContractSupport {
2990                provider: provider.clone(),
2991                family,
2992                adapter_id: binding.id.clone(),
2993                revision,
2994            });
2995        }
2996    }
2997    validate_provider_contract_manifest(&provider_contracts, &provider_adapter_contracts)
2998        .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2999    Ok((provider_contracts, provider_adapter_contracts))
3000}
3001
3002fn declared_adapter_bindings(
3003    spec: &AgentSpec,
3004) -> Result<Vec<(AdapterFamily, &AdapterBinding)>, NodeServerError> {
3005    let mut bindings = Vec::new();
3006    push_declared_binding(
3007        &mut bindings,
3008        AdapterFamily::PtySemantic,
3009        spec.capabilities.transports.pty_adapter.as_ref(),
3010    )?;
3011    push_declared_binding(
3012        &mut bindings,
3013        AdapterFamily::Pipe,
3014        spec.capabilities.adapters.pty_sidecar.as_ref(),
3015    )?;
3016    let pipe_transport = spec.capabilities.transports.pipe.as_ref();
3017    let pipe_family = pipe_transport.map(|transport| match transport.protocol {
3018        gate4agent_types::PipeProtocol::SemanticNdjson
3019        | gate4agent_types::PipeProtocol::StructuredJsonl => AdapterFamily::Pipe,
3020        gate4agent_types::PipeProtocol::OneShotText => AdapterFamily::OneShot,
3021    });
3022    push_declared_binding(
3023        &mut bindings,
3024        AdapterFamily::Pipe,
3025        pipe_transport
3026            .filter(|_| pipe_family == Some(AdapterFamily::Pipe))
3027            .map(|transport| &transport.adapter),
3028    )?;
3029    push_declared_binding(
3030        &mut bindings,
3031        AdapterFamily::Acp,
3032        spec.capabilities
3033            .transports
3034            .acp
3035            .as_ref()
3036            .map(|transport| &transport.adapter),
3037    )?;
3038    push_declared_binding(
3039        &mut bindings,
3040        AdapterFamily::Hook,
3041        spec.capabilities.adapters.hook.as_ref(),
3042    )?;
3043    push_declared_binding(
3044        &mut bindings,
3045        AdapterFamily::ManagedHook,
3046        spec.capabilities.adapters.managed_hook.as_ref(),
3047    )?;
3048    push_declared_binding(
3049        &mut bindings,
3050        AdapterFamily::OneShot,
3051        pipe_transport
3052            .filter(|_| pipe_family == Some(AdapterFamily::OneShot))
3053            .map(|transport| &transport.adapter),
3054    )?;
3055    push_declared_binding(
3056        &mut bindings,
3057        AdapterFamily::OneShot,
3058        spec.capabilities.adapters.one_shot.as_ref(),
3059    )?;
3060    push_declared_binding(
3061        &mut bindings,
3062        AdapterFamily::History,
3063        spec.capabilities.adapters.history.as_ref(),
3064    )?;
3065    push_declared_binding(
3066        &mut bindings,
3067        AdapterFamily::Resume,
3068        spec.capabilities.adapters.resume.as_ref(),
3069    )?;
3070    push_declared_binding(
3071        &mut bindings,
3072        AdapterFamily::SessionOptions,
3073        spec.capabilities.adapters.session_options.as_ref(),
3074    )?;
3075    push_declared_binding(
3076        &mut bindings,
3077        AdapterFamily::CapabilityProbe,
3078        spec.capabilities.adapters.capability_probe.as_ref(),
3079    )?;
3080    Ok(bindings)
3081}
3082
3083fn push_declared_binding<'a>(
3084    bindings: &mut Vec<(AdapterFamily, &'a AdapterBinding)>,
3085    family: AdapterFamily,
3086    binding: Option<&'a AdapterBinding>,
3087) -> Result<(), NodeServerError> {
3088    let Some(binding) = binding else {
3089        return Ok(());
3090    };
3091    if let Some((_, existing)) = bindings
3092        .iter()
3093        .find(|(existing_family, _)| *existing_family == family)
3094    {
3095        if *existing == binding {
3096            return Ok(());
3097        }
3098        return Err(NodeServerError::ProviderContractManifest(format!(
3099            "provider declares conflicting {family:?} adapter bindings"
3100        )));
3101    }
3102    bindings.push((family, binding));
3103    Ok(())
3104}
3105
3106fn merge_durable_state(
3107    configured: &[WorkspaceConfig],
3108    mut loaded: LoadedNodeState,
3109) -> Result<(Vec<WorkspaceConfig>, Vec<ManagedSessionRecord>, Option<String>), NodeServerError> {
3110    let mut workspaces = configured
3111        .iter()
3112        .cloned()
3113        .map(|workspace| (workspace.workspace_id.clone(), workspace))
3114        .collect::<BTreeMap<_, _>>();
3115    let mut roots = workspaces
3116        .values()
3117        .map(|workspace| {
3118            (
3119                platform::root_identity(&workspace.canonical_root),
3120                workspace.workspace_id.clone(),
3121            )
3122        })
3123        .collect::<BTreeMap<_, _>>();
3124    let mut warning = loaded
3125        .warning
3126        .take()
3127        .map(|message| session_registry::sanitized_persistence_summary(&message));
3128
3129    for (workspace_id, canonical_root) in loaded.workspaces {
3130        if let Some(existing) = workspaces.get(&workspace_id) {
3131            if !platform::roots_equal(&existing.canonical_root, &canonical_root) {
3132                return Err(NodeServerError::DurableState(io::Error::new(
3133                    io::ErrorKind::InvalidData,
3134                    DURABLE_STATE_CONFLICT_ERROR,
3135                )));
3136            }
3137            continue;
3138        }
3139        if roots.get(&platform::root_identity(&canonical_root)).is_some() {
3140            return Err(NodeServerError::DurableState(io::Error::new(
3141                io::ErrorKind::InvalidData,
3142                DURABLE_STATE_CONFLICT_ERROR,
3143            )));
3144        }
3145        if !Path::new(&canonical_root).is_dir() {
3146            warning.get_or_insert_with(|| {
3147                session_registry::DURABLE_STATE_WORKSPACE_WARNING.to_owned()
3148            });
3149            continue;
3150        }
3151        let workspace = WorkspaceConfig::new(workspace_id.clone(), &canonical_root).map_err(|_| {
3152            NodeServerError::DurableState(io::Error::new(
3153                io::ErrorKind::InvalidData,
3154                DURABLE_STATE_CONFLICT_ERROR,
3155            ))
3156        })?;
3157        roots.insert(
3158            platform::root_identity(&workspace.canonical_root),
3159            workspace.workspace_id.clone(),
3160        );
3161        workspaces.insert(workspace_id, workspace);
3162    }
3163
3164    for record in &mut loaded.records {
3165        match workspaces.get(&record.workspace_id) {
3166            Some(workspace)
3167                if platform::roots_equal(
3168                    &workspace.canonical_root,
3169                    windows_path_text(&record.canonical_root),
3170                ) => {}
3171            Some(_) => {
3172                return Err(NodeServerError::DurableState(io::Error::new(
3173                    io::ErrorKind::InvalidData,
3174                    DURABLE_STATE_CONFLICT_ERROR,
3175                )));
3176            }
3177            None => {
3178                record.state = ManagedSessionState::Unavailable;
3179                record.active_session = None;
3180                record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
3181            }
3182        }
3183    }
3184    Ok((workspaces.into_values().collect(), loaded.records, warning))
3185}
3186
3187/// One entry of `NodeShared::rejected_command_reasons` -- see that field's
3188/// own doc for the mechanism this exists to close.
3189struct RejectedCommandReason {
3190    message: String,
3191    /// True when the kernel refused this command specifically because the
3192    /// agent does not declare the requested transport (`KernelCommandError::
3193    /// UnsupportedTransport`) -- lets the reader choose the named
3194    /// `NodeFailureCode::UnsupportedTransport` over the generic
3195    /// `BackendOperationFailed` bucket, so this one well-known rejection
3196    /// keeps its identity all the way to the operator wire.
3197    unsupported_transport: bool,
3198    expires_at: Instant,
3199}
3200
3201struct NodeShared {
3202    handle: Gate4AgentHandle,
3203    access_token: String,
3204    node_id: NodeId,
3205    incarnation_id: NodeIncarnationId,
3206    started_at_unix_ms: u64,
3207    workspaces: RwLock<BTreeMap<WorkspaceId, String>>,
3208    worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode>,
3209    managed_worktree_profiles:
3210        BTreeMap<WorkspaceId, BTreeMap<WorktreeProfileId, ManagedWorktreeProfile>>,
3211    managed_worktrees: Mutex<ManagedWorktreeRegistry>,
3212    enabled_providers: Vec<AgentId>,
3213    provider_runtime_statuses: ProviderRuntimeStatuses,
3214    provider_runtime_status_updates:
3215        RwLock<BTreeMap<AgentId, crate::protocol::ProviderRuntimeStatus>>,
3216    provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
3217    provider_contracts: Vec<ProviderContractSupport>,
3218    provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
3219    spawn_profiles: SpawnProfileRegistry,
3220    environment_profiles:
3221        RwLock<BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>>,
3222    environment_materialization_profiles:
3223        RwLock<BTreeMap<SpawnEnvironmentProfileId, NodeSessionMaterializationProfile>>,
3224    /// Station **network** allowlist catalog (opaque policy ids only).
3225    /// Empty by default — any `network_allowlist` on spawn must be registered
3226    /// here or resolve refuses with `UnknownNetworkAllowlist`. Optional
3227    /// startup load via `NodeServerConfig::with_network_allowlist_catalog(_file)`
3228    /// / env `GATE4AGENT_NODE_NETWORK_ALLOWLIST_CATALOG`. Plan
3229    /// `station-network-and-browser-profile-knobs-2026-10-02.md` §2.1 / §4.
3230    /// Never holds cookies / OAuth / proxy credentials.
3231    network_allowlist_catalog: RwLock<crate::network_allowlist_catalog::NetworkAllowlistCatalog>,
3232    /// Node-local exclusive dig2browser station profile leases (feature
3233    /// `dig2-station-probe`). Opaque `browser_profile_id` ↔ session holder;
3234    /// cleared on session end. Never ImportSession / cookies on C2.
3235    #[cfg(feature = "dig2-station-probe")]
3236    browser_station_leases: crate::dig2_station_lease::BrowserStationLeaseTable,
3237    bundle_catalog: RwLock<BundleCatalog>,
3238    delivery_store: Mutex<Option<DeliveryStore>>,
3239    context_pack_store: Mutex<Option<ContextPackStore>>,
3240    context_catalog: RwLock<ContextPackCatalog>,
3241    native_launch_profile_control: Option<NativeLaunchProfileControl>,
3242    harness_mcp_registry: Option<HarnessMcpProxyRegistry>,
3243    native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
3244    session_environment_materializer: Option<SessionEnvironmentMaterializer>,
3245    /// Per-session m4a accounts assigned at spawn (see [`crate::m4a_accounts`]).
3246    m4a_accounts: Option<crate::m4a_accounts::M4aSessionAccounts>,
3247    /// nick -> the session that holds that m4a account (for letter wakes).
3248    m4a_live: Mutex<BTreeMap<String, (AgentInstanceId, AgentId)>>,
3249    /// m4a session key per runtime this node gave an account.
3250    m4a_keys: Mutex<BTreeMap<AgentInstanceId, String>>,
3251    /// Keys a resume hands to its new runtime (same nick and mailbox).
3252    m4a_reuse_keys: Mutex<BTreeMap<AgentInstanceId, String>>,
3253    /// Per-session grok leader sockets (never the operator's shared leader).
3254    grok_leaders: Mutex<BTreeMap<AgentInstanceId, std::path::PathBuf>>,
3255    /// m4a signer proxy (option A): when set, PTY sessions get a process-bound handle
3256    /// instead of a bearer capability.
3257    #[cfg(unix)]
3258    signer_proxy: std::sync::OnceLock<Arc<crate::keystore_proxy::SignerProxy>>,
3259    materializations: Mutex<BTreeMap<MaterializationId, MaterializationOwnershipRecord>>,
3260    spawn_idempotency: Mutex<SpawnIdempotencyCache>,
3261    managed_spawn_replays:
3262        Mutex<BTreeMap<SpawnIdempotencyKey, ManagedWorktreeSpawnReplayRecordV10>>,
3263    controller: Mutex<Option<ControllerLease>>,
3264    /// Fresh spawns waiting for their first successful HQ attach. Removed on
3265    /// Resize/TerminalBytes (sessionize) or when the grace reaper stops them.
3266    provisional_spawns: Mutex<BTreeMap<AgentInstanceId, (SessionAddress, Instant)>>,
3267    /// ACP launch options waiting for the agent to advertise its config options.
3268    pending_acp_options: Mutex<BTreeMap<AgentInstanceId, (Option<String>, Option<String>)>>,
3269    history: Mutex<NodeEventHistory>,
3270    event_tx: broadcast::Sender<NodeEventEnvelope>,
3271    terminal_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3272    /// The outbound `NodeEvent::AgentStream` push channel -- its own
3273    /// dedicated, capability-gated broadcast, mirroring `terminal_event_tx`
3274    /// exactly rather than riding the durable `event_tx` history (see
3275    /// `publish`'s own assertion): a subscriber who never negotiated
3276    /// `NODE_AGENT_STREAM_EVENTS_CAPABILITY` must never receive one of
3277    /// these, the same guarantee `include_terminal_frame_events` already
3278    /// gives terminal frames.
3279    agent_stream_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3280    /// The outbound `NodeEvent::HarnessMcpReadCall` push channel -- mirrors
3281    /// `agent_stream_event_tx` exactly, its own dedicated, capability-gated
3282    /// broadcast rather than a ride on the durable `event_tx` history. A
3283    /// `HarnessMcpReadCall` is a request/response call a live proxy call is
3284    /// waiting on (`harness_mcp_proxy.rs`'s `begin_call`); the shared bus's
3285    /// finite capacity and its willingness to silently evict a lagging
3286    /// subscriber's backlog is exactly how the call vanished with neither
3287    /// side ever logging it (see `publish_transient`'s own doc).
3288    harness_mcp_proxy_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3289    terminal_frame_watermarks:
3290        Mutex<BTreeMap<AgentInstanceId, (SessionAddress, u64)>>,
3291    /// Wakes the drive loop as soon as PTY input is queued (no 10ms nap).
3292    pty_input_wake: tokio::sync::Notify,
3293    /// Per session: when the last PTY input was dispatched (latency probe +
3294    /// a short fast-poll window so the echo is published within ~1ms).
3295    pty_input_marks: Mutex<BTreeMap<AgentInstanceId, (Instant, bool)>>,
3296    /// Provider error classifier + retry state per session (see `provider_error`).
3297    provider_errors: Mutex<crate::provider_error::Tracker>,
3298    next_connection_id: AtomicU64,
3299    next_instance_id: AtomicU64,
3300    next_command_id: AtomicU64,
3301    shutdown: AtomicBool,
3302    shutdown_notify: Notify,
3303    preauth_slots: Arc<Semaphore>,
3304    authenticated_slots: Arc<Semaphore>,
3305    inspection_slots: Arc<Semaphore>,
3306    mutation_gate: AsyncMutex<()>,
3307    session_bindings: Mutex<BTreeMap<AgentInstanceId, SessionBinding>>,
3308    session_records: Mutex<SessionRecords>,
3309    state_path: Option<PathBuf>,
3310    persistence_error: RwLock<Option<String>>,
3311    state_transaction: Mutex<()>,
3312    input_settle_timeout_ms: u64,
3313    /// Drive-loop phase/rate timings -- see `drive_loop_profile`'s own doc
3314    /// comment. Written once per iteration by
3315    /// `drive_runtime_until_shutdown`, read by `GET /metrics`.
3316    drive_loop_profile: Mutex<DriveLoopProfiler>,
3317    /// Latest copy of `NativeRuntime::tick_profile_snapshot()` -- `Copy`,
3318    /// so an overwrite each iteration is cheap. The runtime itself is not
3319    /// reachable from here (it is owned locally by
3320    /// `drive_runtime_until_shutdown`), so this is how its own per-phase
3321    /// tick timings reach `GET /metrics`.
3322    runtime_tick_profile: Mutex<TickProfileSnapshot>,
3323    /// The live shell-efficiency profile, shared with the per-instance
3324    /// worker loops that fold into it.
3325    ///
3326    /// Deliberately NOT the per-iteration snapshot copy `runtime_tick_
3327    /// profile` above keeps: producing a snapshot sorts every ring behind
3328    /// it, and that sort belongs on the read. A `OnceLock` rather than a
3329    /// field set at construction because `NodeShared` is built before the
3330    /// `NativeRuntime` that owns the profile exists -- the drive loop
3331    /// publishes it once, before its first iteration. Unset means the
3332    /// runtime has not started driving yet, which `GET /metrics` reports as
3333    /// empty series rather than as zeroes it never measured.
3334    shell_efficiency_profile: OnceLock<Arc<Mutex<ShellEfficiencyProfile>>>,
3335    /// Physical PTY sessions the native runtime currently owns
3336    /// (`NativeRuntime::active_native_sessions`), refreshed once per drive
3337    /// loop iteration -- deliberately a separate count from
3338    /// `handle.snapshot().sessions.len()` (the control-plane kernel's own
3339    /// session bookkeeping), since the two can disagree and the gap is
3340    /// itself a diagnostic signal `GET /metrics` exposes.
3341    native_session_gauge: AtomicUsize,
3342    /// Lifetime totals for the per-connection serve loop, summed across
3343    /// every live connection.
3344    ///
3345    /// The drive loop's own phase timings account for about ten
3346    /// microseconds an iteration on an idle node, yet the process burns a
3347    /// quarter of a core — and burns it ONLY while a c2 is attached (0.5%
3348    /// with none, 23% with two, measured). That gap is somewhere in
3349    /// serving a connection, and there was no way to see it: iterations
3350    /// says whether the loop is spinning rather than sleeping, and events
3351    /// says whether it is spinning over real work or over nothing.
3352    connection_loop_iterations: AtomicU64,
3353    connection_events_sent: AtomicU64,
3354    /// Lifetime totals for `drive_runtime_until_shutdown`'s own iterations
3355    /// -- `drive_loop_iterations_idle` counts iterations that applied zero
3356    /// `NativeRuntime::tick` observations AND forwarded zero control
3357    /// events; see the exact "produced nothing" definition at the site in
3358    /// `drive_runtime_until_shutdown` that increments these. Read twice
3359    /// and divide by the interval, same rule as `connections` above --
3360    /// `idle / total` is backlog item 5's "wake on work" number.
3361    /// Lifetime count of control commands the kernel refused.
3362    rejected_commands_total: AtomicU64,
3363    /// The named reason behind a recent kernel rejection, by the
3364    /// `CommandId` `dispatch_bounded` handed back when it enqueued that
3365    /// command -- populated by `drive_runtime_until_shutdown` for every
3366    /// `Err` in a tick's `command_outcomes`, and drained by whichever
3367    /// spawn-dispatch waiter recognizes its own `Register`/`Start`
3368    /// `CommandId` among them.
3369    ///
3370    /// A `ControlCommand` only ever fails synchronously (a `NodeFailure`
3371    /// out of `dispatch`/`dispatch_bounded` itself) for a port-level ingress
3372    /// problem -- full or disconnected -- never for the kernel refusing the
3373    /// command's content (an unsupported transport, an unknown agent, ...);
3374    /// that verdict exists only once `NativeRuntime::tick` has processed the
3375    /// command, one drive-loop iteration later, in a different task than
3376    /// whichever caller is awaiting the spawn. Without this map, a spawn
3377    /// wait loop that polls `self.handle.snapshot()` for a session that
3378    /// will now never appear has no way to learn why -- it can only spin
3379    /// until its own commit deadline and report a generic "did not commit"
3380    /// timeout, discarding a reason the kernel already named precisely (see
3381    /// `KernelCommandError`'s variants, e.g. `UnsupportedTransport`).
3382    rejected_command_reasons: Mutex<BTreeMap<CommandId, RejectedCommandReason>>,
3383    drive_loop_iterations_total: AtomicU64,
3384    drive_loop_iterations_idle: AtomicU64,
3385    /// The operator-chosen age/keep-N policy the retention sweep applies to
3386    /// `ManagedSessionState::Unavailable` records. Both `0` by default --
3387    /// see `SessionRecordRetentionConfig`'s own doc comment.
3388    session_record_retention: SessionRecordRetentionConfig,
3389    /// Lifetime count of managed session records the retention sweep has
3390    /// retired. Same tier as `rejected_commands_total` above: a live,
3391    /// process-lifetime instrument, never persisted and never reloaded from
3392    /// `state-v1.json` -- a restart legitimately resets it, matching every
3393    /// other `*_total` counter here.
3394    retired_records_total: AtomicU64,
3395    #[cfg(feature = "fixture")]
3396    fixture_semantic_hook_policy: bool,
3397    #[cfg(feature = "fixture")]
3398    fixture_spawn_managed_worktree_v2_failure_probe: SpawnManagedWorktreeV2FailureProbe,
3399}
3400
3401impl Drop for NodeShared {
3402    fn drop(&mut self) {
3403        SessionLifecycleHook::on_shutdown(self);
3404    }
3405}
3406
3407#[derive(Clone, Debug, Eq, PartialEq)]
3408struct SessionBinding {
3409    workspace_id: WorkspaceId,
3410    generation: SessionGeneration,
3411    runtime_policy: ProviderRuntimePolicy,
3412    pending_resume: Option<(SessionGeneration, CommandId, ProviderRuntimePolicy)>,
3413    record_id: Option<SessionRecordId>,
3414    managed_worktree_lease_id: Option<ManagedWorktreeLeaseId>,
3415    environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
3416    bundle: Option<ResolvedBundleReceipt>,
3417    context: Option<ResolvedContextPackReceipt>,
3418    materialization_id: Option<MaterializationId>,
3419}
3420
3421#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3422enum SpawnRecordPolicy {
3423    ProviderIdentityOnly,
3424    Always,
3425}
3426
3427struct NativeEnvironmentSelectionGuard {
3428    control: Option<NativeLaunchProfileControl>,
3429    instance_id: AgentInstanceId,
3430}
3431
3432struct NativeInstanceOverlayGuard {
3433    control: Option<NativeLaunchProfileControl>,
3434    instance_id: AgentInstanceId,
3435}
3436
3437struct NativeHarnessMcpOverlayGuard {
3438    control: NativeLaunchProfileControl,
3439    instance_id: AgentInstanceId,
3440}
3441
3442impl NativeHarnessMcpOverlayGuard {
3443    fn retain(self) { std::mem::forget(self); }
3444}
3445
3446impl Drop for NativeHarnessMcpOverlayGuard {
3447    fn drop(&mut self) {
3448        self.control
3449            .clear_native_mcp_server_launch_overlay(self.instance_id);
3450    }
3451}
3452
3453impl NativeInstanceOverlayGuard {
3454    fn retain(mut self) {
3455        self.control = None;
3456    }
3457}
3458
3459impl Drop for NativeInstanceOverlayGuard {
3460    fn drop(&mut self) {
3461        if let Some(control) = self.control.as_ref() {
3462            control.clear_native_instance_launch_overlay(self.instance_id);
3463        }
3464    }
3465}
3466
3467enum PreparedNativeLaunchOverlay {
3468    Environment(NativeLaunchEnvironmentOverlay),
3469    Instance(NativeInstanceLaunchOverlay),
3470}
3471
3472impl NativeEnvironmentSelectionGuard {
3473    fn retain(mut self) {
3474        self.control = None;
3475    }
3476}
3477
3478impl Drop for NativeEnvironmentSelectionGuard {
3479    fn drop(&mut self) {
3480        if let Some(control) = self.control.as_ref() {
3481            control.clear_native_launch_profile_selection(self.instance_id);
3482        }
3483    }
3484}
3485
3486struct SessionMaterializationGuard<'a> {
3487    shared: &'a NodeShared,
3488    id: Option<MaterializationId>,
3489    /// Absolute ProviderHome (`home/`) under the session materialization root.
3490    /// Used for Claude station network `--settings` sidecar writes.
3491    provider_home: Option<PathBuf>,
3492}
3493
3494impl SessionMaterializationGuard<'_> {
3495    fn id(&self) -> Option<&MaterializationId> {
3496        self.id.as_ref()
3497    }
3498
3499    fn provider_home(&self) -> Option<&Path> {
3500        self.provider_home.as_deref()
3501    }
3502
3503    fn retain(mut self) {
3504        self.id = None;
3505    }
3506}
3507
3508impl Drop for SessionMaterializationGuard<'_> {
3509    fn drop(&mut self) {
3510        if let Some(id) = self.id.take() {
3511            let _ = self.shared.cleanup_materialization(&id);
3512        }
3513    }
3514}
3515
3516
3517/// Port: product-side reactions to session lifecycle transitions
3518/// (station lease, harness MCP overlay/registry, environment launch overlays).
3519trait SessionLifecycleHook {
3520    /// A binding was removed (unbind or spawn rollback).
3521    fn on_binding_removed(&self, address: &SessionAddress, removed: Option<&SessionBinding>);
3522    /// A session reached a terminal control event that revokes its harness MCP door.
3523    fn on_session_ended(&self, address: &SessionAddress);
3524    /// Node shutdown (or drop): revoke every harness MCP reservation.
3525    fn on_shutdown(&self);
3526    /// A harness MCP reservation was aborted before activation.
3527    fn on_harness_reservation_aborted(&self, instance_id: AgentInstanceId);
3528    /// Release session-owned materialization for a removed binding.
3529    fn cleanup_session_materialization(
3530        &self,
3531        address: &SessionAddress,
3532        binding: &SessionBinding,
3533    ) -> Result<(), NodeFailure>;
3534}
3535
3536impl SessionLifecycleHook for NodeShared {
3537    fn on_session_ended(&self, address: &SessionAddress) {
3538        self.retire_session_grok_leader(address.session.instance_id);
3539        // A process-tree kill (Windows `taskkill /T`, a supervisor stop) ends the children before
3540        // the node: drop the journal entry only if the node is still up a moment later, so a
3541        // node killed with its children still restores them.
3542        if !self.shutdown.load(Ordering::Acquire) {
3543            if let Some(file) = self.session_resume_journal() {
3544                let id = address.session.instance_id.0;
3545                let revision = crate::pty_resume::load(&file).iter()
3546                    .find(|entry| entry.instance_id == id).map(|entry| entry.revision);
3547                let _ = std::thread::Builder::new().name("resume-forget".into()).spawn(move || {
3548                    std::thread::sleep(std::time::Duration::from_secs(5));
3549                    if let Some(revision) = revision {
3550                        crate::pty_resume::remove_revision(&file, id, revision);
3551                    }
3552                });
3553            }
3554        }
3555        if let Some(accounts) = self.m4a_accounts.as_ref() {
3556            let instance = address.session.instance_id;
3557            let live = self.m4a_live.lock().unwrap_or_else(|p| p.into_inner());
3558            for (nick, _) in live.iter().filter(|(_, (i, _))| *i == instance) {
3559                accounts.remove_session_file(nick);
3560            }
3561        }
3562        if let Some(registry) = self.harness_mcp_registry.as_ref() {
3563            registry.revoke_session(address);
3564        }
3565        if let Some(control) = self.native_launch_profile_control.as_ref() {
3566            control.clear_native_mcp_server_launch_overlay(address.session.instance_id);
3567        }
3568    }
3569
3570    fn on_shutdown(&self) {
3571        let leaders: Vec<_> = std::mem::take(&mut *self.grok_leaders.lock().unwrap_or_else(|p| p.into_inner()))
3572            .into_values()
3573            .collect();
3574        for socket in leaders {
3575            crate::grok_leader::retire(&socket);
3576        }
3577        if let Some(registry) = self.harness_mcp_registry.as_ref() {
3578            let instances = registry.shutdown();
3579            if let Some(control) = self.native_launch_profile_control.as_ref() {
3580                for instance_id in instances {
3581                    control.clear_native_mcp_server_launch_overlay(instance_id);
3582                }
3583            }
3584        }
3585    }
3586
3587    fn on_harness_reservation_aborted(&self, instance_id: AgentInstanceId) {
3588        if let Some(control) = self.native_launch_profile_control.as_ref() {
3589            control.clear_native_mcp_server_launch_overlay(instance_id);
3590        }
3591    }
3592
3593    fn cleanup_session_materialization(
3594        &self,
3595        address: &SessionAddress,
3596        binding: &SessionBinding,
3597    ) -> Result<(), NodeFailure> {
3598        self.cleanup_session_owned_materialization(address, binding)
3599    }
3600
3601    fn on_binding_removed(&self, address: &SessionAddress, removed: Option<&SessionBinding>) {
3602        // Exclusive dig2browser station profile lease is node-local; clear
3603        // on every session unbind / rollback. Never ImportSession/cookies.
3604        #[cfg(feature = "dig2-station-probe")]
3605        {
3606            crate::dig2_station_lease::release_lease_for_holder(
3607                &self.browser_station_leases,
3608                address.session.instance_id,
3609            );
3610        }
3611        if let Some(control) = self.native_launch_profile_control.as_ref() {
3612            control.clear_native_mcp_server_launch_overlay(
3613                address.session.instance_id,
3614            );
3615        }
3616        if let Some(registry) = self.harness_mcp_registry.as_ref() {
3617            registry.revoke_session(address);
3618        }
3619        if removed
3620            .is_some_and(|binding| {
3621                binding.environment_profile.is_some()
3622                    || binding.bundle.is_some()
3623                    || binding.context.is_some()
3624            })
3625        {
3626            if let Some(control) = self.native_launch_profile_control.as_ref() {
3627                control.clear_native_launch_profile_selection(
3628                    address.session.instance_id,
3629                );
3630                control.clear_native_instance_launch_overlay(
3631                    address.session.instance_id,
3632                );
3633            }
3634        }
3635    }
3636}
3637
3638struct SpawnPrepareRequest<'r> {
3639    instance_id: AgentInstanceId,
3640    address: &'r SessionAddress,
3641    provider: &'r AgentId,
3642    mode: SessionMode,
3643    environment_profile: Option<&'r ResolvedEnvironmentProfileReceipt>,
3644    bundle: Option<&'r ResolvedBundleReceipt>,
3645    context: Option<&'r ResolvedContextPackReceipt>,
3646    managed_authority: Option<&'r ManagedWorktreeLeaseId>,
3647    harness_mcp: Option<&'r PreparedHarnessMcpSpawn>,
3648    approval_level: ApprovalLevel,
3649    network_allowlist: Option<&'r crate::protocol::SpawnNetworkAllowlistId>,
3650    browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
3651}
3652
3653/// Guards held between "session identity minted" and "launch committed".
3654/// Field order is the drop order and matches the pre-refactor local order
3655/// (instance overlay, environment selection, materialization, harness, station lease).
3656struct PreparedSpawn<'a> {
3657    instance_overlay: Option<NativeInstanceOverlayGuard>,
3658    environment_selection: Option<NativeEnvironmentSelectionGuard>,
3659    materialization_guard: Option<SessionMaterializationGuard<'a>>,
3660    harness_mcp_overlay: Option<NativeHarnessMcpOverlayGuard>,
3661    #[cfg(feature = "dig2-station-probe")]
3662    browser_station_lease_guard: Option<crate::dig2_station_lease::BrowserStationLeaseGuard>,
3663    materialization_id: Option<MaterializationId>,
3664}
3665
3666impl PreparedSpawn<'_> {
3667    /// Session-owned launch state now belongs to the binding.
3668    fn retain_session_guards(&mut self) {
3669        if let Some(selection) = self.environment_selection.take() {
3670            selection.retain();
3671        }
3672        if let Some(overlay) = self.instance_overlay.take() {
3673            overlay.retain();
3674        }
3675        if let Some(guard) = self.materialization_guard.take() {
3676            guard.retain();
3677        }
3678    }
3679
3680    /// Launch committed: keep harness overlay and station lease for the session lifetime.
3681    fn retain_launch_guards(&mut self) {
3682        if let Some(overlay) = self.harness_mcp_overlay.take() {
3683            overlay.retain();
3684        }
3685        #[cfg(feature = "dig2-station-probe")]
3686        if let Some(guard) = self.browser_station_lease_guard.take() {
3687            guard.retain();
3688        }
3689    }
3690}
3691
3692/// Port: everything product-side that must be prepared before a session is
3693/// bound and started (station lease, harness MCP overlay, materialization,
3694/// environment selection, network overlays, launch overlay).
3695trait SpawnPreparer {
3696    fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure>;
3697    /// Select the native launch profile for an environment-profile session.
3698    fn select_environment(
3699        &self,
3700        instance_id: AgentInstanceId,
3701        provider: &AgentId,
3702        mode: SessionMode,
3703        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3704    ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure>;
3705    /// Install a prepared launch overlay for the instance.
3706    fn install_launch_overlay(
3707        &self,
3708        instance_id: AgentInstanceId,
3709        overlay: PreparedNativeLaunchOverlay,
3710    ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure>;
3711}
3712
3713impl NodeShared {
3714    /// m4a session key of a new runtime: a resume reuses the key (and so the nick and mailbox)
3715    /// of the runtime it replaces; a fresh spawn gets `<incarnation>-<instance>`.
3716    fn m4a_session_key(&self, instance_id: AgentInstanceId) -> String {
3717        let reused = self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).remove(&instance_id);
3718        let key = reused.unwrap_or_else(|| format!("{}-{}", self.incarnation_id, instance_id.0));
3719        self.m4a_keys.lock().unwrap_or_else(|p| p.into_inner()).insert(instance_id, key.clone());
3720        if let Some(file) = self.session_resume_journal() {
3721            crate::pty_resume::set_session_key(&file, instance_id.0, &key);
3722        }
3723        key
3724    }
3725
3726    fn session_workdir(&self, root: &std::path::Path, instance_id: AgentInstanceId, provider: &AgentId) -> Option<std::path::PathBuf> {
3727        let accounts = self.m4a_accounts.as_ref()?;
3728        let nick = self.m4a_nick_of(instance_id)?;
3729        match accounts.session_workdir(root, provider.as_str(), &nick) {
3730            Ok(dir) => dir,
3731            Err(error) => {
3732                tracing::warn!(%nick, %error, "session folder not prepared; the session runs in the workspace root");
3733                None
3734            }
3735        }
3736    }
3737
3738    fn m4a_nick_of(&self, instance_id: AgentInstanceId) -> Option<String> {
3739        self.m4a_live.lock().unwrap_or_else(|p| p.into_inner()).iter().find(|(_, (i, _))| *i == instance_id).map(|(n, _)| n.clone())
3740    }
3741
3742    /// The managed record of a node session carries its m4a nick as its name (HQ shows it,
3743    /// `/api/spawn` returns it).
3744    fn name_record_after_nick(&self, instance_id: AgentInstanceId, nick: &str) {
3745        let mut records = self.session_records.lock().unwrap_or_else(|p| p.into_inner());
3746        for record in records.records.values_mut() {
3747            if record.active_session.as_ref().is_some_and(|a| a.session.instance_id == instance_id) {
3748                record.display_name = nick.to_owned();
3749            }
3750        }
3751    }
3752
3753    /// Gives a node session its own m4a account: identity env, session file, MCP entry and
3754    /// the mailbox listener that wakes it.
3755    fn m4a_session_overlay(
3756        &self,
3757        accounts: &crate::m4a_accounts::M4aSessionAccounts,
3758        m4a_provider: &str,
3759        provider: &AgentId,
3760        instance_id: AgentInstanceId,
3761        environment_overlay: Option<PreparedNativeLaunchOverlay>,
3762        session_key: &str,
3763        mode: SessionMode,
3764    ) -> Result<Option<PreparedNativeLaunchOverlay>, NodeFailure> {
3765        let session_key = session_key.to_owned();
3766        if mode == SessionMode::Acp {
3767            return self.m4a_acp_mcp_overlay(accounts, m4a_provider, provider, instance_id, environment_overlay, &session_key);
3768        }
3769        Ok(                match accounts.provision(m4a_provider, &session_key) {
3770                    Ok(account) => {
3771                        crate::pty_resume::note_account_ref(&session_key, &account.session_id);
3772                        let mut identity = account.env_mutations();
3773                        identity.extend(crate::m4a_accounts::provider_session_env(provider.as_str()));
3774                        let merged = match environment_overlay {
3775                            None => NativeInstanceLaunchOverlay::new_session_identity(provider.clone(), identity)
3776                                .map(PreparedNativeLaunchOverlay::Instance),
3777                            Some(PreparedNativeLaunchOverlay::Instance(mut overlay)) => {
3778                                overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
3779                            }
3780                            Some(PreparedNativeLaunchOverlay::Environment(mut overlay)) => {
3781                                overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Environment(overlay))
3782                            }
3783                        }
3784                        .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a session account overlay is invalid"))?;
3785                        tracing::info!(provider = %provider, instance_id = ?instance_id, nick = %account.nick, "m4a session account assigned at spawn");
3786                        let mut merged = merged;
3787                        match accounts.write_session_file(&account) {
3788                            Err(error) => tracing::warn!(nick = %account.nick, %error, "m4a session file not written"),
3789                            Ok(_) => match accounts.mcp_launch_args(provider.as_str(), &account.nick) {
3790                                Ok(args) if !args.is_empty() => {
3791                                    merged = match merged {
3792                                        PreparedNativeLaunchOverlay::Instance(mut overlay) => overlay
3793                                            .append_extra_args(args)
3794                                            .map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
3795                                            .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a mcp overlay is invalid"))?,
3796                                        PreparedNativeLaunchOverlay::Environment(overlay) => overlay
3797                                            .into_instance_with_extra_args(args)
3798                                            .map(PreparedNativeLaunchOverlay::Instance)
3799                                            .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a mcp overlay is invalid"))?,
3800                                    };
3801                                    tracing::info!(nick = %account.nick, "m4a mcp server delivered at spawn");
3802                                }
3803                                Ok(_) => {}
3804                                Err(error) => tracing::warn!(nick = %account.nick, %error, "m4a mcp server not delivered"),
3805                            },
3806                        }
3807                        self.m4a_live
3808                            .lock()
3809                            .unwrap_or_else(|p| p.into_inner())
3810                            .insert(account.nick.clone(), (instance_id, provider.clone()));
3811                        self.name_record_after_nick(instance_id, &account.nick);
3812                        crate::git_watch::bind_nick(instance_id.0, &account.nick);
3813                        tracing::info!(nick = %account.nick, instance = instance_id.0, "m4a nick bound to session");
3814                        // Letters to this account wake the session: the node listens on its mailbox.
3815                        crate::m4a_wake::watch_spawned(provider.as_str(), &account.nick, &account.session_id, account.store_root.as_deref(), None);
3816                        Some(merged)
3817                    }
3818                    Err(error) => {
3819                        tracing::warn!(provider = %provider, instance_id = ?instance_id, %error, "m4a session account not assigned");
3820                        environment_overlay
3821                    }
3822                })
3823    }
3824}
3825
3826impl NodeShared {
3827    /// ACP has no env/argv overlay for this: the session's m4a account rides as an MCP server
3828    /// handed to the agent in session/new, with the account env on that server only.
3829    fn m4a_acp_mcp_overlay(
3830        &self,
3831        accounts: &crate::m4a_accounts::M4aSessionAccounts,
3832        m4a_provider: &str,
3833        provider: &AgentId,
3834        instance_id: AgentInstanceId,
3835        environment_overlay: Option<PreparedNativeLaunchOverlay>,
3836        session_key: &str,
3837    ) -> Result<Option<PreparedNativeLaunchOverlay>, NodeFailure> {
3838        let Some(control) = self.native_launch_profile_control.as_ref() else {
3839            tracing::warn!(%provider, "m4a acp account not assigned: no launch profile control");
3840            return Ok(environment_overlay);
3841        };
3842        let account = match accounts.provision(m4a_provider, session_key) {
3843            Ok(a) => a,
3844            Err(error) => {
3845                tracing::warn!(%provider, instance_id = ?instance_id, %error, "m4a session account not assigned");
3846                return Ok(environment_overlay);
3847            }
3848        };
3849        let session_file = match accounts.write_session_file(&account) {
3850            Ok(p) => p,
3851            Err(error) => {
3852                tracing::warn!(nick = %account.nick, %error, "m4a session file not written");
3853                return Ok(environment_overlay);
3854            }
3855        };
3856        let env_entries: Vec<(OsString, OsString)> = account
3857            .env_mutations()
3858            .into_iter()
3859            .chain(crate::m4a_accounts::provider_session_env(provider.as_str()))
3860            .filter_map(|m| m.value.map(|v| (m.key, v)))
3861            .collect();
3862        let args: Vec<OsString> = vec![
3863            "--session-file".into(),
3864            session_file.into_os_string(),
3865            "mcp".into(),
3866            "--provider".into(),
3867            provider.as_str().into(),
3868        ];
3869        let overlay = NativeMcpServerLaunchOverlay::new(
3870            provider.clone(),
3871            crate::m4a_accounts::mcp_server_name(provider.as_str()).as_str(),
3872            accounts.bin().as_os_str().to_os_string(),
3873            args,
3874            env_entries,
3875            Vec::new(),
3876        );
3877        match overlay.map_err(|e| e.to_string()).and_then(|o| control.install_native_mcp_server_launch_overlay(instance_id, o).map_err(|e| e.to_string())) {
3878            Ok(_) => tracing::info!(nick = %account.nick, instance = instance_id.0, "m4a mcp server delivered over acp"),
3879            Err(error) => {
3880                tracing::warn!(nick = %account.nick, %error, "m4a acp mcp server not delivered");
3881                return Ok(environment_overlay);
3882            }
3883        }
3884        self.m4a_live
3885            .lock()
3886            .unwrap_or_else(|p| p.into_inner())
3887            .insert(account.nick.clone(), (instance_id, provider.clone()));
3888        self.name_record_after_nick(instance_id, &account.nick);
3889        crate::git_watch::bind_nick(instance_id.0, &account.nick);
3890        crate::m4a_wake::watch_spawned(provider.as_str(), &account.nick, &account.session_id, account.store_root.as_deref(), None);
3891        Ok(environment_overlay)
3892    }
3893}
3894
3895impl SpawnPreparer for NodeShared {
3896    fn select_environment(
3897        &self,
3898        instance_id: AgentInstanceId,
3899        provider: &AgentId,
3900        mode: SessionMode,
3901        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3902    ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
3903        self.select_environment_profile(instance_id, provider, mode, environment_profile)
3904    }
3905
3906    fn install_launch_overlay(
3907        &self,
3908        instance_id: AgentInstanceId,
3909        overlay: PreparedNativeLaunchOverlay,
3910    ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
3911        self.install_prepared_launch_overlay(instance_id, overlay)
3912    }
3913
3914    fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure> {
3915        let SpawnPrepareRequest {
3916            instance_id,
3917            address,
3918            provider,
3919            mode,
3920            environment_profile,
3921            bundle,
3922            context,
3923            managed_authority,
3924            harness_mcp,
3925            approval_level,
3926            network_allowlist,
3927            browser_profile_id,
3928        } = request;
3929        // Track A follow-on: exclusive node-local BrowserStationLease after
3930        // dig2 probe (feature dig2-station-probe). Opaque profile id only —
3931        // never ImportSession / cookies on C2. Linux/feature-off: probe
3932        // already refused or stub; this block is cfg'd out when feature off.
3933        #[cfg(feature = "dig2-station-probe")]
3934        let mut browser_station_lease_guard = if let Some(profile_id) = browser_profile_id {
3935            use crate::dig2_station_lease::{acquire_lease, BrowserStationLeaseError};
3936            match acquire_lease(
3937                &self.browser_station_leases,
3938                profile_id,
3939                instance_id,
3940                unix_time_ms(),
3941            ) {
3942                Ok(guard) => Some(guard),
3943                Err(BrowserStationLeaseError::ProfileBusy { .. }) => {
3944                    return Err(failure(
3945                        NodeFailureCode::BrowserStationProfileBusy,
3946                        "dig2browser station profile is already leased by another session",
3947                    ));
3948                }
3949                Err(BrowserStationLeaseError::CapacityExceeded) => {
3950                    return Err(failure(
3951                        NodeFailureCode::BrowserStationProfileBusy,
3952                        "dig2browser station profile lease capacity exhausted",
3953                    ));
3954                }
3955                Err(BrowserStationLeaseError::HolderAlreadyLeased) => {
3956                    return Err(failure(
3957                        NodeFailureCode::BrowserStationProfileBusy,
3958                        "session already holds a dig2browser station profile lease",
3959                    ));
3960                }
3961            }
3962        } else {
3963            None
3964        };
3965        #[cfg(not(feature = "dig2-station-probe"))]
3966        let _ = browser_profile_id;
3967        let mut harness_mcp_overlay = if let Some(prepared) = harness_mcp {
3968            // Slice A(ii) of gate4agent-arc-mailbox-and-task-layer: the door
3969            // exists for PTY (env vars) and ACP (`session/new.mcpServers`,
3970            // `gate4agent-shell-native`'s `mcp_server_acp_entry`) alike --
3971            // both read the identical overlay resolved below, one as child
3972            // environment, the other translated into one stdio MCP server
3973            // entry. `Inline`/`Pipe` stays refused: this arc's proof is ACP
3974            // only, and nothing downstream reads a harness-MCP overlay for
3975            // that transport yet.
3976            if !matches!(mode, SessionMode::Pty | SessionMode::Acp) || prepared.provider != *provider
3977            {
3978                return Err(failure(
3979                    NodeFailureCode::BindingMismatch,
3980                    "harness MCP reservation does not match the exact PTY/ACP provider",
3981                ));
3982            }
3983            prepared.verify_helper_program().map_err(harness_mcp_failure)?;
3984            let control = self.native_launch_profile_control.clone().ok_or_else(|| {
3985                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP launch control is unavailable")
3986            })?;
3987            // Every name below is the caller's, carried in the reservation's
3988            // launch description: which environment variables receive the
3989            // endpoint, token and helper program, what the MCP server is called,
3990            // which argv it gets and which stale variables are erased.
3991            let launch = prepared.launch();
3992            let mut env_entries = vec![
3993                (
3994                    OsString::from(launch.endpoint_env.as_str()),
3995                    prepared.endpoint().as_os_str().to_os_string(),
3996                ),
3997                (
3998                    OsString::from(launch.token_env.as_str()),
3999                    prepared.token().expose().into(),
4000                ),
4001                (
4002                    OsString::from(launch.program_env.as_str()),
4003                    prepared.helper_program().as_os_str().to_os_string(),
4004                ),
4005            ];
4006            if let Some(trace_entry) = harness_mcp_trace_env_entry(launch) {
4007                env_entries.push(trace_entry);
4008            }
4009            if let Some(door) = crate::door::door_listen_addr() {
4010                env_entries.push((
4011                    OsString::from("HATCHERY_NODE_DOOR"),
4012                    OsString::from(door.to_string()),
4013                ));
4014                // Same key shape as HQ browser/draft ids:
4015                // `pty-{incarnation}-{workspace}-{instance}` — never reuse a
4016                // slot across node boots when the instance counter resets.
4017                let session_key = format!(
4018                    "pty-{}-{}-{}",
4019                    self.incarnation_id,
4020                    address.workspace_id.as_str(),
4021                    address.session.instance_id.0
4022                );
4023                env_entries.push((
4024                    OsString::from("HATCHERY_LOCAL_SERVER_SESSION"),
4025                    OsString::from(session_key),
4026                ));
4027            }
4028            let overlay = NativeMcpServerLaunchOverlay::new(
4029                provider.clone(),
4030                launch.server_name.as_str(),
4031                prepared.helper_program().as_os_str().to_os_string(),
4032                launch.args.iter().map(OsString::from).collect(),
4033                env_entries,
4034                launch.scrub_env.iter().map(OsString::from).collect(),
4035            ).map_err(|_| failure(
4036                NodeFailureCode::HarnessMcpUnavailable,
4037                "harness MCP launch overlay is unavailable",
4038            ))?;
4039            control
4040                .install_native_mcp_server_launch_overlay(instance_id, overlay)
4041                .map_err(|_| failure(
4042                    NodeFailureCode::HarnessMcpUnavailable,
4043                    "harness MCP launch overlay could not be installed",
4044                ))?;
4045            // State-change line: the overlay is installed and will be read
4046            // back by the PTY child's environment or, for ACP,
4047            // `gate4agent-shell-native`'s `mcp_server_acp_entry` at spawn
4048            // time -- no secrets here, only which instance/provider/mode
4049            // now carries a harness-MCP door.
4050            tracing::info!(
4051                node_id = %self.node_id,
4052                instance_id = ?instance_id,
4053                provider = %provider,
4054                mode = ?mode,
4055                "harness MCP overlay installed for spawn",
4056            );
4057            Some(NativeHarnessMcpOverlayGuard { control, instance_id })
4058        } else {
4059            None
4060        };
4061        let prepared_materialization = self.prepare_session_materialization(
4062            &address,
4063            &provider,
4064            mode,
4065            environment_profile,
4066            bundle,
4067            context,
4068            managed_authority.cloned(),
4069        )?;
4070        let (environment_overlay, mut materialization_guard) = match prepared_materialization {
4071            Some((overlay, guard)) => (Some(overlay), Some(guard)),
4072            None => (None, None),
4073        };
4074        let materialization_id = materialization_guard
4075            .as_ref()
4076            .and_then(SessionMaterializationGuard::id)
4077            .cloned();
4078        let mut environment_selection = SpawnPreparer::select_environment(self, 
4079            instance_id,
4080            &provider,
4081            mode,
4082            environment_profile,
4083        )?;
4084        // Station network overlays: Codex `-c network_access` and/or Claude
4085        // ProviderHome `--settings` sidecar (permits → allowedDomains).
4086        let provider_home = materialization_guard
4087            .as_ref()
4088            .and_then(SessionMaterializationGuard::provider_home);
4089        let network_overlay_args = self.resolve_network_allowlist_overlay_args(
4090            &provider,
4091            mode,
4092            approval_level,
4093            network_allowlist,
4094            provider_home,
4095        )?;
4096        let mut network_overlay_args = network_overlay_args;
4097        // Auto-permit (default on): pre-trust cwd / onboarding in provider config + auto/yolo flags.
4098        {
4099            let cwd = self.workspace_root(&address.workspace_id).ok().map(PathBuf::from);
4100            let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
4101            if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") {
4102                homes.extend(std::env::split_paths(&extra));
4103            }
4104            if let Some(h) = provider_home { homes.push(h.to_path_buf()); }
4105            crate::auto_permit::prepare(provider.as_str(), &homes, cwd.as_deref());
4106            network_overlay_args.extend(crate::auto_permit::launch_args(provider.as_str(), mode));
4107        }
4108        if mode == SessionMode::Acp && crate::auto_permit::acp_auto_approve(provider.as_str()) {
4109            crate::auto_permit::register_acp(instance_id.0, provider.as_str());
4110        }
4111        // Session registry: journal the session so it is resumed after a node restart.
4112        if matches!(mode, SessionMode::Pty | SessionMode::Acp) && crate::pty_resume::enabled() && crate::pty_resume::journalled(provider.as_str(), mode == SessionMode::Acp) {
4113            if let Some(file) = self.session_resume_journal() {
4114                crate::pty_resume::upsert(&file, crate::pty_resume::Entry {
4115                    revision: 0,
4116                    instance_id: instance_id.0,
4117                    provider: provider.as_str().to_owned(),
4118                    workspace_id: address.workspace_id.as_str().to_owned(),
4119                    provider_session_id: String::new(),
4120                    session_key: self.m4a_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&instance_id).cloned()
4121                        .or_else(|| self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&instance_id).cloned())
4122                        .unwrap_or_else(|| format!("{}-{}", self.incarnation_id, instance_id.0)),
4123                    cwd: String::new(),
4124                    acp: mode == SessionMode::Acp,
4125                    options: SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten(),
4126                    cols: 0,
4127                    rows: 0,
4128                    origin: SPAWN_ORIGIN.try_with(|o| o.clone()).ok().flatten(),
4129                    parent_session_id: SPAWN_PARENT.try_with(|o| o.clone()).ok().flatten(),
4130                });
4131                if let Some(dir) = file.parent() {
4132                    crate::session_meta::upsert(dir, instance_id.0, crate::session_meta::Meta {
4133                        origin: SPAWN_ORIGIN.try_with(|o| o.clone()).ok().flatten(),
4134                        parent_session_id: SPAWN_PARENT.try_with(|o| o.clone()).ok().flatten(),
4135                        provider: Some(provider.as_str().to_owned()),
4136                        ..crate::session_meta::Meta::default()
4137                    });
4138                    crate::session_meta::revive(dir, instance_id.0);
4139                }
4140                tracing::info!(instance_id = instance_id.0, provider = %provider, "session journalled for restart resume");
4141            }
4142        }
4143        if let Some(socket) = self.session_grok_leader_socket(&provider, mode, instance_id) {
4144            network_overlay_args.extend(crate::grok_leader::leader_socket_args(&socket));
4145        }
4146        let environment_overlay = match (environment_overlay.flatten(), network_overlay_args) {
4147            (prepared, args) if args.is_empty() => prepared,
4148            (Some(PreparedNativeLaunchOverlay::Instance(mut overlay)), args) => {
4149                overlay.append_extra_args(args).map_err(|_| {
4150                    failure(
4151                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4152                        "network allowlist overlay could not merge with instance launch args",
4153                    )
4154                })?;
4155                Some(PreparedNativeLaunchOverlay::Instance(overlay))
4156            }
4157            (Some(PreparedNativeLaunchOverlay::Environment(overlay)), args) => {
4158                Some(PreparedNativeLaunchOverlay::Instance(
4159                    overlay
4160                        .into_instance_with_extra_args(args)
4161                        .map_err(|_| {
4162                            failure(
4163                                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4164                                "network allowlist overlay could not merge with environment overlay",
4165                            )
4166                        })?,
4167                ))
4168            }
4169            (None, args) => Some(PreparedNativeLaunchOverlay::Instance(
4170                NativeInstanceLaunchOverlay::new(
4171                    provider.clone(),
4172                    match mode {
4173                        SessionMode::Pty => TransportKind::Pty,
4174                        SessionMode::Acp => TransportKind::Acp,
4175                        SessionMode::Inline => TransportKind::Pipe,
4176                    },
4177                    Vec::new(),
4178                    args,
4179                )
4180                .map_err(|_| {
4181                    failure(
4182                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4183                        "network allowlist overlay is invalid for this transport",
4184                    )
4185                })?,
4186            )),
4187        };
4188        // Per-session m4a account: own nick + capability for this PTY child only, on its own
4189        // overlay or appended to the overlay the spawn already carries.
4190        #[cfg(unix)]
4191        // `HATCHERY_M4A_SIGNER_SHELL=1` also gives operator shells an identity (live canaries).
4192        let signer_provider = crate::m4a_accounts::m4a_provider(provider.as_str()).or_else(|| {
4193            (provider.as_str() == "shell" && std::env::var_os("HATCHERY_M4A_SIGNER_SHELL").is_some_and(|v| v == "1")).then_some("shell")
4194        });
4195        #[cfg(unix)]
4196        // A provider session with its own m4a account (MCP server + cap) takes that path; the
4197        // signer handle is for sessions without one (operator shells).
4198        let signer_provider = signer_provider.filter(|p| *p == "shell" || self.m4a_accounts.is_none());
4199        #[cfg(unix)]
4200        let signer_identity = match (mode, self.signer_proxy.get(), signer_provider) {
4201            (SessionMode::Pty, Some(proxy), Some(m4a_provider)) => {
4202                let session_key = format!("{}-{}", self.incarnation_id, instance_id.0);
4203                let uid = unsafe { libc_geteuid() };
4204                let handle = proxy.reserve(&session_key, m4a_provider, uid);
4205                let sock = std::env::var_os(crate::keystore_proxy::SOCKET_ENV).unwrap_or_default();
4206                tracing::info!(provider = %provider, instance_id = ?instance_id, "m4a signer handle reserved at spawn");
4207                Some(vec![
4208                    gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from(crate::keystore_proxy::HANDLE_ENV), value: Some(std::ffi::OsString::from(handle)) },
4209                    gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from(crate::keystore_proxy::SOCKET_ENV), value: Some(sock) },
4210                    gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from("M4A_SESSION_ID"), value: Some(std::ffi::OsString::from(session_key)) },
4211                ])
4212            }
4213            _ => None,
4214        };
4215        #[cfg(not(unix))]
4216        let signer_identity: Option<Vec<gate4agent_catalog::EnvMutation>> = None;
4217        let environment_overlay = match (mode, self.m4a_accounts.as_ref(), crate::m4a_accounts::m4a_provider(provider.as_str()), signer_identity) {
4218            (SessionMode::Pty, _, _, Some(identity)) => {
4219                let merged = match environment_overlay {
4220                    None => NativeInstanceLaunchOverlay::new_session_identity(provider.clone(), identity).map(PreparedNativeLaunchOverlay::Instance),
4221                    Some(PreparedNativeLaunchOverlay::Instance(mut overlay)) => {
4222                        overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
4223                    }
4224                    Some(PreparedNativeLaunchOverlay::Environment(mut overlay)) => {
4225                        overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Environment(overlay))
4226                    }
4227                }
4228                .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a signer handle overlay is invalid"))?;
4229                Some(merged)
4230            }
4231            (SessionMode::Pty | SessionMode::Acp, Some(accounts), Some(m4a_provider), None) => {
4232                let session_key = self.m4a_session_key(instance_id);
4233                self.m4a_session_overlay(accounts, m4a_provider, provider, instance_id, environment_overlay, &session_key, mode)?
4234            }
4235            (SessionMode::Pty, Some(_), None, _) => {
4236                tracing::warn!(provider = %provider, instance_id = ?instance_id, "m4a session account not assigned: provider has no m4a mapping");
4237                environment_overlay
4238            }
4239            _ => environment_overlay,
4240        };
4241        let mut instance_overlay = environment_overlay
4242            .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
4243            .transpose()?
4244            .flatten();
4245        Ok(PreparedSpawn {
4246            instance_overlay,
4247            environment_selection,
4248            materialization_guard,
4249            harness_mcp_overlay,
4250            #[cfg(feature = "dig2-station-probe")]
4251            browser_station_lease_guard,
4252            materialization_id,
4253        })
4254    }
4255}
4256
4257struct SessionRecords {
4258    records: BTreeMap<SessionRecordId, ManagedSessionRecord>,
4259}
4260
4261struct SpawnIdempotencyCache {
4262    entries: BTreeMap<SpawnIdempotencyKey, SpawnIdempotencyEntry>,
4263}
4264
4265struct SpawnIdempotencyEntry {
4266    value: SpawnIdempotencyValue,
4267    expires_at: Instant,
4268}
4269
4270enum SpawnIdempotencyValue {
4271    Standard {
4272        spec: SpawnSpec,
4273        result: Result<ResolvedSpawnReceipt, NodeFailure>,
4274    },
4275    Managed {
4276        request: ManagedWorktreeSpawnRequest,
4277        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
4278    },
4279    HarnessMcp,
4280}
4281
4282enum DurableManagedSpawnReplayDecision {
4283    Reserved,
4284    PendingLinked,
4285    Committed(ManagedWorktreeSpawnReceipt),
4286}
4287
4288impl SpawnIdempotencyValue {
4289    fn references_context(&self, context_id: &SpawnContextId) -> bool {
4290        match self {
4291            Self::Standard { result, .. } => result
4292                .as_ref()
4293                .ok()
4294                .and_then(|receipt| receipt.context.as_ref()),
4295            Self::Managed { result, .. } => result
4296                .as_ref()
4297                .ok()
4298                .and_then(|receipt| receipt.spawn.context.as_ref()),
4299            Self::HarnessMcp => None,
4300        }
4301        .is_some_and(|receipt| &receipt.id == context_id)
4302    }
4303}
4304
4305#[derive(Clone, Copy)]
4306struct ControllerLease {
4307    connection_id: u64,
4308    expires_at: Instant,
4309}
4310
4311struct NodeEventHistory {
4312    last_sequence: u64,
4313    replay_floor_sequence: u64,
4314    events: VecDeque<NodeEventEnvelope>,
4315    record_providers: BTreeMap<SessionRecordId, AgentId>,
4316    removed_record_providers: BTreeMap<u64, AgentId>,
4317}
4318
4319impl NodeEventHistory {
4320    fn new(record_providers: BTreeMap<SessionRecordId, AgentId>) -> Self {
4321        Self {
4322            last_sequence: 0,
4323            replay_floor_sequence: 1,
4324            events: VecDeque::with_capacity(NODE_EVENT_HISTORY_MAX),
4325            record_providers,
4326            removed_record_providers: BTreeMap::new(),
4327        }
4328    }
4329}
4330
4331impl NodeShared {
4332    /// A node-spawned grok PTY session gets its own leader socket, so its agent backend runs
4333    /// as a descendant of this session's process (with the session's own environment) and
4334    /// never attaches to an operator's shared leader. Same grok home, same login.
4335    fn session_resume_journal(&self) -> Option<PathBuf> {
4336        self.state_path.as_ref().and_then(|p| p.parent()).map(crate::pty_resume::path)
4337    }
4338
4339    /// Node start: resume every journalled session through its provider's resume path,
4340    /// under its old instance number, so HQ shows the same chat.
4341    async fn restore_journalled_sessions(&self) {
4342        let Some(file) = self.session_resume_journal() else { return };
4343        if !crate::pty_resume::enabled() { return; }
4344        for e in crate::pty_resume::load(&file) {
4345            if e.provider == "codex" {
4346                if let Err(err) = self.restore_codex_session(&e).await {
4347                    // Keep the exact conversation retryable; never replace it with session/new.
4348                    crate::pty_resume::upsert(&file, e.clone());
4349                    tracing::warn!(instance_id = e.instance_id, message = %err.message, "journalled codex resume failed");
4350                }
4351                continue;
4352            }
4353            crate::pty_resume::remove(&file, e.instance_id);
4354            // Same m4a session key as before the restart: the session keeps its account and nick.
4355            if !e.session_key.is_empty() {
4356                self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).insert(AgentInstanceId(e.instance_id), e.session_key.clone());
4357            }
4358            let (Ok(ws), Ok(provider)) = (WorkspaceId::new(e.workspace_id.clone()), AgentId::new(e.provider.clone())) else { continue };
4359            if e.acp {
4360                let record_id = {
4361                    let records = self.session_records.lock().unwrap_or_else(|p| p.into_inner());
4362                    records.records.values().find(|r| r.mode == SessionMode::Acp && r.provider_session.as_ref().is_some_and(|i| !e.provider_session_id.is_empty() && i.id == e.provider_session_id)).map(|r| r.record_id.clone())
4363                };
4364                if let Some(next) = e.instance_id.checked_add(1) { self.next_instance_id.fetch_max(next, Ordering::AcqRel); }
4365                let size = gate4agent_types::TerminalSize { rows: 40, columns: 120 };
4366                let resumed = match record_id {
4367                    Some(rid) => RESTORE_INSTANCE_ID.scope(AgentInstanceId(e.instance_id), SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.resume_session_record(&rid, size, None))).await.map(|(_, a)| a),
4368                    None => Err(failure(NodeFailureCode::SessionRecordNotResumable, "no record")),
4369                };
4370                // An agent without ACP session/load (claude) cannot resume: same number, fresh chat.
4371                let r = match resumed {
4372                    Ok(a) => Ok(a),
4373                    Err(err) => {
4374                        tracing::info!(instance_id = e.instance_id, message = %err.message, "acp resume unavailable; starting fresh under the same number");
4375                        self.next_instance_id.fetch_max(e.instance_id.saturating_add(1), Ordering::AcqRel);
4376                        RESTORE_INSTANCE_ID.scope(AgentInstanceId(e.instance_id), SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.spawn_session(ws, provider, SessionMode::Acp, size, None))).await
4377                    }
4378                };
4379                match r {
4380                    Ok(addr) => tracing::info!(instance_id = addr.session.instance_id.0, provider = %e.provider, "journalled acp session restored"),
4381                    Err(err) => tracing::warn!(instance_id = e.instance_id, provider = %e.provider, message = %err.message, "journalled acp session restore failed"),
4382                }
4383                continue;
4384            }
4385            if e.provider_session_id.is_empty() || !provider_session_exists(&e.provider, &e.provider_session_id) {
4386                // Nothing said yet in that chat: start it fresh under the same number.
4387                if let Some(next) = e.instance_id.checked_add(1) { self.next_instance_id.fetch_max(next, Ordering::AcqRel); }
4388                let size = gate4agent_types::TerminalSize { rows: 40, columns: 120 };
4389                match RESTORE_INSTANCE_ID.scope(AgentInstanceId(e.instance_id), SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.spawn_session(ws, provider, SessionMode::Pty, size, None))).await {
4390                    Ok(addr) => tracing::info!(instance_id = addr.session.instance_id.0, provider = %e.provider, "journalled session had no transcript; started fresh"),
4391                    Err(err) => tracing::warn!(instance_id = e.instance_id, provider = %e.provider, message = %err.message, "journalled session restart failed"),
4392                }
4393                continue;
4394            }
4395            let folder = std::path::Path::new(&e.cwd);
4396            let in_session_dir = folder.parent().and_then(|p| p.file_name()).is_some_and(|n| n == ".hatchery-sessions");
4397            let name = match (in_session_dir, folder.file_name()) {
4398                (true, Some(n)) => n.to_string_lossy().into_owned(),
4399                _ => format!("{} #{}", e.provider, e.instance_id),
4400            };
4401            let identity = ProviderSessionIdentity { key: gate4agent_types::ProviderSessionKey::SessionId, id: e.provider_session_id.clone(), transcript_path: None };
4402            let record = match self.index_provider_session(ws, provider, identity, name) {
4403                Ok(r) => r,
4404                Err(err) => { tracing::warn!(instance_id = e.instance_id, message = %err.message, "journalled session not indexed"); continue; }
4405            };
4406            if let Some(next) = e.instance_id.checked_add(1) { self.next_instance_id.fetch_max(next, Ordering::AcqRel); }
4407            let size = gate4agent_types::TerminalSize { rows: if e.rows > 0 { e.rows } else { 40 }, columns: if e.cols > 0 { e.cols } else { 120 } };
4408            let r = RESTORE_INSTANCE_ID.scope(AgentInstanceId(e.instance_id), SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.resume_session_record(&record.record_id, size, None))).await;
4409            match r {
4410                Ok((_, addr)) => tracing::info!(instance_id = addr.session.instance_id.0, provider = %e.provider, "journalled session resumed"),
4411                Err(err) => tracing::warn!(instance_id = e.instance_id, provider = %e.provider, message = %err.message, "journalled session resume failed"),
4412            }
4413        }
4414    }
4415
4416    fn reserve_journalled_instance_ids(&self) {
4417        if let Some(file) = self.session_resume_journal() {
4418            for entry in crate::pty_resume::load(&file) {
4419                if let Some(next) = entry.instance_id.checked_add(1) {
4420                    self.next_instance_id.fetch_max(next, Ordering::AcqRel);
4421                }
4422            }
4423        }
4424    }
4425
4426    async fn restore_codex_session(
4427        &self,
4428        entry: &crate::pty_resume::Entry,
4429    ) -> Result<(), NodeFailure> {
4430        if entry.provider_session_id.is_empty() || entry.instance_id == u64::MAX {
4431            return Err(failure(NodeFailureCode::SessionRecordNotResumable, "journal has no resumable Codex identity or slot"));
4432        }
4433        let instance_id = AgentInstanceId(entry.instance_id);
4434        if self.session_bindings.lock().unwrap_or_else(|p| p.into_inner()).contains_key(&instance_id) {
4435            return Err(failure(NodeFailureCode::SessionRecordBusy, "journalled instance is already bound"));
4436        }
4437        self.next_instance_id.fetch_max(entry.instance_id + 1, Ordering::AcqRel);
4438        let workspace = WorkspaceId::new(entry.workspace_id.clone())
4439            .map_err(|e| failure(NodeFailureCode::InvalidRequest, &e.to_string()))?;
4440        let provider = AgentId::new("codex").expect("static provider id");
4441        let identity = ProviderSessionIdentity {
4442            key: gate4agent_types::ProviderSessionKey::SessionId,
4443            id: entry.provider_session_id.clone(),
4444            transcript_path: None,
4445        };
4446        // The provider owns transcript lookup. A Claude-only filesystem probe cannot
4447        // decide whether a Codex thread is resumable (including on Windows).
4448        let record = self.index_provider_session(
4449            workspace, provider, identity, format!("codex #{}", entry.instance_id),
4450        )?;
4451        if !entry.session_key.is_empty() {
4452            self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner())
4453                .insert(instance_id, entry.session_key.clone());
4454        }
4455        let size = gate4agent_types::TerminalSize {
4456            rows: if entry.rows > 0 { entry.rows } else { 40 },
4457            columns: if entry.cols > 0 { entry.cols } else { 120 },
4458        };
4459        RESTORE_INSTANCE_ID.scope(instance_id, SPAWN_ORIGIN.scope(entry.origin.clone(),
4460            SPAWN_PARENT.scope(entry.parent_session_id.clone(), SPAWN_SESSION_OPTIONS.scope(
4461                entry.options.clone(), self.resume_session_record(&record.record_id, size, None),
4462            )),
4463        )).await?;
4464        Ok(())
4465    }
4466
4467    fn refresh_session_journal(&self) {
4468        let Some(file) = self.session_resume_journal() else { return };
4469        let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
4470        if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") { homes.extend(std::env::split_paths(&extra)); }
4471        crate::pty_resume::refresh(&file, &homes);
4472        let ids: Vec<(u64, String)> = {
4473            let records = self.session_records.lock().unwrap_or_else(|p| p.into_inner());
4474            records.records.values().filter_map(|r| Some((r.active_session.as_ref()?.session.instance_id.0, r.provider_session.as_ref()?.id.clone()))).collect()
4475        };
4476        for (inst, sid) in ids {
4477            crate::pty_resume::set_session_id(&file, inst, &sid);
4478        }
4479    }
4480
4481    fn session_grok_leader_socket(
4482        &self,
4483        provider: &AgentId,
4484        mode: SessionMode,
4485        instance_id: AgentInstanceId,
4486    ) -> Option<std::path::PathBuf> {
4487        if provider.as_str() != "grok" || mode != SessionMode::Pty || crate::grok_leader::disabled() {
4488            return None;
4489        }
4490        let root = self
4491            .m4a_accounts
4492            .as_ref()
4493            .and_then(|a| a.base().parent().map(std::path::Path::to_path_buf))
4494            .unwrap_or_else(std::env::temp_dir);
4495        let socket = crate::grok_leader::socket_path(&root, &format!("{}-{}", self.incarnation_id, instance_id.0));
4496        if let Some(previous) = self
4497            .grok_leaders
4498            .lock()
4499            .unwrap_or_else(|p| p.into_inner())
4500            .insert(instance_id, socket.clone())
4501        {
4502            crate::grok_leader::retire(&previous);
4503        }
4504        Some(socket)
4505    }
4506
4507    /// Stops this session's own grok leader, if it had one. Never touches any other leader.
4508    fn retire_session_grok_leader(&self, instance_id: AgentInstanceId) {
4509        let socket = self.grok_leaders.lock().unwrap_or_else(|p| p.into_inner()).remove(&instance_id);
4510        if let Some(socket) = socket {
4511            crate::grok_leader::retire(&socket);
4512        }
4513    }
4514
4515    fn new_with_incarnation(
4516        handle: Gate4AgentHandle,
4517        access_token: String,
4518        node_id: NodeId,
4519        incarnation_id: NodeIncarnationId,
4520        workspaces: Vec<WorkspaceConfig>,
4521        enabled_providers: Vec<AgentId>,
4522        provider_runtime_statuses: ProviderRuntimeStatuses,
4523        provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
4524        provider_contracts: Vec<ProviderContractSupport>,
4525        provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
4526        spawn_profiles: SpawnProfileRegistry,
4527        native_launch_profile_control: Option<NativeLaunchProfileControl>,
4528        native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
4529        session_environment_materializer: Option<SessionEnvironmentMaterializer>,
4530        state_path: Option<PathBuf>,
4531        records: Vec<ManagedSessionRecord>,
4532        managed_worktrees: Vec<ManagedWorktreeLeaseRecord>,
4533        managed_worktree_tombstones: Vec<ManagedWorktreeLeaseRecord>,
4534        materializations: Vec<MaterializationOwnershipRecord>,
4535        persistence_warning: Option<String>,
4536        input_settle_timeout_ms: u64,
4537    ) -> Self {
4538        let (event_tx, _) = broadcast::channel(NODE_BROADCAST_CAPACITY);
4539        let (terminal_event_tx, _) = broadcast::channel(NODE_TERMINAL_BROADCAST_CAPACITY);
4540        let (agent_stream_event_tx, _) = broadcast::channel(NODE_AGENT_STREAM_BROADCAST_CAPACITY);
4541        let (harness_mcp_proxy_event_tx, _) =
4542            broadcast::channel(NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY);
4543        let record_providers = records
4544            .iter()
4545            .map(|record| (record.record_id.clone(), record.provider.clone()))
4546            .collect();
4547        let mut worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode> = workspaces
4548            .iter()
4549            .map(|workspace| {
4550                (workspace.workspace_id.clone(), workspace.worktree_service_mode)
4551            })
4552            .collect();
4553        for lease in &managed_worktrees {
4554            worktree_service_modes.insert(lease.workspace_id.clone(), WorktreeServiceMode::Off);
4555        }
4556        let managed_worktree_profiles = workspaces.iter()
4557            .map(|workspace| (
4558                workspace.workspace_id.clone(),
4559                workspace.managed_worktree_profiles.clone(),
4560            ))
4561            .collect();
4562        let mut managed_worktrees = ManagedWorktreeRegistry::from_records(
4563            managed_worktrees,
4564            managed_worktree_tombstones,
4565        ).expect("durable managed worktree registry was validated while loading");
4566        managed_worktrees.clear_stale_session_holders(incarnation_id, unix_time_ms());
4567        managed_worktrees.reattach_record_holders(
4568            &managed_worktree_record_holders(&records),
4569            unix_time_ms(),
4570        );
4571        Self {
4572            handle,
4573            access_token,
4574            node_id,
4575            incarnation_id,
4576            started_at_unix_ms: std::time::SystemTime::now()
4577                .duration_since(std::time::UNIX_EPOCH)
4578                .unwrap_or_default()
4579                .as_millis()
4580                .min(u64::MAX as u128) as u64,
4581            workspaces: RwLock::new(
4582                workspaces
4583                    .into_iter()
4584                    .map(|workspace| (workspace.workspace_id, workspace.canonical_root))
4585                    .collect(),
4586            ),
4587            worktree_service_modes,
4588            managed_worktree_profiles,
4589            managed_worktrees: Mutex::new(managed_worktrees),
4590            enabled_providers,
4591            provider_runtime_statuses,
4592            provider_runtime_status_updates: RwLock::new(BTreeMap::new()),
4593            provider_runtime_monitor,
4594            provider_contracts,
4595            provider_adapter_contracts,
4596            spawn_profiles,
4597            environment_profiles: RwLock::new(BTreeMap::new()),
4598            environment_materialization_profiles: RwLock::new(BTreeMap::new()),
4599            network_allowlist_catalog: RwLock::new(crate::network_allowlist_catalog::NetworkAllowlistCatalog::new()),
4600            #[cfg(feature = "dig2-station-probe")]
4601            browser_station_leases: crate::dig2_station_lease::new_lease_table(),
4602            bundle_catalog: RwLock::new(BundleCatalog::default()),
4603            delivery_store: Mutex::new(None),
4604            context_pack_store: Mutex::new(None),
4605            context_catalog: RwLock::new(ContextPackCatalog::default()),
4606            native_launch_profile_control,
4607            harness_mcp_registry: None,
4608            native_session_catalog,
4609            session_environment_materializer,
4610            m4a_accounts: crate::m4a_accounts::M4aSessionAccounts::from_env(),
4611            m4a_live: Mutex::new(BTreeMap::new()),
4612            m4a_keys: Mutex::new(BTreeMap::new()),
4613            m4a_reuse_keys: Mutex::new(BTreeMap::new()),
4614            grok_leaders: Mutex::new(BTreeMap::new()),
4615            #[cfg(unix)]
4616            #[cfg(unix)]
4617            signer_proxy: std::sync::OnceLock::new(),
4618            materializations: Mutex::new(
4619                materializations
4620                    .into_iter()
4621                    .map(|record| (record.id().clone(), record))
4622                    .collect(),
4623            ),
4624            spawn_idempotency: Mutex::new(SpawnIdempotencyCache {
4625                entries: BTreeMap::new(),
4626            }),
4627            managed_spawn_replays: Mutex::new(BTreeMap::new()),
4628            controller: Mutex::new(None),
4629            provisional_spawns: Mutex::new(BTreeMap::new()),
4630            pending_acp_options: Mutex::new(BTreeMap::new()),
4631            history: Mutex::new(NodeEventHistory::new(record_providers)),
4632            event_tx,
4633            terminal_event_tx,
4634            agent_stream_event_tx,
4635            harness_mcp_proxy_event_tx,
4636            terminal_frame_watermarks: Mutex::new(BTreeMap::new()),
4637            pty_input_wake: tokio::sync::Notify::new(),
4638            pty_input_marks: Mutex::new(BTreeMap::new()),
4639            provider_errors: Mutex::new(crate::provider_error::Tracker::default()),
4640            next_connection_id: AtomicU64::new(1),
4641            next_instance_id: AtomicU64::new(1),
4642            next_command_id: AtomicU64::new(1),
4643            shutdown: AtomicBool::new(false),
4644            shutdown_notify: Notify::new(),
4645            preauth_slots: Arc::new(Semaphore::new(MAX_PREAUTH_CONNECTIONS)),
4646            authenticated_slots: Arc::new(Semaphore::new(MAX_AUTHENTICATED_CONNECTIONS)),
4647            inspection_slots: Arc::new(Semaphore::new(WORKSPACE_INSPECTION_MAX_CONCURRENCY)),
4648            mutation_gate: AsyncMutex::new(()),
4649            session_bindings: Mutex::new(BTreeMap::new()),
4650            session_records: Mutex::new(SessionRecords {
4651                records: records
4652                    .into_iter()
4653                    .map(|record| (record.record_id.clone(), record))
4654                    .collect(),
4655            }),
4656            state_path,
4657            persistence_error: RwLock::new(
4658                persistence_warning
4659                    .map(|message| session_registry::sanitized_persistence_summary(&message)),
4660            ),
4661            state_transaction: Mutex::new(()),
4662            input_settle_timeout_ms,
4663            drive_loop_profile: Mutex::new(DriveLoopProfiler::default()),
4664            runtime_tick_profile: Mutex::new(TickProfileSnapshot::default()),
4665            shell_efficiency_profile: OnceLock::new(),
4666            native_session_gauge: AtomicUsize::new(0),
4667            connection_loop_iterations: AtomicU64::new(0),
4668            connection_events_sent: AtomicU64::new(0),
4669            rejected_commands_total: AtomicU64::new(0),
4670            rejected_command_reasons: Mutex::new(BTreeMap::new()),
4671            drive_loop_iterations_total: AtomicU64::new(0),
4672            drive_loop_iterations_idle: AtomicU64::new(0),
4673            session_record_retention: SessionRecordRetentionConfig::default(),
4674            retired_records_total: AtomicU64::new(0),
4675            #[cfg(feature = "fixture")]
4676            fixture_semantic_hook_policy: false,
4677            #[cfg(feature = "fixture")]
4678            fixture_spawn_managed_worktree_v2_failure_probe:
4679                SpawnManagedWorktreeV2FailureProbe::default(),
4680        }
4681    }
4682
4683    #[cfg(test)]
4684    fn new(
4685        handle: Gate4AgentHandle,
4686        access_token: String,
4687        node_id: NodeId,
4688        workspaces: Vec<WorkspaceConfig>,
4689        enabled_providers: Vec<AgentId>,
4690    ) -> Self {
4691        Self::new_with_incarnation(
4692            handle,
4693            access_token,
4694            node_id,
4695            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
4696            workspaces,
4697            enabled_providers,
4698            ProviderRuntimeStatuses::default(),
4699            None,
4700            Vec::new(),
4701            Vec::new(),
4702            SpawnProfileRegistry::default(),
4703            None,
4704            None,
4705            None,
4706            None,
4707            Vec::new(),
4708            Vec::new(),
4709            Vec::new(),
4710            Vec::new(),
4711            None,
4712            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
4713        )
4714    }
4715
4716    fn persistence_error(&self) -> Option<String> {
4717        self.persistence_error
4718            .read()
4719            .unwrap_or_else(|poisoned| poisoned.into_inner())
4720            .clone()
4721    }
4722
4723    async fn admit_provider_runtime(
4724        &self,
4725        provider: &AgentId,
4726        requirement: ProviderRuntimeRequirement,
4727    ) -> Result<ProviderRuntimePolicy, NodeFailure> {
4728        let admission = if let Some(monitor) = self.provider_runtime_monitor.clone() {
4729            let pipe_transport = monitor.supports_pipe_transport(provider);
4730            let acp_transport = monitor.supports_acp_transport(provider);
4731            let provider = provider.clone();
4732            let refresh = tokio::task::spawn_blocking(move || monitor.evaluate(&provider));
4733            let (status, admission) = timeout(
4734                Duration::from_millis(PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS),
4735                refresh,
4736            )
4737            .await
4738            .map_err(|_| failure(
4739                NodeFailureCode::BackendBusy,
4740                "provider runtime probe exceeded its bounded deadline",
4741            ))?
4742            .map_err(|_| failure(
4743                NodeFailureCode::BackendOperationFailed,
4744                "provider runtime probe task failed",
4745            ))?;
4746            if let Some(status) = status {
4747                self
4748                    .provider_runtime_status_updates
4749                    .write()
4750                    .unwrap_or_else(|poisoned| poisoned.into_inner())
4751                    .insert(status.provider().clone(), status);
4752            }
4753            admission.and_then(|policy| {
4754                require_policy(policy, pipe_transport, acp_transport, requirement).map(|()| policy)
4755            })
4756        } else {
4757            // No live monitor means no catalog to consult -- there is
4758            // nothing here to confirm this provider declares a Pipe or ACP
4759            // transport, so Inline/Acp are rejected the same conservative
4760            // way an undeclared provider always was, rather than admitted
4761            // on trust.
4762            crate::provider_runtime::admit_status(
4763                &self.provider_runtime_statuses,
4764                provider,
4765                false,
4766                false,
4767                requirement,
4768            )
4769        };
4770        let admission = admission.map(|policy| self.admit_declared_hook_semantics(provider, policy));
4771        admission.map_err(|error| match error {
4772            ProviderRuntimeAdmissionError::LauncherUnavailable => failure(
4773                NodeFailureCode::BackendOperationFailed,
4774                "provider launcher is unavailable",
4775            ),
4776            ProviderRuntimeAdmissionError::SemanticCapabilityUnverified => failure(
4777                NodeFailureCode::UnsupportedCapability,
4778                "provider semantic capability is not verified",
4779            ),
4780            ProviderRuntimeAdmissionError::ProbeBusy => failure(
4781                NodeFailureCode::BackendBusy,
4782                "provider runtime probe is already in progress",
4783            ),
4784        })
4785    }
4786
4787    /// Whether the catalog declares a Pipe transport for `provider` --
4788    /// the fact `require_policy` needs to admit or reject
4789    /// `ProviderRuntimeRequirement::Inline`. With no live monitor there is no
4790    /// catalog to consult, so this stays conservative and rejects, matching
4791    /// `admit_provider_runtime`'s own no-monitor branch.
4792    fn provider_supports_pipe_transport(&self, provider: &AgentId) -> bool {
4793        match self.provider_runtime_monitor.as_deref() {
4794            Some(monitor) => monitor.supports_pipe_transport(provider),
4795            None => false,
4796        }
4797    }
4798
4799    /// Whether the catalog declares an ACP transport for `provider` -- the
4800    /// fact `require_policy` needs to admit or reject
4801    /// `ProviderRuntimeRequirement::Acp`. Mirrors
4802    /// `provider_supports_pipe_transport` exactly, including the
4803    /// conservative no-monitor default.
4804    fn provider_supports_acp_transport(&self, provider: &AgentId) -> bool {
4805        match self.provider_runtime_monitor.as_deref() {
4806            Some(monitor) => monitor.supports_acp_transport(provider),
4807            None => false,
4808        }
4809    }
4810
4811    /// Grants hook ingestion to a provider whose catalog entry declares a
4812    /// Hook adapter, whatever runtime mode its launcher probe landed in.
4813    ///
4814    /// `admit_status`'s `RawPassthrough` arm hands back
4815    /// `ProviderRuntimePolicy::raw_pty()`, which grants nothing semantic --
4816    /// correct for everything it was written to answer, and wrong for hooks
4817    /// specifically. A hook event is not inferred from terminal behaviour,
4818    /// so it has nothing to do with whether a launcher probe verified this
4819    /// CLI version's PTY contract; the authority is the catalog declaring
4820    /// the adapter plus the ingress authenticating the route. Without this
4821    /// the node installs a provider's hooks, the CLI faithfully calls them,
4822    /// and every event is refused -- which is exactly the state grok was in.
4823    ///
4824    /// Applied to both admission paths so the monitor's own derivation and
4825    /// this coarse fallback cannot disagree about the same provider.
4826    fn admit_declared_hook_semantics(
4827        &self,
4828        provider: &AgentId,
4829        policy: ProviderRuntimePolicy,
4830    ) -> ProviderRuntimePolicy {
4831        if policy.hook_semantics || !policy.raw_pty_lifecycle {
4832            return policy;
4833        }
4834        let declares_hook = self.provider_adapter_contracts.iter().any(|contract| {
4835            contract.provider == *provider && contract.family == AdapterFamily::Hook
4836        });
4837        if !declares_hook {
4838            return policy;
4839        }
4840        ProviderRuntimePolicy::new(
4841            policy.raw_pty_lifecycle,
4842            policy.semantic_readiness,
4843            policy.structured_prompt,
4844            policy.provider_session_identity,
4845            policy.semantic_resume,
4846            true,
4847        )
4848        .expect("granting hook semantics over a raw-PTY policy is internally valid")
4849    }
4850
4851    /// Station network allowlist → provider launch overlay argv.
4852    ///
4853    /// - **Codex**: first-slice `-c sandbox_workspace_write.network_access` from
4854    ///   `provider_native` under Moderate (PTY or ACP).
4855    /// - **Claude**: catalog `permits` → ProviderHome sidecar settings JSON +
4856    ///   documented `--settings <path>` on **PTY** only (Linux/macOS/WSL2).
4857    ///   ACP refuses (no measured ACP settings-path surface). Native Windows
4858    ///   refuses (no vendor Bash sandbox). Membership-only (empty permits) =
4859    ///   no overlay.
4860    /// - **Kimi / Grok**: no first-party settings/argv network surface here —
4861    ///   empty overlay when no `provider_native`; invented native keys already
4862    ///   refuse at load / resolve.
4863    ///
4864    /// Empty when no allowlist / nothing to map. Never cookies / secrets.
4865    fn resolve_network_allowlist_overlay_args(
4866        &self,
4867        provider: &AgentId,
4868        mode: SessionMode,
4869        approval_level: ApprovalLevel,
4870        network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4871        provider_home: Option<&Path>,
4872    ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4873        let Some(allowlist_id) = network_allowlist else {
4874            return Ok(Vec::new());
4875        };
4876        let catalog = self
4877            .network_allowlist_catalog
4878            .read()
4879            .unwrap_or_else(|poisoned| poisoned.into_inner());
4880        let Some(entry) = catalog.get(allowlist_id) else {
4881            return Err(failure(
4882                NodeFailureCode::UnknownNetworkAllowlist,
4883                "spawn network allowlist is unavailable on this node",
4884            ));
4885        };
4886
4887        // Claude: settings-shaped overlay from permits (not provider_native).
4888        if provider.as_str() == "claude" && !entry.permits.is_empty() {
4889            crate::network_allowlist_catalog::claude_bash_sandbox_network_os_supported().map_err(
4890                |message| {
4891                    failure(
4892                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4893                        &message,
4894                    )
4895                },
4896            )?;
4897            if !matches!(mode, SessionMode::Pty) {
4898                return Err(failure(
4899                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4900                    "Claude sandbox.network --settings overlay requires PTY transport; ACP has no measured settings-path surface (refuse rather than invent RPC)",
4901                ));
4902            }
4903            let Some(home) = provider_home else {
4904                return Err(failure(
4905                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4906                    "Claude sandbox.network --settings overlay requires ProviderHome materialization (CLAUDE_CONFIG_DIR)",
4907                ));
4908            };
4909            let domains =
4910                crate::network_allowlist_catalog::claude_allowed_domains_from_permits(&entry.permits);
4911            if domains.is_empty() {
4912                return Err(failure(
4913                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4914                    "Claude sandbox.network overlay could not map catalog permits to allowedDomains",
4915                ));
4916            }
4917            let settings_path = home.join(
4918                crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
4919            );
4920            let json =
4921                crate::network_allowlist_catalog::claude_station_network_settings_json(&domains);
4922            crate::session_environment::secure_create_file(&settings_path, json.as_bytes()).map_err(
4923                |_| {
4924                    failure(
4925                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4926                        "Claude station network settings sidecar could not be written under ProviderHome",
4927                    )
4928                },
4929            )?;
4930            let overlay =
4931                crate::network_allowlist_catalog::claude_settings_network_overlay_args(&settings_path);
4932            return Ok(overlay.into_iter().map(std::ffi::OsString::from).collect());
4933        }
4934
4935        let Some(native) = entry.provider_native.as_ref() else {
4936            return Ok(Vec::new());
4937        };
4938        let overlay = crate::network_allowlist_catalog::resolve_provider_native_launch_overlay(
4939            provider.as_str(),
4940            approval_level,
4941            native,
4942        )
4943        .map_err(|message| {
4944            failure(
4945                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4946                &message,
4947            )
4948        })?;
4949        if overlay.is_empty() {
4950            return Ok(Vec::new());
4951        }
4952        if !matches!(mode, SessionMode::Pty | SessionMode::Acp) {
4953            return Err(failure(
4954                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4955                "Codex network_access -c overlay requires PTY or ACP transport",
4956            ));
4957        }
4958        Ok(overlay.into_iter().map(std::ffi::OsString::from).collect())
4959    }
4960
4961    /// Backward-compatible alias used by Codex-focused unit tests.
4962    fn resolve_codex_network_access_overlay_args(
4963        &self,
4964        provider: &AgentId,
4965        mode: SessionMode,
4966        approval_level: ApprovalLevel,
4967        network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4968    ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4969        self.resolve_network_allowlist_overlay_args(
4970            provider,
4971            mode,
4972            approval_level,
4973            network_allowlist,
4974            None,
4975        )
4976    }
4977
4978    fn resolve_environment_profile(
4979        &self,
4980        resolved: &ResolvedSpawnSpec,
4981    ) -> Result<Option<ResolvedEnvironmentProfileReceipt>, NodeFailure> {
4982        // Station network allowlist catalog enforce (plan
4983        // station-network-and-browser-profile-knobs §2.1 / §4): empty-default
4984        // catalog — unknown ids refuse rather than silent ambient. Runs even
4985        // when no environment_profile_id is present so a bare allowlist still
4986        // cannot bypass the catalog. Never cookies / OAuth / proxy credentials.
4987        if let Some(allowlist_id) = resolved.network_allowlist.as_ref() {
4988            let catalog = self
4989                .network_allowlist_catalog
4990                .read()
4991                .unwrap_or_else(|poisoned| poisoned.into_inner());
4992            let Some(entry) = catalog.get(allowlist_id) else {
4993                return Err(failure(
4994                    NodeFailureCode::UnknownNetworkAllowlist,
4995                    "spawn network allowlist is unavailable on this node",
4996                ));
4997            };
4998            // Track B: refuse provider-native mappings this provider cannot
4999            // honor (e.g. codex_network_access on Claude). Never silent ambient.
5000            if let Some(native) = entry.provider_native.as_ref() {
5001                if let Err(message) = crate::network_allowlist_catalog::provider_native_mapping_supported(
5002                    resolved.provider.as_str(),
5003                    resolved.approval_level,
5004                    native,
5005                ) {
5006                    return Err(failure(
5007                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
5008                        &message,
5009                    ));
5010                }
5011            }
5012        }
5013        // Station knobs Track A (dig2browser-station-probe-and-network-permit-set):
5014        // when feature `dig2-station-probe` is on and browser_profile_id is set,
5015        // cheap named-pipe path reachability must succeed (Windows
5016        // `\\.\pipe\{suffix}`); platform-unsupported or unreachable → clear
5017        // NodeFailureCode. Runs even without environment_profile_id (same
5018        // placement spirit as allowlist catalog). Feature off keeps stub echo.
5019        // Never cookies / OAuth / proxy credentials. Exclusive lease is
5020        // acquired later in spawn_session_with_deadline (after instance id).
5021        #[cfg(feature = "dig2-station-probe")]
5022        if resolved.browser_profile_id.is_some() {
5023            use crate::dig2_station_probe::{probe_station_reachable, StationProbeError};
5024            match probe_station_reachable() {
5025                Ok(()) => {}
5026                Err(StationProbeError::UnsupportedPlatform)
5027                | Err(StationProbeError::InvalidPipeSuffix) => {
5028                    return Err(failure(
5029                        NodeFailureCode::BrowserStationProbeUnavailable,
5030                        "dig2browser station probe unavailable on this platform",
5031                    ));
5032                }
5033                Err(StationProbeError::Unreachable) => {
5034                    return Err(failure(
5035                        NodeFailureCode::BrowserStationUnreachable,
5036                        "dig2browser station named-pipe is unreachable",
5037                    ));
5038                }
5039            }
5040        }
5041        let Some(profile_id) = resolved.environment_profile_id.as_ref() else {
5042            return Ok(None);
5043        };
5044        let profiles = self
5045            .environment_profiles
5046            .read()
5047            .unwrap_or_else(|poisoned| poisoned.into_inner());
5048        let binding = profiles.get(profile_id).ok_or_else(|| {
5049            failure(
5050                NodeFailureCode::UnknownEnvironmentProfile,
5051                "spawn environment profile is unavailable on this node",
5052            )
5053        })?;
5054        if binding.provider != resolved.provider
5055            || binding.native_profile_id(resolved.mode).is_none()
5056        {
5057            return Err(failure(
5058                NodeFailureCode::EnvironmentProfileBindingMismatch,
5059                "spawn environment profile does not match the provider and mode",
5060            ));
5061        }
5062        // Echo opaque network_allowlist / browser_profile_id onto the receipt
5063        // after catalog + optional dig2 probe above. Empty/whitespace ids
5064        // already refuse at Spawn*Id construction.
5065        Ok(Some(ResolvedEnvironmentProfileReceipt {
5066            profile_id: binding.id.clone(),
5067            profile_revision: binding.revision.clone(),
5068            network_allowlist: resolved.network_allowlist.clone(),
5069            browser_profile_id: resolved.browser_profile_id.clone(),
5070        }))
5071    }
5072
5073    fn resolve_bundle(
5074        &self,
5075        resolved: &ResolvedSpawnSpec,
5076        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5077    ) -> Result<Option<ResolvedBundleReceipt>, NodeFailure> {
5078        let Some(bundle_id) = resolved.bundle_id.as_ref() else {
5079            return Ok(None);
5080        };
5081        let catalog = self.bundle_catalog
5082            .read()
5083            .unwrap_or_else(|poisoned| poisoned.into_inner());
5084        let bundle = catalog.get(bundle_id).ok_or_else(|| {
5085                failure(
5086                    NodeFailureCode::UnknownBundle,
5087                    "spawn bundle is unavailable on this node",
5088                )
5089            })?;
5090        self.bundle_layout(
5091            &resolved.provider,
5092            resolved.mode,
5093            environment_profile,
5094            bundle,
5095        )?;
5096        Ok(Some(bundle.receipt()))
5097    }
5098
5099    fn begin_delivery_stage(
5100        &self,
5101        manifest: DeliveryBundleManifestV2,
5102    ) -> Result<NodeResponse, NodeFailure> {
5103        let mut store = self
5104            .delivery_store
5105            .lock()
5106            .unwrap_or_else(|poisoned| poisoned.into_inner());
5107        let store = store.as_mut().ok_or_else(|| {
5108            failure(
5109                NodeFailureCode::DeliveryStageStorageFailed,
5110                "node delivery store is unavailable",
5111            )
5112        })?;
5113        let begun = store.begin(manifest).map_err(delivery_failure)?;
5114        Ok(NodeResponse::DeliveryStageBegun {
5115            stage_id: begun.stage_id,
5116            manifest_digest: begun.manifest_digest,
5117            missing_blobs: begun.missing_blobs,
5118        })
5119    }
5120
5121    fn put_delivery_blob_chunk(
5122        &self,
5123        stage_id: DeliveryStageId,
5124        blob_digest: DeliveryBlobDigestV1,
5125        offset: u64,
5126        chunk_hex: DeliveryBlobChunkHexV1,
5127    ) -> Result<NodeResponse, NodeFailure> {
5128        let mut store = self
5129            .delivery_store
5130            .lock()
5131            .unwrap_or_else(|poisoned| poisoned.into_inner());
5132        let store = store.as_mut().ok_or_else(|| {
5133            failure(
5134                NodeFailureCode::DeliveryStageStorageFailed,
5135                "node delivery store is unavailable",
5136            )
5137        })?;
5138        let accepted = store
5139            .put_chunk(&stage_id, &blob_digest, offset, &chunk_hex.decode())
5140            .map_err(delivery_failure)?;
5141        Ok(NodeResponse::DeliveryBlobChunkAccepted {
5142            stage_id: accepted.stage_id,
5143            blob_digest: accepted.blob_digest,
5144            next_offset: accepted.next_offset,
5145        })
5146    }
5147
5148    fn commit_delivery_stage(
5149        &self,
5150        stage_id: DeliveryStageId,
5151    ) -> Result<DeliveryCommitReceiptV1, NodeFailure> {
5152        let mut store = self
5153            .delivery_store
5154            .lock()
5155            .unwrap_or_else(|poisoned| poisoned.into_inner());
5156        let store = store.as_mut().ok_or_else(|| {
5157            failure(
5158                NodeFailureCode::DeliveryStageStorageFailed,
5159                "node delivery store is unavailable",
5160            )
5161        })?;
5162        let prepared = store.prepare_commit(&stage_id).map_err(delivery_failure)?;
5163        let mut catalog = self
5164            .bundle_catalog
5165            .write()
5166            .unwrap_or_else(|poisoned| poisoned.into_inner());
5167        let mut next_catalog = catalog.clone();
5168        next_catalog
5169            .insert_idempotent(prepared.bundle().clone())
5170            .map_err(|_| {
5171                failure(
5172                    NodeFailureCode::DeliveryStageConflict,
5173                    "delivery bundle identity conflicts with installed content",
5174                )
5175            })?;
5176        let receipt = store.publish_commit(prepared).map_err(delivery_failure)?;
5177        *catalog = next_catalog;
5178        Ok(receipt)
5179    }
5180
5181    fn abort_delivery_stage(
5182        &self,
5183        stage_id: &DeliveryStageId,
5184    ) -> Result<(), NodeFailure> {
5185        let mut store = self
5186            .delivery_store
5187            .lock()
5188            .unwrap_or_else(|poisoned| poisoned.into_inner());
5189        let store = store.as_mut().ok_or_else(|| {
5190            failure(
5191                NodeFailureCode::DeliveryStageStorageFailed,
5192                "node delivery store is unavailable",
5193            )
5194        })?;
5195        store.abort(stage_id).map_err(delivery_failure)
5196    }
5197
5198    fn resolve_context(
5199        &self,
5200        resolved: &ResolvedSpawnSpec,
5201    ) -> Result<Option<ResolvedContextPackReceipt>, NodeFailure> {
5202        let Some(context_id) = resolved.context_id.as_ref() else {
5203            return Ok(None);
5204        };
5205        self.context_catalog
5206            .read()
5207            .unwrap_or_else(|poisoned| poisoned.into_inner())
5208            .get(context_id)
5209            .map(|context| context.receipt().clone())
5210            .ok_or_else(|| {
5211                failure(
5212                    NodeFailureCode::UnknownContextPack,
5213                    "spawn context pack is unavailable on this node",
5214                )
5215            })
5216            .map(Some)
5217    }
5218
5219    fn bundle_layout(
5220        &self,
5221        provider: &AgentId,
5222        mode: SessionMode,
5223        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5224        bundle: &NodeBundle,
5225    ) -> Result<BundleProviderLayout, NodeFailure> {
5226        let layout = validate_bundle_binding(provider, mode, bundle).map_err(|_| {
5227            failure(
5228                NodeFailureCode::BundleBindingMismatch,
5229                "spawn bundle does not match the provider and mode",
5230            )
5231        })?;
5232        if layout != BundleProviderLayout::Codex {
5233            return Ok(layout);
5234        }
5235        let profile = environment_profile.and_then(|receipt| {
5236            let profiles = self
5237                .environment_profiles
5238                .read()
5239                .unwrap_or_else(|poisoned| poisoned.into_inner());
5240            let binding = profiles.get(&receipt.profile_id)?;
5241            if binding.revision != receipt.profile_revision
5242                || &binding.provider != provider
5243                || binding.native_profile_id(mode).is_none()
5244            {
5245                return None;
5246            }
5247            self.environment_materialization_profiles
5248                .read()
5249                .unwrap_or_else(|poisoned| poisoned.into_inner())
5250                .get(&receipt.profile_id)
5251                .cloned()
5252        });
5253        if !profile.is_some_and(|profile| profile.supports_bundle_layout(layout)) {
5254            return Err(failure(
5255                NodeFailureCode::BundleBindingMismatch,
5256                "Codex bundle requires an exact isolated CODEX_HOME profile",
5257            ));
5258        }
5259        Ok(layout)
5260    }
5261
5262    fn select_environment_profile(
5263        &self,
5264        instance_id: AgentInstanceId,
5265        provider: &AgentId,
5266        mode: SessionMode,
5267        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5268    ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
5269        let Some(environment_profile) = environment_profile else {
5270            return Ok(None);
5271        };
5272        let native_profile_id = {
5273            let profiles = self
5274                .environment_profiles
5275                .read()
5276                .unwrap_or_else(|poisoned| poisoned.into_inner());
5277            let binding = profiles
5278                .get(&environment_profile.profile_id)
5279                .ok_or_else(|| {
5280                    failure(
5281                        NodeFailureCode::UnknownEnvironmentProfile,
5282                        "session environment profile is unavailable on this node",
5283                    )
5284                })?;
5285            if &binding.revision != &environment_profile.profile_revision
5286                || &binding.provider != provider
5287            {
5288                return Err(failure(
5289                    NodeFailureCode::EnvironmentProfileBindingMismatch,
5290                    "session environment profile revision or provider changed",
5291                ));
5292            }
5293            binding.native_profile_id(mode).cloned().ok_or_else(|| {
5294                failure(
5295                    NodeFailureCode::EnvironmentProfileBindingMismatch,
5296                    "session environment profile does not support this mode",
5297                )
5298            })?
5299        };
5300        let control = self.native_launch_profile_control.clone().ok_or_else(|| {
5301            failure(
5302                NodeFailureCode::BackendOperationFailed,
5303                "native environment profile controller is unavailable",
5304            )
5305        })?;
5306        control
5307            .select_native_launch_profile(instance_id, native_profile_id)
5308            .map_err(|_| {
5309                failure(
5310                    NodeFailureCode::BackendOperationFailed,
5311                    "native environment profile selection failed",
5312                )
5313            })?;
5314        Ok(Some(NativeEnvironmentSelectionGuard {
5315            control: Some(control),
5316            instance_id,
5317        }))
5318    }
5319
5320    fn install_prepared_launch_overlay(
5321        &self,
5322        instance_id: AgentInstanceId,
5323        overlay: PreparedNativeLaunchOverlay,
5324    ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
5325        let control = self.native_launch_profile_control.clone().ok_or_else(|| {
5326            failure(
5327                NodeFailureCode::BackendOperationFailed,
5328                "native launch profile controller is unavailable",
5329            )
5330        })?;
5331        match overlay {
5332            PreparedNativeLaunchOverlay::Environment(overlay) => {
5333                control
5334                    .install_native_launch_environment_overlay(instance_id, overlay)
5335                    .map_err(|_| failure(
5336                        NodeFailureCode::BackendOperationFailed,
5337                        "session environment overlay installation failed",
5338                    ))?;
5339                Ok(None)
5340            }
5341            PreparedNativeLaunchOverlay::Instance(overlay) => {
5342                control
5343                    .install_native_instance_launch_overlay(instance_id, overlay)
5344                    .map_err(|_| failure(
5345                        NodeFailureCode::BundleBindingMismatch,
5346                        "session bundle launch overlay installation failed",
5347                    ))?;
5348                Ok(Some(NativeInstanceOverlayGuard {
5349                    control: Some(control),
5350                    instance_id,
5351                }))
5352            }
5353        }
5354    }
5355
5356    fn environment_materialization_profile(
5357        &self,
5358        environment_profile: &ResolvedEnvironmentProfileReceipt,
5359    ) -> Option<NodeSessionMaterializationProfile> {
5360        self.environment_materialization_profiles
5361            .read()
5362            .unwrap_or_else(|poisoned| poisoned.into_inner())
5363            .get(&environment_profile.profile_id)
5364            .cloned()
5365    }
5366
5367    fn materialization_profile(
5368        &self,
5369        provider: &AgentId,
5370        mode: SessionMode,
5371        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5372        bundle: Option<&ResolvedBundleReceipt>,
5373        context: Option<&ResolvedContextPackReceipt>,
5374    ) -> Result<Option<(
5375        NodeSessionMaterializationProfile,
5376        bool,
5377        Option<BundleProviderLayout>,
5378    )>, NodeFailure> {
5379        let environment = environment_profile
5380            .and_then(|receipt| self.environment_materialization_profile(receipt));
5381        let (bundle, bundle_layout) = match bundle {
5382            Some(receipt) => {
5383                let catalog = self.bundle_catalog.read()
5384                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5385                let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
5386                    NodeFailureCode::UnknownBundle,
5387                    "session bundle is unavailable on this node",
5388                ))?;
5389                if installed.receipt() != *receipt {
5390                    return Err(failure(
5391                        NodeFailureCode::BundleBindingMismatch,
5392                        "session bundle revision or digest changed",
5393                    ));
5394                }
5395                let layout = self.bundle_layout(
5396                    provider,
5397                    mode,
5398                    environment_profile,
5399                    installed,
5400                )?;
5401                (Some(installed.clone()), Some(layout))
5402            }
5403            None => (None, None),
5404        };
5405        let has_environment_overlay = environment.is_some() || context.is_some();
5406        let profile = match (environment, bundle.as_ref(), bundle_layout) {
5407            (None, None, None) => None,
5408            (Some(profile), None, None) => Some(Ok(profile)),
5409            (None, Some(bundle), Some(layout)) => {
5410                Some(NodeSessionMaterializationProfile::from_bundle(bundle, layout))
5411            }
5412            (Some(profile), Some(bundle), Some(layout)) => {
5413                Some(profile.with_bundle(bundle, layout))
5414            }
5415            _ => {
5416                return Err(failure(
5417                    NodeFailureCode::BundleBindingMismatch,
5418                    "session bundle provider layout is unavailable",
5419                ));
5420            }
5421        };
5422        let profile = profile
5423            .transpose()
5424            .map_err(|_| failure(
5425                NodeFailureCode::BundleMaterializationFailed,
5426                "session bundle materialization profile is invalid",
5427            ))?;
5428        let profile = match (profile, context) {
5429            (Some(profile), Some(receipt)) => {
5430                let catalog = self.context_catalog
5431                    .read()
5432                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5433                let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
5434                    NodeFailureCode::UnknownContextPack,
5435                    "session context pack is unavailable on this node",
5436                ))?;
5437                if pack.receipt() != receipt {
5438                    return Err(failure(
5439                        NodeFailureCode::ContextPackMaterializationFailed,
5440                        "session context pack identity changed",
5441                    ));
5442                }
5443                profile.with_context(pack)
5444            }
5445            (None, Some(receipt)) => {
5446                let catalog = self.context_catalog
5447                    .read()
5448                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5449                let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
5450                    NodeFailureCode::UnknownContextPack,
5451                    "session context pack is unavailable on this node",
5452                ))?;
5453                if pack.receipt() != receipt {
5454                    return Err(failure(
5455                        NodeFailureCode::ContextPackMaterializationFailed,
5456                        "session context pack identity changed",
5457                    ));
5458                }
5459                NodeSessionMaterializationProfile::from_context(pack)
5460            }
5461            (Some(profile), None) => Ok(profile),
5462            (None, None) => return Ok(None),
5463        }
5464        .map_err(|_| failure(
5465            NodeFailureCode::ContextPackMaterializationFailed,
5466            "session context pack materialization profile is invalid",
5467        ))?;
5468        Ok(Some((profile, has_environment_overlay, bundle_layout)))
5469    }
5470
5471    fn prepare_session_materialization<'a>(
5472        &'a self,
5473        address: &SessionAddress,
5474        provider: &AgentId,
5475        mode: SessionMode,
5476        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5477        bundle: Option<&ResolvedBundleReceipt>,
5478        context: Option<&ResolvedContextPackReceipt>,
5479        managed_lease_id: Option<ManagedWorktreeLeaseId>,
5480    ) -> Result<Option<(Option<PreparedNativeLaunchOverlay>, SessionMaterializationGuard<'a>)>, NodeFailure> {
5481        let Some((profile, has_environment_overlay, bundle_layout)) =
5482            self.materialization_profile(provider, mode, environment_profile, bundle, context)?
5483        else {
5484            return Ok(None);
5485        };
5486        let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5487            failure(
5488                NodeFailureCode::BackendOperationFailed,
5489                "session environment materializer is unavailable",
5490            )
5491        })?;
5492        let materialization_failure_code = if context.is_some() {
5493            NodeFailureCode::ContextPackMaterializationFailed
5494        } else if bundle.is_some() {
5495            NodeFailureCode::BundleMaterializationFailed
5496        } else {
5497            NodeFailureCode::BackendOperationFailed
5498        };
5499        let id = MaterializationId::new(format!(
5500            "mat-{}-{}-{}",
5501            self.incarnation_id,
5502            address.session.instance_id.0,
5503            address.session.generation.0,
5504        ))
5505        .map_err(|_| {
5506            failure(
5507                NodeFailureCode::BackendOperationFailed,
5508                "session environment materialization identity failed",
5509            )
5510        })?;
5511        let owner = MaterializationOwner::Session {
5512            incarnation_id: self.incarnation_id,
5513            instance_id: address.session.instance_id,
5514            generation: address.session.generation,
5515        };
5516        let mut ownership = materializer
5517            .begin(
5518                id.clone(),
5519                environment_profile.cloned(),
5520                bundle.cloned(),
5521                context.cloned(),
5522                owner,
5523                managed_lease_id,
5524                &profile,
5525                unix_time_ms(),
5526            )
5527            .map_err(|_| {
5528                failure(
5529                    materialization_failure_code,
5530                    "session materialization preparation failed",
5531                )
5532            })?;
5533        {
5534            let _transaction = self
5535                .state_transaction
5536                .lock()
5537                .unwrap_or_else(|poisoned| poisoned.into_inner());
5538            let duplicate = self
5539                .materializations
5540                .lock()
5541                .unwrap_or_else(|poisoned| poisoned.into_inner())
5542                .insert(id.clone(), ownership.clone());
5543            if duplicate.is_some() {
5544                return Err(failure(
5545                    NodeFailureCode::BackendOperationFailed,
5546                    "session environment materialization identity conflict",
5547                ));
5548            }
5549            if let Err(error) = self.persist_state_locked() {
5550                self.materializations
5551                    .lock()
5552                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5553                    .remove(&id);
5554                return Err(persistence_failure(error));
5555            }
5556        }
5557        let environment = match materializer.materialize(&mut ownership, &profile, unix_time_ms()) {
5558            Ok(environment) => environment,
5559            Err(_) => {
5560                let transaction = self
5561                    .state_transaction
5562                    .lock()
5563                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5564                self.materializations
5565                    .lock()
5566                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5567                    .insert(id.clone(), ownership.clone());
5568                if self.persist_state_locked().is_err() {
5569                    self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5570                }
5571                drop(transaction);
5572                if ownership.state() == MaterializationState::CleanupRequired {
5573                    let _ = self.cleanup_materialization(&id);
5574                }
5575                return Err(failure(
5576                    materialization_failure_code,
5577                    "session materialization failed",
5578                ));
5579            }
5580        };
5581        {
5582            let _transaction = self
5583                .state_transaction
5584                .lock()
5585                .unwrap_or_else(|poisoned| poisoned.into_inner());
5586            self.materializations
5587                .lock()
5588                .unwrap_or_else(|poisoned| poisoned.into_inner())
5589                .insert(id.clone(), ownership.clone());
5590            if let Err(error) = self.persist_state_locked() {
5591                drop(_transaction);
5592                let _ = self.cleanup_materialization(&id);
5593                return Err(persistence_failure(error));
5594            }
5595        }
5596        let guard = SessionMaterializationGuard {
5597            shared: self,
5598            id: Some(id),
5599            provider_home: Some(ownership.provider_home().to_path_buf()),
5600        };
5601        let bundle_arguments = match bundle {
5602            Some(receipt) => {
5603                let catalog = self.bundle_catalog.read()
5604                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5605                let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
5606                    NodeFailureCode::UnknownBundle,
5607                    "session bundle is unavailable on this node",
5608                ))?;
5609                if installed.receipt() != *receipt {
5610                    return Err(failure(
5611                        NodeFailureCode::BundleBindingMismatch,
5612                        "session bundle revision or digest changed",
5613                    ));
5614                }
5615                let layout = bundle_layout.ok_or_else(|| failure(
5616                    NodeFailureCode::BundleBindingMismatch,
5617                    "materialized bundle provider layout is unavailable",
5618                ))?;
5619                materializer.revalidate_bundle(&ownership, installed, layout).map_err(|_| {
5620                    failure(
5621                        NodeFailureCode::BundleMaterializationFailed,
5622                        "session bundle byte revalidation failed",
5623                    )
5624                })?;
5625                Some(bundle_launch_arguments(
5626                    layout,
5627                    ownership.bundle_root(),
5628                    ownership.provider_home(),
5629                ).map_err(|_| failure(
5630                    NodeFailureCode::BundleBindingMismatch,
5631                    "session bundle launch binding is invalid",
5632                ))?)
5633            }
5634            None => None,
5635        };
5636        if let Some(receipt) = context {
5637            materializer
5638                .revalidate_context(&ownership, receipt)
5639                .map_err(|_| failure(
5640                    NodeFailureCode::ContextPackMaterializationFailed,
5641                    "session context pack byte revalidation failed",
5642                ))?;
5643        }
5644        let transport = match mode {
5645            SessionMode::Pty => TransportKind::Pty,
5646            SessionMode::Inline => TransportKind::Pipe,
5647            SessionMode::Acp => TransportKind::Acp,
5648        };
5649        let overlay = if let Some(extra_args) = bundle_arguments {
5650            Some(PreparedNativeLaunchOverlay::Instance(
5651                NativeInstanceLaunchOverlay::new(
5652                    provider.clone(),
5653                    transport,
5654                    if has_environment_overlay {
5655                        environment
5656                    } else {
5657                        Vec::new()
5658                    },
5659                    extra_args,
5660                )
5661                .map_err(|_| {
5662                    failure(
5663                        NodeFailureCode::BundleBindingMismatch,
5664                        "session bundle launch overlay is invalid",
5665                    )
5666                })?,
5667            ))
5668        } else if has_environment_overlay {
5669            let context_only = environment_profile.is_none()
5670                && bundle.is_none()
5671                && context.is_some();
5672            let overlay = if context_only {
5673                NativeLaunchEnvironmentOverlay::new_context_root(
5674                    provider.clone(),
5675                    transport,
5676                    environment,
5677                )
5678                .map_err(|_| {
5679                    failure(
5680                        NodeFailureCode::ContextPackMaterializationFailed,
5681                        "context-only launch overlay is invalid",
5682                    )
5683                })?
5684            } else {
5685                NativeLaunchEnvironmentOverlay::new(
5686                    provider.clone(),
5687                    transport,
5688                    environment,
5689                )
5690                .map_err(|_| {
5691                    failure(
5692                        NodeFailureCode::BackendOperationFailed,
5693                        "session environment overlay is invalid",
5694                    )
5695                })?
5696            };
5697            Some(PreparedNativeLaunchOverlay::Environment(overlay))
5698        } else {
5699            None
5700        };
5701        Ok(Some((overlay, guard)))
5702    }
5703
5704    fn cleanup_materialization(&self, id: &MaterializationId) -> Result<(), NodeFailure> {
5705        let Some(mut ownership) = self
5706            .materializations
5707            .lock()
5708            .unwrap_or_else(|poisoned| poisoned.into_inner())
5709            .get(id)
5710            .cloned()
5711        else {
5712            return Ok(());
5713        };
5714        if ownership.state() != MaterializationState::CleanupRequired {
5715            ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5716                failure(
5717                    NodeFailureCode::BackendOperationFailed,
5718                    "session environment cleanup state is invalid",
5719                )
5720            })?;
5721        }
5722        {
5723            let _transaction = self
5724                .state_transaction
5725                .lock()
5726                .unwrap_or_else(|poisoned| poisoned.into_inner());
5727            let previous = self
5728                .materializations
5729                .lock()
5730                .unwrap_or_else(|poisoned| poisoned.into_inner())
5731                .insert(id.clone(), ownership.clone());
5732            if let Err(error) = self.persist_state_locked() {
5733                let mut materializations = self
5734                    .materializations
5735                    .lock()
5736                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5737                if let Some(previous) = previous {
5738                    materializations.insert(id.clone(), previous);
5739                }
5740                return Err(persistence_failure(error));
5741            }
5742        }
5743        let cleanup = self
5744            .session_environment_materializer
5745            .as_ref()
5746            .ok_or(())
5747            .and_then(|materializer| materializer.cleanup(&ownership).map_err(|_| ()));
5748        if cleanup.is_err() {
5749            let _ = ownership.mark_recovery_required(unix_time_ms());
5750            self.materializations
5751                .lock()
5752                .unwrap_or_else(|poisoned| poisoned.into_inner())
5753                .insert(id.clone(), ownership);
5754            if self.persist_state().is_err() {
5755                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5756            }
5757            return Err(failure(
5758                NodeFailureCode::BackendOperationFailed,
5759                "session environment cleanup requires recovery",
5760            ));
5761        }
5762        let _transaction = self
5763            .state_transaction
5764            .lock()
5765            .unwrap_or_else(|poisoned| poisoned.into_inner());
5766        self.materializations
5767            .lock()
5768            .unwrap_or_else(|poisoned| poisoned.into_inner())
5769            .remove(id);
5770        if let Err(error) = self.persist_state_locked() {
5771            self.materializations
5772                .lock()
5773                .unwrap_or_else(|poisoned| poisoned.into_inner())
5774                .insert(id.clone(), ownership);
5775            return Err(persistence_failure(error));
5776        }
5777        Ok(())
5778    }
5779
5780    fn resolve_record_materialization(
5781        &self,
5782        record_id: &SessionRecordId,
5783        provider: &AgentId,
5784        mode: SessionMode,
5785        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5786        bundle: Option<&ResolvedBundleReceipt>,
5787        context: Option<&ResolvedContextPackReceipt>,
5788    ) -> Result<Option<(MaterializationId, Option<PreparedNativeLaunchOverlay>)>, NodeFailure> {
5789        let ownership = {
5790            let materializations = self
5791                .materializations
5792                .lock()
5793                .unwrap_or_else(|poisoned| poisoned.into_inner());
5794            let mut matches = materializations.values().filter(|record| {
5795                record.owner()
5796                    == &MaterializationOwner::Record {
5797                        record_id: record_id.clone(),
5798                    }
5799            });
5800            let ownership = matches.next().cloned();
5801            if matches.next().is_some() {
5802                return Err(failure(
5803                    NodeFailureCode::BackendOperationFailed,
5804                    "managed session has conflicting environment ownership",
5805                ));
5806            }
5807            ownership
5808        };
5809        if let (Some(ownership), Some(receipt)) = (ownership.as_ref(), context) {
5810            let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5811                failure(
5812                    NodeFailureCode::ContextPackMaterializationFailed,
5813                    "session context materializer is unavailable",
5814                )
5815            })?;
5816            let pack = materializer
5817                .revalidate_context(ownership, receipt)
5818                .map_err(|_| {
5819                    self.mark_materialization_recovery_required(ownership.id());
5820                    failure(
5821                        NodeFailureCode::ContextPackMaterializationFailed,
5822                        "managed session context pack revalidation failed",
5823                    )
5824                })?;
5825            self.context_catalog
5826                .write()
5827                .unwrap_or_else(|poisoned| poisoned.into_inner())
5828                .insert(pack)
5829                .map_err(|_| failure(
5830                    NodeFailureCode::ContextPackMaterializationFailed,
5831                    "managed session context pack catalog restore failed",
5832                ))?;
5833        }
5834        let materialization = self.materialization_profile(
5835            provider,
5836            mode,
5837            environment_profile,
5838            bundle,
5839            context,
5840        )?;
5841        match (ownership, materialization) {
5842            (None, None) => Ok(None),
5843            (None, Some(_)) | (Some(_), None) => Err(failure(
5844                NodeFailureCode::BackendOperationFailed,
5845                "managed session materialization is unavailable",
5846            )),
5847            (Some(ownership), Some((profile, has_environment_materialization, bundle_layout))) => {
5848                if ownership.environment_profile() != environment_profile
5849                    || ownership.bundle() != bundle
5850                    || ownership.context() != context
5851                    || ownership.state() != MaterializationState::Ready
5852                {
5853                    self.mark_materialization_recovery_required(ownership.id());
5854                    return Err(failure(
5855                        NodeFailureCode::BackendOperationFailed,
5856                        "managed session environment ownership requires recovery",
5857                    ));
5858                }
5859                let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5860                    failure(
5861                        NodeFailureCode::BackendOperationFailed,
5862                        "session environment materializer is unavailable",
5863                    )
5864                })?;
5865                if materializer.revalidate(&ownership).is_err() {
5866                    self.mark_materialization_recovery_required(ownership.id());
5867                    return Err(failure(
5868                        NodeFailureCode::BackendOperationFailed,
5869                        "managed session environment revalidation failed",
5870                    ));
5871                }
5872                let bundle_arguments = if let Some(bundle_receipt) = bundle {
5873                    let catalog = self.bundle_catalog.read()
5874                        .unwrap_or_else(|poisoned| poisoned.into_inner());
5875                    let Some(installed) = catalog.get(&bundle_receipt.id) else {
5876                        return Err(failure(
5877                            NodeFailureCode::UnknownBundle,
5878                            "managed session bundle is unavailable",
5879                        ));
5880                    };
5881                    let layout = bundle_layout.ok_or_else(|| failure(
5882                        NodeFailureCode::BundleBindingMismatch,
5883                        "record bundle provider layout is unavailable",
5884                    ))?;
5885                    if materializer.revalidate_bundle(&ownership, installed, layout).is_err() {
5886                        drop(catalog);
5887                        self.mark_materialization_recovery_required(ownership.id());
5888                        return Err(failure(
5889                            NodeFailureCode::BundleMaterializationFailed,
5890                            "managed session bundle revalidation failed",
5891                        ));
5892                    }
5893                    Some(bundle_launch_arguments(
5894                        layout,
5895                        ownership.bundle_root(),
5896                        ownership.provider_home(),
5897                    ).map_err(|_| failure(
5898                        NodeFailureCode::BundleBindingMismatch,
5899                        "managed session bundle launch binding is invalid",
5900                    ))?)
5901                } else {
5902                    None
5903                };
5904                if !has_environment_materialization {
5905                    let overlay = bundle_arguments.map(|extra_args| {
5906                        NativeInstanceLaunchOverlay::new(
5907                            provider.clone(),
5908                            TransportKind::Pty,
5909                            Vec::new(),
5910                            extra_args,
5911                        )
5912                        .map(PreparedNativeLaunchOverlay::Instance)
5913                        .map_err(|_| failure(
5914                            NodeFailureCode::BundleBindingMismatch,
5915                            "managed session bundle launch overlay is invalid",
5916                        ))
5917                    }).transpose()?;
5918                    return Ok(Some((ownership.id().clone(), overlay)));
5919                }
5920                let environment = match materializer.resolve_environment(&ownership, &profile) {
5921                    Ok(environment) => environment,
5922                    Err(
5923                        SessionEnvironmentMaterializeError::SecretUnavailable
5924                        | SessionEnvironmentMaterializeError::SecretDenied
5925                        | SessionEnvironmentMaterializeError::InvalidSecretValue,
5926                    ) => {
5927                        return Err(failure(
5928                            NodeFailureCode::BackendOperationFailed,
5929                            "managed session environment resolution failed",
5930                        ));
5931                    }
5932                    Err(
5933                        SessionEnvironmentMaterializeError::InvalidRoot
5934                        | SessionEnvironmentMaterializeError::OwnershipMismatch
5935                        | SessionEnvironmentMaterializeError::Filesystem(_)
5936                        | SessionEnvironmentMaterializeError::Record(_),
5937                    ) => {
5938                        self.mark_materialization_recovery_required(ownership.id());
5939                        return Err(failure(
5940                            NodeFailureCode::BackendOperationFailed,
5941                            "managed session environment resolution failed",
5942                        ));
5943                    }
5944                };
5945                let transport = match mode {
5946                    SessionMode::Pty => TransportKind::Pty,
5947                    SessionMode::Inline => TransportKind::Pipe,
5948                    SessionMode::Acp => TransportKind::Acp,
5949                };
5950                let overlay = match bundle_arguments {
5951                    Some(extra_args) => PreparedNativeLaunchOverlay::Instance(
5952                        NativeInstanceLaunchOverlay::new(
5953                            provider.clone(),
5954                            transport,
5955                            environment,
5956                            extra_args,
5957                        )
5958                        .map_err(|_| failure(
5959                            NodeFailureCode::BundleBindingMismatch,
5960                            "managed session bundle launch overlay is invalid",
5961                        ))?,
5962                    ),
5963                    None => PreparedNativeLaunchOverlay::Environment(
5964                        NativeLaunchEnvironmentOverlay::new(
5965                            provider.clone(),
5966                            transport,
5967                            environment,
5968                        )
5969                        .map_err(|_| {
5970                            failure(
5971                                NodeFailureCode::BackendOperationFailed,
5972                                "managed session environment overlay is invalid",
5973                            )
5974                        })?,
5975                    ),
5976                };
5977                Ok(Some((ownership.id().clone(), Some(overlay))))
5978            }
5979        }
5980    }
5981
5982    fn destroy_record_materialization(
5983        &self,
5984        record_id: &SessionRecordId,
5985    ) -> Result<bool, NodeFailure> {
5986        let ids = self
5987            .materializations
5988            .lock()
5989            .unwrap_or_else(|poisoned| poisoned.into_inner())
5990            .values()
5991            .filter_map(|record| {
5992                (record.owner()
5993                    == &MaterializationOwner::Record {
5994                        record_id: record_id.clone(),
5995                    })
5996                .then(|| record.id().clone())
5997            })
5998            .collect::<Vec<_>>();
5999        if ids.len() > 1 {
6000            return Err(failure(
6001                NodeFailureCode::BackendOperationFailed,
6002                "managed session has conflicting environment ownership",
6003            ));
6004        }
6005        let Some(id) = ids.first().cloned() else {
6006            return Ok(false);
6007        };
6008        let (terminal_record, mut cleanup_ownership) = {
6009            let _transaction = self
6010                .state_transaction
6011                .lock()
6012                .unwrap_or_else(|poisoned| poisoned.into_inner());
6013            let original_record = self.record(record_id)?;
6014            let mut terminal_record = original_record.clone();
6015            terminal_record.provider_session = None;
6016            terminal_record.active_session = None;
6017            terminal_record.state = ManagedSessionState::Unavailable;
6018            terminal_record.last_error = Some("environment-profile-unavailable".to_owned());
6019            terminal_record.updated_at_unix_ms = unix_time_ms();
6020            let original_ownership = self
6021                .materializations
6022                .lock()
6023                .unwrap_or_else(|poisoned| poisoned.into_inner())
6024                .get(&id)
6025                .cloned()
6026                .ok_or_else(|| {
6027                    failure(
6028                        NodeFailureCode::BackendOperationFailed,
6029                        "managed session environment ownership disappeared during cleanup",
6030                    )
6031                })?;
6032            let mut cleanup_ownership = original_ownership.clone();
6033            if cleanup_ownership.state() != MaterializationState::CleanupRequired {
6034                cleanup_ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
6035                    failure(
6036                        NodeFailureCode::BackendOperationFailed,
6037                        "managed session environment cleanup state is invalid",
6038                    )
6039                })?;
6040            }
6041            self.session_records
6042                .lock()
6043                .unwrap_or_else(|poisoned| poisoned.into_inner())
6044                .records
6045                .insert(record_id.clone(), terminal_record.clone());
6046            self.materializations
6047                .lock()
6048                .unwrap_or_else(|poisoned| poisoned.into_inner())
6049                .insert(id.clone(), cleanup_ownership.clone());
6050            if let Err(error) = self.persist_state_locked() {
6051                self.session_records
6052                    .lock()
6053                    .unwrap_or_else(|poisoned| poisoned.into_inner())
6054                    .records
6055                    .insert(record_id.clone(), original_record);
6056                self.materializations
6057                    .lock()
6058                    .unwrap_or_else(|poisoned| poisoned.into_inner())
6059                    .insert(id.clone(), original_ownership);
6060                return Err(persistence_failure(error));
6061            }
6062            (terminal_record, cleanup_ownership)
6063        };
6064        let cleanup = self
6065            .session_environment_materializer
6066            .as_ref()
6067            .ok_or(())
6068            .and_then(|materializer| materializer.cleanup(&cleanup_ownership).map_err(|_| ()));
6069        if cleanup.is_err() {
6070            let _ = cleanup_ownership.mark_recovery_required(unix_time_ms());
6071            self.materializations
6072                .lock()
6073                .unwrap_or_else(|poisoned| poisoned.into_inner())
6074                .insert(id.clone(), cleanup_ownership);
6075            if self.persist_state().is_err() {
6076                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6077            }
6078            return Err(failure(
6079                NodeFailureCode::BackendOperationFailed,
6080                "managed session environment cleanup requires recovery",
6081            ));
6082        }
6083        let _transaction = self
6084            .state_transaction
6085            .lock()
6086            .unwrap_or_else(|poisoned| poisoned.into_inner());
6087        self.remove_record_memory(record_id);
6088        self.materializations
6089            .lock()
6090            .unwrap_or_else(|poisoned| poisoned.into_inner())
6091            .remove(&id);
6092        if let Err(error) = self.persist_state_locked() {
6093            self.session_records
6094                .lock()
6095                .unwrap_or_else(|poisoned| poisoned.into_inner())
6096                .records
6097                .insert(record_id.clone(), terminal_record);
6098            self.materializations
6099                .lock()
6100                .unwrap_or_else(|poisoned| poisoned.into_inner())
6101                .insert(id, cleanup_ownership);
6102            return Err(persistence_failure(error));
6103        }
6104        Ok(true)
6105    }
6106
6107    fn mark_materialization_recovery_required(&self, id: &MaterializationId) {
6108        let changed = {
6109            let mut materializations = self
6110                .materializations
6111                .lock()
6112                .unwrap_or_else(|poisoned| poisoned.into_inner());
6113            let Some(ownership) = materializations.get_mut(id) else {
6114                return;
6115            };
6116            ownership.mark_recovery_required(unix_time_ms()).is_ok()
6117        };
6118        if changed && self.persist_state().is_err() {
6119            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6120        }
6121    }
6122
6123    fn reconcile_materializations(&self) {
6124        let records = self
6125            .materializations
6126            .lock()
6127            .unwrap_or_else(|poisoned| poisoned.into_inner())
6128            .values()
6129            .cloned()
6130            .collect::<Vec<_>>();
6131        for mut ownership in records {
6132            let result = match (ownership.state(), ownership.owner()) {
6133                (MaterializationState::Preparing, _)
6134                | (MaterializationState::CleanupRequired, _) => {
6135                    self.cleanup_materialization(ownership.id())
6136                }
6137                (MaterializationState::Ready, MaterializationOwner::Session { .. }) => {
6138                    if ownership.mark_recovery_required(unix_time_ms()).is_ok() {
6139                        self.materializations
6140                            .lock()
6141                            .unwrap_or_else(|poisoned| poisoned.into_inner())
6142                            .insert(ownership.id().clone(), ownership.clone());
6143                        self.persist_state().map_err(persistence_failure)
6144                    } else {
6145                        Err(failure(
6146                            NodeFailureCode::BackendOperationFailed,
6147                            "session environment reconciliation failed",
6148                        ))
6149                    }
6150                }
6151                (MaterializationState::Ready, MaterializationOwner::Record { record_id }) => self
6152                    .session_environment_materializer
6153                    .as_ref()
6154                    .ok_or_else(|| {
6155                        failure(
6156                            NodeFailureCode::BackendOperationFailed,
6157                            "session environment materializer is unavailable",
6158                        )
6159                    })
6160                    .and_then(|materializer| {
6161                        materializer.revalidate(&ownership).map_err(|_| failure(
6162                            NodeFailureCode::BackendOperationFailed,
6163                            "session materialization revalidation failed",
6164                        ))?;
6165                        let record = self.record(record_id)?;
6166                        if record.environment_profile.as_ref() != ownership.environment_profile()
6167                            || record.bundle.as_ref() != ownership.bundle()
6168                            || record.context.as_ref() != ownership.context()
6169                        {
6170                            return Err(failure(
6171                                NodeFailureCode::BackendOperationFailed,
6172                                "session materialization ownership changed after restart",
6173                            ));
6174                        }
6175                        if let Some(receipt) = ownership.bundle() {
6176                            let catalog = self.bundle_catalog.read()
6177                                .unwrap_or_else(|poisoned| poisoned.into_inner());
6178                            let bundle = catalog.get(&receipt.id).ok_or_else(|| failure(
6179                                NodeFailureCode::UnknownBundle,
6180                                "session bundle is unavailable after restart",
6181                            ))?;
6182                            if bundle.receipt() != *receipt {
6183                                return Err(failure(
6184                                    NodeFailureCode::BundleBindingMismatch,
6185                                    "session bundle changed after restart",
6186                                ));
6187                            }
6188                            let layout = self.bundle_layout(
6189                                &record.provider,
6190                                record.mode,
6191                                record.environment_profile.as_ref(),
6192                                bundle,
6193                            )?;
6194                            materializer
6195                                .revalidate_bundle(&ownership, bundle, layout)
6196                                .map_err(|_| failure(
6197                                    NodeFailureCode::BundleMaterializationFailed,
6198                                    "session bundle revalidation failed",
6199                                ))?;
6200                        }
6201                        if let Some(receipt) = ownership.context() {
6202                            let pack = materializer
6203                                .revalidate_context(&ownership, receipt)
6204                                .map_err(|_| {
6205                                    failure(
6206                                        NodeFailureCode::ContextPackMaterializationFailed,
6207                                        "session context revalidation failed",
6208                                    )
6209                                })?;
6210                            self.context_catalog
6211                                .write()
6212                                .unwrap_or_else(|poisoned| poisoned.into_inner())
6213                                .insert(pack)
6214                                .map_err(|_| {
6215                                    failure(
6216                                        NodeFailureCode::ContextPackMaterializationFailed,
6217                                        "session context catalog restore failed",
6218                                    )
6219                                })?;
6220                        }
6221                        Ok(())
6222                    }),
6223                (MaterializationState::RecoveryRequired, _) => Ok(()),
6224            };
6225            if result.is_err() && ownership.state() != MaterializationState::RecoveryRequired {
6226                let _ = ownership.mark_recovery_required(unix_time_ms());
6227                self.materializations
6228                    .lock()
6229                    .unwrap_or_else(|poisoned| poisoned.into_inner())
6230                    .insert(ownership.id().clone(), ownership);
6231                if self.persist_state().is_err() {
6232                    self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6233                }
6234            }
6235        }
6236        let materialized_profiles = self
6237            .environment_materialization_profiles
6238            .read()
6239            .unwrap_or_else(|poisoned| poisoned.into_inner())
6240            .keys()
6241            .cloned()
6242            .collect::<Vec<_>>();
6243        let ownership = self
6244            .materializations
6245            .lock()
6246            .unwrap_or_else(|poisoned| poisoned.into_inner())
6247            .values()
6248            .cloned()
6249            .collect::<Vec<_>>();
6250        let changed = {
6251            let mut session_records = self
6252                .session_records
6253                .lock()
6254                .unwrap_or_else(|poisoned| poisoned.into_inner());
6255            let mut changed = false;
6256            for record in session_records.records.values_mut() {
6257                let environment_profile = record.environment_profile.as_ref();
6258                if environment_profile.is_none()
6259                    && record.bundle.is_none()
6260                    && record.context.is_none()
6261                {
6262                    continue;
6263                }
6264                if record.bundle.is_none()
6265                    && environment_profile.is_some_and(|profile| {
6266                        !materialized_profiles.contains(&profile.profile_id)
6267                    })
6268                {
6269                    continue;
6270                }
6271                let has_exact_ownership = ownership.iter().any(|candidate| {
6272                    candidate.state() == MaterializationState::Ready
6273                        && candidate.environment_profile() == environment_profile
6274                        && candidate.bundle() == record.bundle.as_ref()
6275                        && candidate.context() == record.context.as_ref()
6276                        && candidate.owner()
6277                            == &MaterializationOwner::Record {
6278                                record_id: record.record_id.clone(),
6279                            }
6280                });
6281                if !has_exact_ownership {
6282                    record.provider_session = None;
6283                    record.active_session = None;
6284                    record.state = ManagedSessionState::Unavailable;
6285                    record.last_error = Some(if record.bundle.is_some() {
6286                        "bundle-unavailable".to_owned()
6287                    } else if record.context.is_some() {
6288                        "context-unavailable".to_owned()
6289                    } else {
6290                        "environment-profile-unavailable".to_owned()
6291                    });
6292                    record.updated_at_unix_ms = unix_time_ms();
6293                    changed = true;
6294                }
6295            }
6296            changed
6297        };
6298        if changed && self.persist_state().is_err() {
6299            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6300        }
6301    }
6302
6303    fn replay_spawn_spec(
6304        &self,
6305        spec: &SpawnSpec,
6306        now: Instant,
6307    ) -> Result<Option<Result<ResolvedSpawnReceipt, NodeFailure>>, NodeFailure> {
6308        let mut cache = self
6309            .spawn_idempotency
6310            .lock()
6311            .unwrap_or_else(|poisoned| poisoned.into_inner());
6312        cache.entries.retain(|_, entry| entry.expires_at > now);
6313        if let Some(entry) = cache.entries.get(&spec.idempotency_key) {
6314            let SpawnIdempotencyValue::Standard { spec: existing, result } = &entry.value else {
6315                return Err(failure(
6316                    NodeFailureCode::SpawnIdempotencyConflict,
6317                    "spawn idempotency key was reused across spawn kinds",
6318                ));
6319            };
6320            if existing != spec {
6321                return Err(failure(
6322                    NodeFailureCode::SpawnIdempotencyConflict,
6323                    "spawn idempotency key was reused with a different specification",
6324                ));
6325            }
6326            return Ok(Some(result.clone()));
6327        }
6328        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
6329            return Err(failure(
6330                NodeFailureCode::SpawnIdempotencyCapacity,
6331                "spawn idempotency cache reached its bounded capacity",
6332            ));
6333        }
6334        Ok(None)
6335    }
6336
6337    fn remember_spawn_spec(
6338        &self,
6339        spec: SpawnSpec,
6340        result: Result<ResolvedSpawnReceipt, NodeFailure>,
6341        accepted_at: Instant,
6342    ) {
6343        self
6344            .spawn_idempotency
6345            .lock()
6346            .unwrap_or_else(|poisoned| poisoned.into_inner())
6347            .entries
6348            .insert(
6349                spec.idempotency_key.clone(),
6350                SpawnIdempotencyEntry {
6351                    value: SpawnIdempotencyValue::Standard { spec, result },
6352                    expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
6353                },
6354            );
6355    }
6356
6357    fn claim_harness_mcp_spawn(
6358        &self,
6359        _reservation_id: &HarnessMcpReservationId,
6360        _activation_digest: &HarnessMcpActivationDigest,
6361        spec: &SpawnSpec,
6362        now: Instant,
6363    ) -> Result<(), NodeFailure> {
6364        let mut cache = self.spawn_idempotency.lock()
6365            .unwrap_or_else(|poisoned| poisoned.into_inner());
6366        cache.entries.retain(|_, entry| entry.expires_at > now);
6367        if cache.entries.contains_key(&spec.idempotency_key) {
6368            return Err(failure(
6369                NodeFailureCode::SpawnIdempotencyConflict,
6370                "spawn idempotency key was reused across spawn kinds",
6371            ));
6372        }
6373        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
6374            return Err(failure(
6375                NodeFailureCode::SpawnIdempotencyCapacity,
6376                "spawn idempotency cache reached its bounded capacity",
6377            ));
6378        }
6379        cache.entries.insert(spec.idempotency_key.clone(), SpawnIdempotencyEntry {
6380            value: SpawnIdempotencyValue::HarnessMcp,
6381            expires_at: now + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
6382        });
6383        Ok(())
6384    }
6385
6386    fn replay_managed_spawn(
6387        &self,
6388        request: &ManagedWorktreeSpawnRequest,
6389        now: Instant,
6390    ) -> Result<Option<Result<ManagedWorktreeSpawnReceipt, NodeFailure>>, NodeFailure> {
6391        let mut cache = self.spawn_idempotency.lock()
6392            .unwrap_or_else(|poisoned| poisoned.into_inner());
6393        cache.entries.retain(|_, entry| entry.expires_at > now);
6394        if let Some(entry) = cache.entries.get(&request.spawn_spec.idempotency_key) {
6395            let SpawnIdempotencyValue::Managed { request: existing, result } = &entry.value else {
6396                return Err(failure(
6397                    NodeFailureCode::SpawnIdempotencyConflict,
6398                    "spawn idempotency key was reused across spawn kinds",
6399                ));
6400            };
6401            if existing != request {
6402                return Err(failure(
6403                    NodeFailureCode::SpawnIdempotencyConflict,
6404                    "spawn idempotency key was reused with a different managed specification",
6405                ));
6406            }
6407            return Ok(Some(result.clone()));
6408        }
6409        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
6410            return Err(failure(
6411                NodeFailureCode::SpawnIdempotencyCapacity,
6412                "spawn idempotency cache reached its bounded capacity",
6413            ));
6414        }
6415        Ok(None)
6416    }
6417
6418    fn remember_managed_spawn(
6419        &self,
6420        request: ManagedWorktreeSpawnRequest,
6421        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
6422        accepted_at: Instant,
6423    ) {
6424        self.spawn_idempotency.lock()
6425            .unwrap_or_else(|poisoned| poisoned.into_inner())
6426            .entries
6427            .insert(
6428                request.spawn_spec.idempotency_key.clone(),
6429                SpawnIdempotencyEntry {
6430                    value: SpawnIdempotencyValue::Managed { request, result },
6431                    expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
6432                },
6433            );
6434    }
6435
6436    fn remember_managed_spawn_attempt(
6437        &self,
6438        durable_v2_key: Option<&SpawnIdempotencyKey>,
6439        request: ManagedWorktreeSpawnRequest,
6440        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
6441        accepted_at: Instant,
6442    ) {
6443        if durable_v2_key.is_none() {
6444            self.remember_managed_spawn(request, result, accepted_at);
6445        }
6446    }
6447
6448    fn acquire_durable_managed_spawn_v2(
6449        &self,
6450        request: &ManagedWorktreeSpawnRequestV2,
6451        request_digest: String,
6452    ) -> Result<DurableManagedSpawnReplayDecision, NodeFailure> {
6453        if self.state_path.is_none() {
6454            return Err(failure(
6455                NodeFailureCode::BackendOperationFailed,
6456                "managed worktree V2 requires durable node state",
6457            ));
6458        }
6459        let _transaction = self.state_transaction
6460            .lock()
6461            .unwrap_or_else(|poisoned| poisoned.into_inner());
6462        let key = request.spawn_spec.idempotency_key.clone();
6463        let mut previous = None;
6464        {
6465            let mut replays = self.managed_spawn_replays
6466                .lock()
6467                .unwrap_or_else(|poisoned| poisoned.into_inner());
6468            if let Some(record) = replays.get_mut(&key) {
6469                if record.request_digest != request_digest {
6470                    return Err(failure(
6471                        NodeFailureCode::SpawnIdempotencyConflict,
6472                        "managed worktree V2 idempotency key was reused with a different request",
6473                    ));
6474                }
6475                match &record.state {
6476                    ManagedWorktreeSpawnReplayStateV10::Pending {
6477                        owner_incarnation_id,
6478                        lease_id: None,
6479                    } if owner_incarnation_id != &self.incarnation_id => {
6480                        previous = Some(record.clone());
6481                        record.state = ManagedWorktreeSpawnReplayStateV10::Pending {
6482                            owner_incarnation_id: self.incarnation_id,
6483                            lease_id: None,
6484                        };
6485                        record.updated_at_unix_ms = unix_time_ms();
6486                    }
6487                    ManagedWorktreeSpawnReplayStateV10::Pending {
6488                        lease_id: None,
6489                        ..
6490                    } => {
6491                        return Err(failure(
6492                            NodeFailureCode::BackendBusy,
6493                            "managed worktree V2 reservation is already active",
6494                        ));
6495                    }
6496                    ManagedWorktreeSpawnReplayStateV10::Pending {
6497                        lease_id: Some(_),
6498                        ..
6499                    } => {
6500                        return Ok(DurableManagedSpawnReplayDecision::PendingLinked);
6501                    }
6502                    ManagedWorktreeSpawnReplayStateV10::Committed { receipt } => {
6503                        return Ok(DurableManagedSpawnReplayDecision::Committed(
6504                            receipt.clone(),
6505                        ));
6506                    }
6507                }
6508            } else if replays.len() >= MAX_MANAGED_WORKTREE_SPAWN_REPLAYS {
6509                return Err(failure(
6510                    NodeFailureCode::SpawnIdempotencyCapacity,
6511                    "durable managed worktree replay ledger reached its bounded capacity",
6512                ));
6513            } else {
6514                let now = unix_time_ms();
6515                replays.insert(key.clone(), ManagedWorktreeSpawnReplayRecordV10 {
6516                    idempotency_key: key.clone(),
6517                    request_digest,
6518                    source_workspace_id: request.spawn_spec.target.workspace_id.clone(),
6519                    profile_id: request.worktree_profile_id.clone(),
6520                    expected_profile_revision: request.expected_profile_revision.clone(),
6521                    state: ManagedWorktreeSpawnReplayStateV10::Pending {
6522                        owner_incarnation_id: self.incarnation_id,
6523                        lease_id: None,
6524                    },
6525                    created_at_unix_ms: now,
6526                    updated_at_unix_ms: now,
6527                });
6528            }
6529        }
6530        if let Err(error) = self.persist_state_locked() {
6531            let mut replays = self.managed_spawn_replays
6532                .lock()
6533                .unwrap_or_else(|poisoned| poisoned.into_inner());
6534            match previous {
6535                Some(previous) => {
6536                    replays.insert(key, previous);
6537                }
6538                None => {
6539                    replays.remove(&key);
6540                }
6541            }
6542            return Err(persistence_failure(error));
6543        }
6544        Ok(DurableManagedSpawnReplayDecision::Reserved)
6545    }
6546
6547    fn link_durable_managed_spawn_v2_lease_locked(
6548        &self,
6549        key: &SpawnIdempotencyKey,
6550        lease_id: &ManagedWorktreeLeaseId,
6551    ) -> Result<ManagedWorktreeSpawnReplayRecordV10, NodeFailure> {
6552        let mut replays = self.managed_spawn_replays
6553            .lock()
6554            .unwrap_or_else(|poisoned| poisoned.into_inner());
6555        let record = replays.get_mut(key).ok_or_else(|| {
6556            failure(
6557                NodeFailureCode::ManagedWorktreeRecoveryRequired,
6558                "managed worktree V2 reservation disappeared before allocation",
6559            )
6560        })?;
6561        let previous = record.clone();
6562        match &mut record.state {
6563            ManagedWorktreeSpawnReplayStateV10::Pending {
6564                lease_id: current,
6565                ..
6566            }
6567                if current.is_none() =>
6568            {
6569                *current = Some(lease_id.clone());
6570                record.updated_at_unix_ms = unix_time_ms();
6571                Ok(previous)
6572            }
6573            _ => Err(failure(
6574                NodeFailureCode::ManagedWorktreeRecoveryRequired,
6575                "managed worktree V2 reservation is not allocation-ready",
6576            )),
6577        }
6578    }
6579
6580    fn restore_durable_managed_spawn_v2_locked(
6581        &self,
6582        key: &SpawnIdempotencyKey,
6583        previous: ManagedWorktreeSpawnReplayRecordV10,
6584    ) {
6585        self.managed_spawn_replays
6586            .lock()
6587            .unwrap_or_else(|poisoned| poisoned.into_inner())
6588            .insert(key.clone(), previous);
6589    }
6590
6591    fn commit_durable_managed_spawn_v2(
6592        &self,
6593        request: &ManagedWorktreeSpawnRequestV2,
6594        receipt: ManagedWorktreeSpawnReceipt,
6595    ) -> Result<(), NodeFailure> {
6596        let _transaction = self.state_transaction
6597            .lock()
6598            .unwrap_or_else(|poisoned| poisoned.into_inner());
6599        let key = &request.spawn_spec.idempotency_key;
6600        let previous = {
6601            let mut replays = self.managed_spawn_replays
6602                .lock()
6603                .unwrap_or_else(|poisoned| poisoned.into_inner());
6604            let record = replays.get_mut(key).ok_or_else(|| {
6605                failure(
6606                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
6607                    "managed worktree V2 reservation disappeared before commit",
6608                )
6609            })?;
6610            let previous = record.clone();
6611            match &record.state {
6612                ManagedWorktreeSpawnReplayStateV10::Pending {
6613                    lease_id: Some(lease_id),
6614                    ..
6615                } if lease_id == &receipt.lease.lease_id => {}
6616                _ => {
6617                    return Err(failure(
6618                        NodeFailureCode::ManagedWorktreeRecoveryRequired,
6619                        "managed worktree V2 reservation is not linked to the spawned lease",
6620                    ));
6621                }
6622            }
6623            record.state = ManagedWorktreeSpawnReplayStateV10::Committed { receipt };
6624            record.updated_at_unix_ms = unix_time_ms();
6625            previous
6626        };
6627        if let Err(error) = self.persist_state_locked() {
6628            self.restore_durable_managed_spawn_v2_locked(key, previous);
6629            return Err(persistence_failure(error));
6630        }
6631        Ok(())
6632    }
6633
6634    fn remove_unallocated_durable_managed_spawn_v2(
6635        &self,
6636        request: &ManagedWorktreeSpawnRequestV2,
6637    ) -> Result<(), NodeFailure> {
6638        let _transaction = self.state_transaction
6639            .lock()
6640            .unwrap_or_else(|poisoned| poisoned.into_inner());
6641        let key = &request.spawn_spec.idempotency_key;
6642        let removed = {
6643            let mut replays = self.managed_spawn_replays
6644                .lock()
6645                .unwrap_or_else(|poisoned| poisoned.into_inner());
6646            match replays.get(key).map(|record| &record.state) {
6647                Some(ManagedWorktreeSpawnReplayStateV10::Pending {
6648                    lease_id: None,
6649                    ..
6650                }) => {
6651                    replays.remove(key)
6652                }
6653                _ => None,
6654            }
6655        };
6656        let Some(removed) = removed else {
6657            return Ok(());
6658        };
6659        if let Err(error) = self.persist_state_locked() {
6660            self.managed_spawn_replays
6661                .lock()
6662                .unwrap_or_else(|poisoned| poisoned.into_inner())
6663                .insert(key.clone(), removed);
6664            return Err(persistence_failure(error));
6665        }
6666        Ok(())
6667    }
6668
6669    fn resolve_spawn_spec(&self, spec: &SpawnSpec) -> Result<ResolvedSpawnSpec, NodeFailure> {
6670        if spec.target.node_id != self.node_id {
6671            return Err(failure(
6672                NodeFailureCode::SpawnTargetMismatch,
6673                "spawn target does not match this node",
6674            ));
6675        }
6676        let defaults = self.spawn_profiles.get(&spec.profile_id).ok_or_else(|| {
6677            failure(
6678                NodeFailureCode::UnknownSpawnProfile,
6679                "spawn profile is unavailable on this node",
6680            )
6681        })?;
6682        let resolved = spec.resolve(defaults).map_err(|error| match error {
6683            SpawnSpecResolveError::ProfileRevisionMismatch { .. } => failure(
6684                NodeFailureCode::SpawnProfileRevisionMismatch,
6685                "spawn profile revision does not match the loaded profile",
6686            ),
6687            _ => failure(
6688                NodeFailureCode::InvalidRequest,
6689                "spawn specification could not be resolved",
6690            ),
6691        })?;
6692        if resolved.target.worktree_id.is_some() {
6693            self.require_worktree_service(&resolved.target.workspace_id)?;
6694        }
6695        let environment_profile = self.resolve_environment_profile(&resolved)?;
6696        self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6697        self.resolve_context(&resolved)?;
6698        Ok(resolved)
6699    }
6700
6701    async fn resolve_spawn_workspace(
6702        &self,
6703        resolved: &ResolvedSpawnSpec,
6704        deadline: Instant,
6705    ) -> Result<WorkspaceId, NodeFailure> {
6706        let Some(worktree_id) = resolved.target.worktree_id.as_ref() else {
6707            return Ok(resolved.target.workspace_id.clone());
6708        };
6709        if self.managed_worktrees.lock()
6710            .unwrap_or_else(|poisoned| poisoned.into_inner())
6711            .lease_for_workspace(worktree_id)
6712            .is_some()
6713        {
6714            return Err(failure(
6715                NodeFailureCode::ManagedWorktreeOwnershipConflict,
6716                "managed worktree targets may only be spawned through their lease request",
6717            ));
6718        }
6719        self.require_worktree_service(&resolved.target.workspace_id)?;
6720        let source_root = self.workspace_root(&resolved.target.workspace_id)?;
6721        let selected_root = self.workspace_root(worktree_id)?;
6722        let remaining = spawn_deadline_remaining(deadline)?;
6723        let worktrees = timeout(remaining, list_git_worktrees(&source_root))
6724            .await
6725            .map_err(|_| {
6726                failure(
6727                    NodeFailureCode::SpawnDeadlineExceeded,
6728                    "Git worktree selection exceeded the spawn deadline",
6729                )
6730            })?
6731            .map_err(git_worktree_failure)?;
6732        validate_selected_worktree(&source_root, &selected_root, &worktrees)?;
6733        Ok(worktree_id.clone())
6734    }
6735
6736    async fn spawn_from_spec(
6737        &self,
6738        spec: SpawnSpec,
6739    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6740        self.spawn_from_spec_with_proxy(spec, None).await
6741    }
6742
6743    async fn spawn_from_spec_with_proxy(
6744        &self,
6745        spec: SpawnSpec,
6746        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
6747    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6748        let accepted_at = Instant::now();
6749        if harness_mcp.is_none() {
6750            if let Some(replayed) = self.replay_spawn_spec(&spec, accepted_at)? {
6751                return replayed;
6752            }
6753        }
6754        let resolved = self.resolve_spawn_spec(&spec)?;
6755        let environment_profile = self.resolve_environment_profile(&resolved)?;
6756        let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6757        let context = self.resolve_context(&resolved)?;
6758        let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6759        let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6760        let spawn_workspace_id = self.resolve_spawn_workspace(&resolved, deadline).await;
6761        let spawn_workspace_id = match spawn_workspace_id {
6762            Ok(workspace_id) => workspace_id,
6763            Err(error) => {
6764                let result = Err(error);
6765                if harness_mcp.is_none() {
6766                    self.remember_spawn_spec(spec, result.clone(), accepted_at);
6767                }
6768                return result;
6769            }
6770        };
6771        if self.managed_worktrees.lock()
6772            .unwrap_or_else(|poisoned| poisoned.into_inner())
6773            .lease_for_workspace(&spawn_workspace_id)
6774            .is_some()
6775        {
6776            let result = Err(failure(
6777                NodeFailureCode::ManagedWorktreeOwnershipConflict,
6778                "managed worktree targets may only be spawned through their lease request",
6779            ));
6780            if harness_mcp.is_none() {
6781                self.remember_spawn_spec(spec, result.clone(), accepted_at);
6782            }
6783            return result;
6784        }
6785        let result = self
6786            .spawn_session_with_deadline(
6787                spawn_workspace_id,
6788                resolved.provider.clone(),
6789                resolved.mode,
6790                resolved.terminal_size,
6791                resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
6792                environment_profile.clone(),
6793                bundle.clone(),
6794                context.clone(),
6795                None,
6796                None,
6797                SpawnRecordPolicy::Always,
6798                Some(deadline),
6799                &required_capabilities,
6800                harness_mcp,
6801                resolved.approval_level,
6802                resolved.network_allowlist.clone(),
6803                resolved.browser_profile_id.clone(),
6804            )
6805            .await
6806            .map(|(session, _runtime_policy)| {
6807                resolved.receipt_with_materialization(
6808                    self.incarnation_id,
6809                    session,
6810                    environment_profile,
6811                    bundle,
6812                    context,
6813                )
6814            });
6815        if harness_mcp.is_none() {
6816            self.remember_spawn_spec(spec, result.clone(), accepted_at);
6817        }
6818        result
6819    }
6820
6821    async fn spawn_from_spec_with_harness_mcp(
6822        &self,
6823        reservation_id: HarnessMcpReservationId,
6824        activation_digest: HarnessMcpActivationDigest,
6825        spec: SpawnSpec,
6826        deadline_unix_ms: u64,
6827    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6828        let registry = self.harness_mcp_registry.as_ref().ok_or_else(|| {
6829            failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
6830        })?;
6831        if let Some(receipt) = registry
6832            .replay_spawn(&reservation_id, &activation_digest, &spec)
6833            .map_err(harness_mcp_failure)?
6834        {
6835            return Ok(receipt);
6836        }
6837        let resolved = self.resolve_spawn_spec(&spec)?;
6838        let prepared = registry
6839            .prepare_spawn(
6840                &reservation_id,
6841                &activation_digest,
6842                &spec,
6843                deadline_unix_ms,
6844                resolved.provider.clone(),
6845            )
6846            .map_err(harness_mcp_failure)?;
6847        self.claim_harness_mcp_spawn(
6848            &reservation_id,
6849            &activation_digest,
6850            &spec,
6851            Instant::now(),
6852        )?;
6853        let spawn = self.spawn_from_spec_with_proxy(spec, Some(&prepared)).await;
6854        let mut receipt = match spawn {
6855            Ok(receipt) => receipt,
6856            Err(error) => {
6857                let _ = registry.abort(&reservation_id, &activation_digest);
6858                return Err(error);
6859            }
6860        };
6861        let record_id = self
6862            .session_bindings
6863            .lock()
6864            .unwrap_or_else(|poisoned| poisoned.into_inner())
6865            .get(&receipt.session.session.instance_id)
6866            .and_then(|binding| binding.record_id.clone());
6867        let record_id = match record_id {
6868            Some(record_id) => record_id,
6869            None => {
6870                let rollback = self.remove_session(&receipt.session).await;
6871                let _ = registry.abort(&reservation_id, &activation_digest);
6872                if let Err(rollback_error) = rollback {
6873                    return Err(rollback_error);
6874                }
6875                return Err(failure(
6876                    NodeFailureCode::BindingMismatch,
6877                    "harness MCP spawn did not retain its exact managed record",
6878                ));
6879            }
6880        };
6881        receipt.harness_mcp_proxy = Some(ResolvedHarnessMcpProxyReceiptV1 {
6882            reservation_id: reservation_id.clone(),
6883            activation_digest: activation_digest.clone(),
6884        });
6885        if let Err(error) = registry.mark_spawned(
6886            &prepared,
6887            receipt.session.clone(),
6888            record_id,
6889            receipt.clone(),
6890        ) {
6891            let rollback = self.remove_session(&receipt.session).await;
6892            let _ = registry.abort(&reservation_id, &activation_digest);
6893            if let Err(rollback_error) = rollback {
6894                return Err(rollback_error);
6895            }
6896            return Err(harness_mcp_failure(error));
6897        }
6898        Ok(receipt)
6899    }
6900
6901    async fn spawn_managed_worktree(
6902        &self,
6903        request: ManagedWorktreeSpawnRequest,
6904    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6905        self.spawn_managed_worktree_inner(request, None).await
6906    }
6907
6908    async fn spawn_managed_worktree_inner(
6909        &self,
6910        request: ManagedWorktreeSpawnRequest,
6911        durable_v2_key: Option<&SpawnIdempotencyKey>,
6912    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6913        let accepted_at = Instant::now();
6914        if durable_v2_key.is_none() {
6915            if let Some(replayed) = self.replay_managed_spawn(&request, accepted_at)? {
6916                return replayed;
6917            }
6918        }
6919        let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6920        if request.spawn_spec.target.worktree_id.is_some() {
6921            let result = Err(failure(
6922                NodeFailureCode::InvalidRequest,
6923                "managed worktree spawn must not select a caller-provided worktree",
6924            ));
6925            self.remember_managed_spawn_attempt(
6926                durable_v2_key,
6927                request,
6928                result.clone(),
6929                accepted_at,
6930            );
6931            return result;
6932        }
6933        let environment_profile = self.resolve_environment_profile(&resolved)?;
6934        let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6935        let context = self.resolve_context(&resolved)?;
6936        let source_workspace_id = resolved.target.workspace_id.clone();
6937        let profile = self.managed_profile(&source_workspace_id, &request.worktree_profile_id)?;
6938        let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6939        let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6940        let runtime_requirement = match (resolved.mode, resolved.prompt.is_some()) {
6941            (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
6942            (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
6943            (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
6944            (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
6945        };
6946        let runtime_policy = timeout(
6947            spawn_deadline_remaining(deadline)?,
6948            self.admit_provider_runtime(&resolved.provider, runtime_requirement),
6949        ).await.map_err(|_| failure(
6950            NodeFailureCode::SpawnDeadlineExceeded,
6951            "provider admission exceeded the managed spawn deadline",
6952        ))??;
6953        if required_capabilities.iter().any(|capability| !runtime_policy.admits(*capability)) {
6954            return Err(failure(
6955                NodeFailureCode::UnsupportedSpawnCapability,
6956                "provider runtime does not admit a required spawn capability",
6957            ));
6958        }
6959        self.ensure_binding_capacity()?;
6960        let source_root = self.workspace_root(&source_workspace_id)?;
6961        profile.validate_for_workspace(&source_root)
6962            .map_err(|message| failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message))?;
6963        let base_timeout_ms = u64::try_from(
6964            spawn_deadline_remaining(deadline)?.as_millis(),
6965        ).unwrap_or(u64::MAX).max(1);
6966        let base_commit = resolve_base_commit_with_timeout(
6967            &source_root,
6968            profile.base(),
6969            base_timeout_ms,
6970        ).await.map_err(|error| {
6971            if Instant::now() >= deadline || error.message.contains("timed out") {
6972                failure(
6973                    NodeFailureCode::SpawnDeadlineExceeded,
6974                    "managed worktree base resolution exceeded the spawn deadline",
6975                )
6976            } else {
6977                managed_git_worktree_failure(error)
6978            }
6979        })?;
6980
6981        let lease = {
6982            let _transaction = self.state_transaction.lock()
6983                .unwrap_or_else(|poisoned| poisoned.into_inner());
6984            let previous = self.managed_worktrees.lock()
6985                .unwrap_or_else(|poisoned| poisoned.into_inner()).clone();
6986            let lease = self.managed_worktrees.lock()
6987                .unwrap_or_else(|poisoned| poisoned.into_inner())
6988                .allocate(source_workspace_id.clone(), &profile, base_commit.clone(), unix_time_ms())
6989                .map_err(|message| failure(NodeFailureCode::BackendBusy, &message))?;
6990            let previous_replay = match durable_v2_key
6991                .map(|key| self.link_durable_managed_spawn_v2_lease_locked(key, &lease.lease_id))
6992                .transpose()
6993            {
6994                Ok(previous) => previous,
6995                Err(error) => {
6996                    *self.managed_worktrees.lock()
6997                        .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6998                    return Err(error);
6999                }
7000            };
7001            if let Err(error) = self.persist_state_locked() {
7002                *self.managed_worktrees.lock()
7003                    .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
7004                if let (Some(key), Some(previous_replay)) =
7005                    (durable_v2_key, previous_replay)
7006                {
7007                    self.restore_durable_managed_spawn_v2_locked(key, previous_replay);
7008                }
7009                return Err(persistence_failure(error));
7010            }
7011            lease
7012        };
7013        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() });
7014        if let Err(message) = profile.validate_target_authority(&lease.lease_id, &lease.target_root) {
7015            self.managed_worktrees.lock()
7016                .unwrap_or_else(|poisoned| poisoned.into_inner())
7017                .remove_unmutated(&lease.lease_id);
7018            if self.persist_state().is_err() {
7019                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
7020            }
7021            let result = Err(failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message));
7022            self.remember_managed_spawn_attempt(
7023                durable_v2_key,
7024                request,
7025                result.clone(),
7026                accepted_at,
7027            );
7028            return result;
7029        }
7030        let mutation_timeout_ms = match spawn_deadline_remaining(deadline) {
7031            Ok(remaining) => u64::try_from(remaining.as_millis()).unwrap_or(u64::MAX).max(1),
7032            Err(error) => {
7033                self.reconcile_failed_allocation(&lease, &source_root).await;
7034                let result = Err(error);
7035                self.remember_managed_spawn_attempt(
7036                    durable_v2_key,
7037                    request,
7038                    result.clone(),
7039                    accepted_at,
7040                );
7041                return result;
7042            }
7043        };
7044        let created = match create_git_worktree_with_timeout(
7045                &source_root,
7046                &lease.target_root,
7047                &lease.branch,
7048                Some(&base_commit),
7049                mutation_timeout_ms,
7050            ).await {
7051            Ok(created) => created,
7052            Err(error) => {
7053                self.reconcile_failed_allocation(&lease, &source_root).await;
7054                let result = Err(if Instant::now() >= deadline
7055                    || error.message.contains("timed out")
7056                    || error.message.contains("deadline")
7057                {
7058                    failure(
7059                        NodeFailureCode::SpawnDeadlineExceeded,
7060                        "managed worktree creation exceeded the spawn deadline",
7061                    )
7062                } else {
7063                    managed_git_worktree_failure(error)
7064                });
7065                self.remember_managed_spawn_attempt(
7066                    durable_v2_key,
7067                    request,
7068                    result.clone(),
7069                    accepted_at,
7070                );
7071                return result;
7072            }
7073        };
7074        if !exact_created_worktree(&lease, &created.path, created.branch.as_deref(), &created.head) {
7075            self.mark_managed_recovery_required(&lease.lease_id, ManagedWorktreeCleanupFailure::OwnershipConflict);
7076            let result = Err(failure(
7077                NodeFailureCode::ManagedWorktreeRecoveryRequired,
7078                "created worktree identity did not match its durable lease",
7079            ));
7080            self.remember_managed_spawn_attempt(
7081                durable_v2_key,
7082                request,
7083                result.clone(),
7084                accepted_at,
7085            );
7086            return result;
7087        }
7088        {
7089            let mut registry = self.managed_worktrees.lock()
7090                .unwrap_or_else(|poisoned| poisoned.into_inner());
7091            let current = registry.get_mut(&lease.lease_id)
7092                .expect("allocated managed worktree lease remains present");
7093            current.expected_head = Some(created.head.clone());
7094            current.state = ManagedWorktreeLeaseState::Ready;
7095            current.updated_at_unix_ms = unix_time_ms();
7096        }
7097        if let Err(error) = self.persist_state() {
7098            self.mark_managed_recovery_required(
7099                &lease.lease_id,
7100                ManagedWorktreeCleanupFailure::Backend,
7101            );
7102            let result = Err(persistence_failure(error));
7103            self.remember_managed_spawn_attempt(
7104                durable_v2_key,
7105                request,
7106                result.clone(),
7107                accepted_at,
7108            );
7109            return result;
7110        }
7111        let ready = self.managed_worktrees.lock()
7112            .unwrap_or_else(|poisoned| poisoned.into_inner())
7113            .get(&lease.lease_id)
7114            .expect("ready managed worktree lease remains present")
7115            .snapshot();
7116        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: ready });
7117        if let Err(error) = self.register_workspace(lease.workspace_id.clone(), created.path.clone()).await {
7118            let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
7119            let result = Err(error);
7120            self.remember_managed_spawn_attempt(
7121                durable_v2_key,
7122                request,
7123                result.clone(),
7124                accepted_at,
7125            );
7126            return result;
7127        }
7128        let spawn = self.spawn_session_with_deadline(
7129            lease.workspace_id.clone(),
7130            resolved.provider.clone(),
7131            resolved.mode,
7132            resolved.terminal_size,
7133            resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
7134            environment_profile.clone(),
7135            bundle.clone(),
7136            context.clone(),
7137            Some(lease.lease_id.clone()),
7138            Some(runtime_policy),
7139            SpawnRecordPolicy::Always,
7140            Some(deadline),
7141            &required_capabilities,
7142            None,
7143            resolved.approval_level,
7144            resolved.network_allowlist.clone(),
7145            resolved.browser_profile_id.clone(),
7146        ).await;
7147        let (session, effective_runtime_policy) = match spawn {
7148            Ok(spawned) => spawned,
7149            Err(error) => {
7150                if self
7151                    .cleanup_managed_worktree(&lease.lease_id, true)
7152                    .await
7153                    .is_err()
7154                {
7155                    self.mark_managed_recovery_required(
7156                        &lease.lease_id,
7157                        ManagedWorktreeCleanupFailure::Backend,
7158                    );
7159                }
7160                let result = Err(error);
7161                self.remember_managed_spawn_attempt(
7162                    durable_v2_key,
7163                    request,
7164                    result.clone(),
7165                    accepted_at,
7166                );
7167                return result;
7168            }
7169        };
7170        let (record_id, raw_inventory_record_id) = {
7171            let mut bindings = self.session_bindings.lock()
7172                .unwrap_or_else(|poisoned| poisoned.into_inner());
7173            let binding = bindings.get_mut(&session.session.instance_id)
7174                .expect("successful spawn retains its session binding");
7175            binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
7176            managed_spawn_record_ownership(binding, effective_runtime_policy)
7177        };
7178        let bound = self.managed_worktrees.lock()
7179            .unwrap_or_else(|poisoned| poisoned.into_inner())
7180            .bind_session(
7181                &lease.lease_id,
7182                ManagedWorktreeSessionHolder {
7183                    incarnation_id: self.incarnation_id,
7184                    instance_id: session.session.instance_id,
7185                    generation: session.session.generation,
7186                },
7187                record_id,
7188                unix_time_ms(),
7189            )
7190            .map_err(|message| failure(NodeFailureCode::ManagedWorktreeRecoveryRequired, &message));
7191        let snapshot = match bound {
7192            Ok(snapshot) => snapshot,
7193            Err(error) => {
7194                if raw_inventory_record_id
7195                    .as_ref()
7196                    .is_some_and(|record_id| self.discard_record(record_id).is_err())
7197                {
7198                    self.mark_managed_recovery_required(
7199                        &lease.lease_id,
7200                        ManagedWorktreeCleanupFailure::Backend,
7201                    );
7202                }
7203                let _ = self.remove_session(&session).await;
7204                self.mark_managed_recovery_required(
7205                    &lease.lease_id,
7206                    ManagedWorktreeCleanupFailure::Backend,
7207                );
7208                let result = Err(error);
7209                self.remember_managed_spawn_attempt(
7210                    durable_v2_key,
7211                    request,
7212                    result.clone(),
7213                    accepted_at,
7214                );
7215                return result;
7216            }
7217        };
7218        if let Err(error) = self.persist_state() {
7219            if raw_inventory_record_id
7220                .as_ref()
7221                .is_some_and(|record_id| self.discard_record(record_id).is_err())
7222            {
7223                self.mark_managed_recovery_required(
7224                    &lease.lease_id,
7225                    ManagedWorktreeCleanupFailure::Backend,
7226                );
7227            }
7228            let _ = self.remove_session(&session).await;
7229            if self
7230                .cleanup_managed_worktree(&lease.lease_id, true)
7231                .await
7232                .is_err()
7233            {
7234                self.mark_managed_recovery_required(
7235                    &lease.lease_id,
7236                    ManagedWorktreeCleanupFailure::Backend,
7237                );
7238            }
7239            let result = Err(persistence_failure(error));
7240            self.remember_managed_spawn_attempt(
7241                durable_v2_key,
7242                request,
7243                result.clone(),
7244                accepted_at,
7245            );
7246            return result;
7247        }
7248        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
7249        let receipt = managed_spawn_receipt(
7250            &resolved,
7251            self.incarnation_id,
7252            session,
7253            snapshot,
7254            environment_profile,
7255            bundle,
7256            context,
7257        );
7258        let result = Ok(receipt);
7259        self.remember_managed_spawn_attempt(
7260            durable_v2_key,
7261            request,
7262            result.clone(),
7263            accepted_at,
7264        );
7265        result
7266    }
7267
7268    async fn spawn_managed_worktree_v2(
7269        &self,
7270        request: ManagedWorktreeSpawnRequestV2,
7271    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
7272        if request.spawn_spec.target.node_id != self.node_id {
7273            return Err(failure(
7274                NodeFailureCode::SpawnTargetMismatch,
7275                "spawn target does not match this node",
7276            ));
7277        }
7278        if request.spawn_spec.target.worktree_id.is_some() {
7279            return Err(failure(
7280                NodeFailureCode::InvalidRequest,
7281                "managed worktree spawn must not select a caller-provided worktree",
7282            ));
7283        }
7284        let request_digest = managed_spawn_request_digest_v2(&request)?;
7285        let replay = self.acquire_durable_managed_spawn_v2(&request, request_digest)?;
7286        match replay {
7287            DurableManagedSpawnReplayDecision::Committed(receipt) => return Ok(receipt),
7288            DurableManagedSpawnReplayDecision::PendingLinked => {
7289                return Err(failure(
7290                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
7291                    "managed worktree V2 reservation has an incomplete durable allocation",
7292                ));
7293            }
7294            DurableManagedSpawnReplayDecision::Reserved => {}
7295        }
7296        let validated = (|| {
7297            let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
7298            let profile = self.managed_profile(
7299                &resolved.target.workspace_id,
7300                &request.worktree_profile_id,
7301            )?;
7302            if profile.revision() != &request.expected_profile_revision {
7303                return Err(failure(
7304                    NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
7305                    "managed worktree profile revision does not match the loaded profile",
7306                ));
7307            }
7308            Ok(())
7309        })();
7310        match validated {
7311            Ok(()) => {}
7312            Err(error) => {
7313                self.remove_unallocated_durable_managed_spawn_v2(&request)?;
7314                return Err(error);
7315            }
7316        }
7317        let legacy = ManagedWorktreeSpawnRequest {
7318            spawn_spec: request.spawn_spec.clone(),
7319            worktree_profile_id: request.worktree_profile_id.clone(),
7320        };
7321        match self
7322            .spawn_managed_worktree_inner(
7323                legacy,
7324                Some(&request.spawn_spec.idempotency_key),
7325            )
7326            .await
7327        {
7328            Ok(receipt) => {
7329                self.commit_durable_managed_spawn_v2(&request, receipt.clone())?;
7330                Ok(receipt)
7331            }
7332            Err(error) => {
7333                self.remove_unallocated_durable_managed_spawn_v2(&request)?;
7334                Err(error)
7335            }
7336        }
7337    }
7338
7339    fn managed_profile(
7340        &self,
7341        workspace_id: &WorkspaceId,
7342        profile_id: &WorktreeProfileId,
7343    ) -> Result<ManagedWorktreeProfile, NodeFailure> {
7344        if self.worktree_service_modes.get(workspace_id).copied()
7345            != Some(WorktreeServiceMode::Managed)
7346        {
7347            return Err(failure(
7348                NodeFailureCode::UnsupportedSpawnCapability,
7349                "workspace does not enable managed worktrees",
7350            ));
7351        }
7352        self.managed_worktree_profiles.get(workspace_id)
7353            .and_then(|profiles| profiles.get(profile_id))
7354            .cloned()
7355            .ok_or_else(|| failure(
7356                NodeFailureCode::InvalidRequest,
7357                "managed worktree profile is unavailable for this workspace",
7358            ))
7359    }
7360
7361    async fn reconcile_failed_allocation(
7362        &self,
7363        lease: &ManagedWorktreeLeaseRecord,
7364        source_root: &str,
7365    ) {
7366        match list_git_worktrees(source_root).await {
7367            Ok(worktrees) => {
7368                match worktrees.iter().find(|item| worktree_paths_equal(&item.path, &lease.target_root)) {
7369                    None => {
7370                        match std::fs::symlink_metadata(&lease.target_root) {
7371                            Err(error) if error.kind() == io::ErrorKind::NotFound => {
7372                                self.managed_worktrees.lock()
7373                                    .unwrap_or_else(|poisoned| poisoned.into_inner())
7374                                    .remove_unmutated(&lease.lease_id);
7375                                let _ = self.persist_state();
7376                            }
7377                            _ => self.mark_managed_recovery_required(
7378                                &lease.lease_id,
7379                                ManagedWorktreeCleanupFailure::OwnershipConflict,
7380                            ),
7381                        }
7382                    }
7383                    Some(item) if exact_owned_worktree(
7384                        lease,
7385                        &item.path,
7386                        item.branch.as_deref(),
7387                        &item.head,
7388                    ) => {
7389                        let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
7390                    }
7391                    Some(_) => self.mark_managed_recovery_required(
7392                        &lease.lease_id,
7393                        ManagedWorktreeCleanupFailure::OwnershipConflict,
7394                    ),
7395                }
7396            }
7397            Err(_) => self.mark_managed_recovery_required(
7398                &lease.lease_id,
7399                ManagedWorktreeCleanupFailure::Backend,
7400            ),
7401        }
7402    }
7403
7404    async fn reconcile_managed_worktrees(&self) {
7405        let leases = self.managed_worktrees.lock()
7406            .unwrap_or_else(|poisoned| poisoned.into_inner())
7407            .active_records()
7408            .cloned()
7409            .collect::<Vec<_>>();
7410        for lease in leases {
7411            let profile = match self.managed_profile(&lease.source_workspace_id, &lease.profile_id) {
7412                Ok(profile) if profile.revision() == &lease.profile_revision => profile,
7413                _ => {
7414                    self.mark_managed_recovery_required(
7415                        &lease.lease_id,
7416                        ManagedWorktreeCleanupFailure::OwnershipConflict,
7417                    );
7418                    continue;
7419                }
7420            };
7421            if profile.validate_target_authority(&lease.lease_id, &lease.target_root).is_err() {
7422                self.mark_managed_recovery_required(
7423                    &lease.lease_id,
7424                    ManagedWorktreeCleanupFailure::OwnershipConflict,
7425                );
7426                continue;
7427            }
7428            let source_root = match self.workspace_root(&lease.source_workspace_id) {
7429                Ok(root) => root,
7430                Err(_) => {
7431                    self.mark_managed_recovery_required(
7432                        &lease.lease_id,
7433                        ManagedWorktreeCleanupFailure::OwnershipConflict,
7434                    );
7435                    continue;
7436                }
7437            };
7438            let worktrees = match list_git_worktrees(&source_root).await {
7439                Ok(worktrees) => worktrees,
7440                Err(_) => {
7441                    self.mark_managed_recovery_required(
7442                        &lease.lease_id,
7443                        ManagedWorktreeCleanupFailure::Backend,
7444                    );
7445                    continue;
7446                }
7447            };
7448            let listed = worktrees.iter()
7449                .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
7450            let Some(listed) = listed else {
7451                match std::fs::symlink_metadata(&lease.target_root) {
7452                    Err(error) if error.kind() == io::ErrorKind::NotFound
7453                        && lease.state == ManagedWorktreeLeaseState::Allocating
7454                        && !lease.has_holders() => {
7455                            let removed = self.managed_worktrees.lock()
7456                                .unwrap_or_else(|poisoned| poisoned.into_inner())
7457                                .tombstone(&lease.lease_id, unix_time_ms());
7458                            if self.persist_state().is_ok() && removed.is_some() {
7459                                self.publish(NodeEvent::ManagedWorktreeRemoved {
7460                                    lease_id: lease.lease_id.clone(),
7461                                });
7462                            }
7463                        }
7464                    _ => self.mark_managed_recovery_required(
7465                        &lease.lease_id,
7466                        ManagedWorktreeCleanupFailure::OwnershipConflict,
7467                    ),
7468                }
7469                continue;
7470            };
7471            if listed.is_main || listed.is_bare || !exact_owned_worktree(
7472                &lease,
7473                &listed.path,
7474                listed.branch.as_deref(),
7475                &listed.head,
7476            ) {
7477                self.mark_managed_recovery_required(
7478                    &lease.lease_id,
7479                    ManagedWorktreeCleanupFailure::OwnershipConflict,
7480                );
7481                continue;
7482            }
7483            if !self.workspaces.read()
7484                .unwrap_or_else(|poisoned| poisoned.into_inner())
7485                .contains_key(&lease.workspace_id)
7486            {
7487                if self.register_workspace(lease.workspace_id.clone(), listed.path.clone()).await.is_err() {
7488                    self.mark_managed_recovery_required(
7489                        &lease.lease_id,
7490                        ManagedWorktreeCleanupFailure::Backend,
7491                    );
7492                    continue;
7493                }
7494            }
7495            let snapshot = {
7496                let mut registry = self.managed_worktrees.lock()
7497                    .unwrap_or_else(|poisoned| poisoned.into_inner());
7498                let current = registry.get_mut(&lease.lease_id)
7499                    .expect("reconciled managed lease remains present");
7500                current.expected_head = Some(listed.head.clone());
7501                current.cleanup_failure = None;
7502                current.state = if current.has_holders() {
7503                    ManagedWorktreeLeaseState::InUse
7504                } else if current.retention == ManagedWorktreeRetention::Retain {
7505                    ManagedWorktreeLeaseState::Retained
7506                } else {
7507                    ManagedWorktreeLeaseState::Ready
7508                };
7509                current.updated_at_unix_ms = unix_time_ms();
7510                current.snapshot()
7511            };
7512            if self.persist_state().is_err() {
7513                self.mark_managed_recovery_required(
7514                    &lease.lease_id,
7515                    ManagedWorktreeCleanupFailure::Backend,
7516                );
7517                continue;
7518            }
7519            self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
7520            if snapshot.state == ManagedWorktreeLeaseState::Ready {
7521                let _ = self.cleanup_managed_worktree(&lease.lease_id, false).await;
7522            }
7523        }
7524    }
7525
7526    /// Crash-recovery startup sweep: durably capture the pack for every
7527    /// `Live` managed record whose most recent session has not yet produced
7528    /// one (recovers a session whose `Exited{0}` event was observed but the
7529    /// Node crashed before the export finished). The reactive fast path for
7530    /// a single just-exited record does not go through this — see
7531    /// `drive_runtime_until_shutdown`, which resolves and spawns directly so
7532    /// it can capture the exact `SessionAddress` before `publish_control`
7533    /// clears it, rather than re-deriving it from (by then likely already
7534    /// `Dormant`) record state the way a rescan naturally would.
7535    ///
7536    /// Returns as soon as every eligible record's export has been *spawned*,
7537    /// not completed — each one runs on its own detached task (see
7538    /// `reconcile_context_pack_export_for_record`'s own doc comment for why:
7539    /// `NativeRuntime::tick()` has not even started its own loop yet at the
7540    /// point this is called from `NodeServer::run()`, so nothing could ever
7541    /// drain the history round trip if this awaited it inline here either).
7542    /// Best-effort throughout: never panics, no unwrap/expect; every failure
7543    /// is a silent skip for THIS record — an unusable source or a racing
7544    /// record is not surfaced to any caller.
7545    async fn reconcile_context_pack_exports(self: &Arc<Self>) {
7546        let candidates: Vec<(SessionRecordId, SessionAddress)> = self
7547            .session_records
7548            .lock()
7549            .unwrap_or_else(|poisoned| poisoned.into_inner())
7550            .records
7551            .values()
7552            .filter(|record| {
7553                record.state == ManagedSessionState::Live
7554                    && record.exported_context.is_none()
7555                    && record.provider_session.is_some()
7556            })
7557            .filter_map(|record| {
7558                record
7559                    .active_session
7560                    .clone()
7561                    .map(|session| (record.record_id.clone(), session))
7562            })
7563            .collect();
7564        for (record_id, session) in candidates {
7565            let shared = Arc::clone(self);
7566            tokio::spawn(async move {
7567                shared
7568                    .reconcile_context_pack_export_for_record(&record_id, session)
7569                    .await;
7570            });
7571        }
7572    }
7573
7574    /// Runs on its own detached task (`tokio::spawn`, see both call sites),
7575    /// never inline on `drive_runtime_until_shutdown`'s own task: the history
7576    /// round trip this performs (`discover_history`/`load_history`, via
7577    /// `export_context_pack_for_session_record`) can only ever settle once
7578    /// `NativeRuntime::tick()` runs again to drain the authority worker's
7579    /// completion — and `tick()` is exclusively owned by, and only ever
7580    /// called from, that same drive-loop task. Awaiting this inline there
7581    /// would starve `tick()` and this call could never observe its own
7582    /// command complete.
7583    ///
7584    /// Because this now genuinely races the drive loop's own
7585    /// `publish_control` (which downgrades this exact record from `Live` to
7586    /// `Dormant`, clearing `active_session`, for the very `Exited` event that
7587    /// triggered this reconcile), the record legitimately may already be
7588    /// `Dormant` by the time this reads it, or may transition mid-flight —
7589    /// `allow_clean_detachment: true` on the downstream export call is what
7590    /// tolerates exactly that transition (and only that transition; see
7591    /// `session_record_export_target_matches`).
7592    async fn reconcile_context_pack_export_for_record(
7593        &self,
7594        record_id: &SessionRecordId,
7595        session: SessionAddress,
7596    ) {
7597        let Ok(record) = self.record(record_id) else {
7598            return;
7599        };
7600        if !matches!(
7601            record.state,
7602            ManagedSessionState::Live | ManagedSessionState::Dormant
7603        ) || record.exported_context.is_some()
7604            || record.provider_session.is_none()
7605        {
7606            return;
7607        }
7608        // Pack bytes are already durably committed inside this call, via
7609        // commit_context_pack_for_session_record's own durable
7610        // write-through (context_pack_store) — no separate step here.
7611        // Unclean exits (non-zero/forced/no loadable history) are already
7612        // rejected by context_pack_source_status_is_usable inside
7613        // materialize_context_pack: this simply returns Err and is skipped.
7614        let Ok(receipt) = self
7615            .export_context_pack_for_session_record(&record.record_id, &session, true)
7616            .await
7617        else {
7618            return;
7619        };
7620        self.set_exported_context(&record.record_id, receipt);
7621    }
7622
7623    /// Records the record's most recent clean-exit pack. No-op (not an
7624    /// error) if the record disappeared, or if it already carries an
7625    /// `exported_context` — first-write-wins, matching the harness-side
7626    /// idempotent reconciliation that consumes this field.
7627    fn set_exported_context(&self, record_id: &SessionRecordId, receipt: ResolvedContextPackReceipt) {
7628        let transaction = self
7629            .state_transaction
7630            .lock()
7631            .unwrap_or_else(|poisoned| poisoned.into_inner());
7632        let (previous, updated) = {
7633            let mut records = self
7634                .session_records
7635                .lock()
7636                .unwrap_or_else(|poisoned| poisoned.into_inner());
7637            let Some(record) = records.records.get_mut(record_id) else {
7638                return;
7639            };
7640            if record.exported_context.is_some() {
7641                return;
7642            }
7643            let previous = record.clone();
7644            record.exported_context = Some(receipt);
7645            record.updated_at_unix_ms = unix_time_ms().max(record.updated_at_unix_ms);
7646            (previous, record.clone())
7647        };
7648        if self.persist_state_locked().is_err() {
7649            self.session_records
7650                .lock()
7651                .unwrap_or_else(|poisoned| poisoned.into_inner())
7652                .records
7653                .insert(record_id.clone(), previous);
7654            return;
7655        }
7656        drop(transaction);
7657        self.publish_record(updated);
7658    }
7659
7660    /// Resolves the managed record and its exact `SessionAddress` currently
7661    /// bound to `instance_id` at exactly `generation`, used by the reactive
7662    /// `Exited{0}` hook in `drive_runtime_until_shutdown` to target a single
7663    /// record without a full rescan. Mirrors the lookup already inlined in
7664    /// `publish_control`. Captured synchronously, before `publish_control`
7665    /// for the same event clears the record's own `active_session` — the
7666    /// caller passes the resolved address through to a detached
7667    /// reconciliation task rather than re-deriving it from the record later,
7668    /// after that race has necessarily already happened.
7669    fn managed_record_export_target_for_instance(
7670        &self,
7671        instance_id: AgentInstanceId,
7672        generation: SessionGeneration,
7673    ) -> Option<(SessionRecordId, SessionAddress)> {
7674        let bindings = self
7675            .session_bindings
7676            .lock()
7677            .unwrap_or_else(|poisoned| poisoned.into_inner());
7678        let binding = bindings.get(&instance_id).filter(|binding| binding.generation == generation)?;
7679        let record_id = binding.record_id.clone()?;
7680        Some((
7681            record_id,
7682            SessionAddress {
7683                workspace_id: binding.workspace_id.clone(),
7684                session: SessionKey { instance_id, generation },
7685            },
7686        ))
7687    }
7688
7689    fn mark_managed_recovery_required(
7690        &self,
7691        lease_id: &ManagedWorktreeLeaseId,
7692        failure_kind: ManagedWorktreeCleanupFailure,
7693    ) {
7694        let snapshot = {
7695            let mut registry = self.managed_worktrees.lock()
7696                .unwrap_or_else(|poisoned| poisoned.into_inner());
7697            let Some(lease) = registry.get_mut(lease_id) else { return };
7698            lease.state = ManagedWorktreeLeaseState::RecoveryRequired;
7699            lease.cleanup_failure = Some(failure_kind);
7700            lease.updated_at_unix_ms = unix_time_ms();
7701            lease.snapshot()
7702        };
7703        let _ = self.persist_state();
7704        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
7705    }
7706
7707    fn set_persistence_error(&self, error: Option<String>) {
7708        *self
7709            .persistence_error
7710            .write()
7711            .unwrap_or_else(|poisoned| poisoned.into_inner()) = error
7712            .map(|message| session_registry::sanitized_persistence_summary(&message));
7713    }
7714
7715    fn persist_state(&self) -> io::Result<()> {
7716        let _transaction = self
7717            .state_transaction
7718            .lock()
7719            .unwrap_or_else(|poisoned| poisoned.into_inner());
7720        self.persist_state_locked()
7721    }
7722
7723    fn persist_state_locked(&self) -> io::Result<()> {
7724        let workspaces = self
7725            .workspaces
7726            .read()
7727            .unwrap_or_else(|poisoned| poisoned.into_inner())
7728            .clone();
7729        let records = self
7730            .session_records
7731            .lock()
7732            .unwrap_or_else(|poisoned| poisoned.into_inner())
7733            .records
7734            .values()
7735            .cloned()
7736            .collect::<Vec<_>>();
7737        let mut managed = self.managed_worktrees
7738            .lock()
7739            .unwrap_or_else(|poisoned| poisoned.into_inner());
7740        managed.reattach_record_holders(
7741            &managed_worktree_record_holders(&records),
7742            unix_time_ms(),
7743        );
7744        let materializations = self
7745            .materializations
7746            .lock()
7747            .unwrap_or_else(|poisoned| poisoned.into_inner())
7748            .values()
7749            .cloned()
7750            .collect::<Vec<_>>();
7751        let managed_spawn_replays = self.managed_spawn_replays
7752            .lock()
7753            .unwrap_or_else(|poisoned| poisoned.into_inner())
7754            .values()
7755            .cloned()
7756            .collect::<Vec<_>>();
7757        let result = session_registry::save_v10(
7758            self.state_path.as_deref(),
7759            &self.node_id,
7760            &workspaces,
7761            &records,
7762            &managed.records(),
7763            &managed.tombstones(),
7764            &materializations,
7765            &managed_spawn_replays,
7766        );
7767        match &result {
7768            Ok(warning) => self.set_persistence_error(warning.clone()),
7769            Err(_) => self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned())),
7770        }
7771        result.map(|_| ())
7772    }
7773
7774    async fn begin_shutdown(&self) -> Result<(), NodeFailure> {
7775        let _mutation_guard = self.mutation_gate.lock().await;
7776        self.begin_shutdown_locked().await
7777    }
7778
7779    async fn begin_shutdown_locked(&self) -> Result<(), NodeFailure> {
7780        if self.shutdown.swap(true, Ordering::AcqRel) {
7781            return Ok(());
7782        }
7783        SessionLifecycleHook::on_shutdown(self);
7784        let deadline = Instant::now() + Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
7785        let mut first_error = None;
7786        for session in &self.handle.snapshot().sessions {
7787            let remaining = deadline.saturating_duration_since(Instant::now());
7788            if remaining.is_zero() {
7789                first_error.get_or_insert_with(|| {
7790                    failure(NodeFailureCode::BackendBusy, "stop-all dispatch exceeded its bounded deadline")
7791                });
7792                break;
7793            }
7794            if let Err(error) = self
7795                .dispatch_bounded(
7796                    ControlCommand::Stop {
7797                        instance_id: session.instance_id,
7798                        force: true,
7799                    },
7800                    remaining,
7801                )
7802                .await
7803            {
7804                first_error.get_or_insert(error);
7805            }
7806        }
7807        self.shutdown_notify.notify_waiters();
7808        match first_error {
7809            Some(error) => Err(error),
7810            None => Ok(()),
7811        }
7812    }
7813
7814    fn ensure_binding_capacity(&self) -> Result<(), NodeFailure> {
7815        let bindings = self
7816            .session_bindings
7817            .lock()
7818            .unwrap_or_else(|poisoned| poisoned.into_inner());
7819        if bindings.len() >= CONTROL_SESSIONS_MAX {
7820            Err(failure(
7821                NodeFailureCode::BackendBusy,
7822                "node session-binding capacity is full; remove an existing session first",
7823            ))
7824        } else {
7825            Ok(())
7826        }
7827    }
7828
7829    /// One-owner workspace/env collision guard: a live session on
7830    /// `workspace_id` with a different `environment_profile` (option
7831    /// equality on the resolved receipt, including Some vs None) blocks
7832    /// another spawn on that same cwd. Same profile may still share the
7833    /// workspace; distinct profiles need distinct workspace ids (managed
7834    /// worktree).
7835    fn ensure_workspace_environment_profile_compatible(
7836        &self,
7837        workspace_id: &WorkspaceId,
7838        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
7839    ) -> Result<(), NodeFailure> {
7840        let bindings = self
7841            .session_bindings
7842            .lock()
7843            .unwrap_or_else(|poisoned| poisoned.into_inner());
7844        for binding in bindings.values() {
7845            if &binding.workspace_id != workspace_id {
7846                continue;
7847            }
7848            if binding.environment_profile.as_ref() != environment_profile {
7849                return Err(failure(
7850                    NodeFailureCode::WorkspaceBusy,
7851                    "workspace already has a live session with a different environment profile; use a managed worktree for concurrent distinct ProviderHome/env bindings on one repo",
7852                ));
7853            }
7854        }
7855        Ok(())
7856    }
7857
7858    #[cfg(test)]
7859    fn bind_session(
7860        &self,
7861        instance_id: AgentInstanceId,
7862        workspace_id: WorkspaceId,
7863        generation: SessionGeneration,
7864    ) {
7865        self.bind_session_with_policy(
7866            &SessionAddress {
7867                workspace_id,
7868                session: SessionKey { instance_id, generation },
7869            },
7870            ProviderRuntimePolicy::raw_pty(),
7871            None,
7872        );
7873    }
7874
7875    #[cfg(test)]
7876    fn bind_session_with_policy(
7877        &self,
7878        address: &SessionAddress,
7879        runtime_policy: ProviderRuntimePolicy,
7880        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7881    ) {
7882        self.bind_session_with_materialization(
7883            address,
7884            runtime_policy,
7885            environment_profile,
7886            None,
7887            None,
7888            None,
7889        );
7890    }
7891
7892    fn bind_session_with_materialization(
7893        &self,
7894        address: &SessionAddress,
7895        runtime_policy: ProviderRuntimePolicy,
7896        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7897        bundle: Option<ResolvedBundleReceipt>,
7898        context: Option<ResolvedContextPackReceipt>,
7899        materialization_id: Option<MaterializationId>,
7900    ) {
7901        let mut bindings = self
7902            .session_bindings
7903            .lock()
7904            .unwrap_or_else(|poisoned| poisoned.into_inner());
7905        debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7906        debug_assert!(!bindings.contains_key(&address.session.instance_id));
7907        bindings.insert(address.session.instance_id, SessionBinding {
7908            workspace_id: address.workspace_id.clone(),
7909            generation: address.session.generation,
7910            runtime_policy,
7911            pending_resume: None,
7912            record_id: None,
7913            managed_worktree_lease_id: None,
7914            environment_profile,
7915            bundle,
7916            context,
7917            materialization_id,
7918        });
7919    }
7920
7921    #[cfg(test)]
7922    fn bind_managed_session(
7923        &self,
7924        address: &SessionAddress,
7925        record_id: SessionRecordId,
7926        runtime_policy: ProviderRuntimePolicy,
7927        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7928    ) {
7929        self.bind_managed_session_with_materialization(
7930            address,
7931            record_id,
7932            runtime_policy,
7933            environment_profile,
7934            None,
7935            None,
7936            None,
7937        );
7938    }
7939
7940    fn bind_managed_session_with_materialization(
7941        &self,
7942        address: &SessionAddress,
7943        record_id: SessionRecordId,
7944        runtime_policy: ProviderRuntimePolicy,
7945        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7946        bundle: Option<ResolvedBundleReceipt>,
7947        context: Option<ResolvedContextPackReceipt>,
7948        materialization_id: Option<MaterializationId>,
7949    ) {
7950        let mut bindings = self
7951            .session_bindings
7952            .lock()
7953            .unwrap_or_else(|poisoned| poisoned.into_inner());
7954        debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7955        debug_assert!(!bindings.contains_key(&address.session.instance_id));
7956        bindings.insert(
7957            address.session.instance_id,
7958            SessionBinding {
7959                workspace_id: address.workspace_id.clone(),
7960                generation: address.session.generation,
7961                runtime_policy,
7962                pending_resume: None,
7963                record_id: Some(record_id),
7964                managed_worktree_lease_id: None,
7965                environment_profile,
7966                bundle,
7967                context,
7968                materialization_id,
7969            },
7970        );
7971    }
7972
7973    #[cfg(test)]
7974    fn bind_spawn_session(
7975        &self,
7976        address: &SessionAddress,
7977        provider: AgentId,
7978        mode: SessionMode,
7979        runtime_policy: ProviderRuntimePolicy,
7980        record_policy: SpawnRecordPolicy,
7981        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7982    ) -> Result<Option<SessionRecordId>, NodeFailure> {
7983        self.bind_spawn_session_with_materialization(
7984            address,
7985            provider,
7986            mode,
7987            runtime_policy,
7988            record_policy,
7989            environment_profile,
7990            None,
7991            None,
7992            None,
7993        )
7994    }
7995
7996    fn bind_spawn_session_with_materialization(
7997        &self,
7998        address: &SessionAddress,
7999        provider: AgentId,
8000        mode: SessionMode,
8001        runtime_policy: ProviderRuntimePolicy,
8002        record_policy: SpawnRecordPolicy,
8003        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
8004        bundle: Option<ResolvedBundleReceipt>,
8005        context: Option<ResolvedContextPackReceipt>,
8006        materialization_id: Option<MaterializationId>,
8007    ) -> Result<Option<SessionRecordId>, NodeFailure> {
8008        if record_policy == SpawnRecordPolicy::ProviderIdentityOnly
8009            && !runtime_policy.provider_session_identity
8010        {
8011            self.bind_session_with_materialization(
8012                address,
8013                runtime_policy,
8014                environment_profile,
8015                bundle,
8016                context,
8017                materialization_id,
8018            );
8019            return Ok(None);
8020        }
8021        let record_owns_lifecycle = runtime_policy.provider_session_identity;
8022        let record = self.new_record(
8023            address,
8024            provider,
8025            mode,
8026            active_record_state(runtime_policy.provider_session_identity, false),
8027            None,
8028            record_owns_lifecycle.then(|| environment_profile.clone()).flatten(),
8029            record_owns_lifecycle.then(|| bundle.clone()).flatten(),
8030            record_owns_lifecycle.then(|| context.clone()).flatten(),
8031        )?;
8032        let record_id = record.record_id.clone();
8033        let transaction = self
8034            .state_transaction
8035            .lock()
8036            .unwrap_or_else(|poisoned| poisoned.into_inner());
8037        self.insert_record(record.clone())?;
8038        let previous_materialization = if record_owns_lifecycle {
8039            if let Some(materialization_id) = materialization_id.as_ref() {
8040                let mut materializations = self
8041                    .materializations
8042                    .lock()
8043                    .unwrap_or_else(|poisoned| poisoned.into_inner());
8044                let Some(ownership) = materializations.get_mut(materialization_id) else {
8045                    drop(materializations);
8046                    self.remove_record_memory(&record_id);
8047                    return Err(failure(
8048                        NodeFailureCode::BackendOperationFailed,
8049                        "session environment ownership disappeared before binding",
8050                    ));
8051                };
8052                let previous = ownership.clone();
8053                if ownership
8054                    .transfer_to_record(record_id.clone(), unix_time_ms())
8055                    .is_err()
8056                {
8057                    drop(materializations);
8058                    self.remove_record_memory(&record_id);
8059                    return Err(failure(
8060                        NodeFailureCode::BackendOperationFailed,
8061                        "session environment ownership transfer failed",
8062                    ));
8063                }
8064                Some(previous)
8065            } else {
8066                None
8067            }
8068        } else {
8069            None
8070        };
8071        self.bind_managed_session_with_materialization(
8072            address,
8073            record_id.clone(),
8074            runtime_policy,
8075            environment_profile,
8076            bundle,
8077            context,
8078            materialization_id.clone(),
8079        );
8080        if let Err(error) = self.persist_state_locked() {
8081            self.remove_binding(address);
8082            self.remove_record_memory(&record_id);
8083            if let (Some(materialization_id), Some(previous)) =
8084                (materialization_id.as_ref(), previous_materialization)
8085            {
8086                self.materializations
8087                    .lock()
8088                    .unwrap_or_else(|poisoned| poisoned.into_inner())
8089                    .insert(materialization_id.clone(), previous);
8090            }
8091            return Err(persistence_failure(error));
8092        }
8093        drop(transaction);
8094        self.publish_record(record);
8095        Ok(Some(record_id))
8096    }
8097
8098    fn allocate_record_id(&self) -> Result<SessionRecordId, NodeFailure> {
8099        for _ in 0..8 {
8100            let bytes = random_nonce().map_err(|error| {
8101                failure(
8102                    NodeFailureCode::BackendOperationFailed,
8103                    &format!("session record identity generation failed: {error}"),
8104                )
8105            })?;
8106            let mut value = String::with_capacity(27);
8107            value.push_str("sr-");
8108            for byte in &bytes[..12] {
8109                use std::fmt::Write as _;
8110                let _ = write!(&mut value, "{byte:02x}");
8111            }
8112            let record_id = SessionRecordId::new(value).map_err(|error| {
8113                failure(NodeFailureCode::BackendOperationFailed, &error.to_string())
8114            })?;
8115            let records = self
8116                .session_records
8117                .lock()
8118                .unwrap_or_else(|poisoned| poisoned.into_inner());
8119            if !records.records.contains_key(&record_id) {
8120                return Ok(record_id);
8121            }
8122        }
8123        Err(failure(
8124            NodeFailureCode::BackendBusy,
8125            "could not allocate a unique session record ID",
8126        ))
8127    }
8128
8129    fn allocate_task_id(&self) -> Result<TaskId, NodeFailure> {
8130        for _ in 0..8 {
8131            let bytes = random_nonce().map_err(|error| {
8132                failure(
8133                    NodeFailureCode::BackendOperationFailed,
8134                    &format!("task identity generation failed: {error}"),
8135                )
8136            })?;
8137            let mut nonce = [0_u8; 12];
8138            nonce.copy_from_slice(&bytes[..12]);
8139            let task_id = TaskId::from_nonce(nonce);
8140            let records = self
8141                .session_records
8142                .lock()
8143                .unwrap_or_else(|poisoned| poisoned.into_inner());
8144            if !records.records.values().any(|record| {
8145                record.task_binding.as_ref().and_then(|binding| binding.task_id.as_ref())
8146                    == Some(&task_id)
8147            }) {
8148                return Ok(task_id);
8149            }
8150        }
8151        Err(failure(
8152            NodeFailureCode::BackendBusy,
8153            "could not allocate a unique task ID",
8154        ))
8155    }
8156
8157    fn default_record_name(&self, provider: &AgentId) -> String {
8158        let records = self
8159            .session_records
8160            .lock()
8161            .unwrap_or_else(|poisoned| poisoned.into_inner());
8162        let ordinal = records
8163            .records
8164            .values()
8165            .filter(|record| &record.provider == provider)
8166            .count()
8167            .saturating_add(1);
8168        format!("{} #{ordinal}", provider.as_str())
8169    }
8170
8171    fn new_record(
8172        &self,
8173        address: &SessionAddress,
8174        provider: AgentId,
8175        mode: SessionMode,
8176        state: ManagedSessionState,
8177        provider_session: Option<gate4agent_types::ProviderSessionIdentity>,
8178        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
8179        bundle: Option<ResolvedBundleReceipt>,
8180        context: Option<ResolvedContextPackReceipt>,
8181    ) -> Result<ManagedSessionRecord, NodeFailure> {
8182        let canonical_root = self.workspace_root(&address.workspace_id)?;
8183        let now = unix_time_ms();
8184        Ok(ManagedSessionRecord {
8185            record_id: self.allocate_record_id()?,
8186            display_name: self.m4a_nick_of(address.session.instance_id).unwrap_or_else(|| self.default_record_name(&provider)),
8187            provider,
8188            mode,
8189            state,
8190            workspace_id: address.workspace_id.clone(),
8191            canonical_root: opaque_windows_path(canonical_root),
8192            provider_session,
8193            active_session: Some(address.clone()),
8194            environment_profile,
8195            bundle,
8196            context_id: context.as_ref().map(|receipt| receipt.id.clone()),
8197            context,
8198            exported_context: None,
8199            task_binding: None,
8200            created_at_unix_ms: now,
8201            updated_at_unix_ms: now,
8202            last_error: None,
8203        })
8204    }
8205
8206    fn insert_record(&self, record: ManagedSessionRecord) -> Result<(), NodeFailure> {
8207        let mut records = self
8208            .session_records
8209            .lock()
8210            .unwrap_or_else(|poisoned| poisoned.into_inner());
8211        if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
8212            return Err(failure(
8213                NodeFailureCode::BackendBusy,
8214                "managed session record capacity is full; forget an inactive record first",
8215            ));
8216        }
8217        if let Some(identity) = record.provider_session.as_ref() {
8218            if records.records.values().any(|candidate| {
8219                candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8220                    session_registry::same_provider_session(
8221                        &candidate.provider,
8222                        candidate_identity,
8223                        &record.provider,
8224                        identity,
8225                    )
8226                })
8227            }) {
8228                return Err(failure(
8229                    NodeFailureCode::SessionRecordConflict,
8230                    "provider session is already represented by another managed record",
8231                ));
8232            }
8233        }
8234        match records.records.entry(record.record_id.clone()) {
8235            std::collections::btree_map::Entry::Vacant(entry) => {
8236                entry.insert(record);
8237            }
8238            std::collections::btree_map::Entry::Occupied(_) => {
8239                return Err(failure(
8240                    NodeFailureCode::SessionRecordConflict,
8241                    "session record ID collision",
8242                ));
8243            }
8244        }
8245        Ok(())
8246    }
8247
8248    fn record(&self, record_id: &SessionRecordId) -> Result<ManagedSessionRecord, NodeFailure> {
8249        self.session_records
8250            .lock()
8251            .unwrap_or_else(|poisoned| poisoned.into_inner())
8252            .records
8253            .get(record_id)
8254            .cloned()
8255            .ok_or_else(|| {
8256                failure(
8257                    NodeFailureCode::UnknownSessionRecord,
8258                    "managed session record does not exist",
8259                )
8260            })
8261    }
8262
8263    fn revalidate_session_record_preview(
8264        &self,
8265        expected: &ManagedSessionRecord,
8266        identity: &ProviderSessionIdentity,
8267    ) -> Result<(), NodeFailure> {
8268        let current = self.record(&expected.record_id)?;
8269        let current_root = self.workspace_root(&current.workspace_id)?;
8270        if current.provider != expected.provider
8271            || current.workspace_id != expected.workspace_id
8272            || current.canonical_root != expected.canonical_root
8273            || current.provider_session.as_ref() != Some(identity)
8274            || !platform::roots_equal(
8275                &current_root,
8276                windows_path_text(&current.canonical_root),
8277            )
8278        {
8279            return Err(failure(
8280                NodeFailureCode::SessionRecordConflict,
8281                "managed session changed while its preview was loading",
8282            ));
8283        }
8284        Ok(())
8285    }
8286
8287    fn revalidate_session_record_context_export(
8288        &self,
8289        expected: &ManagedSessionRecord,
8290        identity: Option<&ProviderSessionIdentity>,
8291        session: &SessionAddress,
8292        allow_clean_detachment: bool,
8293    ) -> Result<(), NodeFailure> {
8294        let current = self.record(&expected.record_id)?;
8295        let current_root = self.workspace_root(&current.workspace_id)?;
8296        let snapshot = self.internal_session_snapshot(session)?;
8297        let binding = self
8298            .session_bindings
8299            .lock()
8300            .unwrap_or_else(|poisoned| poisoned.into_inner())
8301            .get(&session.session.instance_id)
8302            .cloned()
8303            .ok_or_else(|| {
8304                failure(
8305                    NodeFailureCode::SessionRecordConflict,
8306                    "managed session runtime binding disappeared during context export",
8307                )
8308            })?;
8309        if !session_record_context_export_binding_is_exact(
8310            expected,
8311            &current,
8312            identity,
8313            session,
8314            &binding,
8315            &snapshot.agent_id,
8316            &current_root,
8317            allow_clean_detachment,
8318        ) || !session_record_context_export_source_is_usable(
8319            &current,
8320            &snapshot.status,
8321            allow_clean_detachment,
8322        ) {
8323            return Err(failure(
8324                NodeFailureCode::SessionRecordConflict,
8325                "managed session binding changed during context export",
8326            ));
8327        }
8328        Ok(())
8329    }
8330
8331    fn revalidate_loaded_history(
8332        &self,
8333        session: &SessionAddress,
8334        candidate_id: &str,
8335        loaded: &HistorySessionRecord,
8336    ) -> Result<(), NodeFailure> {
8337        let snapshot = self.internal_session_snapshot(session)?;
8338        if snapshot.history.pending.is_some()
8339            || snapshot.history.loaded_candidate_id.as_deref() != Some(candidate_id)
8340            || snapshot.history.loaded.as_ref() != Some(loaded)
8341        {
8342            return Err(failure(
8343                NodeFailureCode::SessionRecordConflict,
8344                "managed session history changed during context export",
8345            ));
8346        }
8347        Ok(())
8348    }
8349
8350    fn remove_record_memory(&self, record_id: &SessionRecordId) -> Option<ManagedSessionRecord> {
8351        self.session_records
8352            .lock()
8353            .unwrap_or_else(|poisoned| poisoned.into_inner())
8354            .records
8355            .remove(record_id)
8356    }
8357
8358    fn discard_record(&self, record_id: &SessionRecordId) -> Result<(), NodeFailure> {
8359        if self.destroy_record_materialization(record_id)? {
8360            self.publish(NodeEvent::SessionRecordRemoved {
8361                record_id: record_id.clone(),
8362            });
8363            return Ok(());
8364        }
8365        let transaction = self
8366            .state_transaction
8367            .lock()
8368            .unwrap_or_else(|poisoned| poisoned.into_inner());
8369        let Some(removed) = self.remove_record_memory(record_id) else {
8370            return Ok(());
8371        };
8372        if let Err(error) = self.persist_state_locked() {
8373            let _ = self.insert_record(removed);
8374            return Err(persistence_failure(error));
8375        }
8376        drop(transaction);
8377        self.publish(NodeEvent::SessionRecordRemoved {
8378            record_id: record_id.clone(),
8379        });
8380        Ok(())
8381    }
8382
8383    fn publish_record(&self, record: ManagedSessionRecord) {
8384        self.publish(NodeEvent::SessionRecordUpserted { record });
8385    }
8386
8387    fn mark_record_error(
8388        &self,
8389        record_id: &SessionRecordId,
8390        message: &str,
8391    ) -> Result<(), NodeFailure> {
8392        let transaction = self
8393            .state_transaction
8394            .lock()
8395            .unwrap_or_else(|poisoned| poisoned.into_inner());
8396        let previous = {
8397            let mut records = self
8398                .session_records
8399                .lock()
8400                .unwrap_or_else(|poisoned| poisoned.into_inner());
8401            let Some(record) = records.records.get_mut(record_id) else {
8402                return Ok(());
8403            };
8404            let previous = record.clone();
8405            record.state = ManagedSessionState::Unavailable;
8406            record.last_error = Some(session_registry::sanitized_record_error_summary(message));
8407            record.updated_at_unix_ms = unix_time_ms();
8408            previous
8409        };
8410        if let Err(error) = self.persist_state_locked() {
8411            self.session_records
8412                .lock()
8413                .unwrap_or_else(|poisoned| poisoned.into_inner())
8414                .records
8415                .insert(record_id.clone(), previous);
8416            return Err(persistence_failure(error));
8417        }
8418        let record = self.record(record_id)?;
8419        drop(transaction);
8420        self.publish_record(record);
8421        Ok(())
8422    }
8423
8424    fn rename_session_record(
8425        &self,
8426        record_id: &SessionRecordId,
8427        display_name: String,
8428    ) -> Result<ManagedSessionRecord, NodeFailure> {
8429        validate_display_name(&display_name)
8430            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8431        let _transaction = self
8432            .state_transaction
8433            .lock()
8434            .unwrap_or_else(|poisoned| poisoned.into_inner());
8435        let previous = {
8436            let mut records = self
8437                .session_records
8438                .lock()
8439                .unwrap_or_else(|poisoned| poisoned.into_inner());
8440            let record = records.records.get_mut(record_id).ok_or_else(|| {
8441                failure(
8442                    NodeFailureCode::UnknownSessionRecord,
8443                    "managed session record does not exist",
8444                )
8445            })?;
8446            let previous = record.clone();
8447            record.display_name = display_name;
8448            record.updated_at_unix_ms = unix_time_ms();
8449            previous
8450        };
8451        if let Err(error) = self.persist_state_locked() {
8452            self.session_records
8453                .lock()
8454                .unwrap_or_else(|poisoned| poisoned.into_inner())
8455                .records
8456                .insert(record_id.clone(), previous);
8457            return Err(persistence_failure(error));
8458        }
8459        let updated = self.record(record_id)?;
8460        self.publish_record(updated.clone());
8461        Ok(updated)
8462    }
8463
8464    fn set_session_task(
8465        &self,
8466        record_id: &SessionRecordId,
8467        expected_revision: u64,
8468        target: SessionTaskTargetV1,
8469    ) -> Result<ManagedSessionRecord, NodeFailure> {
8470        let transaction = self
8471            .state_transaction
8472            .lock()
8473            .unwrap_or_else(|poisoned| poisoned.into_inner());
8474        let current = self.record(record_id)?;
8475        let current_task_id = current
8476            .task_binding
8477            .as_ref()
8478            .and_then(|binding| binding.task_id.as_ref());
8479        let current_revision = current
8480            .task_binding
8481            .as_ref()
8482            .map_or(0, |binding| binding.revision);
8483        if expected_revision != current_revision {
8484            return Err(failure(
8485                NodeFailureCode::SessionRecordConflict,
8486                "managed session task binding revision changed",
8487            ));
8488        }
8489        let exact = match &target {
8490            SessionTaskTargetV1::Existing { task_id } => current_task_id == Some(task_id),
8491            SessionTaskTargetV1::Clear => current_task_id.is_none(),
8492            SessionTaskTargetV1::New => false,
8493        };
8494        if exact {
8495            return Ok(current);
8496        }
8497        let revision = current_revision.checked_add(1).ok_or_else(|| {
8498            failure(
8499                NodeFailureCode::SessionRecordConflict,
8500                "managed session task binding revision is exhausted",
8501            )
8502        })?;
8503        let task_id = match target {
8504            SessionTaskTargetV1::New => Some(self.allocate_task_id()?),
8505            SessionTaskTargetV1::Existing { task_id } => Some(task_id),
8506            SessionTaskTargetV1::Clear => None,
8507        };
8508        let changed_at_unix_ms = unix_time_ms()
8509            .max(current.updated_at_unix_ms.checked_add(1).ok_or_else(|| {
8510                failure(
8511                    NodeFailureCode::SessionRecordConflict,
8512                    "managed session record timestamp is exhausted",
8513                )
8514            })?);
8515        let updated = {
8516            let mut records = self
8517                .session_records
8518                .lock()
8519                .unwrap_or_else(|poisoned| poisoned.into_inner());
8520            let record = records.records.get_mut(record_id).ok_or_else(|| {
8521                failure(
8522                    NodeFailureCode::UnknownSessionRecord,
8523                    "managed session record does not exist",
8524                )
8525            })?;
8526            record.task_binding = Some(SessionTaskBindingV1 {
8527                revision,
8528                task_id,
8529                changed_at_unix_ms,
8530            });
8531            record.updated_at_unix_ms = changed_at_unix_ms;
8532            record.clone()
8533        };
8534        if let Err(error) = self.persist_state_locked() {
8535            self.session_records
8536                .lock()
8537                .unwrap_or_else(|poisoned| poisoned.into_inner())
8538                .records
8539                .insert(record_id.clone(), current);
8540            return Err(persistence_failure(error));
8541        }
8542        drop(transaction);
8543        self.publish_record(updated.clone());
8544        Ok(updated)
8545    }
8546
8547    fn index_provider_session(
8548        &self,
8549        workspace_id: WorkspaceId,
8550        provider: AgentId,
8551        identity: ProviderSessionIdentity,
8552        display_name: String,
8553    ) -> Result<ManagedSessionRecord, NodeFailure> {
8554        self.index_provider_session_with_policy(
8555            workspace_id,
8556            provider,
8557            identity,
8558            display_name,
8559            false,
8560        )
8561    }
8562
8563    fn index_provider_session_with_policy(
8564        &self,
8565        workspace_id: WorkspaceId,
8566        provider: AgentId,
8567        identity: ProviderSessionIdentity,
8568        display_name: String,
8569        allow_validated_transcript_path: bool,
8570    ) -> Result<ManagedSessionRecord, NodeFailure> {
8571        validate_display_name(&display_name)
8572            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8573        identity
8574            .validate()
8575            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8576        if identity.transcript_path.is_some() && !allow_validated_transcript_path {
8577            return Err(failure(
8578                NodeFailureCode::InvalidRequest,
8579                "provider session reference index v1 does not accept transcript paths",
8580            ));
8581        }
8582        let canonical_root = self.workspace_root(&workspace_id)?;
8583        if !self.enabled_providers.contains(&provider) {
8584            return Err(failure(
8585                NodeFailureCode::InvalidRequest,
8586                "provider is not enabled on this node",
8587            ));
8588        }
8589
8590        let transaction = self
8591            .state_transaction
8592            .lock()
8593            .unwrap_or_else(|poisoned| poisoned.into_inner());
8594        {
8595            let records = self
8596                .session_records
8597                .lock()
8598                .unwrap_or_else(|poisoned| poisoned.into_inner());
8599            if let Some(existing) = records.records.values().find(|candidate| {
8600                candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8601                    candidate_identity.key == identity.key && candidate_identity.id == identity.id
8602                })
8603            }) {
8604                if existing.provider == provider && existing.workspace_id == workspace_id {
8605                    return Ok(existing.clone());
8606                }
8607                return Err(failure(
8608                    NodeFailureCode::SessionRecordConflict,
8609                    "provider session reference conflicts with another provider or workspace",
8610                ));
8611            }
8612            if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
8613                return Err(failure(
8614                    NodeFailureCode::BackendBusy,
8615                    "managed session record capacity is full; forget an inactive record first",
8616                ));
8617            }
8618        }
8619
8620        let now = unix_time_ms();
8621        let record = ManagedSessionRecord {
8622            record_id: self.allocate_record_id()?,
8623            display_name,
8624            provider,
8625            mode: SessionMode::Pty,
8626            state: ManagedSessionState::Dormant,
8627            workspace_id,
8628            canonical_root: opaque_windows_path(canonical_root),
8629            provider_session: Some(identity),
8630            active_session: None,
8631            environment_profile: None,
8632            bundle: None,
8633            context_id: None,
8634            context: None,
8635            exported_context: None,
8636            task_binding: None,
8637            created_at_unix_ms: now,
8638            updated_at_unix_ms: now,
8639            last_error: None,
8640        };
8641        self.insert_record(record.clone())?;
8642        if let Err(error) = self.persist_state_locked() {
8643            self.remove_record_memory(&record.record_id);
8644            return Err(persistence_failure(error));
8645        }
8646        drop(transaction);
8647        self.publish_record(record.clone());
8648        Ok(record)
8649    }
8650
8651    fn bound_address_for_record(&self, record_id: &SessionRecordId) -> Option<SessionAddress> {
8652        self.session_bindings
8653            .lock()
8654            .unwrap_or_else(|poisoned| poisoned.into_inner())
8655            .iter()
8656            .find_map(|(instance_id, binding)| {
8657                (binding.record_id.as_ref() == Some(record_id)).then(|| SessionAddress {
8658                    workspace_id: binding.workspace_id.clone(),
8659                    session: SessionKey {
8660                        instance_id: *instance_id,
8661                        generation: binding.generation,
8662                    },
8663                })
8664            })
8665    }
8666
8667    async fn forget_session_record(
8668        &self,
8669        record_id: &SessionRecordId,
8670    ) -> Result<(), NodeFailure> {
8671        let record = self.record(record_id)?;
8672        if record.active_session.is_some()
8673            || matches!(
8674                record.state,
8675                ManagedSessionState::Live | ManagedSessionState::IdentityPending
8676            )
8677        {
8678            return Err(failure(
8679                NodeFailureCode::SessionRecordBusy,
8680                "stop the managed session before forgetting it",
8681            ));
8682        }
8683        if let Some(address) = self.bound_address_for_record(record_id) {
8684            self.remove_session(&address).await?;
8685        }
8686        self.remove_managed_session_record(record_id, &record.workspace_id).await
8687    }
8688
8689    /// Removes one managed session record from durable state and notifies
8690    /// every listener -- the shared transaction body behind both
8691    /// `forget_session_record`'s operator-driven path (busy-checked above
8692    /// it, and clears any still-live session binding before calling in
8693    /// here) and the retention sweep (`retire_unavailable_session_records`,
8694    /// which only ever selects `ManagedSessionState::Unavailable` records
8695    /// that are already known never-busy and never bound, so it calls
8696    /// straight into this with neither check). Cleans a managed-worktree
8697    /// lease if the record held one, removes the record's environment
8698    /// materialization (or, absent one, removes it from memory directly),
8699    /// persists under `state_transaction` with rollback on persist failure,
8700    /// then publishes `NodeEvent::SessionRecordRemoved` so c2's own
8701    /// legacy-provider bookkeeping (`record_is_legacy`) stays in sync
8702    /// exactly as it does for an operator-driven forget.
8703    async fn remove_managed_session_record(
8704        &self,
8705        record_id: &SessionRecordId,
8706        workspace_id: &WorkspaceId,
8707    ) -> Result<(), NodeFailure> {
8708        let managed_lease_id = self.managed_worktrees.lock()
8709            .unwrap_or_else(|poisoned| poisoned.into_inner())
8710            .lease_for_workspace(workspace_id);
8711        if !self.destroy_record_materialization(record_id)? {
8712            let transaction = self
8713                .state_transaction
8714                .lock()
8715                .unwrap_or_else(|poisoned| poisoned.into_inner());
8716            let removed = self.remove_record_memory(record_id).ok_or_else(|| {
8717                failure(
8718                    NodeFailureCode::UnknownSessionRecord,
8719                    "managed session record does not exist",
8720                )
8721            })?;
8722            if let Err(error) = self.persist_state_locked() {
8723                let _ = self.insert_record(removed);
8724                return Err(persistence_failure(error));
8725            }
8726            drop(transaction);
8727        }
8728        self.publish(NodeEvent::SessionRecordRemoved {
8729            record_id: record_id.clone(),
8730        });
8731        if let Some(lease_id) = managed_lease_id {
8732            if let Some(snapshot) = self.managed_worktrees.lock()
8733                .unwrap_or_else(|poisoned| poisoned.into_inner())
8734                .get(&lease_id)
8735                .map(ManagedWorktreeLeaseRecord::snapshot)
8736            {
8737                self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
8738            }
8739            let _ = self.cleanup_managed_worktree(&lease_id, false).await;
8740        }
8741        Ok(())
8742    }
8743
8744    /// Retires `ManagedSessionState::Unavailable` records against the
8745    /// node's configured `session_record_retention` age/keep-N policy.
8746    /// Never touches `Live`, `IdentityPending`, or `Dormant` records --
8747    /// `Dormant` is resumable and folding it into this sweep would delete
8748    /// records a caller can still act on. Both policy fields default to
8749    /// `0` (disabled); when both are `0` this is a no-op. When both are
8750    /// set, a record is retired only if it fails BOTH tests (older than
8751    /// `age_ms` AND beyond the newest `keep_per_workspace` in its
8752    /// workspace); when only one is set, that one alone decides. Called
8753    /// once at startup (alongside `reconcile_managed_worktrees`) and then
8754    /// on a `SESSION_RECORD_RETENTION_SWEEP_PERIOD` cadence from the drive
8755    /// loop -- see both call sites.
8756    async fn retire_unavailable_session_records(&self) {
8757        let policy = self.session_record_retention;
8758        let age_enabled = policy.age_ms > 0;
8759        let keep_enabled = policy.keep_per_workspace > 0;
8760        if !age_enabled && !keep_enabled {
8761            return;
8762        }
8763        let now = unix_time_ms();
8764        let candidates: Vec<(SessionRecordId, WorkspaceId, AgentId, u64)> = {
8765            self.session_records
8766                .lock()
8767                .unwrap_or_else(|poisoned| poisoned.into_inner())
8768                .records
8769                .values()
8770                .filter(|record| record.state == ManagedSessionState::Unavailable)
8771                .map(|record| {
8772                    (
8773                        record.record_id.clone(),
8774                        record.workspace_id.clone(),
8775                        record.provider.clone(),
8776                        record.updated_at_unix_ms,
8777                    )
8778                })
8779                .collect()
8780        };
8781        if candidates.is_empty() {
8782            return;
8783        }
8784        let mut by_workspace: BTreeMap<WorkspaceId, Vec<(SessionRecordId, AgentId, u64)>> =
8785            BTreeMap::new();
8786        for (record_id, workspace_id, provider, updated_at_unix_ms) in candidates {
8787            by_workspace
8788                .entry(workspace_id)
8789                .or_default()
8790                .push((record_id, provider, updated_at_unix_ms));
8791        }
8792        let reason = if age_enabled && keep_enabled {
8793            "age+keep-n"
8794        } else if age_enabled {
8795            "age"
8796        } else {
8797            "keep-n"
8798        };
8799        let mut retired: u64 = 0;
8800        for (workspace_id, mut records) in by_workspace {
8801            // Newest `updated_at_unix_ms` first, so index `>=
8802            // keep_per_workspace` names exactly "beyond the newest N".
8803            records.sort_by(|left, right| right.2.cmp(&left.2));
8804            for (index, (record_id, provider, updated_at_unix_ms)) in
8805                records.into_iter().enumerate()
8806            {
8807                let record_age_ms = now.saturating_sub(updated_at_unix_ms);
8808                let age_hit = age_enabled && record_age_ms >= policy.age_ms;
8809                let keep_hit = keep_enabled && index >= policy.keep_per_workspace as usize;
8810                let should_retire = if age_enabled && keep_enabled {
8811                    age_hit && keep_hit
8812                } else {
8813                    age_hit || keep_hit
8814                };
8815                if !should_retire {
8816                    continue;
8817                }
8818                match self.remove_managed_session_record(&record_id, &workspace_id).await {
8819                    Ok(()) => {
8820                        self.retired_records_total.fetch_add(1, Ordering::Relaxed);
8821                        retired += 1;
8822                        tracing::info!(
8823                            node_id = %self.node_id,
8824                            record_id = %record_id,
8825                            workspace_id = %workspace_id,
8826                            provider = %provider,
8827                            age_ms = record_age_ms,
8828                            reason,
8829                            "managed session record retired"
8830                        );
8831                    }
8832                    Err(error) => tracing::warn!(
8833                        node_id = %self.node_id,
8834                        record_id = %record_id,
8835                        workspace_id = %workspace_id,
8836                        code = ?error.code,
8837                        cause = %error.message,
8838                        "session record retention sweep failed to retire a record"
8839                    ),
8840                }
8841            }
8842        }
8843        if retired > 0 {
8844            let remaining_unavailable = self
8845                .session_records
8846                .lock()
8847                .unwrap_or_else(|poisoned| poisoned.into_inner())
8848                .records
8849                .values()
8850                .filter(|record| record.state == ManagedSessionState::Unavailable)
8851                .count();
8852            tracing::info!(
8853                node_id = %self.node_id,
8854                retired,
8855                remaining_unavailable,
8856                "session record retention sweep completed"
8857            );
8858        }
8859    }
8860
8861    async fn resume_session_record(
8862        &self,
8863        record_id: &SessionRecordId,
8864        terminal_size: gate4agent_types::TerminalSize,
8865        initial_prompt: Option<String>,
8866    ) -> Result<(ManagedSessionRecord, SessionAddress), NodeFailure> {
8867        let record = self.record(record_id)?;
8868        let acp_codex = record.mode == SessionMode::Acp && record.provider.as_str() == "codex";
8869        let instance_id = RESTORE_INSTANCE_ID.try_with(|id| *id).unwrap_or_else(|_| {
8870            AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel))
8871        });
8872        // Both attempts must retain the journalled slot and its launch metadata.
8873        RESTORE_INSTANCE_ID.scope(instance_id, async {
8874            let result = self.resume_session_record_with_mode(
8875                record_id, terminal_size, initial_prompt.clone(), None,
8876            ).await;
8877            match result {
8878                Err(error) if acp_codex && matches!(
8879                    error.code,
8880                    NodeFailureCode::BackendOperationFailed | NodeFailureCode::SessionRecordNotResumable
8881                ) => {
8882                    tracing::info!(record = %record_id, "ACP resume unavailable; trying native PTY resume");
8883                    self.resume_session_record_with_mode(
8884                        record_id, terminal_size, initial_prompt, Some(SessionMode::Pty),
8885                    ).await
8886                }
8887                other => other,
8888            }
8889        }).await
8890    }
8891
8892    async fn resume_session_record_with_mode(
8893        &self,
8894        record_id: &SessionRecordId,
8895        terminal_size: gate4agent_types::TerminalSize,
8896        initial_prompt: Option<String>,
8897        mode: Option<SessionMode>,
8898    ) -> Result<(ManagedSessionRecord, SessionAddress), NodeFailure> {
8899        let mut record = self.record(record_id)?;
8900        if let Some(mode) = mode {
8901            record.mode = mode;
8902        }
8903        // Claude uses its existing transcript-backed native resume path.
8904        if record.mode == SessionMode::Acp
8905            && record.provider_session.as_ref().is_some_and(|i| {
8906                record.provider.as_str() == "claude" && provider_session_exists("claude", &i.id)
8907            })
8908        {
8909            record.mode = SessionMode::Pty;
8910            tracing::info!(record = %record_id, "acp record resumed through its native pty resume command");
8911        }
8912        if record.active_session.is_some()
8913            || matches!(
8914                record.state,
8915                ManagedSessionState::Live | ManagedSessionState::IdentityPending
8916            )
8917        {
8918            return Err(failure(
8919                NodeFailureCode::SessionRecordBusy,
8920                "managed session already has a live runtime binding",
8921            ));
8922        }
8923        let identity = record.provider_session.clone().ok_or_else(|| {
8924            failure(
8925                NodeFailureCode::SessionRecordNotResumable,
8926                "managed session has no verified provider session identity",
8927            )
8928        })?;
8929        let working_directory = self.workspace_root(&record.workspace_id)?;
8930        if !platform::roots_equal(
8931            &working_directory,
8932            windows_path_text(&record.canonical_root),
8933        ) {
8934            return Err(failure(
8935                NodeFailureCode::SessionRecordConflict,
8936                "managed session workspace root changed; refusing to resume in another directory",
8937            ));
8938        }
8939        {
8940            let records = self
8941                .session_records
8942                .lock()
8943                .unwrap_or_else(|poisoned| poisoned.into_inner());
8944            if records.records.values().any(|candidate| {
8945                candidate.record_id != *record_id
8946                    && candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8947                        session_registry::same_provider_session(
8948                            &candidate.provider,
8949                            candidate_identity,
8950                            &record.provider,
8951                            &identity,
8952                        )
8953                    })
8954                    && (candidate.active_session.is_some()
8955                        || matches!(
8956                            candidate.state,
8957                            ManagedSessionState::Live | ManagedSessionState::IdentityPending
8958                        ))
8959            }) {
8960                return Err(failure(
8961                    NodeFailureCode::SessionRecordConflict,
8962                    "provider session is already active through another managed record",
8963                ));
8964            }
8965        }
8966        let runtime_requirement = if initial_prompt.is_some() {
8967            ProviderRuntimeRequirement::ResumeWithPrompt
8968        } else {
8969            ProviderRuntimeRequirement::Resume
8970        };
8971        let runtime_policy = self.admit_provider_runtime(
8972            &record.provider,
8973            runtime_requirement,
8974        )
8975        .await?;
8976        // The resumed runtime runs in the session folder its record was named after.
8977        let working_directory = match self.m4a_accounts.as_ref() {
8978            Some(_) => {
8979                let dir = std::path::Path::new(&working_directory).join(".hatchery-sessions").join(&record.display_name);
8980                if !record.display_name.contains(' ') && dir.is_dir() { dir.to_string_lossy().into_owned() } else { working_directory }
8981            }
8982            None => working_directory,
8983        };
8984        let request = ResumeLaunchRequest {
8985            working_directory,
8986            terminal_size,
8987            initial_prompt,
8988        };
8989        request
8990            .validate()
8991            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8992        self.ensure_binding_capacity()?;
8993        let resolved_materialization = match self.resolve_record_materialization(
8994            record_id,
8995            &record.provider,
8996            record.mode,
8997            record.environment_profile.as_ref(),
8998            record.bundle.as_ref(),
8999            record.context.as_ref(),
9000        ) {
9001            Ok(materialization) => materialization,
9002            Err(error) => {
9003                let label = match error.code {
9004                    NodeFailureCode::UnknownBundle => "bundle-unavailable",
9005                    NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
9006                    NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
9007                    _ => "environment-profile-unavailable",
9008                };
9009                self.mark_record_error(record_id, label)?;
9010                return Err(error);
9011            }
9012        };
9013        let (materialization_id, environment_overlay) = match resolved_materialization {
9014            Some((id, overlay)) => (Some(id), overlay),
9015            None => (None, None),
9016        };
9017        let instance_id = RESTORE_INSTANCE_ID.try_with(|id| *id)
9018            .unwrap_or_else(|_| AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel)));
9019        // Resumed runtime: same auto-permit flags and launch options (model / effort) as a spawn.
9020        let environment_overlay = {
9021            let mut args: Vec<OsString> = Vec::new();
9022            if let Ok(cwd) = self.workspace_root(&record.workspace_id) {
9023                let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
9024                if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") { homes.extend(std::env::split_paths(&extra)); }
9025                crate::auto_permit::prepare(record.provider.as_str(), &homes, Some(std::path::Path::new(&cwd)));
9026            }
9027            args.extend(crate::auto_permit::launch_args(record.provider.as_str(), record.mode));
9028            let options = SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten();
9029            if record.mode == SessionMode::Pty && record.provider.as_str() == "claude" {
9030                if let Some(sel) = options.as_ref() {
9031                    args.push("--model".into()); args.push(sel.model.clone().into());
9032                    if let Some(effort) = sel.values.get("effort").and_then(|v| v.as_str()) {
9033                        args.push("--effort".into()); args.push(effort.to_owned().into());
9034                    }
9035                }
9036            }
9037            if record.mode == SessionMode::Pty && record.provider.as_str() == "codex" {
9038                if let Some(selection) = options.as_ref() {
9039                    args.push("--model".into());
9040                    args.push(selection.model.clone().into());
9041                    if let Some(effort) = selection.values.get("effort").and_then(|v| v.as_str()) {
9042                        args.push("-c".into());
9043                        args.push(format!("model_reasoning_effort={}", serde_json::to_string(effort).expect("string serialization")).into());
9044                    }
9045                }
9046            }
9047            if matches!(record.mode, SessionMode::Pty | SessionMode::Acp) && crate::pty_resume::journalled(record.provider.as_str(), record.mode == SessionMode::Acp) {
9048                if let (Some(file), Some(identity)) = (self.session_resume_journal(), record.provider_session.as_ref()) {
9049                    crate::pty_resume::upsert(&file, crate::pty_resume::Entry {
9050                        revision: 0,
9051                        instance_id: instance_id.0,
9052                        provider: record.provider.as_str().to_owned(),
9053                        workspace_id: record.workspace_id.as_str().to_owned(),
9054                        provider_session_id: identity.id.clone(),
9055                        session_key: self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&instance_id).cloned().unwrap_or_default(),
9056                        cwd: request.working_directory.clone(),
9057                        acp: record.mode == SessionMode::Acp,
9058                        options,
9059                        cols: terminal_size.columns,
9060                        rows: terminal_size.rows,
9061                        origin: SPAWN_ORIGIN.try_with(|o| o.clone()).ok().flatten(),
9062                        parent_session_id: SPAWN_PARENT.try_with(|o| o.clone()).ok().flatten(),
9063                    });
9064                }
9065            }
9066            overlay_with_extra_args(&record.provider, record.mode, environment_overlay, args)?
9067        };
9068        // The resumed runtime keeps the m4a account of the runtime it replaces.
9069        let environment_overlay = match (self.m4a_accounts.as_ref(), crate::m4a_accounts::m4a_provider(record.provider.as_str())) {
9070            (Some(accounts), Some(m4a_provider)) if matches!(record.mode, SessionMode::Pty | SessionMode::Acp) => {
9071                if let Some(old) = self.bound_address_for_record(record_id) {
9072                    if let Some(key) = self.m4a_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&old.session.instance_id).cloned() {
9073                        self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).insert(instance_id, key);
9074                    }
9075                }
9076                let session_key = self.m4a_session_key(instance_id);
9077                self.m4a_session_overlay(accounts, m4a_provider, &record.provider, instance_id, environment_overlay, &session_key, record.mode)?
9078            }
9079            _ => environment_overlay,
9080        };
9081        let mut environment_selection = match SpawnPreparer::select_environment(self, 
9082            instance_id,
9083            &record.provider,
9084            record.mode,
9085            record.environment_profile.as_ref(),
9086        ) {
9087            Ok(selection) => selection,
9088            Err(error) => {
9089                self.mark_record_error(record_id, "environment-profile-unavailable")?;
9090                return Err(error);
9091            }
9092        };
9093        let mut instance_overlay = environment_overlay
9094            .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
9095            .transpose()?
9096            .flatten();
9097        if let Some(stale_address) = self.bound_address_for_record(record_id) {
9098            self.remove_session(&stale_address).await?;
9099        }
9100        let mut address = SessionAddress {
9101            workspace_id: record.workspace_id.clone(),
9102            session: SessionKey {
9103                instance_id,
9104                generation: SessionGeneration(1),
9105            },
9106        };
9107        let transaction = self
9108            .state_transaction
9109            .lock()
9110            .unwrap_or_else(|poisoned| poisoned.into_inner());
9111        let current = self.record(record_id)?;
9112        if current.active_session.is_some()
9113            || matches!(
9114                current.state,
9115                ManagedSessionState::Live | ManagedSessionState::IdentityPending
9116            )
9117        {
9118            return Err(failure(
9119                NodeFailureCode::SessionRecordBusy,
9120                "managed session became active while resume was being prepared",
9121            ));
9122        }
9123        if let Some(current) = self.session_records.lock().unwrap_or_else(|p| p.into_inner()).records.get_mut(record_id) {
9124            current.mode = record.mode;
9125        }
9126        self.bind_managed_session_with_materialization(
9127            &address,
9128            record_id.clone(),
9129            runtime_policy,
9130            record.environment_profile.clone(),
9131            record.bundle.clone(),
9132            record.context.clone(),
9133            materialization_id,
9134        );
9135        if let Some(selection) = environment_selection.take() {
9136            selection.retain();
9137        }
9138        if let Some(overlay) = instance_overlay.take() {
9139            overlay.retain();
9140        }
9141        drop(transaction);
9142
9143        let transport = match record.mode {
9144            SessionMode::Pty => TransportKind::Pty,
9145            SessionMode::Inline => TransportKind::Pipe,
9146            SessionMode::Acp => TransportKind::Acp,
9147        };
9148        let agent_id = record.provider.clone();
9149        let dispatch_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
9150        let registration = self
9151            .dispatch_bounded(
9152                ControlCommand::Register {
9153                    instance_id,
9154                    agent_id,
9155                    transport,
9156                },
9157                dispatch_timeout,
9158            )
9159            .await;
9160        let registration = match registration {
9161            Ok(command_id) => command_id,
9162            Err(error) => {
9163                if let Some(binding) = self.remove_binding(&address) {
9164                    SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
9165                }
9166                return Err(error);
9167            }
9168        };
9169        // Re-registering a slot after a failed ACP attempt advances the engine's
9170        // generation watermark. Bind to that registered generation before arming
9171        // Resume; assuming generation 1 would discard its authorization and result.
9172        let deadline = Instant::now() + dispatch_timeout;
9173        loop {
9174            if let Some(reason) = self.take_rejected_command_reason(registration) {
9175                if let Some(binding) = self.remove_binding(&address) {
9176                    SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
9177                }
9178                return Err(failure(NodeFailureCode::BackendOperationFailed, &reason.message));
9179            }
9180            if let Some(session) = self.handle.snapshot().sessions.iter()
9181                .find(|session| session.instance_id == instance_id)
9182            {
9183                address.session.generation = session.generation;
9184                if let Some(binding) = self.session_bindings.lock()
9185                    .unwrap_or_else(|p| p.into_inner()).get_mut(&instance_id)
9186                {
9187                    binding.generation = session.generation;
9188                }
9189                break;
9190            }
9191            if Instant::now() >= deadline {
9192                self.rollback_spawn(&address, dispatch_timeout).await?;
9193                return Err(failure(NodeFailureCode::BackendBusy,
9194                    "resume registration did not settle before the bounded deadline"));
9195            }
9196            sleep(Duration::from_millis(2)).await;
9197        }
9198        let command = self.prepare_command(ControlCommand::Resume {
9199            instance_id,
9200            target: ResumeTarget::ProviderSession { identity },
9201            runtime_policy,
9202            request,
9203        });
9204        let started_after = self.current_sequence();
9205        if let Err(error) = self.arm_resume(&address, command.id, runtime_policy) {
9206            self.rollback_spawn(&address, dispatch_timeout).await?;
9207            return Err(error);
9208        }
9209        if let Err(error) = self.dispatch_envelope(command) {
9210            self.clear_armed_resume(&address);
9211            self.rollback_spawn(&address, dispatch_timeout).await?;
9212            return Err(error);
9213        }
9214        match self
9215            .wait_for_record_resume(&address, started_after)
9216            .await
9217        {
9218            Ok(settled_address) => {
9219                let managed_lease_id = {
9220                    self.managed_worktrees.lock()
9221                        .unwrap_or_else(|poisoned| poisoned.into_inner())
9222                        .lease_for_workspace(&record.workspace_id)
9223                };
9224                if let Some(lease_id) = managed_lease_id {
9225                    {
9226                        let mut bindings = self.session_bindings.lock()
9227                            .unwrap_or_else(|poisoned| poisoned.into_inner());
9228                        let binding = bindings.get_mut(&settled_address.session.instance_id)
9229                            .ok_or_else(|| failure(
9230                                NodeFailureCode::ManagedWorktreeRecoveryRequired,
9231                                "resumed managed session lost its exact binding",
9232                            ))?;
9233                        if binding.generation != settled_address.session.generation {
9234                            return Err(failure(
9235                                NodeFailureCode::ManagedWorktreeRecoveryRequired,
9236                                "resumed managed session generation diverged",
9237                            ));
9238                        }
9239                        binding.managed_worktree_lease_id = Some(lease_id.clone());
9240                    }
9241                    let snapshot = self.managed_worktrees.lock()
9242                        .unwrap_or_else(|poisoned| poisoned.into_inner())
9243                        .bind_session(
9244                            &lease_id,
9245                            ManagedWorktreeSessionHolder {
9246                                incarnation_id: self.incarnation_id,
9247                                instance_id: settled_address.session.instance_id,
9248                                generation: settled_address.session.generation,
9249                            },
9250                            Some(record_id.clone()),
9251                            unix_time_ms(),
9252                        ).map_err(|message| failure(
9253                            NodeFailureCode::ManagedWorktreeRecoveryRequired,
9254                            &message,
9255                        ))?;
9256                    if let Err(error) = self.persist_state() {
9257                        let _ = self.remove_session(&settled_address).await;
9258                        return Err(persistence_failure(error));
9259                    }
9260                    self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
9261                }
9262                Ok((self.record(record_id)?, settled_address))
9263            }
9264            Err(error) => {
9265                if matches!(
9266                    error.code,
9267                    NodeFailureCode::SessionRecordNotResumable
9268                        | NodeFailureCode::BackendOperationFailed
9269                ) {
9270                    if let Some(bound) = self.bound_address_for_record(record_id) {
9271                        let _ = self.remove_session(&bound).await;
9272                    }
9273                }
9274                Err(error)
9275            }
9276        }
9277    }
9278
9279    async fn wait_for_record_resume(
9280        &self,
9281        initial_address: &SessionAddress,
9282        after_sequence: u64,
9283    ) -> Result<SessionAddress, NodeFailure> {
9284        let deadline = Instant::now()
9285            + Duration::from_millis(MANAGED_RESUME_SETTLE_TIMEOUT_MS);
9286        let mut scan_after = after_sequence;
9287        loop {
9288            let events = self
9289                .history
9290                .lock()
9291                .unwrap_or_else(|poisoned| poisoned.into_inner())
9292                .events
9293                .iter()
9294                .filter(|event| event.sequence > scan_after)
9295                .cloned()
9296                .collect::<Vec<_>>();
9297            for envelope in events {
9298                scan_after = scan_after.max(envelope.sequence);
9299                let NodeEvent::Control { address, event } = envelope.event else {
9300                    continue;
9301                };
9302                if address.session.instance_id != initial_address.session.instance_id {
9303                    continue;
9304                }
9305                match event.event {
9306                    ControlEventKind::Resumed { .. } => return Ok(address),
9307                    ControlEventKind::ResumeDenied { reason } => {
9308                        return Err(failure(
9309                            NodeFailureCode::SessionRecordNotResumable,
9310                            &reason,
9311                        ));
9312                    }
9313                    ControlEventKind::ResumeFailed { message }
9314                    | ControlEventKind::CommandRejected { message } => {
9315                        return Err(failure(
9316                            NodeFailureCode::BackendOperationFailed,
9317                            &message,
9318                        ));
9319                    }
9320                    _ => {}
9321                }
9322            }
9323            if Instant::now() >= deadline {
9324                return Err(failure(
9325                    NodeFailureCode::BackendBusy,
9326                    "managed resume did not settle before the bounded deadline",
9327                ));
9328            }
9329            sleep(Duration::from_millis(5)).await;
9330        }
9331    }
9332
9333    fn workspace_root(&self, workspace_id: &WorkspaceId) -> Result<String, NodeFailure> {
9334        self.workspaces
9335            .read()
9336            .unwrap_or_else(|poisoned| poisoned.into_inner())
9337            .get(workspace_id)
9338            .cloned()
9339            .ok_or_else(|| failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"))
9340    }
9341
9342    fn workspace_roots(&self) -> Vec<PathBuf> {
9343        self.workspaces
9344            .read()
9345            .unwrap_or_else(|poisoned| poisoned.into_inner())
9346            .values()
9347            .map(PathBuf::from)
9348            .collect()
9349    }
9350
9351    async fn inspect_workspace(
9352        &self,
9353        workspace_id: WorkspaceId,
9354    ) -> Result<WorkspaceInspection, NodeFailure> {
9355        let _permit = self
9356            .inspection_slots
9357            .clone()
9358            .try_acquire_owned()
9359            .map_err(|_| {
9360                tracing::warn!(
9361                    workspace_id = %workspace_id,
9362                    "workspace inspection rejected: inspection capacity is busy",
9363                );
9364                failure(
9365                    NodeFailureCode::BackendBusy,
9366                    "workspace inspection capacity is busy",
9367                )
9368            })?;
9369        let canonical_root = self.workspace_root(&workspace_id).map_err(|error| {
9370            tracing::warn!(
9371                workspace_id = %workspace_id,
9372                code = ?error.code,
9373                "workspace inspection rejected: workspace is not registered",
9374            );
9375            error
9376        })?;
9377        let tree_root = canonical_root.clone();
9378        let started_at = Instant::now();
9379        let time_budget_ms = workspace_inspection_time_budget_ms();
9380        let entry_cap = workspace_inspection_entry_cap();
9381        let deadline = started_at + Duration::from_millis(time_budget_ms);
9382        let (entries, tree_truncated, walk_budget) = tokio::task::spawn_blocking(move || {
9383            collect_workspace_entries(Path::new(&tree_root), deadline, entry_cap)
9384        })
9385        .await
9386        .map_err(|error| {
9387            tracing::warn!(
9388                workspace_id = %workspace_id,
9389                error = %error,
9390                "workspace inspection rejected: walk task failed",
9391            );
9392            failure(
9393                NodeFailureCode::BackendOperationFailed,
9394                &format!("workspace inspection task failed: {error}"),
9395            )
9396        })?;
9397        let (mut git, git_time_budget_exceeded) =
9398            inspect_git_workspace(&canonical_root, deadline).await;
9399        if !git.worktrees.is_empty() {
9400            let registered = self
9401                .workspaces
9402                .read()
9403                .unwrap_or_else(|poisoned| poisoned.into_inner())
9404                .iter()
9405                .map(|(workspace_id, root)| (workspace_id.clone(), root.clone()))
9406                .collect::<Vec<_>>();
9407            for worktree in &mut git.worktrees {
9408                worktree.workspace_id = registered
9409                    .iter()
9410                    .find(|(_, root)| {
9411                        worktree_paths_equal(root, windows_path_text(&worktree.path))
9412                    })
9413                    .map(|(workspace_id, _)| workspace_id.clone());
9414            }
9415        }
9416        if git.is_repository {
9417            git.managed_worktree = self
9418                .managed_worktrees
9419                .lock()
9420                .unwrap_or_else(|poisoned| poisoned.into_inner())
9421                .git_scope(&workspace_id, git.branch.as_deref());
9422        }
9423        let truncation = workspace_inspection_truncation(
9424            &walk_budget,
9425            git_time_budget_exceeded,
9426            started_at.elapsed(),
9427        );
9428        if let Some(truncation) = &truncation {
9429            tracing::info!(
9430                workspace_id = %workspace_id,
9431                walk_time_budget_exceeded = truncation.walk_time_budget_exceeded,
9432                walk_entry_cap_exceeded = truncation.walk_entry_cap_exceeded,
9433                git_time_budget_exceeded = truncation.git_time_budget_exceeded,
9434                entries_visited = truncation.entries_visited,
9435                elapsed_ms = truncation.elapsed_ms,
9436                "workspace inspection truncated by its inner budget",
9437            );
9438        }
9439        Ok(WorkspaceInspection {
9440            workspace_id,
9441            entries,
9442            tree_truncated,
9443            git,
9444            truncation,
9445        })
9446    }
9447
9448    async fn browse_host_directories(
9449        &self,
9450        directory: Option<OpaqueHostPath>,
9451        after: Option<OpaqueHostPath>,
9452    ) -> Result<HostDirectoryListing, NodeFailure> {
9453        let permit = self
9454            .inspection_slots
9455            .clone()
9456            .try_acquire_owned()
9457            .map_err(|_| {
9458                failure(
9459                    NodeFailureCode::BackendBusy,
9460                    "host directory browse capacity is busy",
9461                )
9462            })?;
9463        let task = tokio::task::spawn_blocking(move || {
9464            let _permit = permit;
9465            browse_host_directories(directory, after)
9466        });
9467        timeout(Duration::from_millis(HOST_DIRECTORY_BROWSE_TIMEOUT_MS), task)
9468            .await
9469            .map_err(|_| {
9470                failure(
9471                    NodeFailureCode::HostDirectoryReadTimedOut,
9472                    "host directory browse exceeded its bounded deadline",
9473                )
9474            })?
9475            .map_err(|_| {
9476                failure(
9477                    NodeFailureCode::HostDirectoryReadFailed,
9478                    "host directory browse task failed",
9479                )
9480            })?
9481            .map_err(host_directory_failure)
9482    }
9483
9484    async fn read_workspace_file(
9485        &self,
9486        workspace_id: WorkspaceId,
9487        path: RepositoryPath,
9488    ) -> Result<WorkspaceFileRead, NodeFailure> {
9489        let canonical_root = self.workspace_root(&workspace_id)?;
9490        let permit = self
9491            .inspection_slots
9492            .clone()
9493            .try_acquire_owned()
9494            .map_err(|_| {
9495                failure(
9496                    NodeFailureCode::BackendBusy,
9497                    "workspace file read capacity is busy",
9498                )
9499            })?;
9500        let path_to_read = path.clone();
9501        let task = tokio::task::spawn_blocking(move || {
9502            let _permit = permit;
9503            read_workspace_file_from_disk(Path::new(&canonical_root), &path_to_read)
9504        });
9505        let content = timeout(
9506            Duration::from_millis(WORKSPACE_FILE_READ_TIMEOUT_MS),
9507            task,
9508        )
9509        .await
9510        .map_err(|_| {
9511            failure(
9512                NodeFailureCode::RepositoryFileReadTimedOut,
9513                "repository file read exceeded its bounded deadline",
9514            )
9515        })?
9516        .map_err(|_| {
9517            failure(
9518                NodeFailureCode::RepositoryFileReadFailed,
9519                "repository file read task failed",
9520            )
9521        })?
9522        .map_err(workspace_file_failure)?;
9523        let content = match content {
9524            WorkspaceFileBytes::Utf8(text) => {
9525                let revision = Some(workspace_file_revision(text.as_bytes()));
9526                return Ok(WorkspaceFileRead {
9527                    workspace_id,
9528                    path,
9529                    content: WorkspaceFileContent::Utf8 {
9530                        byte_len: u32::try_from(text.len())
9531                            .expect("bounded workspace text length must fit u32"),
9532                        text,
9533                    },
9534                    revision,
9535                });
9536            }
9537            WorkspaceFileBytes::NonUtf8 { byte_length } => {
9538                WorkspaceFileContent::NonUtf8 {
9539                    byte_len: u32::try_from(byte_length)
9540                        .expect("bounded workspace file length must fit u32"),
9541                }
9542            }
9543            WorkspaceFileBytes::TooLarge => WorkspaceFileContent::TooLarge {
9544                limit_bytes: MAX_WORKSPACE_FILE_BYTES as u32,
9545            },
9546        };
9547        Ok(WorkspaceFileRead {
9548            workspace_id,
9549            path,
9550            content,
9551            revision: None,
9552        })
9553    }
9554
9555    async fn write_workspace_file(
9556        &self,
9557        workspace_id: WorkspaceId,
9558        path: RepositoryPath,
9559        expected_revision: WorkspaceFileRevision,
9560        text: String,
9561    ) -> Result<WorkspaceFileRead, NodeFailure> {
9562        let canonical_root = self.workspace_root(&workspace_id)?;
9563        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9564            failure(NodeFailureCode::BackendBusy, "workspace file write capacity is busy")
9565        })?;
9566        let path_to_write = path.clone();
9567        let expected = expected_revision.as_str().to_owned();
9568        let text_to_write = text.clone();
9569        let task = tokio::task::spawn_blocking(move || {
9570            let _permit = permit;
9571            write_workspace_file_to_disk(
9572                Path::new(&canonical_root),
9573                &path_to_write,
9574                &expected,
9575                &text_to_write,
9576            )
9577        });
9578        let revision = timeout(Duration::from_millis(WORKSPACE_FILE_WRITE_TIMEOUT_MS), task)
9579            .await
9580            .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteTimedOut, "repository file write exceeded its bounded deadline"))?
9581            .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteFailed, "repository file write task failed"))?
9582            .map_err(workspace_file_write_failure)?;
9583        Ok(WorkspaceFileRead {
9584            workspace_id,
9585            path,
9586            content: WorkspaceFileContent::Utf8 {
9587                byte_len: u32::try_from(text.len()).expect("bounded workspace text length must fit u32"),
9588                text,
9589            },
9590            revision: Some(WorkspaceFileRevision::new(revision)
9591                .expect("workspace writer returns a valid SHA-256 revision")),
9592        })
9593    }
9594
9595    async fn create_workspace_file(
9596        &self,
9597        workspace_id: WorkspaceId,
9598        path: RepositoryPath,
9599    ) -> Result<WorkspaceFileRead, NodeFailure> {
9600        let canonical_root = self.workspace_root(&workspace_id)?;
9601        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9602            failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
9603        })?;
9604        let path_to_create = path.clone();
9605        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
9606        let task_commit_state = Arc::clone(&commit_state);
9607        let task = tokio::task::spawn_blocking(move || {
9608            let _permit = permit;
9609            create_workspace_file_on_disk(
9610                Path::new(&canonical_root),
9611                &path_to_create,
9612                &task_commit_state,
9613            )
9614        });
9615        let revision = settle_workspace_entry_create(
9616            task,
9617            commit_state,
9618            Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
9619            "repository file create exceeded its bounded deadline",
9620            "repository file create task failed",
9621        )
9622        .await?;
9623        Ok(WorkspaceFileRead {
9624            workspace_id,
9625            path,
9626            content: WorkspaceFileContent::Utf8 {
9627                text: String::new(),
9628                byte_len: 0,
9629            },
9630            revision: Some(WorkspaceFileRevision::new(revision)
9631                .expect("workspace creator returns a valid SHA-256 revision")),
9632        })
9633    }
9634
9635    async fn create_workspace_directory(
9636        &self,
9637        workspace_id: WorkspaceId,
9638        path: RepositoryPath,
9639    ) -> Result<WorkspaceEntry, NodeFailure> {
9640        let canonical_root = self.workspace_root(&workspace_id)?;
9641        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9642            failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
9643        })?;
9644        let path_to_create = path.clone();
9645        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
9646        let task_commit_state = Arc::clone(&commit_state);
9647        let task = tokio::task::spawn_blocking(move || {
9648            let _permit = permit;
9649            create_workspace_directory_on_disk(
9650                Path::new(&canonical_root),
9651                &path_to_create,
9652                &task_commit_state,
9653            )
9654        });
9655        settle_workspace_entry_create(
9656            task,
9657            commit_state,
9658            Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
9659            "repository directory create exceeded its bounded deadline",
9660            "repository directory create task failed",
9661        )
9662        .await?;
9663        Ok(WorkspaceEntry {
9664            relative_path: path,
9665            kind: WorkspaceEntryKind::Directory,
9666        })
9667    }
9668
9669    async fn read_git_history(
9670        &self,
9671        workspace_id: WorkspaceId,
9672        path: Option<RepositoryPath>,
9673        before: Option<GitObjectId>,
9674        limit: u16,
9675    ) -> Result<GitHistoryPage, NodeFailure> {
9676        let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9677            failure(NodeFailureCode::BackendBusy, "git history capacity is busy")
9678        })?;
9679        let root = self.workspace_root(&workspace_id)?;
9680        read_git_history_bounded(&root, path.as_ref(), before.as_ref(), limit).await
9681    }
9682
9683    async fn read_git_diff(
9684        &self,
9685        workspace_id: WorkspaceId,
9686        request: GitDiffRequest,
9687    ) -> Result<GitDiff, NodeFailure> {
9688        let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9689            failure(NodeFailureCode::BackendBusy, "git diff capacity is busy")
9690        })?;
9691        let root = self.workspace_root(&workspace_id)?;
9692        read_git_diff_bounded(&root, request).await
9693    }
9694
9695    fn native_session_route_root(
9696        &self,
9697        route: &NativeSessionCatalogRoute,
9698    ) -> Result<Option<String>, NodeFailure> {
9699        route
9700            .validate()
9701            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9702        match route.scope {
9703            gate4agent_types::NativeSessionCatalogScope::Workspace => route
9704                .workspace_id
9705                .as_ref()
9706                .map(|workspace_id| self.workspace_root(workspace_id))
9707                .transpose(),
9708            gate4agent_types::NativeSessionCatalogScope::Unregistered => Ok(None),
9709        }
9710    }
9711
9712    fn project_native_session_entries(
9713        &self,
9714        route: &NativeSessionCatalogRoute,
9715        entries: Vec<ScopedNativeSessionCatalogEntry>,
9716    ) -> Vec<crate::protocol::NativeSessionCatalogEntry> {
9717        let current_root = route
9718            .workspace_id
9719            .as_ref()
9720            .and_then(|workspace_id| self.workspace_root(workspace_id).ok());
9721        let records = self
9722            .session_records
9723            .lock()
9724            .unwrap_or_else(|poisoned| poisoned.into_inner());
9725        let mut seen_records = std::collections::HashSet::new();
9726        entries
9727            .into_iter()
9728            .map(|entry| {
9729                let record_id = route.workspace_id.as_ref().and_then(|workspace_id| {
9730                    let matches = records
9731                        .records
9732                        .values()
9733                        .filter(|record| {
9734                            record.provider == route.provider
9735                                && record.workspace_id == *workspace_id
9736                                && current_root.as_deref().is_some_and(|root| {
9737                                    platform::roots_equal(
9738                                        root,
9739                                        windows_path_text(&record.canonical_root),
9740                                    )
9741                                })
9742                                && record.provider_session.as_ref().is_some_and(|identity| {
9743                                    session_registry::same_provider_session(
9744                                        &record.provider,
9745                                        identity,
9746                                        &route.provider,
9747                                        &entry.provider_session,
9748                                    )
9749                                })
9750                        })
9751                        .map(|record| record.record_id.clone())
9752                        .collect::<Vec<_>>();
9753                    (matches.len() == 1
9754                        && seen_records.insert(matches[0].clone()))
9755                        .then(|| matches[0].clone())
9756                });
9757                let metadata = entry.metadata;
9758                crate::protocol::NativeSessionCatalogEntry {
9759                    selection_id: metadata.selection_id,
9760                    title: metadata.title,
9761                    modified_at_unix_ms: metadata.modified_at_unix_ms,
9762                    model: metadata.model,
9763                    message_count: metadata.message_count,
9764                    completed_turn_count: metadata.completed_turn_count,
9765                    external_group: entry.external_group,
9766                    record_id,
9767                }
9768            })
9769            .collect()
9770    }
9771
9772    async fn catalog_native_sessions(
9773        &self,
9774        route: NativeSessionCatalogRoute,
9775        limit: u16,
9776    ) -> Result<(
9777        Vec<crate::protocol::NativeSessionCatalogEntry>,
9778        crate::protocol::NativeSessionCatalogSummary,
9779    ), NodeFailure> {
9780        let canonical_root = self.native_session_route_root(&route)?;
9781        let registered_workspace_roots = self.workspace_roots();
9782        let provider = route.provider.clone();
9783        let scope = route.scope;
9784        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9785            failure(
9786                NodeFailureCode::InvalidRequest,
9787                "native session catalog is not configured",
9788            )
9789        })?;
9790        let _permit = Arc::clone(&self.inspection_slots)
9791            .try_acquire_owned()
9792            .map_err(|_| {
9793                failure(
9794                    NodeFailureCode::BackendBusy,
9795                    "native session catalog capacity is busy",
9796                )
9797            })?;
9798        let page = run_native_session_catalog_operation(
9799            catalog,
9800            "native session catalog",
9801            move |catalog| {
9802                catalog.catalog_initial_for_scope(
9803                    &provider,
9804                    scope,
9805                    canonical_root.as_deref().map(Path::new),
9806                    &registered_workspace_roots,
9807                    limit,
9808                )
9809            },
9810        )
9811        .await?;
9812        let summary = crate::protocol::NativeSessionCatalogSummary {
9813            catalog_revision: page.revision,
9814            recent_cutoff_unix_ms: page.cutoff_unix_ms,
9815            recent_total_count: page.recent_total_count,
9816            older_total_count: page.older_total_count,
9817            recent_next_after_selection_id: page.next_after_selection_id,
9818            recent_has_more: page.remaining_count > 0,
9819        };
9820        Ok((self.project_native_session_entries(&route, page.entries), summary))
9821    }
9822
9823    async fn page_native_sessions(
9824        &self,
9825        route: NativeSessionCatalogRoute,
9826        window: crate::protocol::NativeSessionCatalogWindow,
9827        catalog_revision: u64,
9828        recent_cutoff_unix_ms: u64,
9829        after_selection_id: Option<String>,
9830        limit: u16,
9831    ) -> Result<crate::protocol::NativeSessionCatalogPage, NodeFailure> {
9832        let canonical_root = self.native_session_route_root(&route)?;
9833        let registered_workspace_roots = self.workspace_roots();
9834        let provider = route.provider.clone();
9835        let scope = route.scope;
9836        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9837            failure(
9838                NodeFailureCode::InvalidRequest,
9839                "native session catalog is not configured",
9840            )
9841        })?;
9842        let _permit = Arc::clone(&self.inspection_slots)
9843            .try_acquire_owned()
9844            .map_err(|_| {
9845                failure(
9846                    NodeFailureCode::BackendBusy,
9847                    "native session catalog capacity is busy",
9848                )
9849            })?;
9850        let page = run_native_session_catalog_operation(
9851            catalog,
9852            "native session catalog page",
9853            move |catalog| {
9854                Ok::<_, std::convert::Infallible>(catalog.catalog_page_for_scope(
9855                    &provider,
9856                    scope,
9857                    canonical_root.as_deref().map(Path::new),
9858                    &registered_workspace_roots,
9859                    window,
9860                    catalog_revision,
9861                    recent_cutoff_unix_ms,
9862                    after_selection_id.as_deref(),
9863                    limit,
9864                ))
9865            },
9866        )
9867        .await?;
9868        let page = page.map_err(|error| match error {
9869            NativeSessionCatalogError::StaleCatalog => failure(
9870                NodeFailureCode::StaleNativeSessionCatalog,
9871                "native session catalog revision or cursor is stale",
9872            ),
9873            _ => failure(
9874                NodeFailureCode::BackendOperationFailed,
9875                "native session catalog page failed",
9876            ),
9877        })?;
9878        let entries = self.project_native_session_entries(&route, page.entries);
9879        Ok(crate::protocol::NativeSessionCatalogPage {
9880            window: page.window,
9881            revision: page.revision,
9882            entries,
9883            next_after_selection_id: page.next_after_selection_id,
9884            remaining_count: page.remaining_count,
9885            has_more: page.remaining_count > 0,
9886        })
9887    }
9888
9889    async fn preview_native_session(
9890        &self,
9891        selection: NativeSessionSelection,
9892        message_limit: u16,
9893    ) -> Result<crate::protocol::NativeSessionPreview, NodeFailure> {
9894        selection
9895            .validate()
9896            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9897        let canonical_root = self.native_session_route_root(&selection.route)?;
9898        let registered_workspace_roots = self.workspace_roots();
9899        let provider = selection.route.provider.clone();
9900        let scope = selection.route.scope;
9901        let catalog_revision = selection.catalog_revision;
9902        let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9903        let selection_id = selection.selection_id.clone();
9904        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9905            failure(
9906                NodeFailureCode::InvalidRequest,
9907                "native session preview is not configured",
9908            )
9909        })?;
9910        let _permit = Arc::clone(&self.inspection_slots)
9911            .try_acquire_owned()
9912            .map_err(|_| {
9913                failure(
9914                    NodeFailureCode::BackendBusy,
9915                    "native session preview capacity is busy",
9916                )
9917            })?;
9918        let preview = run_native_session_catalog_operation(
9919            catalog,
9920            "native session preview",
9921            move |catalog| {
9922                Ok::<_, std::convert::Infallible>(catalog.preview_for_scope(
9923                    &provider,
9924                    scope,
9925                    canonical_root.as_deref().map(Path::new),
9926                    &registered_workspace_roots,
9927                    catalog_revision,
9928                    recent_cutoff_unix_ms,
9929                    &selection_id,
9930                    message_limit,
9931                ))
9932            },
9933        )
9934        .await?;
9935        preview
9936            .map(Into::into)
9937            .map_err(native_session_preview_failure)
9938    }
9939
9940    async fn index_native_session(
9941        &self,
9942        selection: NativeSessionSelection,
9943        display_name: String,
9944    ) -> Result<ManagedSessionRecord, NodeFailure> {
9945        selection
9946            .validate()
9947            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9948        if selection.route.scope
9949            != gate4agent_types::NativeSessionCatalogScope::Workspace
9950        {
9951            return Err(failure(
9952                NodeFailureCode::WorkspaceRegistrationRequired,
9953                "register the external project as a workspace before indexing or resuming it",
9954            ));
9955        }
9956        let workspace_id = selection
9957            .route
9958            .workspace_id
9959            .clone()
9960            .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9961        let canonical_root = self
9962            .native_session_route_root(&selection.route)?
9963            .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9964        let registered_workspace_roots = self.workspace_roots();
9965        let provider = selection.route.provider.clone();
9966        let catalog_revision = selection.catalog_revision;
9967        let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9968        let selection_id = selection.selection_id.clone();
9969        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9970            failure(
9971                NodeFailureCode::InvalidRequest,
9972                "native session index is not configured",
9973            )
9974        })?;
9975        let _permit = Arc::clone(&self.inspection_slots)
9976            .try_acquire_owned()
9977            .map_err(|_| {
9978                failure(
9979                    NodeFailureCode::BackendBusy,
9980                    "native session index capacity is busy",
9981                )
9982            })?;
9983        let resolved = run_native_session_catalog_operation(
9984            catalog,
9985            "native session index resolution",
9986            move |catalog| {
9987                Ok::<_, std::convert::Infallible>(catalog.resolve_selection_for_scope(
9988                    &provider,
9989                    gate4agent_types::NativeSessionCatalogScope::Workspace,
9990                    Some(Path::new(&canonical_root)),
9991                    &registered_workspace_roots,
9992                    catalog_revision,
9993                    recent_cutoff_unix_ms,
9994                    &selection_id,
9995                ))
9996            },
9997        )
9998        .await?
9999        .map_err(native_session_preview_failure)?;
10000        self.index_provider_session_with_policy(
10001            workspace_id,
10002            selection.route.provider,
10003            resolved.identity,
10004            display_name,
10005            true,
10006        )
10007    }
10008
10009    async fn preview_native_session_by_session_id(
10010        &self,
10011        workspace_id: WorkspaceId,
10012        provider: AgentId,
10013        session_id: String,
10014        message_limit: u16,
10015    ) -> Result<gate4agent_types::NativeSessionPreview, NodeFailure> {
10016        let canonical_root = self.workspace_root(&workspace_id)?;
10017        let registered_workspace_roots = self.workspace_roots();
10018        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
10019            failure(
10020                NodeFailureCode::InvalidRequest,
10021                "native session preview is not configured",
10022            )
10023        })?;
10024        let _permit = Arc::clone(&self.inspection_slots)
10025            .try_acquire_owned()
10026            .map_err(|_| {
10027                failure(
10028                    NodeFailureCode::BackendBusy,
10029                    "native session preview capacity is busy",
10030                )
10031            })?;
10032        run_native_session_catalog_operation(
10033            catalog,
10034            "native session preview",
10035            move |catalog| {
10036                catalog.preview_session_id_for_workspace(
10037                    &provider,
10038                    Path::new(&canonical_root),
10039                    &registered_workspace_roots,
10040                    &session_id,
10041                    message_limit,
10042                )
10043            },
10044        )
10045        .await
10046    }
10047
10048    async fn register_workspace(
10049        &self,
10050        workspace_id: WorkspaceId,
10051        root: String,
10052    ) -> Result<WorkspaceSnapshot, NodeFailure> {
10053        validate_workspace_request_root(&root)?;
10054        let canonicalize_workspace_id = workspace_id.clone();
10055        let workspace = tokio::task::spawn_blocking(move || {
10056            WorkspaceConfig::new(canonicalize_workspace_id, root)
10057        })
10058        .await
10059        .map_err(|error| {
10060            failure(
10061                NodeFailureCode::BackendOperationFailed,
10062                &format!("workspace canonicalization task failed: {error}"),
10063            )
10064        })?
10065        .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
10066        let transaction = self
10067            .state_transaction
10068            .lock()
10069            .unwrap_or_else(|poisoned| poisoned.into_inner());
10070        let mut workspaces = self
10071            .workspaces
10072            .write()
10073            .unwrap_or_else(|poisoned| poisoned.into_inner());
10074        if workspaces.contains_key(&workspace_id) {
10075            return Err(failure(
10076                NodeFailureCode::DuplicateWorkspaceId,
10077                "workspace ID is already registered",
10078            ));
10079        }
10080        if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
10081            platform::roots_equal(existing_root, workspace.canonical_root())
10082        }) {
10083            return Err(failure(
10084                NodeFailureCode::DuplicateWorkspaceRoot,
10085                &format!("workspace root is already registered as '{existing_id}'"),
10086            ));
10087        }
10088        {
10089            let records = self
10090                .session_records
10091                .lock()
10092                .unwrap_or_else(|poisoned| poisoned.into_inner());
10093            if let Some(record) = records.records.values().find(|record| {
10094                record.workspace_id == workspace_id
10095                    && !record
10096                        .canonical_root
10097                        .as_utf8()
10098                        .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
10099            }) {
10100                return Err(failure(
10101                    NodeFailureCode::SessionRecordConflict,
10102                    &format!(
10103                        "workspace ID is retained by session record '{}' at another root",
10104                        record.record_id,
10105                    ),
10106                ));
10107            }
10108        }
10109        let snapshot = WorkspaceSnapshot {
10110            workspace_id: workspace_id.clone(),
10111            canonical_root: opaque_windows_path(workspace.canonical_root().to_owned()),
10112            sessions: Vec::new(),
10113            worktree_service_mode: Some(WorktreeServiceMode::Manual),
10114            managed_worktree_profiles: Some(WorktreeProfileInventory {
10115                profiles: Vec::new(),
10116            }),
10117        };
10118        workspaces.insert(workspace_id.clone(), workspace.canonical_root().to_owned());
10119        drop(workspaces);
10120        let previous_records = {
10121            let mut records = self
10122                .session_records
10123                .lock()
10124                .unwrap_or_else(|poisoned| poisoned.into_inner());
10125            let previous = records.records.clone();
10126            for record in records.records.values_mut().filter(|record| {
10127                record.workspace_id == workspace_id
10128                    && record
10129                        .canonical_root
10130                        .as_utf8()
10131                        .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
10132            }) {
10133                if record.active_session.is_none() {
10134                    record.state = detached_record_state(record);
10135                }
10136                if record.last_error.as_deref() == Some(WORKSPACE_UNAVAILABLE_ERROR) {
10137                    record.last_error = None;
10138                }
10139                record.updated_at_unix_ms = unix_time_ms();
10140            }
10141            previous
10142        };
10143        if let Err(error) = self.persist_state_locked() {
10144            self.workspaces
10145                .write()
10146                .unwrap_or_else(|poisoned| poisoned.into_inner())
10147                .remove(&workspace_id);
10148            self.session_records
10149                .lock()
10150                .unwrap_or_else(|poisoned| poisoned.into_inner())
10151                .records = previous_records;
10152            return Err(persistence_failure(error));
10153        }
10154        let restored = self
10155            .session_records
10156            .lock()
10157            .unwrap_or_else(|poisoned| poisoned.into_inner())
10158            .records
10159            .values()
10160            .filter(|record| record.workspace_id == workspace_id)
10161            .cloned()
10162            .collect::<Vec<_>>();
10163        drop(transaction);
10164        self.publish(NodeEvent::WorkspaceAdded {
10165            workspace: snapshot.clone(),
10166        });
10167        for record in restored {
10168            self.publish_record(record);
10169        }
10170        Ok(snapshot)
10171    }
10172
10173    async fn create_standalone_workspace(
10174        &self,
10175        workspace_id: WorkspaceId,
10176        root: String,
10177        initial_branch: Option<String>,
10178    ) -> Result<WorkspaceSnapshot, NodeFailure> {
10179        validate_workspace_request_root(&root)?;
10180        validate_git_revision("initial branch", initial_branch.as_deref())?;
10181        let preflight_workspace_id = workspace_id.clone();
10182        let preflight_root = root.clone();
10183        let canonical_candidate = tokio::task::spawn_blocking(move || {
10184            canonical_standalone_workspace_candidate(&preflight_workspace_id, &preflight_root)
10185        })
10186        .await
10187        .map_err(|_| failure(
10188            NodeFailureCode::BackendOperationFailed,
10189            "standalone workspace root preflight task failed",
10190        ))?
10191        .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
10192        {
10193            let workspaces = self
10194                .workspaces
10195                .read()
10196                .unwrap_or_else(|poisoned| poisoned.into_inner());
10197            if workspaces.contains_key(&workspace_id) {
10198                return Err(failure(
10199                    NodeFailureCode::DuplicateWorkspaceId,
10200                    "workspace ID is already registered",
10201                ));
10202            }
10203            if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
10204                platform::roots_equal(existing_root, &canonical_candidate)
10205            }) {
10206                return Err(failure(
10207                    NodeFailureCode::DuplicateWorkspaceRoot,
10208                    &format!("workspace root is already registered as '{existing_id}'"),
10209                ));
10210            }
10211        }
10212        let prepared = prepare_standalone_workspace(root, initial_branch.as_deref())
10213            .await
10214            .map_err(standalone_workspace_failure)?;
10215        prepared
10216            .verify_for_registration()
10217            .await
10218            .map_err(standalone_workspace_failure)?;
10219        match self
10220            .register_workspace(workspace_id, prepared.root().to_owned())
10221            .await
10222        {
10223            Ok(workspace) => Ok(workspace),
10224            Err(registration_error) => {
10225                if prepared.compensate_registration_failure().await {
10226                    Err(registration_error)
10227                } else {
10228                    Err(failure(
10229                        NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
10230                        "standalone workspace initialization succeeded but registration failed; recovery is required",
10231                    ))
10232                }
10233            }
10234        }
10235    }
10236
10237    fn unregister_workspace(&self, workspace_id: &WorkspaceId) -> Result<(), NodeFailure> {
10238        let bindings = self
10239            .session_bindings
10240            .lock()
10241            .unwrap_or_else(|poisoned| poisoned.into_inner());
10242        if bindings.values().any(|binding| &binding.workspace_id == workspace_id) {
10243            return Err(failure(
10244                NodeFailureCode::WorkspaceBusy,
10245                "workspace retains a bound session; remove every session before unregistering it",
10246            ));
10247        }
10248        drop(bindings);
10249        let transaction = self
10250            .state_transaction
10251            .lock()
10252            .unwrap_or_else(|poisoned| poisoned.into_inner());
10253        let mut workspaces = self
10254            .workspaces
10255            .write()
10256            .unwrap_or_else(|poisoned| poisoned.into_inner());
10257        if !workspaces.contains_key(workspace_id) {
10258            return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
10259        }
10260        if workspaces.len() == 1 {
10261            return Err(failure(
10262                NodeFailureCode::LastWorkspace,
10263                "node must retain at least one workspace",
10264            ));
10265        }
10266        let removed_root = workspaces
10267            .remove(workspace_id)
10268            .expect("workspace presence was validated");
10269        drop(workspaces);
10270        let previous_records = {
10271            let mut records = self
10272                .session_records
10273                .lock()
10274                .unwrap_or_else(|poisoned| poisoned.into_inner());
10275            let previous = records.records.clone();
10276            for record in records
10277                .records
10278                .values_mut()
10279                .filter(|record| &record.workspace_id == workspace_id)
10280            {
10281                record.active_session = None;
10282                record.state = ManagedSessionState::Unavailable;
10283                record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
10284                record.updated_at_unix_ms = unix_time_ms();
10285            }
10286            previous
10287        };
10288        if let Err(error) = self.persist_state_locked() {
10289            self.workspaces
10290                .write()
10291                .unwrap_or_else(|poisoned| poisoned.into_inner())
10292                .insert(workspace_id.clone(), removed_root);
10293            self.session_records
10294                .lock()
10295                .unwrap_or_else(|poisoned| poisoned.into_inner())
10296                .records = previous_records;
10297            return Err(persistence_failure(error));
10298        }
10299        let unavailable = self
10300            .session_records
10301            .lock()
10302            .unwrap_or_else(|poisoned| poisoned.into_inner())
10303            .records
10304            .values()
10305            .filter(|record| &record.workspace_id == workspace_id)
10306            .cloned()
10307            .collect::<Vec<_>>();
10308        drop(transaction);
10309        self.publish(NodeEvent::WorkspaceRemoved {
10310            workspace_id: workspace_id.clone(),
10311        });
10312        for record in unavailable {
10313            self.publish_record(record);
10314        }
10315        Ok(())
10316    }
10317
10318    async fn create_worktree(
10319        &self,
10320        source_workspace_id: WorkspaceId,
10321        workspace_id: WorkspaceId,
10322        target_root: String,
10323        branch: String,
10324        base: Option<String>,
10325    ) -> Result<(GitWorktreeSnapshot, WorkspaceSnapshot), NodeFailure> {
10326        self.require_worktree_service(&source_workspace_id)?;
10327        validate_workspace_request_root(&target_root)?;
10328        validate_git_revision("worktree base", base.as_deref())?;
10329        let source_root = self.workspace_root(&source_workspace_id)?;
10330        {
10331            let workspaces = self
10332                .workspaces
10333                .read()
10334                .unwrap_or_else(|poisoned| poisoned.into_inner());
10335            if workspaces.contains_key(&workspace_id) {
10336                return Err(failure(
10337                    NodeFailureCode::DuplicateWorkspaceId,
10338                    "workspace ID is already registered",
10339                ));
10340            }
10341            if let Some((existing_id, _)) = workspaces
10342                .iter()
10343                .find(|(_, root)| worktree_paths_equal(root, &target_root))
10344            {
10345                return Err(failure(
10346                    NodeFailureCode::DuplicateWorkspaceRoot,
10347                    &format!("workspace root is already registered as '{existing_id}'"),
10348                ));
10349            }
10350        }
10351        let mut worktree = create_git_worktree(
10352            &source_root,
10353            &target_root,
10354            &branch,
10355            base.as_deref(),
10356        )
10357        .await
10358        .map_err(git_worktree_failure)?;
10359        let workspace = match self
10360            .register_workspace(workspace_id, worktree.path.clone())
10361            .await
10362        {
10363            Ok(workspace) => workspace,
10364            Err(registration_error) => {
10365                if remove_git_worktree(&source_root, &worktree.path).await.is_err() {
10366                    return Err(failure(
10367                        NodeFailureCode::ManagedWorktreeRecoveryRequired,
10368                        "worktree registration failed and compensation requires recovery",
10369                    ));
10370                }
10371                return Err(failure(
10372                    registration_error.code,
10373                    &registration_error.message,
10374                ));
10375            }
10376        };
10377        worktree.workspace_id = Some(workspace.workspace_id.clone());
10378        Ok((protocol_worktree(worktree), workspace))
10379    }
10380
10381    async fn remove_worktree(
10382        &self,
10383        source_workspace_id: WorkspaceId,
10384        target_root: String,
10385    ) -> Result<Option<WorkspaceId>, NodeFailure> {
10386        self.require_worktree_service(&source_workspace_id)?;
10387        validate_workspace_request_root(&target_root)?;
10388        let source_root = self.workspace_root(&source_workspace_id)?;
10389        let target_root = normalize_worktree_removal_target(&target_root)
10390            .map_err(git_worktree_failure)?;
10391        let target = list_git_worktrees(&source_root)
10392            .await
10393            .map_err(git_worktree_failure)?
10394            .into_iter()
10395            .find(|worktree| worktree_paths_equal(&worktree.path, &target_root))
10396            .ok_or_else(|| {
10397                failure(
10398                    NodeFailureCode::WorktreeProtected,
10399                    "refusing to remove a path that is not in Git's worktree listing",
10400                )
10401            })?;
10402        let registered_workspace_id = {
10403            let workspaces = self
10404                .workspaces
10405                .read()
10406                .unwrap_or_else(|poisoned| poisoned.into_inner());
10407            let matched = workspaces
10408                .iter()
10409                .find(|(_, root)| worktree_paths_equal(root, &target.path))
10410                .map(|(workspace_id, _)| workspace_id.clone());
10411            if matched.is_some() && workspaces.len() == 1 {
10412                return Err(failure(
10413                    NodeFailureCode::LastWorkspace,
10414                    "node must retain at least one workspace",
10415                ));
10416            }
10417            matched
10418        };
10419        if let Some(workspace_id) = registered_workspace_id.as_ref() {
10420            let bindings = self
10421                .session_bindings
10422                .lock()
10423                .unwrap_or_else(|poisoned| poisoned.into_inner());
10424            if bindings
10425                .values()
10426                .any(|binding| &binding.workspace_id == workspace_id)
10427            {
10428                return Err(failure(
10429                    NodeFailureCode::WorkspaceBusy,
10430                    "worktree workspace retains a bound session; remove every session first",
10431                ));
10432            }
10433        }
10434        remove_git_worktree(&source_root, &target.path)
10435            .await
10436            .map_err(git_worktree_failure)?;
10437        if let Some(workspace_id) = registered_workspace_id.as_ref() {
10438            self.unregister_workspace(workspace_id)
10439                .map_err(|error| failure(error.code, &error.message))?;
10440        }
10441        Ok(registered_workspace_id)
10442    }
10443
10444    async fn cleanup_managed_worktree(
10445        &self,
10446        lease_id: &ManagedWorktreeLeaseId,
10447        explicit: bool,
10448    ) -> Result<ManagedWorktreeLeaseSnapshot, NodeFailure> {
10449        let lease = self.managed_worktrees.lock()
10450            .unwrap_or_else(|poisoned| poisoned.into_inner())
10451            .get(lease_id)
10452            .cloned()
10453            .ok_or_else(|| failure(
10454                NodeFailureCode::UnknownManagedWorktreeLease,
10455                "managed worktree lease does not exist",
10456            ))?;
10457        if lease.state == ManagedWorktreeLeaseState::Removed {
10458            return Ok(lease.snapshot());
10459        }
10460        if lease.has_holders() {
10461            return Err(failure(
10462                NodeFailureCode::ManagedWorktreeBusy,
10463                "managed worktree lease still has a session or durable-record holder",
10464            ));
10465        }
10466        if self
10467            .materializations
10468            .lock()
10469            .unwrap_or_else(|poisoned| poisoned.into_inner())
10470            .values()
10471            .any(|record| record.managed_lease_id() == Some(lease_id))
10472        {
10473            return Err(failure(
10474                NodeFailureCode::ManagedWorktreeRecoveryRequired,
10475                "managed worktree cleanup is blocked by session environment ownership",
10476            ));
10477        }
10478        if !explicit && lease.retention == ManagedWorktreeRetention::Retain {
10479            let snapshot = {
10480                let mut registry = self.managed_worktrees.lock()
10481                    .unwrap_or_else(|poisoned| poisoned.into_inner());
10482                let current = registry.get_mut(lease_id)
10483                    .expect("managed worktree lease remains present");
10484                current.state = ManagedWorktreeLeaseState::Retained;
10485                current.cleanup_failure = None;
10486                current.updated_at_unix_ms = unix_time_ms();
10487                current.snapshot()
10488            };
10489            if self.persist_state().is_err() {
10490                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
10491            }
10492            self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
10493            return Ok(snapshot);
10494        }
10495        let profile = self.managed_profile(&lease.source_workspace_id, &lease.profile_id)?;
10496        if profile.revision() != &lease.profile_revision {
10497            self.mark_managed_recovery_required(
10498                lease_id,
10499                ManagedWorktreeCleanupFailure::OwnershipConflict,
10500            );
10501            return Err(failure(
10502                NodeFailureCode::ManagedWorktreeRecoveryRequired,
10503                "managed worktree profile revision changed",
10504            ));
10505        }
10506        profile.validate_target_authority(&lease.lease_id, &lease.target_root).map_err(|message| {
10507            self.mark_managed_recovery_required(
10508                lease_id,
10509                ManagedWorktreeCleanupFailure::OwnershipConflict,
10510            );
10511            failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message)
10512        })?;
10513        let source_root = self.workspace_root(&lease.source_workspace_id)?;
10514        let listed = list_git_worktrees(&source_root).await.map_err(|error| {
10515            self.mark_managed_cleanup_blocked(lease_id, ManagedWorktreeCleanupFailure::Backend);
10516            managed_git_worktree_failure(error)
10517        })?;
10518        let target = listed.iter()
10519            .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
10520        if let Some(target) = target {
10521            if target.is_main || target.is_bare || !exact_owned_worktree(
10522                &lease,
10523                &target.path,
10524                target.branch.as_deref(),
10525                &target.head,
10526            ) {
10527                self.mark_managed_recovery_required(
10528                    lease_id,
10529                    ManagedWorktreeCleanupFailure::OwnershipConflict,
10530                );
10531                return Err(failure(
10532                    NodeFailureCode::ManagedWorktreeOwnershipConflict,
10533                    "Git worktree identity no longer matches the managed lease",
10534                ));
10535            }
10536            if target.prunable {
10537                self.mark_managed_cleanup_blocked(
10538                    lease_id,
10539                    ManagedWorktreeCleanupFailure::Prunable,
10540                );
10541                return Err(failure(
10542                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
10543                    "managed worktree is prunable and requires recovery",
10544                ));
10545            }
10546            if let Err(error) = remove_git_worktree(&source_root, &target.path).await {
10547                let failure_kind = match error.kind {
10548                    GitWorktreeErrorKind::Dirty => ManagedWorktreeCleanupFailure::Dirty,
10549                    GitWorktreeErrorKind::Locked => ManagedWorktreeCleanupFailure::Locked,
10550                    GitWorktreeErrorKind::Protected => ManagedWorktreeCleanupFailure::OwnershipConflict,
10551                    GitWorktreeErrorKind::Conflict => ManagedWorktreeCleanupFailure::Busy,
10552                    GitWorktreeErrorKind::Invalid | GitWorktreeErrorKind::NotRepository
10553                    | GitWorktreeErrorKind::Failed => ManagedWorktreeCleanupFailure::Backend,
10554                };
10555                if failure_kind == ManagedWorktreeCleanupFailure::OwnershipConflict {
10556                    self.mark_managed_recovery_required(lease_id, failure_kind);
10557                } else {
10558                    self.mark_managed_cleanup_blocked(lease_id, failure_kind);
10559                }
10560                return Err(managed_git_worktree_failure(error));
10561            }
10562        } else {
10563            match std::fs::symlink_metadata(&lease.target_root) {
10564                Err(error) if error.kind() == io::ErrorKind::NotFound => {}
10565                Ok(_) => {
10566                    self.mark_managed_recovery_required(
10567                        lease_id,
10568                        ManagedWorktreeCleanupFailure::OwnershipConflict,
10569                    );
10570                    return Err(failure(
10571                        NodeFailureCode::ManagedWorktreeOwnershipConflict,
10572                        "managed target exists but is not owned by Git",
10573                    ));
10574                }
10575                Err(_) => {
10576                    self.mark_managed_cleanup_blocked(
10577                        lease_id,
10578                        ManagedWorktreeCleanupFailure::Backend,
10579                    );
10580                    return Err(failure(
10581                        NodeFailureCode::BackendOperationFailed,
10582                        "managed target identity could not be inspected",
10583                    ));
10584                }
10585            }
10586        }
10587        let registered = self.workspaces.read()
10588            .unwrap_or_else(|poisoned| poisoned.into_inner())
10589            .contains_key(&lease.workspace_id);
10590        if registered {
10591            self.unregister_workspace(&lease.workspace_id)?;
10592        }
10593        let removed = {
10594            self.managed_worktrees.lock()
10595                .unwrap_or_else(|poisoned| poisoned.into_inner())
10596                .tombstone(lease_id, unix_time_ms())
10597                .expect("managed worktree lease remains present during cleanup")
10598        };
10599        self.persist_state().map_err(persistence_failure)?;
10600        self.publish(NodeEvent::ManagedWorktreeRemoved { lease_id: lease_id.clone() });
10601        Ok(removed.snapshot())
10602    }
10603
10604    fn mark_managed_cleanup_blocked(
10605        &self,
10606        lease_id: &ManagedWorktreeLeaseId,
10607        failure_kind: ManagedWorktreeCleanupFailure,
10608    ) {
10609        let snapshot = {
10610            let mut registry = self.managed_worktrees.lock()
10611                .unwrap_or_else(|poisoned| poisoned.into_inner());
10612            let Some(lease) = registry.get_mut(lease_id) else { return };
10613            lease.state = ManagedWorktreeLeaseState::CleanupBlocked;
10614            lease.cleanup_failure = Some(failure_kind);
10615            lease.updated_at_unix_ms = unix_time_ms();
10616            lease.snapshot()
10617        };
10618        let _ = self.persist_state();
10619        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
10620    }
10621
10622    fn require_worktree_service(
10623        &self,
10624        source_workspace_id: &WorkspaceId,
10625    ) -> Result<(), NodeFailure> {
10626        if self.managed_worktrees.lock()
10627            .unwrap_or_else(|poisoned| poisoned.into_inner())
10628            .lease_for_workspace(source_workspace_id)
10629            .is_some()
10630        {
10631            return Err(failure(
10632                NodeFailureCode::ManagedWorktreeOwnershipConflict,
10633                "managed worktree targets are internal-only workspaces",
10634            ));
10635        }
10636        match self
10637            .worktree_service_modes
10638            .get(source_workspace_id)
10639            .copied()
10640            .unwrap_or(WorktreeServiceMode::Manual)
10641        {
10642            WorktreeServiceMode::Manual => Ok(()),
10643            WorktreeServiceMode::Managed | WorktreeServiceMode::Off => Err(failure(
10644                NodeFailureCode::UnsupportedSpawnCapability,
10645                "Git worktree service mode is unavailable in this node build",
10646            )),
10647        }
10648    }
10649
10650    fn managed_reservation_conflict(
10651        &self,
10652        workspace_id: Option<&WorkspaceId>,
10653        root: Option<&str>,
10654    ) -> bool {
10655        self.managed_worktrees.lock()
10656            .unwrap_or_else(|poisoned| poisoned.into_inner())
10657            .active_records()
10658            .any(|lease| {
10659                workspace_id.is_some_and(|workspace_id| {
10660                    workspace_id == &lease.source_workspace_id
10661                        || workspace_id == &lease.workspace_id
10662                }) || root.is_some_and(|root| worktree_paths_equal(root, &lease.target_root))
10663            })
10664    }
10665
10666    fn reject_managed_reservation(
10667        &self,
10668        workspace_id: Option<&WorkspaceId>,
10669        root: Option<&str>,
10670    ) -> Result<(), NodeFailure> {
10671        if self.managed_reservation_conflict(workspace_id, root) {
10672            Err(failure(
10673                NodeFailureCode::ManagedWorktreeOwnershipConflict,
10674                "managed worktree ownership may only be mutated through its lease cleanup",
10675            ))
10676        } else {
10677            Ok(())
10678        }
10679    }
10680
10681    fn bound_workspace_root(&self, address: &SessionAddress) -> Result<String, NodeFailure> {
10682        self.validate_address(address)?;
10683        self.workspace_root(&address.workspace_id)
10684    }
10685
10686    /// `Resume`/`ResumeWithPrompt` are always a raw-PTY relaunch (see
10687    /// `require_policy`'s own doc on those two requirements), so they keep
10688    /// gating on `binding.runtime_policy`'s PTY-terminal-text-inference
10689    /// flags exactly as before; `pipe_transport`/`acp_transport` stay
10690    /// `false` on the `require_policy` call below for them because a PTY
10691    /// resume never needs those facts, not because this method cannot look
10692    /// them up.
10693    ///
10694    /// `SemanticPrompt` -- the requirement `Prompt`/`Paste` share -- reads
10695    /// the SAME `binding.runtime_policy` every other requirement reads here
10696    /// (one computed policy, read by every gate). That policy is no longer
10697    /// PTY-terminal-verification-shaped for an ACP session --
10698    /// `spawn_session_with_deadline` computes it BY TRANSPORT now, through
10699    /// `provider_runtime::policy_for_transport` -- so consulting it for
10700    /// `Prompt`/`Paste` on ACP is sound where it used to gate the wrong fact
10701    /// (this is what made an ACP `Prompt` come back `UnsupportedCapability`
10702    /// even though a prompt is an ACP session's only interface). Pipe keeps
10703    /// reading the same PTY-probe-derived value it always has --
10704    /// `policy_for_transport` passes Pipe through unchanged this pass (see
10705    /// its own doc for why).
10706    ///
10707    /// What this method still adds on top, for ACP/Pipe only, is a
10708    /// turn-in-flight PRECONDITION ahead of that policy check: a session
10709    /// takes one turn at a time, so a `Prompt`/`Paste` is refused by name
10710    /// (`NodeFailureCode::TurnInFlight`) while a turn is already running,
10711    /// read off the same live `SessionSnapshot` state `require_acp_session`
10712    /// already reads for the ACP control verbs -- `TurnStarted` observed
10713    /// with no matching `TurnCompleted` yet is exactly `ProviderSnapshot::
10714    /// lead_activity != Idle`. Sending a second prompt mid-turn would put
10715    /// its fate in vendor-specific behaviour this node cannot observe (one
10716    /// agent may queue it, another drop it, another interleave it), so this
10717    /// refuses it outright rather than gambling on that -- a rule with no
10718    /// PTY analogue, which is why PTY does not take this extra precondition.
10719    fn require_session_runtime_policy(
10720        &self,
10721        address: &SessionAddress,
10722        requirement: ProviderRuntimeRequirement,
10723    ) -> Result<ProviderRuntimePolicy, NodeFailure> {
10724        let runtime_policy = {
10725            let bindings = self
10726                .session_bindings
10727                .lock()
10728                .unwrap_or_else(|poisoned| poisoned.into_inner());
10729            let binding = bindings
10730                .get(&address.session.instance_id)
10731                .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10732            if binding.workspace_id != address.workspace_id {
10733                return Err(failure(
10734                    NodeFailureCode::SessionWorkspaceMismatch,
10735                    "session belongs to another workspace",
10736                ));
10737            }
10738            if binding.generation != address.session.generation {
10739                return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10740            }
10741            binding.runtime_policy
10742        };
10743        if requirement == ProviderRuntimeRequirement::SemanticPrompt {
10744            let snapshot = self.internal_session_snapshot(address)?;
10745            if matches!(snapshot.transport, TransportKind::Acp | TransportKind::Pipe)
10746                && snapshot.provider.lead_activity != ProviderActivity::Idle
10747            {
10748                return Err(failure(
10749                    NodeFailureCode::TurnInFlight,
10750                    "session already has a turn in flight; wait for it to complete before sending another prompt",
10751                ));
10752            }
10753        }
10754        require_policy(runtime_policy, false, false, requirement)
10755            .map(|()| runtime_policy)
10756            .map_err(|_| failure(
10757                NodeFailureCode::UnsupportedCapability,
10758                "session runtime policy does not admit this semantic operation",
10759            ))
10760    }
10761
10762    fn remove_binding(&self, address: &SessionAddress) -> Option<SessionBinding> {
10763        let mut bindings = self
10764            .session_bindings
10765            .lock()
10766            .unwrap_or_else(|poisoned| poisoned.into_inner());
10767        if bindings.get(&address.session.instance_id).is_some_and(|binding| {
10768            binding.workspace_id == address.workspace_id
10769                && binding.generation == address.session.generation
10770        }) {
10771            let removed = bindings.remove(&address.session.instance_id);
10772            self.clear_terminal_frame_watermark(address);
10773            SessionLifecycleHook::on_binding_removed(self, address, removed.as_ref());
10774            return removed;
10775        }
10776        None
10777    }
10778
10779    fn cleanup_session_owned_materialization(
10780        &self,
10781        address: &SessionAddress,
10782        binding: &SessionBinding,
10783    ) -> Result<(), NodeFailure> {
10784        let Some(id) = binding.materialization_id.as_ref() else {
10785            return Ok(());
10786        };
10787        let owner = self
10788            .materializations
10789            .lock()
10790            .unwrap_or_else(|poisoned| poisoned.into_inner())
10791            .get(id)
10792            .map(|record| record.owner().clone());
10793        match owner {
10794            None | Some(MaterializationOwner::Record { .. }) => Ok(()),
10795            Some(MaterializationOwner::Session {
10796                incarnation_id,
10797                instance_id,
10798                generation,
10799            }) if incarnation_id == self.incarnation_id
10800                && instance_id == address.session.instance_id
10801                && generation == address.session.generation =>
10802            {
10803                self.cleanup_materialization(id)
10804            }
10805            Some(MaterializationOwner::Session { .. }) => Err(failure(
10806                NodeFailureCode::BackendOperationFailed,
10807                "session environment ownership requires recovery",
10808            )),
10809        }
10810    }
10811
10812    fn clear_terminal_frame_watermark(&self, address: &SessionAddress) {
10813        let mut watermarks = self
10814            .terminal_frame_watermarks
10815            .lock()
10816            .unwrap_or_else(|poisoned| poisoned.into_inner());
10817        if watermarks
10818            .get(&address.session.instance_id)
10819            .is_some_and(|(current, _)| current == address)
10820        {
10821            watermarks.remove(&address.session.instance_id);
10822        }
10823    }
10824
10825    #[cfg(test)]
10826    fn address_for(
10827        &self,
10828        instance_id: AgentInstanceId,
10829        generation: SessionGeneration,
10830    ) -> Option<SessionAddress> {
10831        let binding = self.session_bindings
10832            .lock()
10833            .unwrap_or_else(|poisoned| poisoned.into_inner())
10834            .get(&instance_id)
10835            .cloned()?;
10836        (binding.generation == generation).then_some(SessionAddress {
10837            workspace_id: binding.workspace_id,
10838            session: SessionKey { instance_id, generation },
10839        })
10840    }
10841
10842    fn arm_resume(
10843        &self,
10844        address: &SessionAddress,
10845        command_id: CommandId,
10846        runtime_policy: ProviderRuntimePolicy,
10847    ) -> Result<(), NodeFailure> {
10848        let mut bindings = self
10849            .session_bindings
10850            .lock()
10851            .unwrap_or_else(|poisoned| poisoned.into_inner());
10852        let binding = bindings
10853            .get_mut(&address.session.instance_id)
10854            .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10855        if binding.workspace_id != address.workspace_id {
10856            return Err(failure(
10857                NodeFailureCode::SessionWorkspaceMismatch,
10858                "session belongs to another workspace",
10859            ));
10860        }
10861        if binding.generation != address.session.generation {
10862            return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10863        }
10864        binding.pending_resume = Some((binding.generation, command_id, runtime_policy));
10865        Ok(())
10866    }
10867
10868    fn clear_armed_resume(&self, address: &SessionAddress) {
10869        let mut bindings = self
10870            .session_bindings
10871            .lock()
10872            .unwrap_or_else(|poisoned| poisoned.into_inner());
10873        if let Some(binding) = bindings.get_mut(&address.session.instance_id) {
10874            if binding.workspace_id == address.workspace_id
10875                && binding.generation == address.session.generation
10876            {
10877                binding.pending_resume = None;
10878            }
10879        }
10880    }
10881
10882    fn publish_control(&self, event: ControlEvent) {
10883        let revokes_harness_mcp = matches!(
10884            &event.event,
10885            ControlEventKind::Exited { .. }
10886                | ControlEventKind::Failed { .. }
10887                | ControlEventKind::Removed
10888        );
10889        let (address, previous_address, record_id, identity_admitted, resume_command_rejected) = {
10890            let mut bindings = self
10891                .session_bindings
10892                .lock()
10893                .unwrap_or_else(|poisoned| poisoned.into_inner());
10894            let Some(binding) = bindings.get_mut(&event.instance_id) else {
10895                return;
10896            };
10897            let previous_address = SessionAddress {
10898                workspace_id: binding.workspace_id.clone(),
10899                session: SessionKey {
10900                    instance_id: event.instance_id,
10901                    generation: binding.generation,
10902                },
10903            };
10904            let mut resume_command_rejected = false;
10905            if binding.generation == event.generation {
10906                if let Some((pending_generation, pending_command_id, _)) = binding.pending_resume {
10907                    let rejected_resume = matches!(
10908                        &event.event,
10909                        ControlEventKind::CommandRejected { .. }
10910                    ) && event.command_id == Some(pending_command_id);
10911                    let resume_failed = matches!(
10912                        &event.event,
10913                        ControlEventKind::ResumeDenied { .. }
10914                            | ControlEventKind::ResumeFailed { .. }
10915                    );
10916                    if pending_generation == binding.generation
10917                        && (rejected_resume || resume_failed)
10918                    {
10919                        resume_command_rejected = rejected_resume;
10920                        binding.pending_resume = None;
10921                    }
10922                }
10923            } else {
10924                let expected = binding.generation.0.checked_add(1);
10925                let authorized = matches!(&event.event, ControlEventKind::ResumeAuthorized { .. });
10926                if !binding.pending_resume.is_some_and(|(generation, _, _)| {
10927                    generation == binding.generation
10928                })
10929                    || expected != Some(event.generation.0)
10930                    || !authorized
10931                {
10932                    return;
10933                }
10934                let (_, _, runtime_policy) = binding
10935                    .pending_resume
10936                    .expect("authorized resume retains its pending runtime policy");
10937                binding.generation = event.generation;
10938                binding.runtime_policy = runtime_policy;
10939                binding.pending_resume = None;
10940            }
10941            (
10942                SessionAddress {
10943                    workspace_id: binding.workspace_id.clone(),
10944                    session: SessionKey {
10945                        instance_id: event.instance_id,
10946                        generation: event.generation,
10947                    },
10948                },
10949                (previous_address.session.generation != event.generation)
10950                    .then_some(previous_address),
10951                binding.record_id.clone(),
10952                binding.runtime_policy.provider_session_identity,
10953                resume_command_rejected,
10954            )
10955        };
10956        if let Some(previous_address) = previous_address.as_ref() {
10957            self.clear_terminal_frame_watermark(previous_address);
10958        }
10959        if let Some(record_id) = record_id {
10960            self.reconcile_managed_record(
10961                &record_id,
10962                &address,
10963                &event,
10964                identity_admitted,
10965                resume_command_rejected,
10966            );
10967        }
10968        // Every state change a session's process lifecycle can reach gets a
10969        // line here: this is the single chokepoint every `ControlEventKind`
10970        // passes through regardless of transport (PTY/pipe/ACP) or whether it
10971        // came from a fresh spawn or a resume. Per-message provider chatter
10972        // (`ProviderEvent`/`ProviderGap`/terminal frames) is deliberately not
10973        // matched here -- that is exactly the per-frame noise this crate
10974        // family's level discipline forbids at INFO.
10975        match &event.event {
10976            ControlEventKind::Running { process_id } => {
10977                if let Some(pid) = process_id {
10978                    crate::git_watch::running(address.workspace_id.as_str(), address.session.instance_id.0, "", *pid);
10979                }
10980                #[cfg(unix)]
10981                if let (Some(proxy), Some(pid)) = (self.signer_proxy.get(), process_id) {
10982                    proxy.attach_pid(&format!("{}-{}", self.incarnation_id, address.session.instance_id.0), *pid);
10983                }
10984                tracing::info!(
10985                    node_id = %self.node_id,
10986                    workspace_id = %address.workspace_id,
10987                    session = ?address.session,
10988                    pid = ?process_id,
10989                    "session process is running",
10990                );
10991            }
10992            ControlEventKind::Exited { exit_code, forced } => {
10993                crate::git_watch::exited(address.workspace_id.as_str(), address.session.instance_id.0);
10994                #[cfg(unix)]
10995                if let Some(proxy) = self.signer_proxy.get().cloned() {
10996                    let key = format!("{}-{}", self.incarnation_id, address.session.instance_id.0);
10997                    if proxy.has_session(&key) {
10998                        // Revoke at core off the event path (blocking uplink call).
10999                        std::thread::spawn(move || proxy.release(&key));
11000                    }
11001                }
11002                tracing::info!(
11003                    node_id = %self.node_id,
11004                    workspace_id = %address.workspace_id,
11005                    session = ?address.session,
11006                    exit_code = ?exit_code,
11007                    forced = forced,
11008                    "session process exited",
11009                );
11010            }
11011            ControlEventKind::Failed { message } => {
11012                tracing::warn!(
11013                    node_id = %self.node_id,
11014                    workspace_id = %address.workspace_id,
11015                    session = ?address.session,
11016                    cause = %message,
11017                    "session failed",
11018                );
11019            }
11020            ControlEventKind::CommandRejected { message } => {
11021                tracing::warn!(
11022                    node_id = %self.node_id,
11023                    workspace_id = %address.workspace_id,
11024                    session = ?address.session,
11025                    cause = %message,
11026                    "session command rejected",
11027                );
11028            }
11029            ControlEventKind::Removed => {
11030                tracing::info!(
11031                    node_id = %self.node_id,
11032                    workspace_id = %address.workspace_id,
11033                    session = ?address.session,
11034                    "session removed",
11035                );
11036            }
11037            ControlEventKind::ResumeAuthorized { session: provider_session } => {
11038                tracing::info!(
11039                    node_id = %self.node_id,
11040                    workspace_id = %address.workspace_id,
11041                    session = ?address.session,
11042                    provider_session = ?provider_session,
11043                    "session resume authorized",
11044                );
11045            }
11046            ControlEventKind::Resumed { session: provider_session, process_id } => {
11047                tracing::info!(
11048                    node_id = %self.node_id,
11049                    workspace_id = %address.workspace_id,
11050                    session = ?address.session,
11051                    provider_session = ?provider_session,
11052                    pid = ?process_id,
11053                    "session resumed",
11054                );
11055            }
11056            ControlEventKind::ResumeDenied { reason } => {
11057                tracing::warn!(
11058                    node_id = %self.node_id,
11059                    workspace_id = %address.workspace_id,
11060                    session = ?address.session,
11061                    cause = %reason,
11062                    "session resume denied",
11063                );
11064            }
11065            ControlEventKind::ResumeFailed { message } => {
11066                tracing::warn!(
11067                    node_id = %self.node_id,
11068                    workspace_id = %address.workspace_id,
11069                    session = ?address.session,
11070                    cause = %message,
11071                    "session resume failed",
11072                );
11073            }
11074            _ => {}
11075        }
11076        self.apply_pending_acp_options(&address, &event);
11077        self.apply_acp_auto_permit(&address, &event);
11078        trace_acp_turn(&address, &event);
11079        acp_tool_evidence(&address, &event);
11080        self.observe_acp_provider_error(&address, &event);
11081        let stream_chunk = agent_stream_chunk(&event);
11082        self.publish(NodeEvent::Control {
11083            address: address.clone(),
11084            event,
11085        });
11086        if let Some(chunk) = stream_chunk {
11087            self.publish_agent_stream_chunk(address.clone(), chunk);
11088        }
11089        if revokes_harness_mcp {
11090            SessionLifecycleHook::on_session_ended(self, &address);
11091        }
11092    }
11093
11094    fn reconcile_managed_record(
11095        &self,
11096        record_id: &SessionRecordId,
11097        address: &SessionAddress,
11098        event: &ControlEvent,
11099        identity_admitted: bool,
11100        resume_command_rejected: bool,
11101    ) {
11102        let _transaction = self
11103            .state_transaction
11104            .lock()
11105            .unwrap_or_else(|poisoned| poisoned.into_inner());
11106        let observed_identity = match &event.event {
11107            ControlEventKind::ProviderEvent {
11108                event: gate4agent_types::ProviderEvent::SessionIdentityObserved { identity },
11109                ..
11110            } if identity_admitted => Some(identity.clone()),
11111            _ => None,
11112        };
11113        let identity_was_observed = observed_identity.is_some();
11114        let replacement_id = observed_identity
11115            .as_ref()
11116            .and_then(|_| self.allocate_record_id().ok());
11117        let mut upserts = Vec::new();
11118        let mut removals = Vec::new();
11119        let mut rebind = None;
11120        let original_records;
11121        let original_materializations = self
11122            .materializations
11123            .lock()
11124            .unwrap_or_else(|poisoned| poisoned.into_inner())
11125            .clone();
11126        {
11127            let mut records = self
11128                .session_records
11129                .lock()
11130                .unwrap_or_else(|poisoned| poisoned.into_inner());
11131            original_records = records.records.clone();
11132            let Some(current_snapshot) = records.records.get(record_id).cloned() else {
11133                return;
11134            };
11135            if let Some(identity) = observed_identity {
11136                let existing_id = records
11137                    .records
11138                    .values()
11139                    .find(|record| {
11140                        record.record_id != *record_id
11141                            && record.provider_session.as_ref().is_some_and(|record_identity| {
11142                                session_registry::same_provider_session(
11143                                    &record.provider,
11144                                    record_identity,
11145                                    &current_snapshot.provider,
11146                                    &identity,
11147                                )
11148                            })
11149                    })
11150                    .map(|record| record.record_id.clone());
11151                if let Some(existing_id) = existing_id {
11152                    let scope_matches = records.records.get(&existing_id).is_some_and(|record| {
11153                        record.mode == current_snapshot.mode
11154                            && record.workspace_id == current_snapshot.workspace_id
11155                            && record.environment_profile == current_snapshot.environment_profile
11156                            && record.bundle == current_snapshot.bundle
11157                            && record.context_id == current_snapshot.context_id
11158                            && record.context == current_snapshot.context
11159                            && record
11160                                .canonical_root
11161                                .as_utf8()
11162                                .zip(current_snapshot.canonical_root.as_utf8())
11163                                .is_some_and(|(left, right)| platform::roots_equal(left, right))
11164                    });
11165                    if !scope_matches {
11166                        if let Some(current) = records.records.get_mut(record_id) {
11167                            current.state = ManagedSessionState::Unavailable;
11168                            current.last_error =
11169                                Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR.to_owned());
11170                            current.updated_at_unix_ms = unix_time_ms();
11171                            upserts.push(current.clone());
11172                        }
11173                    } else {
11174                        let existing_is_active = records
11175                            .records
11176                            .get(&existing_id)
11177                            .is_some_and(|record| record.active_session.is_some());
11178                        if existing_is_active {
11179                            if let Some(current) = records.records.get_mut(record_id) {
11180                                current.state = ManagedSessionState::Unavailable;
11181                                current.last_error =
11182                                    Some(PROVIDER_SESSION_LIVE_CONFLICT_ERROR.to_owned());
11183                                current.updated_at_unix_ms = unix_time_ms();
11184                                upserts.push(current.clone());
11185                            }
11186                        } else {
11187                            if current_snapshot.provider_session.is_some() {
11188                                if let Some(current) = records.records.get_mut(record_id) {
11189                                    current.active_session = None;
11190                                    current.state = ManagedSessionState::Dormant;
11191                                    current.updated_at_unix_ms = unix_time_ms();
11192                                    upserts.push(current.clone());
11193                                }
11194                            } else {
11195                                records.records.remove(record_id);
11196                                removals.push(record_id.clone());
11197                            }
11198                            if let Some(existing) = records.records.get_mut(&existing_id) {
11199                                existing.provider_session = Some(identity.clone());
11200                                existing.active_session = Some(address.clone());
11201                                existing.state = ManagedSessionState::Live;
11202                                existing.updated_at_unix_ms = unix_time_ms();
11203                                existing.last_error = None;
11204                                upserts.push(existing.clone());
11205                            }
11206                            rebind = Some(existing_id);
11207                        }
11208                    }
11209                } else if current_snapshot
11210                    .provider_session
11211                    .as_ref()
11212                    .is_some_and(|current| {
11213                        !session_registry::same_provider_session(
11214                            &current_snapshot.provider,
11215                            current,
11216                            &current_snapshot.provider,
11217                            &identity,
11218                        )
11219                    })
11220                {
11221                    if let Some(current) = records.records.get_mut(record_id) {
11222                        current.active_session = None;
11223                        if current.bundle.is_some() || current.context.is_some() {
11224                            let had_bundle = current.bundle.is_some();
11225                            current.provider_session = None;
11226                            current.bundle = None;
11227                            current.context_id = None;
11228                            current.context = None;
11229                            current.state = ManagedSessionState::Unavailable;
11230                            current.last_error = Some(if had_bundle {
11231                                "bundle-unavailable".to_owned()
11232                            } else {
11233                                "context-unavailable".to_owned()
11234                            });
11235                        } else {
11236                            current.state = ManagedSessionState::Dormant;
11237                        }
11238                        current.updated_at_unix_ms = unix_time_ms();
11239                        upserts.push(current.clone());
11240                    }
11241                    if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
11242                        if let Some(current) = records.records.get_mut(record_id) {
11243                            current.state = ManagedSessionState::Unavailable;
11244                            current.last_error =
11245                                Some(MANAGED_SESSION_CAPACITY_ERROR.to_owned());
11246                            current.updated_at_unix_ms = unix_time_ms();
11247                            upserts.push(current.clone());
11248                        }
11249                    } else if let Some(new_record_id) = replacement_id {
11250                        let now = unix_time_ms();
11251                        let replacement = ManagedSessionRecord {
11252                            record_id: new_record_id.clone(),
11253                            display_name: format!(
11254                                "{} #{}",
11255                                current_snapshot.provider.as_str(),
11256                                records
11257                                    .records
11258                                    .values()
11259                                    .filter(|record| {
11260                                        record.provider == current_snapshot.provider
11261                                    })
11262                                    .count()
11263                                    .saturating_add(1),
11264                            ),
11265                            provider: current_snapshot.provider.clone(),
11266                            mode: current_snapshot.mode,
11267                            state: ManagedSessionState::Live,
11268                            workspace_id: current_snapshot.workspace_id,
11269                            canonical_root: current_snapshot.canonical_root,
11270                            provider_session: Some(identity),
11271                            active_session: Some(address.clone()),
11272                            environment_profile: current_snapshot.environment_profile,
11273                            bundle: current_snapshot.bundle,
11274                            context_id: current_snapshot.context_id,
11275                            context: current_snapshot.context,
11276                            // This is a fresh record_id representing the session under its
11277                            // newly-observed identity; it has not yet produced any clean
11278                            // exit of its own, so it starts with no exported pack (the old
11279                            // record, still addressable under its own record_id, keeps
11280                            // whatever exported_context it already had).
11281                            exported_context: None,
11282                            task_binding: current_snapshot.task_binding,
11283                            created_at_unix_ms: now,
11284                            updated_at_unix_ms: now,
11285                            last_error: None,
11286                        };
11287                        records
11288                            .records
11289                            .insert(new_record_id.clone(), replacement.clone());
11290                        upserts.push(replacement);
11291                        rebind = Some(new_record_id);
11292                    } else if let Some(current) = records.records.get_mut(record_id) {
11293                        current.state = ManagedSessionState::Unavailable;
11294                        current.last_error =
11295                            Some(PROVIDER_IDENTITY_ALLOCATION_ERROR.to_owned());
11296                        upserts.push(current.clone());
11297                    }
11298                } else if let Some(current) = records.records.get_mut(record_id) {
11299                    current.provider_session = Some(identity);
11300                    current.active_session = Some(address.clone());
11301                    current.state = ManagedSessionState::Live;
11302                    current.updated_at_unix_ms = unix_time_ms();
11303                    current.last_error = None;
11304                    upserts.push(current.clone());
11305                }
11306            } else if let Some(current) = records.records.get_mut(record_id) {
11307                if resume_command_rejected {
11308                    current.active_session = None;
11309                    current.state = detached_record_state(current);
11310                    current.updated_at_unix_ms = unix_time_ms();
11311                    current.last_error =
11312                        Some(PROVIDER_RESUME_REJECTED_ERROR.to_owned());
11313                    upserts.push(current.clone());
11314                } else {
11315                    match &event.event {
11316                    ControlEventKind::Running { .. }
11317                    | ControlEventKind::ResumeAuthorized { .. }
11318                    | ControlEventKind::Resumed { .. } => {
11319                        current.active_session = Some(address.clone());
11320                        current.state = active_record_state(
11321                            identity_admitted,
11322                            current.provider_session.is_some(),
11323                        );
11324                        current.updated_at_unix_ms = unix_time_ms();
11325                        current.last_error = None;
11326                        upserts.push(current.clone());
11327                    }
11328                    ControlEventKind::Exited { .. } | ControlEventKind::Removed => {
11329                        current.active_session = None;
11330                        current.state = detached_record_state(current);
11331                        current.updated_at_unix_ms = unix_time_ms();
11332                        upserts.push(current.clone());
11333                    }
11334                    ControlEventKind::Failed { message }
11335                    | ControlEventKind::ResumeFailed { message } => {
11336                        current.active_session = None;
11337                        current.state = detached_record_state(current);
11338                        current.updated_at_unix_ms = unix_time_ms();
11339                        current.last_error = Some(
11340                            session_registry::sanitized_record_error_summary(message),
11341                        );
11342                        upserts.push(current.clone());
11343                    }
11344                    ControlEventKind::ResumeDenied { reason } => {
11345                        current.active_session = None;
11346                        current.state = detached_record_state(current);
11347                        current.updated_at_unix_ms = unix_time_ms();
11348                        current.last_error = Some(
11349                            session_registry::sanitized_record_error_summary(reason),
11350                        );
11351                        upserts.push(current.clone());
11352                    }
11353                        _ => {}
11354                    }
11355                }
11356            }
11357        }
11358        let materialization_owner_record = identity_was_observed.then(|| {
11359            rebind.clone().unwrap_or_else(|| record_id.clone())
11360        }).and_then(|candidate| {
11361            self.session_records
11362                .lock()
11363                .unwrap_or_else(|poisoned| poisoned.into_inner())
11364                .records
11365                .get(&candidate)
11366                .filter(|record| {
11367                    record.provider_session.is_some()
11368                        && record.active_session.as_ref() == Some(address)
11369                })
11370                .map(|_| candidate)
11371        });
11372        if let Some(owner_record_id) = materialization_owner_record {
11373            let materialization_id = self
11374                .session_bindings
11375                .lock()
11376                .unwrap_or_else(|poisoned| poisoned.into_inner())
11377                .get(&address.session.instance_id)
11378                .and_then(|binding| binding.materialization_id.clone());
11379            if let Some(materialization_id) = materialization_id {
11380                let mut transfer_failed = false;
11381                {
11382                    let mut materializations = self
11383                        .materializations
11384                        .lock()
11385                        .unwrap_or_else(|poisoned| poisoned.into_inner());
11386                    if let Some(ownership) = materializations.get_mut(&materialization_id) {
11387                        match ownership.owner() {
11388                            MaterializationOwner::Session {
11389                                incarnation_id,
11390                                instance_id,
11391                                generation,
11392                            } if *incarnation_id == self.incarnation_id
11393                                && *instance_id == address.session.instance_id
11394                                && *generation == address.session.generation =>
11395                            {
11396                                transfer_failed = ownership
11397                                    .transfer_to_record(owner_record_id.clone(), unix_time_ms())
11398                                    .is_err();
11399                            }
11400                            MaterializationOwner::Record { record_id }
11401                                if record_id == &owner_record_id => {}
11402                            MaterializationOwner::Record { record_id } => {
11403                                let expected = record_id.clone();
11404                                transfer_failed = ownership
11405                                    .transfer_record_owner(
11406                                        &expected,
11407                                        owner_record_id.clone(),
11408                                        unix_time_ms(),
11409                                    )
11410                                    .is_err();
11411                            }
11412                            _ => transfer_failed = true,
11413                        }
11414                        if transfer_failed {
11415                            let _ = ownership.mark_recovery_required(unix_time_ms());
11416                        }
11417                    } else {
11418                        transfer_failed = true;
11419                    }
11420                }
11421                if transfer_failed {
11422                    if let Some(record) = self
11423                        .session_records
11424                        .lock()
11425                        .unwrap_or_else(|poisoned| poisoned.into_inner())
11426                        .records
11427                        .get_mut(&owner_record_id)
11428                    {
11429                        record.state = ManagedSessionState::Unavailable;
11430                        record.last_error = Some("environment-profile-unavailable".to_owned());
11431                        record.updated_at_unix_ms = unix_time_ms();
11432                        upserts.push(record.clone());
11433                    }
11434                }
11435            }
11436        }
11437        if let Some(new_record_id) = rebind.clone() {
11438            if let Some(binding) = self
11439                .session_bindings
11440                .lock()
11441                .unwrap_or_else(|poisoned| poisoned.into_inner())
11442                .get_mut(&address.session.instance_id)
11443            {
11444                binding.record_id = Some(new_record_id);
11445            }
11446        }
11447        if upserts.is_empty() && removals.is_empty() {
11448            return;
11449        }
11450        if self.persist_state_locked().is_err() {
11451            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
11452            let mut records = self
11453                .session_records
11454                .lock()
11455                .unwrap_or_else(|poisoned| poisoned.into_inner());
11456            records.records = original_records;
11457            *self
11458                .materializations
11459                .lock()
11460                .unwrap_or_else(|poisoned| poisoned.into_inner()) = original_materializations;
11461            if let Some(current) = records.records.get_mut(record_id) {
11462                current.state = ManagedSessionState::Unavailable;
11463                current.last_error = Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned());
11464                current.updated_at_unix_ms = unix_time_ms();
11465                upserts = vec![current.clone()];
11466            } else {
11467                upserts.clear();
11468            }
11469            removals.clear();
11470            drop(records);
11471            if let Some(binding) = self
11472                .session_bindings
11473                .lock()
11474                .unwrap_or_else(|poisoned| poisoned.into_inner())
11475                .get_mut(&address.session.instance_id)
11476            {
11477                binding.record_id = Some(record_id.clone());
11478            }
11479            eprintln!("gate4agent-node: {DURABLE_STATE_COMMIT_FAILED_ERROR}");
11480        }
11481        for record in upserts {
11482            if record.active_session.as_ref() == Some(address) {
11483                if let (Some(file), Some(identity)) = (self.session_resume_journal(), record.provider_session.as_ref()) {
11484                    crate::pty_resume::set_session_id(&file, address.session.instance_id.0, &identity.id);
11485                }
11486            }
11487            self.publish_record(record);
11488        }
11489        for record_id in removals {
11490            self.publish(NodeEvent::SessionRecordRemoved { record_id });
11491        }
11492    }
11493
11494    fn snapshot(&self) -> NodeSnapshot {
11495        let control = self.handle.snapshot();
11496        let bindings = self
11497            .session_bindings
11498            .lock()
11499            .unwrap_or_else(|poisoned| poisoned.into_inner())
11500            .clone();
11501        let workspace_roots = self
11502            .workspaces
11503            .read()
11504            .unwrap_or_else(|poisoned| poisoned.into_inner())
11505            .clone();
11506        let environment_profiles = self
11507            .environment_profiles
11508            .read()
11509            .unwrap_or_else(|poisoned| poisoned.into_inner());
11510        let mut workspaces = workspace_roots
11511            .iter()
11512            .map(|(workspace_id, canonical_root)| {
11513                (
11514                    workspace_id.clone(),
11515                    WorkspaceSnapshot {
11516                        workspace_id: workspace_id.clone(),
11517                        canonical_root: opaque_windows_path(canonical_root.clone()),
11518                        sessions: Vec::new(),
11519                        worktree_service_mode: Some(
11520                            self.worktree_service_modes
11521                                .get(workspace_id)
11522                                .copied()
11523                                .unwrap_or(WorktreeServiceMode::Manual),
11524                        ),
11525                        managed_worktree_profiles: Some(WorktreeProfileInventory {
11526                            profiles: self
11527                                .managed_worktree_profiles
11528                                .get(workspace_id)
11529                                .into_iter()
11530                                .flat_map(BTreeMap::values)
11531                                .map(|profile| ManagedWorktreeProfileSummary {
11532                                    id: profile.profile_id().clone(),
11533                                    revision: profile.revision().clone(),
11534                                    retention: profile.retention(),
11535                                })
11536                                .collect(),
11537                        }),
11538                    },
11539                )
11540            })
11541            .collect::<BTreeMap<_, _>>();
11542        let mut agent_progress = Vec::new();
11543        for session in &control.sessions {
11544            let Some(binding) = bindings.get(&session.instance_id) else {
11545                continue;
11546            };
11547            if binding.generation != session.generation {
11548                continue;
11549            }
11550            if let Some(workspace) = workspaces.get_mut(&binding.workspace_id) {
11551                let address = SessionAddress {
11552                    workspace_id: binding.workspace_id.clone(),
11553                    session: SessionKey {
11554                        instance_id: session.instance_id,
11555                        generation: session.generation,
11556                    },
11557                };
11558                if let Some(progress) =
11559                    agent_progress_from_provider_snapshot(address, &session.provider)
11560                {
11561                    agent_progress.push(progress);
11562                }
11563                workspace.sessions.push(session.clone());
11564            }
11565        }
11566        NodeSnapshot {
11567            node_id: self.node_id.clone(),
11568            enabled_providers: self.enabled_providers.clone(),
11569            provider_runtime_statuses: {
11570                let updates = self
11571                    .provider_runtime_status_updates
11572                    .read()
11573                    .unwrap_or_else(|poisoned| poisoned.into_inner());
11574                ProviderRuntimeStatuses::new(
11575                    self.provider_runtime_statuses
11576                        .iter()
11577                        .filter(|status| !updates.contains_key(status.provider()))
11578                        .cloned()
11579                        .chain(updates.values().cloned()),
11580                )
11581                .expect("refreshed provider runtime inventory remains bounded and unique")
11582            },
11583            workspaces: workspaces.into_values().collect(),
11584            session_records: self
11585                .session_records
11586                .lock()
11587                .unwrap_or_else(|poisoned| poisoned.into_inner())
11588                .records
11589                .values()
11590                .cloned()
11591                .collect(),
11592            managed_worktrees: self.managed_worktrees
11593                .lock()
11594                .unwrap_or_else(|poisoned| poisoned.into_inner())
11595                .snapshots(),
11596            launch_inventory: Some(LaunchInventory {
11597                spawn_profiles: Some(
11598                    self.spawn_profiles
11599                        .iter()
11600                        .filter_map(|profile| {
11601                            resolved_spawn_profile_summary(profile, &environment_profiles)
11602                        })
11603                        .collect(),
11604                ),
11605                bundles: Some(
11606                    self.bundle_catalog
11607                        .read()
11608                        .unwrap_or_else(|poisoned| poisoned.into_inner())
11609                        .iter()
11610                        .map(NodeBundle::receipt)
11611                        .collect(),
11612                ),
11613                // Opaque station allowlist ids from empty-default catalog /
11614                // optional startup loader. Never secrets. Dig2 probe stays stub.
11615                network_allowlists: {
11616                    let catalog = self
11617                        .network_allowlist_catalog
11618                        .read()
11619                        .unwrap_or_else(|poisoned| poisoned.into_inner());
11620                    if catalog.is_empty() {
11621                        None
11622                    } else {
11623                        Some(catalog.ids().cloned().collect())
11624                    }
11625                },
11626            }),
11627            agent_progress,
11628        }
11629    }
11630
11631    fn publish(&self, event: NodeEvent) -> NodeEventEnvelope {
11632        assert!(
11633            !matches!(
11634                &event,
11635                NodeEvent::TerminalFrame { .. } | NodeEvent::AgentStream { .. }
11636            ),
11637            "terminal frame and agent stream events must use their own dedicated live channel",
11638        );
11639        let mut history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11640        let sequence = history
11641            .last_sequence
11642            .checked_add(1)
11643            .expect("node event sequence exhausted");
11644        if history.events.len() == NODE_EVENT_HISTORY_MAX {
11645            if let Some(evicted) = history.events.pop_front() {
11646                history.replay_floor_sequence = evicted
11647                    .sequence
11648                    .checked_add(1)
11649                    .expect("node replay floor sequence exhausted");
11650                history.removed_record_providers.remove(&evicted.sequence);
11651            }
11652        }
11653        match &event {
11654            NodeEvent::HarnessMcpReadCall { .. } => {
11655                panic!("transient harness MCP calls must not enter durable event history")
11656            }
11657            NodeEvent::SessionRecordUpserted { record } => {
11658                history
11659                    .record_providers
11660                    .insert(record.record_id.clone(), record.provider.clone());
11661            }
11662            NodeEvent::SessionRecordRemoved { record_id } => {
11663                if let Some(provider) = history.record_providers.remove(record_id) {
11664                    history.removed_record_providers.insert(sequence, provider);
11665                }
11666            }
11667            NodeEvent::ManagedWorktreeUpserted { .. }
11668            | NodeEvent::ManagedWorktreeRemoved { .. }
11669            | NodeEvent::Control { .. }
11670            | NodeEvent::ControllerChanged { .. }
11671            | NodeEvent::SessionRecordHistorySummarized { .. }
11672            | NodeEvent::WorkspaceAdded { .. }
11673            | NodeEvent::WorkspaceRemoved { .. }
11674            | NodeEvent::ResyncRequired { .. }
11675            | NodeEvent::AgentStream { .. }
11676            | NodeEvent::TerminalFrame { .. } => {}
11677        }
11678        let envelope = NodeEventEnvelope { sequence, event };
11679        history.events.push_back(envelope.clone());
11680        history.last_sequence = sequence;
11681        let _ = self.event_tx.send(envelope.clone());
11682        drop(history);
11683        envelope
11684    }
11685
11686    /// Publishes a transient (never durable) `NodeEvent` -- today only
11687    /// `NodeEvent::HarnessMcpReadCall` -- onto the dedicated
11688    /// `harness_mcp_proxy_event_tx` broadcast (see that field's own doc for
11689    /// why this must never ride the shared `event_tx` bus). Returns whether
11690    /// the send reached at least one subscriber: `broadcast::Sender::send`
11691    /// fails in exactly one way, no active receiver at that instant, and
11692    /// the caller (`HarnessMcpProxyRegistry::begin_call`) uses a `false`
11693    /// return to fail the pending call immediately rather than leave it to
11694    /// expire silently at `LOCAL_READ_TIMEOUT`/
11695    /// `MAX_HARNESS_MCP_CALL_DEADLINE_MS`.
11696    fn publish_transient(&self, event: NodeEvent) -> bool {
11697        assert!(event.requires_harness_mcp_proxy_capability());
11698        assert!(event.harness_mcp_contract_is_valid_at(unix_time_ms()));
11699        let envelope = NodeEventEnvelope { sequence: 0, event };
11700        match self.harness_mcp_proxy_event_tx.send(Arc::new(vec![envelope])) {
11701            Ok(_) => true,
11702            Err(broadcast::error::SendError(undelivered)) => {
11703                if let Some(NodeEventEnvelope {
11704                    event: NodeEvent::HarnessMcpReadCall { call_id, request, .. },
11705                    ..
11706                }) = undelivered.first() {
11707                    // `request.content_type` only, never a per-tool label --
11708                    // this crate does not decode `request.body` and does not
11709                    // hold `hatchery-harness-api` (Nested Control Plane
11710                    // doctrine, Law 3; see this crate's dependency on
11711                    // `gate4agent-node-protocol`'s own `Forbidden:` line).
11712                    tracing::warn!(
11713                        call_id = call_id.as_str(),
11714                        content_type = ?request.content_type,
11715                        cause = "no-subscribers",
11716                        "harness MCP proxy event could not be published",
11717                    );
11718                }
11719                false
11720            }
11721        }
11722    }
11723
11724    fn publish_terminal_frames(&self) {
11725        let snapshot = self.handle.snapshot();
11726        let bindings = self
11727            .session_bindings
11728            .lock()
11729            .unwrap_or_else(|poisoned| poisoned.into_inner());
11730        let candidates = snapshot
11731            .sessions
11732            .iter()
11733            .filter_map(|session| {
11734                let binding = bindings.get(&session.instance_id)?;
11735                if binding.generation != session.generation {
11736                    return None;
11737                }
11738                let frame = session.terminal_frame.clone()?;
11739                crate::session_signals::feed(
11740                    binding.workspace_id.as_str(),
11741                    session.instance_id.0,
11742                    session.agent_id.as_str(),
11743                    &frame.contents,
11744                );
11745                crate::provider_limits::observe_screen(session.agent_id.as_str(), &frame.contents);
11746                let act = self.provider_errors.lock().unwrap_or_else(|p| p.into_inner()).observe_screen(session.instance_id.0, &frame.contents, Instant::now());
11747                if let Some(act) = act {
11748                    provider_error_act(binding.workspace_id.as_str(), session.instance_id.0, session.agent_id.as_str(), "pty", act);
11749                }
11750                crate::git_watch::provider_hint(binding.workspace_id.as_str(), session.instance_id.0, session.agent_id.as_str());
11751                Some((
11752                    SessionAddress {
11753                        workspace_id: binding.workspace_id.clone(),
11754                        session: SessionKey {
11755                            instance_id: session.instance_id,
11756                            generation: session.generation,
11757                        },
11758                    },
11759                    frame,
11760                ))
11761            })
11762            .collect();
11763        drop(bindings);
11764        self.publish_terminal_frame_candidates(candidates);
11765    }
11766
11767    fn publish_terminal_frame_candidates(
11768        &self,
11769        candidates: Vec<(SessionAddress, TerminalFrame)>,
11770    ) {
11771        let mut watermarks = self
11772            .terminal_frame_watermarks
11773            .lock()
11774            .unwrap_or_else(|poisoned| poisoned.into_inner());
11775        let mut events = Vec::with_capacity(candidates.len());
11776        for (address, frame) in candidates {
11777            let is_first_frame = !watermarks.contains_key(&address.session.instance_id);
11778            let advanced = watermarks
11779                .get(&address.session.instance_id)
11780                .map_or(true, |(current_address, sequence)| {
11781                    current_address != &address || frame.sequence > *sequence
11782                });
11783            if !advanced {
11784                continue;
11785            }
11786            if is_first_frame {
11787                // Do NOT sessionize on first PTY output: a session is a
11788                // provider conversation id, not bytes on the wire. Drafts
11789                // leave the provisional map only when HQ attaches
11790                // (Resize / TerminalBytes). The attach-never-arrived timer
11791                // (PROVISIONAL_SPAWN_GRACE_MS) covers deploy lag; HQ's
11792                // disconnect grace starts only on a real client WS close.
11793                // This is the first PTY output this node has observed for
11794                // this session -- a one-time event per spawn, not per-frame
11795                // chatter (the watermark makes sure of that). A session that
11796                // reaches this line with no controller attached anywhere on
11797                // the node is running unobserved: exactly how processes from
11798                // repeated spawn attempts went orphaned for hours without a
11799                // single line about it.
11800                if self.controller_state().is_none() {
11801                    tracing::warn!(
11802                        node_id = %self.node_id,
11803                        workspace_id = %address.workspace_id,
11804                        session = ?address.session,
11805                        terminal_size = ?frame.size,
11806                        "session producing output with no controller attached",
11807                    );
11808                } else {
11809                    tracing::info!(
11810                        node_id = %self.node_id,
11811                        workspace_id = %address.workspace_id,
11812                        session = ?address.session,
11813                        terminal_size = ?frame.size,
11814                        "session produced its first output",
11815                    );
11816                }
11817            }
11818            watermarks.insert(
11819                address.session.instance_id,
11820                (address.clone(), frame.sequence),
11821            );
11822            if let Some((at, reported)) = self
11823                .pty_input_marks
11824                .lock()
11825                .unwrap_or_else(|poisoned| poisoned.into_inner())
11826                .get_mut(&address.session.instance_id)
11827            {
11828                // First frame after an input: PTY write -> output published.
11829                if !*reported && at.elapsed() < Duration::from_secs(5) {
11830                    *reported = true;
11831                    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");
11832                }
11833            }
11834            events.push(NodeEvent::TerminalFrame { address, frame });
11835        }
11836        drop(watermarks);
11837        if events.is_empty() {
11838            return;
11839        }
11840        let mut history = self
11841            .history
11842            .lock()
11843            .unwrap_or_else(|poisoned| poisoned.into_inner());
11844        let mut envelopes = Vec::with_capacity(events.len());
11845        for event in events {
11846            let sequence = history
11847                .last_sequence
11848                .checked_add(1)
11849                .expect("node event sequence exhausted");
11850            history.last_sequence = sequence;
11851            envelopes.push(NodeEventEnvelope { sequence, event });
11852        }
11853        let _ = self.terminal_event_tx.send(Arc::new(envelopes));
11854        drop(history);
11855    }
11856
11857    /// Publishes one `AgentStreamChunkV1` onto the dedicated agent-stream
11858    /// channel, mirroring `publish_terminal_frame_candidates` above: the
11859    /// envelope's `sequence` is drawn from the same `history.last_sequence`
11860    /// counter every other event kind shares, never a counter of its own --
11861    /// `queue_connection_event`/`queue_connection_event_batch` compare
11862    /// `envelope.sequence` against one `discard_events_through` watermark
11863    /// regardless of which broadcast channel it arrived on, so a
11864    /// per-channel counter would desynchronize that comparison.
11865    fn publish_agent_stream_chunk(&self, address: SessionAddress, chunk: AgentStreamChunkV1) {
11866        let mut history = self
11867            .history
11868            .lock()
11869            .unwrap_or_else(|poisoned| poisoned.into_inner());
11870        let sequence = history
11871            .last_sequence
11872            .checked_add(1)
11873            .expect("node event sequence exhausted");
11874        history.last_sequence = sequence;
11875        drop(history);
11876        // Debug, not info/warn -- this fires on every single chunk, but it
11877        // is exactly the counter the next drop-measurement pass needs: what
11878        // the node actually emitted (`source_sequence`, `kind`) versus what
11879        // a harness subscriber's own forwarder later logs having received
11880        // (`hatchery-harness-service::runtime`'s "forwarding agent stream
11881        // event"), so a gap between the two logs names itself instead of
11882        // needing a new counter added ad hoc.
11883        tracing::debug!(
11884            source_sequence = chunk.source_sequence,
11885            kind = agent_stream_chunk_kind_label(&chunk.kind),
11886            envelope_sequence = sequence,
11887            "publishing agent stream chunk",
11888        );
11889        let envelope = NodeEventEnvelope {
11890            sequence,
11891            event: NodeEvent::AgentStream { address, chunk },
11892        };
11893        let _ = self.agent_stream_event_tx.send(Arc::new(vec![envelope]));
11894    }
11895
11896    fn current_sequence(&self) -> u64 {
11897        self.history
11898            .lock()
11899            .unwrap_or_else(|poisoned| poisoned.into_inner())
11900            .last_sequence
11901    }
11902
11903    fn controller_state(&self) -> Option<ControllerState> {
11904        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11905        let lease = controller.as_ref().copied()?;
11906        let remaining = lease.expires_at.saturating_duration_since(Instant::now());
11907        if remaining.is_zero() {
11908            *controller = None;
11909            return None;
11910        }
11911        Some(ControllerState {
11912            connection_id: lease.connection_id,
11913            lease_remaining_ms: remaining.as_millis().min(u64::MAX as u128) as u64,
11914        })
11915    }
11916
11917    fn acquire_controller(&self, connection_id: u64, role: ClientRole, lease_ms: u64) -> Result<ControllerState, NodeFailure> {
11918        if role != ClientRole::Operator {
11919            tracing::warn!(
11920                node_id = %self.node_id,
11921                connection_id,
11922                "controller lease rejected: observer connections cannot acquire control",
11923            );
11924            return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections cannot acquire control"));
11925        }
11926        let lease_ms = if lease_ms == 0 {
11927            DEFAULT_CONTROLLER_LEASE_MS
11928        } else {
11929            lease_ms.clamp(MIN_CONTROLLER_LEASE_MS, MAX_CONTROLLER_LEASE_MS)
11930        };
11931        let now = Instant::now();
11932        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11933        if let Some(active) = controller.as_ref() {
11934            if active.connection_id != connection_id && active.expires_at > now {
11935                tracing::warn!(
11936                    node_id = %self.node_id,
11937                    connection_id,
11938                    holder_connection_id = active.connection_id,
11939                    "controller lease rejected: another operator holds it",
11940                );
11941                return Err(failure(NodeFailureCode::ControllerBusy, "another operator holds the controller lease"));
11942            }
11943        }
11944        // A reconnect (this connection already holds the lease, renewing
11945        // before expiry) happens on a periodic cadence; logging it at INFO
11946        // every renewal would be exactly the heartbeat chatter this crate
11947        // family's level discipline forbids. A genuine attach -- the
11948        // controller identity actually changing -- is a state change and
11949        // gets INFO; a same-connection renewal gets DEBUG.
11950        let is_new_attach = match controller.as_ref() {
11951            Some(active) => active.connection_id != connection_id,
11952            None => true,
11953        };
11954        *controller = Some(ControllerLease {
11955            connection_id,
11956            expires_at: now + Duration::from_millis(lease_ms),
11957        });
11958        drop(controller);
11959        let state = ControllerState { connection_id, lease_remaining_ms: lease_ms };
11960        if is_new_attach {
11961            tracing::info!(
11962                node_id = %self.node_id,
11963                connection_id,
11964                lease_ms,
11965                "controller lease acquired",
11966            );
11967        } else {
11968            tracing::debug!(
11969                node_id = %self.node_id,
11970                connection_id,
11971                lease_ms,
11972                "controller lease renewed",
11973            );
11974        }
11975        self.publish(NodeEvent::ControllerChanged { controller: Some(state.clone()) });
11976        Ok(state)
11977    }
11978
11979    fn release_controller(&self, connection_id: u64) -> bool {
11980        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11981        let released = controller.as_ref().is_some_and(|active| active.connection_id == connection_id);
11982        if released {
11983            *controller = None;
11984        }
11985        drop(controller);
11986        if released {
11987            tracing::info!(
11988                node_id = %self.node_id,
11989                connection_id,
11990                "controller lease released",
11991            );
11992            self.publish(NodeEvent::ControllerChanged { controller: None });
11993        }
11994        released
11995    }
11996
11997    /// Mark a fresh Spawn as provisional and arm the attach-never-arrived
11998    /// reaper (PROVISIONAL_SPAWN_GRACE_MS from spawn). Removed from this map
11999    /// only when HQ attaches (Resize / TerminalBytes) — that is *not* a
12000    /// provider session; it only cancels the orphan timer. Disconnect/reload
12001    /// draft kills are owned by HQ and arm only on a real client WS close.
12002    /// ACP path of launch options: once the agent advertises its config options, write the
12003    /// pending model / effort through session/set_config_option (session/set_model when the
12004    /// agent has no model option). Gaps are logged, never forced.
12005    fn apply_pending_acp_options(&self, address: &SessionAddress, event: &ControlEvent) {
12006        let ControlEventKind::ProviderEvent { event: ProviderEvent::ConfigOptionsUpdated { options }, .. } = &event.event else {
12007            return;
12008        };
12009        let id = address.session.instance_id;
12010        let Some((model, effort)) = self.pending_acp_options.lock().unwrap_or_else(|p| p.into_inner()).remove(&id) else {
12011            return;
12012        };
12013        let advertised: Vec<(String, Option<String>, Vec<String>)> = options
12014            .iter()
12015            .map(|o| (o.id.clone(), o.category.clone(), o.choices.iter().map(|c| c.value_json.clone()).collect()))
12016            .collect();
12017        let (writes, set_model, effort_gap) =
12018            backend_paths::acp_option_writes(&advertised, model.as_deref(), effort.as_deref());
12019        if set_model {
12020            if let Some(model_id) = model.clone() {
12021                if let Err(e) = self.dispatch(ControlCommand::SetSessionModel { instance_id: id, model_id }) {
12022                    tracing::warn!(instance_id = id.0, message = %e.message, "acp set_model failed");
12023                }
12024            }
12025        }
12026        for (option_id, value_json) in writes {
12027            if let Err(e) = self.dispatch(ControlCommand::SetSessionConfigOption { instance_id: id, option_id, value_json }) {
12028                tracing::warn!(instance_id = id.0, message = %e.message, "acp set_config_option failed");
12029            }
12030        }
12031        if effort_gap {
12032            tracing::warn!(instance_id = id.0, effort = ?effort, "acp agent advertises no matching thought_level option; effort not applied");
12033        }
12034        tracing::info!(instance_id = id.0, model = ?model, effort = ?effort, "launch options applied over acp");
12035    }
12036
12037    /// ACP auto-permit: switch to the provider's no-ask mode once modes are advertised, and
12038    /// answer every session/request_permission with approve.
12039    fn apply_acp_auto_permit(&self, address: &SessionAddress, event: &ControlEvent) {
12040        let id = address.session.instance_id;
12041        let Some(provider) = crate::auto_permit::acp_provider(id.0) else { return };
12042        if matches!(&event.event, ControlEventKind::Exited { .. } | ControlEventKind::Failed { .. } | ControlEventKind::Removed) {
12043            crate::auto_permit::forget_acp(id.0);
12044            return;
12045        }
12046        let ControlEventKind::ProviderEvent { sequence, event: provider_event, .. } = &event.event else { return };
12047        if let ProviderEvent::InteractionRequested { request_id, interaction_kind, options, .. } = provider_event {
12048            tracing::debug!(instance_id = id.0, sequence = *sequence, request_id = ?request_id, kind = ?interaction_kind, options = options.len(), "acp interaction requested");
12049        }
12050        match provider_event {
12051            ProviderEvent::ModeChanged { mode_id, available } => {
12052                if let Some(want) = crate::auto_permit::pick_acp_mode(provider, mode_id, available.iter().map(|m| m.id.as_str())) {
12053                    match self.dispatch(ControlCommand::SetSessionMode { instance_id: id, mode_id: want.clone() }) {
12054                        Ok(_) => tracing::info!(instance_id = id.0, provider, mode = %want, "acp auto-permit mode set"),
12055                        Err(e) => tracing::warn!(instance_id = id.0, provider, message = %e.message, "acp auto-permit set_mode failed"),
12056                    }
12057                }
12058            }
12059            ProviderEvent::InteractionRequested { interaction_kind: gate4agent_types::ProviderInteractionKind::Approval, tool_name, options, .. } if !options.is_empty() => {
12060                match self.dispatch(ControlCommand::ResolveInteraction {
12061                    instance_id: id,
12062                    generation: address.session.generation,
12063                    interaction_id: gate4agent_types::ProviderInteractionId(*sequence),
12064                    response: gate4agent_types::ProviderInteractionResponse::ApproveOnce,
12065                }) {
12066                    Ok(_) => tracing::info!(instance_id = id.0, provider, tool = %tool_name, "acp permission auto-approved"),
12067                    Err(e) => tracing::warn!(instance_id = id.0, provider, message = %e.message, "acp auto-approve failed"),
12068                }
12069            }
12070            // NB: ACP emits SessionEnded at the end of every *turn* (prompt stopReason), so it
12071            // must not drop the auto-permit registration; that happens on process exit below.
12072            _ => {}
12073        }
12074    }
12075
12076    /// ACP side of the provider error classifier: remember the turn's prompt, reset on a clean
12077    /// end, classify a turn that ended with a provider error (or a provider `Error` event).
12078    fn observe_acp_provider_error(&self, address: &SessionAddress, event: &ControlEvent) {
12079        let id = address.session.instance_id.0;
12080        let found = match &event.event {
12081            ControlEventKind::Exited { .. } | ControlEventKind::Removed => {
12082                self.provider_errors.lock().unwrap_or_else(|p| p.into_inner()).forget(id);
12083                return;
12084            }
12085            ControlEventKind::ProviderEvent { event: e, .. } => match e {
12086                ProviderEvent::TurnStarted { prompt } => {
12087                    self.provider_errors.lock().unwrap_or_else(|p| p.into_inner()).turn_started(id, prompt.as_deref());
12088                    return;
12089                }
12090                ProviderEvent::Text { text, is_delta } => {
12091                    self.provider_errors.lock().unwrap_or_else(|p| p.into_inner()).turn_text(id, text, *is_delta);
12092                    return;
12093                }
12094                ProviderEvent::ToolStarted { .. } => {
12095                    self.provider_errors.lock().unwrap_or_else(|p| p.into_inner()).turn_tool(id);
12096                    return;
12097                }
12098                ProviderEvent::SessionEnded { is_error: false, result, .. } => {
12099                    let act = self.provider_errors.lock().unwrap_or_else(|p| p.into_inner()).turn_ended(id, result, Instant::now());
12100                    if let Some(act) = act {
12101                        let provider = crate::auto_permit::acp_provider(id).map(|p| p.to_string()).unwrap_or_else(|| "acp".into());
12102                        provider_error_act(address.workspace_id.as_str(), id, &provider, "acp", act);
12103                    }
12104                    return;
12105                }
12106                ProviderEvent::SessionEnded { stop_reason: Some(ProviderStopReason::ProviderError { message, vendor_code, .. }), .. } => {
12107                    let found = crate::provider_error::classify_message(message, vendor_code.as_deref());
12108                    let provider = crate::auto_permit::acp_provider(id).unwrap_or("acp");
12109                    let window = crate::provider_limits::fresh_5h_used(provider, 10 * 60 * 1000);
12110                    crate::provider_error::limit_from_window(found, vendor_code.as_deref(), window)
12111                }
12112                ProviderEvent::Error { message } => crate::provider_error::classify_message(message, None),
12113                _ => return,
12114            },
12115            _ => return,
12116        };
12117        let provider = crate::auto_permit::acp_provider(id).map(|p| p.to_string()).unwrap_or_else(|| "acp".into());
12118        let act = self.provider_errors.lock().unwrap_or_else(|p| p.into_inner()).observe_acp_error(id, found, Instant::now());
12119        if let Some(act) = act {
12120            provider_error_act(address.workspace_id.as_str(), id, &provider, "acp", act);
12121        }
12122    }
12123
12124    /// Run provider-error retries whose backoff is over (called from the drive loop).
12125    fn run_due_provider_retries(&self) {
12126        let due = self.provider_errors.lock().unwrap_or_else(|p| p.into_inner()).take_due(Instant::now());
12127        for (id, how, attempt) in due {
12128            let instance_id = AgentInstanceId(id);
12129            let r = match how {
12130                crate::provider_error::Retry::Pty => self
12131                    .dispatch(ControlCommand::SendInput { instance_id, action: InputAction::TerminalText(TerminalText { text: "continue".to_owned() }) })
12132                    .and_then(|_| self.dispatch(ControlCommand::SendInput { instance_id, action: InputAction::TerminalControl(TerminalControl::Enter) })),
12133                crate::provider_error::Retry::Acp { prompt } => self.dispatch(ControlCommand::SendInput {
12134                    instance_id,
12135                    action: InputAction::SubmitPrompt(PromptPayload { text: prompt, framing: PromptFraming::BracketedPaste }),
12136                }),
12137            };
12138            match r {
12139                Ok(_) => tracing::info!(target: "provider_retry", instance_id = id, attempt, "provider error: turn retried"),
12140                Err(e) => tracing::warn!(target: "provider_retry", instance_id = id, attempt, message = %e.message, "provider error: retry not sent"),
12141            }
12142        }
12143    }
12144
12145    fn mark_spawn_provisional(&self, address: SessionAddress) {
12146        let id = address.session.instance_id;
12147        self.provisional_spawns
12148            .lock()
12149            .unwrap_or_else(|poisoned| poisoned.into_inner())
12150            .insert(id, (address, Instant::now()));
12151        tracing::info!(
12152            node_id = %self.node_id,
12153            instance_id = id.0,
12154            grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
12155            "draft spawn; waiting for HQ view",
12156        );
12157    }
12158
12159    fn sessionize_spawn(&self, instance_id: AgentInstanceId) {
12160        let removed = self
12161            .provisional_spawns
12162            .lock()
12163            .unwrap_or_else(|poisoned| poisoned.into_inner())
12164            .remove(&instance_id);
12165        if removed.is_some() {
12166            tracing::info!(
12167                node_id = %self.node_id,
12168                instance_id = instance_id.0,
12169                "draft spawn claimed by HQ view",
12170            );
12171        }
12172    }
12173
12174    fn take_due_provisional_spawns(&self) -> Vec<SessionAddress> {
12175        let grace = Duration::from_millis(PROVISIONAL_SPAWN_GRACE_MS);
12176        let mut map = self
12177            .provisional_spawns
12178            .lock()
12179            .unwrap_or_else(|poisoned| poisoned.into_inner());
12180        let due: Vec<AgentInstanceId> = map
12181            .iter()
12182            .filter(|(_, (_, at))| at.elapsed() >= grace)
12183            .map(|(id, _)| *id)
12184            .collect();
12185        due.into_iter()
12186            .filter_map(|id| map.remove(&id).map(|(addr, _)| addr))
12187            .collect()
12188    }
12189
12190    async fn reap_provisional_spawn(&self, address: SessionAddress) {
12191        let instance_id = address.session.instance_id;
12192        tracing::warn!(
12193            node_id = %self.node_id,
12194            instance_id = instance_id.0,
12195            workspace = %address.workspace_id,
12196            reason = "attach_never_arrived",
12197            grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
12198            "draft spawn never viewed; stopping PTY",
12199        );
12200        let _ = self.dispatch(ControlCommand::Stop {
12201            instance_id,
12202            force: true,
12203        });
12204        let _ = self.remove_session(&address).await;
12205    }
12206
12207    fn require_controller(&self, connection_id: u64, role: ClientRole) -> Result<(), NodeFailure> {
12208        if role != ClientRole::Operator {
12209            return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections are read-only"));
12210        }
12211        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
12212        let now = Instant::now();
12213        match controller.as_ref().copied() {
12214            Some(active) if active.connection_id == connection_id && active.expires_at > now => Ok(()),
12215            Some(active) if active.expires_at <= now => {
12216                *controller = None;
12217                Err(failure(NodeFailureCode::ControllerRequired, "controller lease expired"))
12218            }
12219            _ => Err(failure(NodeFailureCode::ControllerRequired, "controller lease is required")),
12220        }
12221    }
12222
12223    fn validate_address(&self, address: &SessionAddress) -> Result<AgentId, NodeFailure> {
12224        if !self
12225            .workspaces
12226            .read()
12227            .unwrap_or_else(|poisoned| poisoned.into_inner())
12228            .contains_key(&address.workspace_id)
12229        {
12230            return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
12231        }
12232        let binding = self
12233            .session_bindings
12234            .lock()
12235            .unwrap_or_else(|poisoned| poisoned.into_inner())
12236            .get(&address.session.instance_id)
12237            .cloned()
12238            .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
12239        if binding.workspace_id != address.workspace_id {
12240            return Err(failure(
12241                NodeFailureCode::SessionWorkspaceMismatch,
12242                "session belongs to another workspace",
12243            ));
12244        }
12245        if binding.generation != address.session.generation {
12246            return Err(failure(
12247                NodeFailureCode::StaleGeneration,
12248                &format!(
12249                    "stale session generation: expected {}, received {}",
12250                    binding.generation.0,
12251                    address.session.generation.0,
12252                ),
12253            ));
12254        }
12255        let snapshot = self.handle.snapshot();
12256        let Some(current) = snapshot
12257            .sessions
12258            .iter()
12259            .find(|item| item.instance_id == address.session.instance_id)
12260        else {
12261            return Err(failure(NodeFailureCode::UnknownSession, "session instance does not exist"));
12262        };
12263        if current.generation != address.session.generation {
12264            return Err(failure(
12265                NodeFailureCode::StaleGeneration,
12266                &format!(
12267                    "stale session generation: expected {}, received {}",
12268                    current.generation.0,
12269                    address.session.generation.0,
12270                ),
12271            ));
12272        }
12273        Ok(current.agent_id.clone())
12274    }
12275
12276    fn dispatch(&self, command: ControlCommand) -> Result<CommandId, NodeFailure> {
12277        self.dispatch_envelope(self.prepare_command(command))
12278    }
12279
12280    fn prepare_command(&self, command: ControlCommand) -> CommandEnvelope {
12281        let id = self.next_command_id.fetch_add(1, Ordering::AcqRel);
12282        CommandEnvelope {
12283            id: CommandId(id),
12284            command,
12285        }
12286    }
12287
12288    fn dispatch_envelope(&self, envelope: CommandEnvelope) -> Result<CommandId, NodeFailure> {
12289        let command_id = envelope.id;
12290        self.handle
12291            .dispatch(envelope)
12292            .map_err(|error| match error {
12293                PortDispatchError::Full => failure(NodeFailureCode::BackendBusy, "backend command ingress is full"),
12294                PortDispatchError::Disconnected => failure(NodeFailureCode::BackendDisconnected, "backend command ingress is disconnected"),
12295            })?;
12296        Ok(command_id)
12297    }
12298
12299    /// Records the kernel's own reason for asynchronously refusing
12300    /// `command_id` -- see `rejected_command_reasons`'s doc for why a
12301    /// spawn-dispatch waiter otherwise has no way to learn it. Bounded by
12302    /// `REJECTED_COMMAND_REASON_MAX_ENTRIES`: expired entries are swept
12303    /// first, and if the map is still at capacity after that, the single
12304    /// oldest surviving entry is evicted -- `CommandId` is monotonically
12305    /// assigned (`next_command_id.fetch_add`), so the `BTreeMap`'s first key
12306    /// is always the oldest.
12307    fn record_rejected_command(
12308        &self,
12309        command_id: CommandId,
12310        message: String,
12311        unsupported_transport: bool,
12312    ) {
12313        let now = Instant::now();
12314        let mut reasons = self.rejected_command_reasons.lock()
12315            .unwrap_or_else(|poisoned| poisoned.into_inner());
12316        reasons.retain(|_, reason| reason.expires_at > now);
12317        if reasons.len() >= REJECTED_COMMAND_REASON_MAX_ENTRIES {
12318            if let Some(&oldest) = reasons.keys().next() {
12319                reasons.remove(&oldest);
12320            }
12321        }
12322        reasons.insert(
12323            command_id,
12324            RejectedCommandReason {
12325                message,
12326                unsupported_transport,
12327                expires_at: now + Duration::from_millis(REJECTED_COMMAND_REASON_TTL_MS),
12328            },
12329        );
12330    }
12331
12332    /// Removes and returns the kernel's reason for refusing `command_id`,
12333    /// if the drive loop has recorded one and it has not expired -- see
12334    /// `rejected_command_reasons`'s doc. Consumed at most once: a spawn
12335    /// wait loop calls this instead of blindly polling out its whole
12336    /// deadline when the command it is waiting on turns out to have been
12337    /// refused rather than merely slow.
12338    fn take_rejected_command_reason(&self, command_id: CommandId) -> Option<RejectedCommandReason> {
12339        let now = Instant::now();
12340        let mut reasons = self.rejected_command_reasons.lock()
12341            .unwrap_or_else(|poisoned| poisoned.into_inner());
12342        match reasons.remove(&command_id) {
12343            Some(reason) if reason.expires_at > now => Some(reason),
12344            _ => None,
12345        }
12346    }
12347
12348    async fn dispatch_bounded(
12349        &self,
12350        command: ControlCommand,
12351        timeout_duration: Duration,
12352    ) -> Result<CommandId, NodeFailure> {
12353        let deadline = Instant::now() + timeout_duration;
12354        loop {
12355            match self.dispatch(command.clone()) {
12356                Ok(command_id) => return Ok(command_id),
12357                Err(error) if error.code == NodeFailureCode::BackendBusy && Instant::now() < deadline => {
12358                    sleep(Duration::from_millis(2)).await;
12359                }
12360                Err(error) => return Err(error),
12361            }
12362        }
12363    }
12364
12365    fn internal_session_snapshot(
12366        &self,
12367        address: &SessionAddress,
12368    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
12369        self.validate_address(address)?;
12370        self.handle
12371            .snapshot()
12372            .sessions
12373            .clone()
12374            .into_iter()
12375            .find(|session| {
12376                session.instance_id == address.session.instance_id
12377                    && session.generation == address.session.generation
12378            })
12379            .ok_or_else(|| {
12380                failure(
12381                    NodeFailureCode::UnknownSession,
12382                    "session snapshot is unavailable",
12383                )
12384            })
12385    }
12386
12387    /// Confirms `address` names a live session spawned over the ACP
12388    /// transport and returns its current snapshot -- the gate every
12389    /// `acp-control-v1` verb shares. `require_session_runtime_policy`
12390    /// cannot answer this by itself: `ProviderRuntimePolicy` carries no
12391    /// transport bit for `Acp`/`Inline` (see that method's own doc), only
12392    /// the session's own recorded `TransportKind` does, and that lives on
12393    /// the live snapshot, not the binding.
12394    fn require_acp_session(
12395        &self,
12396        address: &SessionAddress,
12397    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
12398        let snapshot = self.internal_session_snapshot(address)?;
12399        if snapshot.transport != TransportKind::Acp {
12400            return Err(failure(
12401                NodeFailureCode::UnsupportedTransport,
12402                "this verb requires a session spawned over the ACP transport",
12403            ));
12404        }
12405        Ok(snapshot)
12406    }
12407
12408    async fn dispatch_history_bounded(
12409        &self,
12410        address: &SessionAddress,
12411        command: ControlCommand,
12412        operation: HistoryOperation,
12413    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
12414        self.validate_address(address)?;
12415        let started_after = self.current_sequence();
12416        let command_id = self
12417            .dispatch_bounded(
12418                command,
12419                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12420            )
12421            .await?;
12422        let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
12423        let mut scan_after = started_after;
12424        let mut accepted_after = None;
12425        loop {
12426            let events = self
12427                .history
12428                .lock()
12429                .unwrap_or_else(|poisoned| poisoned.into_inner())
12430                .events
12431                .iter()
12432                .filter(|envelope| envelope.sequence > scan_after)
12433                .cloned()
12434                .collect::<Vec<_>>();
12435            for envelope in events {
12436                scan_after = scan_after.max(envelope.sequence);
12437                let NodeEvent::Control {
12438                    address: event_address,
12439                    event,
12440                } = envelope.event
12441                else {
12442                    continue;
12443                };
12444                if event_address != *address {
12445                    continue;
12446                }
12447                if event.command_id == Some(command_id) {
12448                    match &event.event {
12449                        ControlEventKind::CommandRejected { .. } => {
12450                            return Err(failure(
12451                                NodeFailureCode::InvalidRequest,
12452                                "history request was rejected",
12453                            ));
12454                        }
12455                        ControlEventKind::HistoryRequested {
12456                            operation: requested,
12457                            ..
12458                        } if requested == &operation => {
12459                            accepted_after = Some(envelope.sequence);
12460                            continue;
12461                        }
12462                        _ => {}
12463                    }
12464                }
12465                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12466                    match (&operation, &event.event) {
12467                        (
12468                            HistoryOperation::Discover { .. },
12469                            ControlEventKind::HistoryDiscovered { .. },
12470                        )
12471                        | (
12472                            HistoryOperation::Load { .. },
12473                            ControlEventKind::HistoryLoaded { .. },
12474                        ) => return self.internal_session_snapshot(address),
12475                        (_, ControlEventKind::HistoryFailed { .. }) => {
12476                            return Err(failure(
12477                                NodeFailureCode::BackendOperationFailed,
12478                                "history operation failed",
12479                            ));
12480                        }
12481                        _ => {}
12482                    }
12483                }
12484            }
12485            if Instant::now() >= deadline {
12486                return Err(failure(
12487                    NodeFailureCode::BackendBusy,
12488                    if accepted_after.is_some() {
12489                        "history operation did not settle before the bounded deadline"
12490                    } else {
12491                        "history operation was not accepted before the bounded deadline"
12492                    },
12493                ));
12494            }
12495            sleep(Duration::from_millis(2)).await;
12496        }
12497    }
12498
12499    async fn discover_history(
12500        &self,
12501        address: &SessionAddress,
12502        limit: u16,
12503    ) -> Result<Vec<HistoryCandidateSummary>, NodeFailure> {
12504        let query = HistoryQuery {
12505            working_directory: Some(self.workspace_root(&address.workspace_id)?),
12506            limit,
12507        };
12508        query
12509            .validate()
12510            .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history query"))?;
12511        let snapshot = self
12512            .dispatch_history_bounded(
12513                address,
12514                ControlCommand::DiscoverHistory {
12515                    instance_id: address.session.instance_id,
12516                    query: query.clone(),
12517                },
12518                HistoryOperation::Discover { query },
12519            )
12520            .await?;
12521        if snapshot.history.pending.is_some() || snapshot.history.loaded.is_some() {
12522            return Err(failure(
12523                NodeFailureCode::BackendOperationFailed,
12524                "history discovery settled into an invalid state",
12525            ));
12526        }
12527        Ok(snapshot.history.candidates)
12528    }
12529
12530    async fn load_history(
12531        &self,
12532        address: &SessionAddress,
12533        candidate_id: String,
12534    ) -> Result<HistorySessionRecord, NodeFailure> {
12535        validate_candidate_id(&candidate_id)
12536            .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history candidate"))?;
12537        let snapshot = self
12538            .dispatch_history_bounded(
12539                address,
12540                ControlCommand::LoadHistory {
12541                    instance_id: address.session.instance_id,
12542                    candidate_id: candidate_id.clone(),
12543                },
12544                HistoryOperation::Load {
12545                    candidate_id: candidate_id.clone(),
12546                },
12547            )
12548            .await?;
12549        if snapshot.history.pending.is_some()
12550            || snapshot.history.loaded_candidate_id.as_ref() != Some(&candidate_id)
12551        {
12552            return Err(failure(
12553                NodeFailureCode::BackendOperationFailed,
12554                "history load settled into an invalid state",
12555            ));
12556        }
12557        snapshot.history.loaded.ok_or_else(|| {
12558            failure(
12559                NodeFailureCode::BackendOperationFailed,
12560                "history load produced no bounded session",
12561            )
12562        })
12563    }
12564
12565    /// `allow_clean_detachment`: `false` for the on-demand
12566    /// `NodeRequest::ExportContextPackForSessionRecord` API path (the
12567    /// managed record must stay untouched, still live, for the whole call);
12568    /// `true` only for the reactive auto-export-at-exit path (`§2.1`), which
12569    /// reacts to the very clean-exit event that detaches the record out from
12570    /// under this same call — see `session_record_export_target_matches`.
12571    async fn export_context_pack_for_session_record(
12572        &self,
12573        record_id: &SessionRecordId,
12574        session: &SessionAddress,
12575        allow_clean_detachment: bool,
12576    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12577        // C2 replaces a relayed failure's prose with a canned per-code
12578        // string (`C2NodeFailure`'s own `From`), so this path's dozen
12579        // distinct refusals are indistinguishable to anyone downstream --
12580        // and a refusal here expires a continuation, which keeps no field
12581        // for why. The prose stays on the node, where it was written.
12582        let outcome = self
12583            .export_context_pack_for_session_record_inner(record_id, session, allow_clean_detachment)
12584            .await;
12585        if let Err(failure) = &outcome {
12586            tracing::warn!(
12587                record = %record_id,
12588                code = ?failure.code,
12589                reason = %failure.message,
12590                "session-record context-pack export refused",
12591            );
12592        }
12593        outcome
12594    }
12595
12596    async fn export_context_pack_for_session_record_inner(
12597        &self,
12598        record_id: &SessionRecordId,
12599        session: &SessionAddress,
12600        allow_clean_detachment: bool,
12601    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12602        // Several steps here refuse with the same BackendOperationFailed
12603        // code and no prose of their own, so which step failed is
12604        // otherwise unrecoverable -- and a refusal here expires a
12605        // continuation.
12606        let step = |name: &'static str| move |failure: NodeFailure| {
12607            tracing::warn!(step = name, code = ?failure.code, "context-pack export step refused");
12608            failure
12609        };
12610        let record = self.record(record_id).map_err(step("record-lookup"))?;
12611        // `provider_session` is `Some` only for a provider whose resolved
12612        // runtime policy admits live identity verification (raw PTY plus a
12613        // semantic PTY adapter -- `provider_runtime::policy_from_capability_flags`).
12614        // The PTY-sidecar-observation shape (no fleet provider today, but
12615        // capability-keyed rather than vendor-locked) hardcodes
12616        // `provider_session_identity: false` in `provider_runtime::evaluate`,
12617        // so its records go straight to `Live` with `provider_session: None`
12618        // and stay that way for the record's entire life -- that is this
12619        // shape's permanent resting state, not a race with observation, and
12620        // `discover_history`/`load_history` below do not need an identity to
12621        // do their job for it. `IdentityPending` is the actual race (an
12622        // identity-admitted provider whose observation has not landed yet):
12623        // it keeps refusing here rather than falling back, because that
12624        // provider's own history the fallback would otherwise trust is not
12625        // guaranteed to be exact without the identity it is still waiting on.
12626        let identity = match record.provider_session.clone() {
12627            Some(identity) => {
12628                if identity.key != ProviderSessionKey::SessionId {
12629                    return Err(step("provider-identity-key")(failure(
12630                        NodeFailureCode::BackendOperationFailed,
12631                        "managed session provider identity cannot bind history exactly",
12632                    )));
12633                }
12634                Some(identity)
12635            }
12636            None if record.state == ManagedSessionState::IdentityPending => {
12637                return Err(step("provider-session-identity")(failure(
12638                    NodeFailureCode::BackendOperationFailed,
12639                    "managed session has not yet observed its provider session identity",
12640                )));
12641            }
12642            None => None,
12643        };
12644        self.revalidate_session_record_context_export(
12645            &record,
12646            identity.as_ref(),
12647            session,
12648            allow_clean_detachment,
12649        ).map_err(step("revalidate-before-discovery"))?;
12650        let candidates = self
12651            .discover_history(session, HISTORY_DISCOVERY_LIMIT_MAX)
12652            .await
12653            .map_err(step("discover-history"))?;
12654        // With no verified identity to filter by, the discovered set must
12655        // still resolve to exactly one candidate: the same ambiguity refusal
12656        // an identity filter would otherwise produce is the only safety
12657        // property left available for this shape.
12658        let candidate_id = select_history_candidate(
12659            &candidates,
12660            identity.as_ref().map(|identity| identity.id.as_str()),
12661        ).map_err(step("unique-candidate"))?;
12662        let loaded = self.load_history(session, candidate_id.clone()).await
12663            .map_err(step("load-history"))?;
12664        if let Some(identity) = identity.as_ref() {
12665            if loaded.session_id != identity.id {
12666                return Err(failure(
12667                    NodeFailureCode::SessionRecordConflict,
12668                    "loaded history identity does not match the managed session record",
12669                ));
12670            }
12671        }
12672        self.revalidate_session_record_context_export(
12673            &record,
12674            identity.as_ref(),
12675            session,
12676            allow_clean_detachment,
12677        )?;
12678        self.revalidate_loaded_history(session, &candidate_id, &loaded)
12679            .map_err(step("revalidate-loaded-history"))?;
12680        let pack = self.materialize_context_pack(session).await
12681            .map_err(step("materialize-context-pack"))?;
12682        self.commit_context_pack_for_session_record(
12683            &record,
12684            identity.as_ref(),
12685            session,
12686            &candidate_id,
12687            &loaded,
12688            pack,
12689            allow_clean_detachment,
12690        )
12691    }
12692
12693    async fn export_context_pack(
12694        &self,
12695        address: &SessionAddress,
12696    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12697        let pack = self.materialize_context_pack(address).await?;
12698        self.commit_context_pack(pack)
12699    }
12700
12701    async fn materialize_context_pack(
12702        &self,
12703        address: &SessionAddress,
12704    ) -> Result<NodeContextPack, NodeFailure> {
12705        let snapshot = self.internal_session_snapshot(address)?;
12706        if !context_pack_source_status_is_usable(&snapshot.status) {
12707            return Err(failure(
12708                NodeFailureCode::InvalidRequest,
12709                "context export requires a running or successfully exited source session",
12710            ));
12711        }
12712        if snapshot.history.pending.is_some()
12713            || snapshot.history.loaded_candidate_id.is_none()
12714        {
12715            return Err(failure(
12716                NodeFailureCode::InvalidRequest,
12717                "context export requires settled loaded history",
12718            ));
12719        }
12720        let loaded = snapshot.history.loaded.clone().ok_or_else(|| {
12721            failure(
12722                NodeFailureCode::InvalidRequest,
12723                "context export requires settled loaded history",
12724            )
12725        })?;
12726        let source_provider = snapshot.agent_id.clone();
12727        let loaded_candidate_id = snapshot.history.loaded_candidate_id.clone();
12728        let repository = self
12729            .context_pack_repository(&address.workspace_id)
12730            .await?;
12731        let current = self.internal_session_snapshot(address)?;
12732        if !context_pack_source_status_is_usable(&current.status)
12733            || current.agent_id != source_provider
12734            || current.history.pending.is_some()
12735            || current.history.loaded_candidate_id != loaded_candidate_id
12736            || current.history.loaded.as_ref() != Some(&loaded)
12737        {
12738            return Err(failure(
12739                NodeFailureCode::ContextPackMaterializationFailed,
12740                "context source changed during bounded repository capture",
12741            ));
12742        }
12743        NodeContextPack::export_with_repository(
12744            ContextPackLineageReceipt {
12745                source_node_id: self.node_id.clone(),
12746                source_session: address.clone(),
12747                source_provider,
12748            },
12749            &loaded,
12750            Some(repository),
12751        )
12752        .map_err(|_| {
12753            failure(
12754                NodeFailureCode::ContextPackMaterializationFailed,
12755                "bounded context pack export failed",
12756            )
12757        })
12758    }
12759
12760    /// Write-through to the durable [`ContextPackStore`], if one is
12761    /// configured (`state_path` is `Some`). A node with no durable state
12762    /// path — fixtures, tests, ephemeral runs — has no `ContextPackStore`
12763    /// open at all and this is a no-op, matching `persist_state`'s own
12764    /// "durability optional, degrade gracefully when unconfigured" idiom.
12765    fn commit_context_pack_durably(&self, pack: &NodeContextPack) -> Result<(), NodeFailure> {
12766        let mut store = self
12767            .context_pack_store
12768            .lock()
12769            .unwrap_or_else(|poisoned| poisoned.into_inner());
12770        let Some(store) = store.as_mut() else {
12771            return Ok(());
12772        };
12773        store.commit(pack).map_err(|_| {
12774            failure(
12775                NodeFailureCode::ContextPackMaterializationFailed,
12776                "durable context pack commit failed",
12777            )
12778        })
12779    }
12780
12781    fn commit_context_pack(
12782        &self,
12783        pack: NodeContextPack,
12784    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12785        self.commit_context_pack_durably(&pack)?;
12786        self.context_catalog
12787            .write()
12788            .unwrap_or_else(|poisoned| poisoned.into_inner())
12789            .insert(pack)
12790            .map_err(|_| {
12791                failure(
12792                    NodeFailureCode::ContextPackMaterializationFailed,
12793                    "context pack catalog is full",
12794                )
12795            })
12796    }
12797
12798    fn commit_context_pack_for_session_record(
12799        &self,
12800        expected: &ManagedSessionRecord,
12801        identity: Option<&ProviderSessionIdentity>,
12802        session: &SessionAddress,
12803        candidate_id: &str,
12804        loaded: &HistorySessionRecord,
12805        pack: NodeContextPack,
12806        allow_clean_detachment: bool,
12807    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12808        let current_root = self.workspace_root(&expected.workspace_id)?;
12809        let _transaction = self
12810            .state_transaction
12811            .lock()
12812            .unwrap_or_else(|poisoned| poisoned.into_inner());
12813        let records = self
12814            .session_records
12815            .lock()
12816            .unwrap_or_else(|poisoned| poisoned.into_inner());
12817        let current = records.records.get(&expected.record_id).ok_or_else(|| {
12818            failure(
12819                NodeFailureCode::SessionRecordConflict,
12820                "managed session record disappeared during context export",
12821            )
12822        })?;
12823        if !session_record_export_target_matches(expected, current, session, allow_clean_detachment) {
12824            return Err(failure(
12825                NodeFailureCode::SessionRecordConflict,
12826                "managed session record changed during context export",
12827            ));
12828        }
12829        let bindings = self
12830            .session_bindings
12831            .lock()
12832            .unwrap_or_else(|poisoned| poisoned.into_inner());
12833        let binding = bindings.get(&session.session.instance_id).ok_or_else(|| {
12834            failure(
12835                NodeFailureCode::SessionRecordConflict,
12836                "managed session runtime binding disappeared during context export",
12837            )
12838        })?;
12839        let runtime = self
12840            .handle
12841            .snapshot()
12842            .sessions
12843            .iter()
12844            .find(|snapshot| {
12845                snapshot.instance_id == session.session.instance_id
12846                    && snapshot.generation == session.session.generation
12847            })
12848            .cloned()
12849            .ok_or_else(|| {
12850                failure(
12851                    NodeFailureCode::SessionRecordConflict,
12852                    "managed session runtime disappeared during context export",
12853                )
12854            })?;
12855        let receipt = pack.receipt();
12856        if !session_record_context_export_binding_is_exact(
12857            expected,
12858            current,
12859            identity,
12860            session,
12861            binding,
12862            &runtime.agent_id,
12863            &current_root,
12864            allow_clean_detachment,
12865        ) || !session_record_context_export_source_is_usable(
12866            current,
12867            &runtime.status,
12868            allow_clean_detachment,
12869        ) || runtime.history.pending.is_some()
12870            || runtime.history.loaded_candidate_id.as_deref() != Some(candidate_id)
12871            || runtime.history.loaded.as_ref() != Some(loaded)
12872            || receipt.lineage.source_node_id != self.node_id
12873            || receipt.lineage.source_session != *session
12874            || receipt.lineage.source_provider != current.provider
12875            || receipt.source_message_count != loaded.message_count
12876        {
12877            return Err(failure(
12878                NodeFailureCode::SessionRecordConflict,
12879                "managed session source changed before context catalog commit",
12880            ));
12881        }
12882        self.commit_context_pack_durably(&pack)?;
12883        self.context_catalog
12884            .write()
12885            .unwrap_or_else(|poisoned| poisoned.into_inner())
12886            .insert(pack)
12887            .map_err(|_| {
12888                failure(
12889                    NodeFailureCode::ContextPackMaterializationFailed,
12890                    "context pack catalog is full",
12891                )
12892            })
12893    }
12894
12895    async fn context_pack_repository(
12896        &self,
12897        workspace_id: &WorkspaceId,
12898    ) -> Result<ContextPackRepository, NodeFailure> {
12899        let first = self
12900            .context_pack_repository_observation(workspace_id)
12901            .await?;
12902        let second = self
12903            .context_pack_repository_observation(workspace_id)
12904            .await?;
12905        stable_context_pack_repository(first, second)
12906    }
12907
12908    async fn context_pack_repository_observation(
12909        &self,
12910        workspace_id: &WorkspaceId,
12911    ) -> Result<ContextPackRepositoryObservation, NodeFailure> {
12912        let git = self.inspect_workspace(workspace_id.clone()).await?.git;
12913        let head = if git.is_repository {
12914            let root = self.workspace_root(workspace_id)?;
12915            match resolve_base_commit_with_timeout(
12916                &root,
12917                "HEAD",
12918                GIT_COMMAND_TIMEOUT_MS,
12919            )
12920            .await
12921            {
12922                Ok(commit) => ContextPackRepositoryHead::Commit(commit),
12923                Err(error) if error.kind == GitWorktreeErrorKind::Conflict => {
12924                    ContextPackRepositoryHead::Unborn
12925                }
12926                Err(_) => {
12927                    return Err(failure(
12928                        NodeFailureCode::ContextPackMaterializationFailed,
12929                        "context pack HEAD observation failed",
12930                    ));
12931                }
12932            }
12933        } else {
12934            ContextPackRepositoryHead::NotRepository
12935        };
12936        let mut files = Vec::with_capacity(CONTEXT_PACK_SELECTED_FILES.len());
12937        for selected_path in CONTEXT_PACK_SELECTED_FILES {
12938            let path = RepositoryPath::utf8(selected_path.to_owned()).map_err(|_| {
12939                failure(
12940                    NodeFailureCode::InvalidRepositoryPath,
12941                    "context pack selected file path is invalid",
12942                )
12943            })?;
12944            match self.read_workspace_file(workspace_id.clone(), path).await {
12945                Ok(WorkspaceFileRead {
12946                    content: WorkspaceFileContent::Utf8 { text, byte_len },
12947                    ..
12948                }) => files.push(ContextPackRepositoryFileSource::utf8(
12949                    selected_path,
12950                    text,
12951                    byte_len,
12952                )),
12953                Ok(WorkspaceFileRead {
12954                    content: WorkspaceFileContent::NonUtf8 { byte_len },
12955                    ..
12956                }) => files.push(ContextPackRepositoryFileSource::skipped(
12957                    selected_path,
12958                    ContextPackSelectedFileSkipReason::NonUtf8,
12959                    Some(byte_len),
12960                )),
12961                Ok(WorkspaceFileRead {
12962                    content: WorkspaceFileContent::TooLarge { .. },
12963                    ..
12964                }) => files.push(ContextPackRepositoryFileSource::skipped(
12965                    selected_path,
12966                    ContextPackSelectedFileSkipReason::TooLarge,
12967                    None,
12968                )),
12969                Err(error) => {
12970                    let reason = if matches!(
12971                        error.code,
12972                        NodeFailureCode::InvalidRepositoryPath
12973                            | NodeFailureCode::RepositoryFileNotRegular
12974                            | NodeFailureCode::RepositoryPathUnsafe
12975                    ) {
12976                        ContextPackSelectedFileSkipReason::Unsafe
12977                    } else {
12978                        ContextPackSelectedFileSkipReason::Unavailable
12979                    };
12980                    files.push(ContextPackRepositoryFileSource::skipped(
12981                        selected_path,
12982                        reason,
12983                        None,
12984                    ));
12985                }
12986            }
12987        }
12988        Ok(ContextPackRepositoryObservation { head, git, files })
12989    }
12990
12991    fn forget_context_pack(&self, context_id: &SpawnContextId) -> Result<(), NodeFailure> {
12992        let referenced_by_binding = self
12993            .session_bindings
12994            .lock()
12995            .unwrap_or_else(|poisoned| poisoned.into_inner())
12996            .values()
12997            .any(|binding| {
12998                binding.context.as_ref().map(|receipt| &receipt.id) == Some(context_id)
12999            });
13000        let referenced_by_record = self
13001            .session_records
13002            .lock()
13003            .unwrap_or_else(|poisoned| poisoned.into_inner())
13004            .records
13005            .values()
13006            .any(|record| record.context_id.as_ref() == Some(context_id));
13007        let referenced_by_materialization = self
13008            .materializations
13009            .lock()
13010            .unwrap_or_else(|poisoned| poisoned.into_inner())
13011            .values()
13012            .any(|ownership| {
13013                ownership.context().map(|receipt| &receipt.id) == Some(context_id)
13014            });
13015        if referenced_by_binding
13016            || referenced_by_record
13017            || referenced_by_materialization
13018        {
13019            return Err(failure(
13020                NodeFailureCode::ContextPackBusy,
13021                "context pack is still referenced",
13022            ));
13023        }
13024        let mut catalog = self.context_catalog
13025            .write()
13026            .unwrap_or_else(|poisoned| poisoned.into_inner());
13027        if catalog.get(context_id).is_none() {
13028            return Err(failure(
13029                NodeFailureCode::UnknownContextPack,
13030                "context pack is unavailable",
13031            ));
13032        }
13033        self.spawn_idempotency
13034            .lock()
13035            .unwrap_or_else(|poisoned| poisoned.into_inner())
13036            .entries
13037            .retain(|_, entry| !entry.value.references_context(context_id));
13038        let removed = catalog.remove(context_id);
13039        debug_assert!(removed.is_some(), "validated context pack remains present");
13040        Ok(())
13041    }
13042
13043    async fn dispatch_input_bounded(
13044        &self,
13045        address: &SessionAddress,
13046        action: InputAction,
13047    ) -> Result<(), NodeFailure> {
13048        let started_after = self.current_sequence();
13049        self.pty_input_marks
13050            .lock()
13051            .unwrap_or_else(|poisoned| poisoned.into_inner())
13052            .insert(address.session.instance_id, (Instant::now(), false));
13053        self.pty_input_wake.notify_one();
13054        let command_id = self
13055            .dispatch_bounded(
13056                ControlCommand::SendInput {
13057                    instance_id: address.session.instance_id,
13058                    action,
13059                },
13060                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13061            )
13062            .await?;
13063        // A semantic prompt may spend the provider's full readiness budget before
13064        // the runtime emits InputCompleted. Keep the node request deadline bounded,
13065        // but strictly beyond every readiness policy in this node's catalog.
13066        let deadline = Instant::now() + Duration::from_millis(self.input_settle_timeout_ms);
13067        let mut scan_after = started_after;
13068        let mut accepted_after = None;
13069        loop {
13070            let events = self
13071                .history
13072                .lock()
13073                .unwrap_or_else(|poisoned| poisoned.into_inner())
13074                .events
13075                .iter()
13076                .filter(|envelope| envelope.sequence > scan_after)
13077                .cloned()
13078                .collect::<Vec<_>>();
13079            for envelope in events {
13080                scan_after = scan_after.max(envelope.sequence);
13081                let NodeEvent::Control {
13082                    address: event_address,
13083                    event,
13084                } = envelope.event
13085                else {
13086                    continue;
13087                };
13088                if event_address != *address {
13089                    continue;
13090                }
13091                if event.command_id == Some(command_id) {
13092                    match &event.event {
13093                        ControlEventKind::CommandRejected { message } => {
13094                            return Err(failure(NodeFailureCode::InvalidRequest, message));
13095                        }
13096                        ControlEventKind::InputRequested { .. } => {
13097                            accepted_after = Some(envelope.sequence);
13098                            continue;
13099                        }
13100                        _ => {}
13101                    }
13102                }
13103                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
13104                    match &event.event {
13105                        ControlEventKind::InputCompleted { .. } => return Ok(()),
13106                        ControlEventKind::InputFailed { message, .. } => {
13107                            return Err(failure(
13108                                NodeFailureCode::BackendOperationFailed,
13109                                message,
13110                            ));
13111                        }
13112                        _ => {}
13113                    }
13114                }
13115            }
13116            if Instant::now() >= deadline {
13117                return Err(failure(
13118                    NodeFailureCode::BackendBusy,
13119                    if accepted_after.is_some() {
13120                        "input was accepted but did not settle before the bounded deadline"
13121                    } else {
13122                        "input was not accepted before the bounded deadline"
13123                    },
13124                ));
13125            }
13126            sleep(Duration::from_millis(2)).await;
13127        }
13128    }
13129
13130    async fn dispatch_resize_bounded(
13131        &self,
13132        address: &SessionAddress,
13133        size: gate4agent_types::TerminalSize,
13134    ) -> Result<(), NodeFailure> {
13135        let started_after = self.current_sequence();
13136        let command_id = self
13137            .dispatch_bounded(
13138                ControlCommand::Resize {
13139                    instance_id: address.session.instance_id,
13140                    size,
13141                },
13142                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13143            )
13144            .await?;
13145        let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
13146        let mut scan_after = started_after;
13147        let mut accepted_after = None;
13148        loop {
13149            let events = self
13150                .history
13151                .lock()
13152                .unwrap_or_else(|poisoned| poisoned.into_inner())
13153                .events
13154                .iter()
13155                .filter(|envelope| envelope.sequence > scan_after)
13156                .cloned()
13157                .collect::<Vec<_>>();
13158            for envelope in events {
13159                scan_after = scan_after.max(envelope.sequence);
13160                let NodeEvent::Control {
13161                    address: event_address,
13162                    event,
13163                } = envelope.event
13164                else {
13165                    continue;
13166                };
13167                if event_address != *address {
13168                    continue;
13169                }
13170                if event.command_id == Some(command_id) {
13171                    match &event.event {
13172                        ControlEventKind::CommandRejected { message } => {
13173                            return Err(failure(NodeFailureCode::InvalidRequest, message));
13174                        }
13175                        ControlEventKind::ResizeRequested {
13176                            size: requested_size,
13177                            ..
13178                        } if *requested_size == size => {
13179                            accepted_after = Some(envelope.sequence);
13180                            continue;
13181                        }
13182                        _ => {}
13183                    }
13184                }
13185                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
13186                    match &event.event {
13187                        ControlEventKind::Resized { size: settled_size }
13188                            if *settled_size == size =>
13189                        {
13190                            return Ok(());
13191                        }
13192                        ControlEventKind::ResizeFailed { message } => {
13193                            return Err(failure(
13194                                NodeFailureCode::BackendOperationFailed,
13195                                message,
13196                            ));
13197                        }
13198                        _ => {}
13199                    }
13200                }
13201            }
13202            if Instant::now() >= deadline {
13203                return Err(failure(
13204                    NodeFailureCode::BackendBusy,
13205                    if accepted_after.is_some() {
13206                        "resize was accepted but did not settle before the bounded deadline"
13207                    } else {
13208                        "resize was not accepted before the bounded deadline"
13209                    },
13210                ));
13211            }
13212            sleep(Duration::from_millis(2)).await;
13213        }
13214    }
13215
13216    async fn spawn_session(
13217        &self,
13218        workspace_id: WorkspaceId,
13219        provider: AgentId,
13220        mode: SessionMode,
13221        terminal_size: gate4agent_types::TerminalSize,
13222        initial_prompt: Option<String>,
13223    ) -> Result<SessionAddress, NodeFailure> {
13224        if self.managed_worktrees.lock()
13225            .unwrap_or_else(|poisoned| poisoned.into_inner())
13226            .lease_for_workspace(&workspace_id)
13227            .is_some()
13228        {
13229            return Err(failure(
13230                NodeFailureCode::ManagedWorktreeOwnershipConflict,
13231                "managed worktree targets may only be spawned through their lease request",
13232            ));
13233        }
13234        self
13235            .spawn_session_with_deadline(
13236                workspace_id,
13237                provider.clone(),
13238                mode,
13239                terminal_size,
13240                initial_prompt,
13241                None,
13242                None,
13243                None,
13244                None,
13245                None,
13246                SpawnRecordPolicy::ProviderIdentityOnly,
13247                None,
13248                &[],
13249                None,
13250                // `NodeRequest::Spawn` (this method's only caller) carries no
13251                // approval-level selection of its own -- see
13252                // `HarnessOperatorRequestV1::SpawnSession`'s own field for
13253                // the one operator-wire path that can choose something other
13254                // than the axis default. Auto-permit may lower it where the agent
13255                // cannot offer its full-auto mode (claude ACP under root).
13256                crate::auto_permit::spawn_approval_level(provider.as_str(), mode).unwrap_or_default(),
13257                None,
13258                None,
13259            )
13260            .await
13261            .map(|(session, _runtime_policy)| session)
13262    }
13263
13264    async fn spawn_session_with_deadline(
13265        &self,
13266        workspace_id: WorkspaceId,
13267        provider: AgentId,
13268        mode: SessionMode,
13269        terminal_size: gate4agent_types::TerminalSize,
13270        initial_prompt: Option<String>,
13271        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
13272        bundle: Option<ResolvedBundleReceipt>,
13273        context: Option<ResolvedContextPackReceipt>,
13274        managed_authority: Option<ManagedWorktreeLeaseId>,
13275        admitted_runtime_policy: Option<ProviderRuntimePolicy>,
13276        record_policy: SpawnRecordPolicy,
13277        deadline: Option<Instant>,
13278        required_capabilities: &[ProviderRuntimeCapability],
13279        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
13280        approval_level: ApprovalLevel,
13281        network_allowlist: Option<crate::protocol::SpawnNetworkAllowlistId>,
13282        // Opaque dig2browser station profile id (ids only). Feature
13283        // `dig2-station-probe`: after probe at resolve, spawn takes an
13284        // exclusive node-local lease; cleared on session end. Never
13285        // ImportSession / cookies on C2. Feature off: ignored (stub echo
13286        // already happened on the receipt path).
13287        browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
13288    ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
13289        let reserved_lease = self.managed_worktrees.lock()
13290            .unwrap_or_else(|poisoned| poisoned.into_inner())
13291            .lease_for_workspace(&workspace_id);
13292        match (reserved_lease.as_ref(), managed_authority.as_ref()) {
13293            (Some(expected), Some(provided)) if expected == provided => {}
13294            (None, None) => {}
13295            _ => {
13296                return Err(failure(
13297                    NodeFailureCode::ManagedWorktreeOwnershipConflict,
13298                    "spawn lacks exact managed worktree lease authority",
13299                ));
13300            }
13301        }
13302        // Refuse concurrent distinct environment profiles (incl. ProviderHome
13303        // bindings) on the same workspace cwd. Parallel path: managed worktree
13304        // spawn so each profile gets its own workspace_id.
13305        self.ensure_workspace_environment_profile_compatible(
13306            &workspace_id,
13307            environment_profile.as_ref(),
13308        )?;
13309        let runtime_requirement = match (mode, initial_prompt.is_some()) {
13310            (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
13311            (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
13312            (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
13313            (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
13314        };
13315        // This session's own transport -- computed once, here, from `mode`,
13316        // and reused both to admit spawn (below) and, further down, to
13317        // compute the final `runtime_policy` this session actually operates
13318        // under (`provider_runtime::policy_for_transport`). Registration
13319        // reuses this exact value rather than recomputing it a second time.
13320        let transport = match mode {
13321            SessionMode::Pty => TransportKind::Pty,
13322            SessionMode::Inline => TransportKind::Pipe,
13323            SessionMode::Acp => TransportKind::Acp,
13324        };
13325        let pipe_transport = self.provider_supports_pipe_transport(&provider);
13326        let acp_transport = self.provider_supports_acp_transport(&provider);
13327        let runtime_policy = if let Some(runtime_policy) = admitted_runtime_policy {
13328            require_policy(runtime_policy, pipe_transport, acp_transport, runtime_requirement).map_err(|_| failure(
13329                NodeFailureCode::UnsupportedSpawnCapability,
13330                "pre-admitted provider runtime no longer satisfies spawn requirements",
13331            ))?;
13332            runtime_policy
13333        } else if let Some(deadline) = deadline {
13334            let remaining = spawn_deadline_remaining(deadline)?;
13335            timeout(
13336                remaining,
13337                self.admit_provider_runtime(&provider, runtime_requirement),
13338            )
13339            .await
13340            .map_err(|_| {
13341                failure(
13342                    NodeFailureCode::SpawnDeadlineExceeded,
13343                    "provider admission exceeded the spawn deadline",
13344                )
13345            })??
13346        } else {
13347            self.admit_provider_runtime(&provider, runtime_requirement).await?
13348        };
13349        let runtime_policy = self.effective_spawn_runtime_policy(runtime_policy);
13350        // The single point where this session's `runtime_policy` becomes
13351        // transport-correct: `admit_provider_runtime` (and the pre-admitted
13352        // branch above) only ever compute a PTY-terminal-verification-shaped
13353        // value, sound outright only for `TransportKind::Pty` (Pipe still
13354        // relies on that same value downstream -- see `policy_for_transport`'s
13355        // own doc for why). `policy_for_transport` is the explicit,
13356        // exhaustive match Item 1 asks for -- this is the ONE place its
13357        // output becomes the session's `runtime_policy`, so every later
13358        // reader (`ControlCommand::Start`, `session_bindings`, and every
13359        // engine gate that reads `state.runtime_policy`) sees the same,
13360        // correctly-computed value.
13361        let runtime_policy =
13362            crate::provider_runtime::policy_for_transport(transport, runtime_policy);
13363        if required_capabilities
13364            .iter()
13365            .any(|capability| !runtime_policy.admits(*capability))
13366        {
13367            return Err(failure(
13368                NodeFailureCode::UnsupportedSpawnCapability,
13369                "provider runtime does not admit a required spawn capability",
13370            ));
13371        }
13372        if let Some(deadline) = deadline {
13373            spawn_deadline_remaining(deadline)?;
13374        }
13375        self.ensure_binding_capacity()?;
13376        let mut working_directory = self.workspace_root(&workspace_id)?;
13377        let instance_id = RESTORE_INSTANCE_ID.try_with(|id| *id)
13378            .unwrap_or_else(|_| AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel)));
13379        let session = SessionKey {
13380            instance_id,
13381            generation: SessionGeneration(1),
13382        };
13383        let address = SessionAddress {
13384            workspace_id: workspace_id.clone(),
13385            session,
13386        };
13387        // This is the request-accepted checkpoint: admission (managed
13388        // worktree authority, runtime capability, dispatch deadline) already
13389        // passed and a concrete session identity is minted. It is a distinct
13390        // state from the process actually running (`ControlEventKind::Running`,
13391        // logged in `publish_control`) or the terminal outcome (`Exited`/
13392        // `Failed`, logged there too) -- this line exists so that gap is no
13393        // longer silent.
13394        tracing::info!(
13395            node_id = %self.node_id,
13396            workspace_id = %workspace_id,
13397            provider = %provider,
13398            environment_profile = ?environment_profile.as_ref().map(|receipt| &receipt.profile_id),
13399            session = ?address.session,
13400            "session spawn request accepted",
13401        );
13402        let mut prepared_spawn = SpawnPreparer::prepare(
13403            self,
13404            SpawnPrepareRequest {
13405                instance_id,
13406                address: &address,
13407                provider: &provider,
13408                mode,
13409                environment_profile: environment_profile.as_ref(),
13410                bundle: bundle.as_ref(),
13411                context: context.as_ref(),
13412                managed_authority: managed_authority.as_ref(),
13413                harness_mcp,
13414                approval_level,
13415                network_allowlist: network_allowlist.as_ref(),
13416                browser_profile_id,
13417            },
13418        )?;
13419        let materialization_id = prepared_spawn.materialization_id.take();
13420        let dispatch_timeout = spawn_dispatch_timeout(deadline)?;
13421        let record_id = self.bind_spawn_session_with_materialization(
13422            &address,
13423            provider.clone(),
13424            mode,
13425            runtime_policy,
13426            record_policy,
13427            environment_profile,
13428            bundle,
13429            context,
13430            materialization_id,
13431        )?;
13432        prepared_spawn.retain_session_guards();
13433        // `transport` was already computed above, alongside this session's
13434        // `runtime_policy`; reused here for `Register` rather than derived a
13435        // second time.
13436        let session_provider = provider.clone();
13437        let agent_id = provider;
13438        let register_command_id = match self.dispatch_bounded(
13439            ControlCommand::Register {
13440                instance_id,
13441                agent_id,
13442                transport,
13443            },
13444            dispatch_timeout,
13445        )
13446        .await {
13447            Ok(command_id) => command_id,
13448            Err(error) => {
13449                if let Some(binding) = self.remove_binding(&address) {
13450                    SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
13451                }
13452                if let Some(record_id) = record_id.as_ref() {
13453                    self.discard_record(record_id)?;
13454                }
13455                return Err(spawn_dispatch_error(error, deadline));
13456            }
13457        };
13458        let start_timeout = match spawn_dispatch_timeout(deadline) {
13459            Ok(timeout) => timeout,
13460            Err(error) => {
13461                let recovery = self
13462                    .rollback_spawn(
13463                        &address,
13464                        Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13465                    )
13466                    .await;
13467                return match recovery {
13468                    Ok(()) => {
13469                        if let Some(record_id) = record_id.as_ref() {
13470                            self.discard_record(record_id)?;
13471                        }
13472                        Err(error)
13473                    }
13474                    Err(recovery_error) => {
13475                        if let Some(record_id) = record_id.as_ref() {
13476                            self.mark_record_error(
13477                                record_id,
13478                                "spawn deadline elapsed and registration recovery failed",
13479                            )?;
13480                        }
13481                        Err(recovery_error)
13482                    }
13483                };
13484            }
13485        };
13486        if let Err(start_error) = self.verify_harness_mcp_before_start(harness_mcp) {
13487            if let Err(recovery_error) = self
13488                .rollback_spawn(&address, Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS))
13489                .await
13490            {
13491                return Err(recovery_error);
13492            }
13493            if let Some(record_id) = record_id.as_ref() {
13494                self.discard_record(record_id)?;
13495            }
13496            return Err(start_error);
13497        }
13498        // A session with its own m4a account runs in its own folder inside the workspace.
13499        if let Some(dir) = self.session_workdir(std::path::Path::new(&working_directory), instance_id, &session_provider) {
13500            working_directory = dir.to_string_lossy().into_owned();
13501        }
13502        let start_result = self
13503            .dispatch_bounded(
13504                ControlCommand::Start {
13505                    instance_id,
13506                    runtime_policy,
13507                    request: StartRequest {
13508                        working_directory,
13509                        terminal_size,
13510                        initial_prompt,
13511                        session_options: SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten(),
13512                        approval_level,
13513                    },
13514                },
13515                start_timeout,
13516            )
13517            .await;
13518        let start_command_id = match start_result {
13519            Ok(command_id) => command_id,
13520            Err(start_error) => {
13521                let start_error = spawn_dispatch_error(start_error, deadline);
13522                let recovery = self
13523                    .rollback_spawn(
13524                        &address,
13525                        Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13526                    )
13527                    .await;
13528                return match recovery {
13529                    Ok(()) => {
13530                        if let Some(record_id) = record_id.as_ref() {
13531                            self.discard_record(record_id)?;
13532                        }
13533                        Err(start_error)
13534                    }
13535                    Err(recovery_error) => {
13536                        let diagnostic = format!(
13537                            "start failed and registration recovery failed: {}",
13538                            recovery_error.message,
13539                        );
13540                        if let Some(record_id) = record_id.as_ref() {
13541                            self.mark_record_error(record_id, &diagnostic)?;
13542                        }
13543                        Err(failure(recovery_error.code, &diagnostic))
13544                    }
13545                };
13546            }
13547        };
13548
13549        let commit_deadline = deadline.unwrap_or_else(|| Instant::now() + dispatch_timeout);
13550        loop {
13551            // `Register`/`Start` only fail synchronously for a port-level
13552            // ingress problem (see `dispatch_bounded`); the kernel refusing
13553            // either command's content -- an unsupported transport, an
13554            // unknown agent -- lands one drive-loop tick later, and no
13555            // session ever appears in `self.handle.snapshot()` for it to
13556            // notice below. Check for that named reason before falling
13557            // through to the blind presence poll, so a rejected spawn
13558            // reports why instead of just timing out.
13559            if let Some(reason) = self.take_rejected_command_reason(register_command_id)
13560                .or_else(|| self.take_rejected_command_reason(start_command_id))
13561            {
13562                self.rollback_spawn(
13563                    &address,
13564                    Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13565                ).await.map_err(|error| {
13566                        failure(
13567                            error.code,
13568                            &format!(
13569                                "spawn was rejected ({}) and registration recovery failed: {}",
13570                                reason.message, error.message
13571                            ),
13572                        )
13573                    })?;
13574                if let Some(record_id) = record_id.as_ref() {
13575                    self.discard_record(record_id)?;
13576                }
13577                let code = if reason.unsupported_transport {
13578                    NodeFailureCode::UnsupportedTransport
13579                } else {
13580                    NodeFailureCode::BackendOperationFailed
13581                };
13582                return Err(failure(code, &reason.message));
13583            }
13584            if let Some(current) = self
13585                .handle
13586                .snapshot()
13587                .sessions
13588                .iter()
13589                .find(|current| current.instance_id == instance_id)
13590            {
13591                if current.generation != session.generation {
13592                    let divergence = failure(
13593                        NodeFailureCode::StaleGeneration,
13594                        "spawn generation diverged",
13595                    );
13596                    return match self
13597                        .rollback_spawn(
13598                            &address,
13599                            Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13600                        )
13601                        .await
13602                    {
13603                        Ok(()) => {
13604                            if let Some(record_id) = record_id.as_ref() {
13605                                self.discard_record(record_id)?;
13606                            }
13607                            Err(divergence)
13608                        }
13609                        Err(recovery_error) => {
13610                            if let Some(record_id) = record_id.as_ref() {
13611                                self.mark_record_error(
13612                                    record_id,
13613                                    "spawn generation diverged and recovery failed",
13614                                )?;
13615                            }
13616                            Err(recovery_error)
13617                        }
13618                    };
13619                }
13620                if !matches!(current.status, gate4agent_types::SessionStatus::Registered) {
13621                    prepared_spawn.retain_launch_guards();
13622                    return Ok((address, runtime_policy));
13623                }
13624            }
13625            if Instant::now() >= commit_deadline {
13626                self.rollback_spawn(
13627                    &address,
13628                    Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13629                ).await.map_err(|error| {
13630                        failure(
13631                            error.code,
13632                            &format!(
13633                                "start did not commit and registration recovery failed: {}",
13634                                error.message
13635                            ),
13636                        )
13637                    })?;
13638                if let Some(record_id) = record_id.as_ref() {
13639                    self.discard_record(record_id)?;
13640                }
13641                return Err(failure(
13642                    if deadline.is_some() {
13643                        NodeFailureCode::SpawnDeadlineExceeded
13644                    } else {
13645                        NodeFailureCode::BackendBusy
13646                    },
13647                    "start did not commit before the bounded deadline; registration was removed",
13648                ));
13649            }
13650            sleep(Duration::from_millis(2)).await;
13651        }
13652    }
13653
13654    fn effective_spawn_runtime_policy(
13655        &self,
13656        runtime_policy: ProviderRuntimePolicy,
13657    ) -> ProviderRuntimePolicy {
13658        #[cfg(feature = "fixture")]
13659        let runtime_policy = if self.fixture_semantic_hook_policy {
13660            ProviderRuntimePolicy::new(true, true, true, true, false, true)
13661                .expect("monitoring Hook fixture policy is internally valid")
13662        } else {
13663            runtime_policy
13664        };
13665        runtime_policy
13666    }
13667
13668    fn verify_harness_mcp_before_start(
13669        &self,
13670        prepared: Option<&PreparedHarnessMcpSpawn>,
13671    ) -> Result<(), NodeFailure> {
13672        prepared
13673            .map(PreparedHarnessMcpSpawn::verify_helper_program)
13674            .transpose()
13675            .map(|_| ())
13676            .map_err(harness_mcp_failure)
13677    }
13678
13679    async fn rollback_spawn(
13680        &self,
13681        address: &SessionAddress,
13682        commit_timeout: Duration,
13683    ) -> Result<(), NodeFailure> {
13684        self.dispatch_bounded(
13685            ControlCommand::Remove {
13686                instance_id: address.session.instance_id,
13687            },
13688            commit_timeout,
13689        )
13690        .await?;
13691        self.wait_until_removed(address, commit_timeout).await?;
13692        if let Some(binding) = self.remove_binding(address) {
13693            SessionLifecycleHook::cleanup_session_materialization(self, address, &binding)?;
13694        }
13695        Ok(())
13696    }
13697
13698    async fn remove_session(&self, address: &SessionAddress) -> Result<(), NodeFailure> {
13699        let commit_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
13700        self.dispatch_bounded(
13701            ControlCommand::Remove {
13702                instance_id: address.session.instance_id,
13703            },
13704            commit_timeout,
13705        )
13706            .await?;
13707        self.wait_until_removed(address, commit_timeout).await?;
13708        let removed = self.remove_binding(address);
13709        if let Some(binding) = removed.as_ref() {
13710            SessionLifecycleHook::cleanup_session_materialization(self, address, binding)?;
13711        }
13712        if let Some(lease_id) = removed.and_then(|binding| binding.managed_worktree_lease_id) {
13713            let snapshot = self.managed_worktrees.lock()
13714                .unwrap_or_else(|poisoned| poisoned.into_inner())
13715                .release_session(
13716                    &lease_id,
13717                    self.incarnation_id,
13718                    address.session.instance_id,
13719                    address.session.generation,
13720                    unix_time_ms(),
13721                );
13722            if self.persist_state().is_err() {
13723                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
13724            }
13725            if let Some(snapshot) = snapshot {
13726                self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
13727            }
13728            let _ = self.cleanup_managed_worktree(&lease_id, false).await;
13729        }
13730        Ok(())
13731    }
13732
13733    /// Polls until `address`'s session leaves the engine's own session map,
13734    /// or the bounded deadline expires.
13735    ///
13736    /// A `Remove` sent while the session is not yet in a terminal status
13737    /// (e.g. still `Stopping`, mid-effect right after a `Stop`) is rejected
13738    /// once by the engine (`SessionStatus::allows_remove`) and
13739    /// then discarded -- nothing re-issues it on its own, so the removal
13740    /// that would satisfy this wait never arrives and the caller was
13741    /// guaranteed to time out even though the session settles microseconds
13742    /// later. So this re-dispatches on every poll tick: a session that
13743    /// settles mid-wait -- the common case right after a `Stop` -- still
13744    /// gets removed inside the same bounded deadline.
13745    ///
13746    /// It re-dispatches only once the status will ACCEPT the command. A
13747    /// rejected re-dispatch is not the harmless no-op this loop first
13748    /// assumed: every rejection publishes a `ControlEventKind::Command
13749    /// Rejected` to every subscriber and writes a WARN line. Ungated, one
13750    /// ordinary teardown produced 241 of them in 1.6 seconds -- the whole
13751    /// `Stopping` window at one known-doomed dispatch per 2ms tick, fanned
13752    /// out to the harness and every attached client. Gated, the same
13753    /// teardown sends exactly one `Remove`, at the first tick the engine
13754    /// can act on it.
13755    ///
13756    /// The status is read from the snapshot this loop already takes, so
13757    /// the gate costs nothing extra. It races -- the status can move
13758    /// between the read and the dispatch -- and that is fine: a rare lost
13759    /// race is one rejection, and the next tick retries.
13760    ///
13761    /// Re-dispatch is separately rate-limited to
13762    /// [`REMOVE_REDISPATCH_INTERVAL_MS`], because the status gate alone
13763    /// leaves a second burst behind it. Once the session settles, every
13764    /// 2ms tick sent another `Remove` while the accepted one was still in
13765    /// flight, and each of those landed on an instance the engine had
13766    /// already dropped -- `agent instance … is not registered`, observed
13767    /// live as a tail of rejections immediately after the `session
13768    /// removed` line. The presence poll stays at 2ms so the wait still
13769    /// exits promptly; only the sending slows down.
13770    ///
13771    /// Rate-limited rather than once-only on purpose: the status gate
13772    /// covers the status precondition, but `Gate4AgentEngine::remove` has
13773    /// others (a pending operation, an in-flight capability probe or
13774    /// history request) that clear on their own. A single shot would turn
13775    /// those into a guaranteed `BackendBusy` timeout; at 10Hz they still
13776    /// get their retries, just twenty of them across the deadline instead
13777    /// of a thousand.
13778    async fn wait_until_removed(
13779        &self,
13780        address: &SessionAddress,
13781        commit_timeout: Duration,
13782    ) -> Result<(), NodeFailure> {
13783        let deadline = Instant::now() + commit_timeout;
13784        let mut next_dispatch = Instant::now();
13785        loop {
13786            let snapshot = self.handle.snapshot();
13787            let status = snapshot
13788                .sessions
13789                .iter()
13790                .find(|session| session.instance_id == address.session.instance_id)
13791                .map(|session| session.status.clone());
13792            let Some(status) = status else {
13793                return Ok(());
13794            };
13795            let now = Instant::now();
13796            if now >= deadline {
13797                return Err(failure(
13798                    NodeFailureCode::BackendBusy,
13799                    "remove did not commit before the bounded deadline; session binding was retained",
13800                ));
13801            }
13802            if status.allows_remove() && now >= next_dispatch {
13803                let _ = self.dispatch(ControlCommand::Remove {
13804                    instance_id: address.session.instance_id,
13805                });
13806                next_dispatch = now + Duration::from_millis(REMOVE_REDISPATCH_INTERVAL_MS);
13807            }
13808            sleep(Duration::from_millis(2)).await;
13809        }
13810    }
13811
13812    fn resync(&self, after_sequence: u64) -> NodeResponse {
13813        let history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
13814        let event_sequence = history.last_sequence;
13815        let oldest_available_sequence = history.replay_floor_sequence;
13816        let events = history
13817            .events
13818            .iter()
13819            .filter(|event| event.sequence > after_sequence)
13820            .cloned()
13821            .collect();
13822        drop(history);
13823        NodeResponse::Resync {
13824            event_sequence,
13825            oldest_available_sequence,
13826            snapshot: self.snapshot(),
13827            events,
13828        }
13829    }
13830}
13831
13832/// Coarse label for one `AgentStreamChunkKindV1`, used only by
13833/// `NodeShared::publish_agent_stream_chunk`'s own debug log -- mirrors
13834/// `hatchery-harness-service`'s `agent_stream_event_kind_label` (same
13835/// "name what crossed this boundary" reasoning, one layer upstream of it).
13836fn agent_stream_chunk_kind_label(kind: &AgentStreamChunkKindV1) -> &'static str {
13837    match kind {
13838        AgentStreamChunkKindV1::Text { .. } => "text",
13839        AgentStreamChunkKindV1::Thinking { .. } => "thinking",
13840        AgentStreamChunkKindV1::InteractionPrompt { .. } => "interaction-prompt",
13841        AgentStreamChunkKindV1::ModeCatalog { .. } => "mode-catalog",
13842        AgentStreamChunkKindV1::ConfigOptions { .. } => "config-options",
13843        AgentStreamChunkKindV1::ModelCatalog { .. } => "model-catalog",
13844        AgentStreamChunkKindV1::Blocked { .. } => "blocked",
13845    }
13846}
13847
13848/// Serves this wire on the local endpoint, and -- when `call_home` names a
13849/// relay -- over an outbound connection to it as well.
13850///
13851/// The two live in one future rather than a fourth arm of `run`'s own
13852/// `select!` because they are one concern: this is where the node's wire
13853/// is served, and the direction a given connection was opened in does not
13854/// change what happens on it. It also means the existing graceful drain
13855/// below covers both without being duplicated.
13856///
13857/// Either side finishing ends the pair, which is correct: the listener
13858/// only returns on shutdown or a bind failure, and `call_home_loop` only
13859/// returns on shutdown -- a failed relay connection is retried inside it,
13860/// never propagated out.
13861async fn accept_connections(
13862    endpoint: &str,
13863    call_home: Option<std::net::SocketAddr>,
13864    identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13865    shared: Arc<NodeShared>,
13866) -> Result<(), NodeServerError> {
13867    let mut connections = JoinSet::new();
13868    let result = match call_home {
13869        None => accept_connections_inner(endpoint, shared, &mut connections).await,
13870        Some(relay) => {
13871            let listener =
13872                accept_connections_inner(endpoint, Arc::clone(&shared), &mut connections);
13873            let dialler = call_home_loop(relay, identity, Arc::clone(&shared));
13874            tokio::pin!(listener);
13875            tokio::pin!(dialler);
13876            tokio::select! {
13877                result = &mut listener => result,
13878                result = &mut dialler => result,
13879            }
13880        }
13881    };
13882    let graceful_deadline = Instant::now() + Duration::from_millis(CONNECTION_SHUTDOWN_GRACE_MS);
13883    while !connections.is_empty() {
13884        let remaining = graceful_deadline.saturating_duration_since(Instant::now());
13885        if remaining.is_zero()
13886            || timeout(remaining, connections.join_next()).await.is_err()
13887        {
13888            break;
13889        }
13890    }
13891    connections.abort_all();
13892    while connections.join_next().await.is_some() {}
13893    result
13894}
13895
13896/// Backoff ladder for a relay that will not answer, capped rather than
13897/// unbounded: a node whose relay is down is not broken and should keep
13898/// trying forever, but it should not spend a core doing it.
13899const CALL_HOME_BACKOFFS_MS: [u64; 5] = [500, 1_000, 2_000, 4_000, 8_000];
13900
13901/// Dials the relay, serves this wire over the connection, and does it
13902/// again when the connection ends.
13903///
13904/// The node is the SERVER on the socket it just opened. That inversion is
13905/// the whole point: `serve_connection` is the same function the local
13906/// listener feeds, and it neither knows nor cares which end placed the
13907/// call. The one thing this direction adds is the preface -- the relay
13908/// accepted a socket and cannot know whose it is, so the node says its
13909/// name before the handshake it would otherwise begin with.
13910///
13911/// Never returns an error. A relay that is down, refusing, or speaking a
13912/// different protocol is a condition to retry, not a reason to take the
13913/// node's local endpoint down with it; the only way out is shutdown.
13914async fn call_home_loop(
13915    relay: std::net::SocketAddr,
13916    identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13917    shared: Arc<NodeShared>,
13918) -> Result<(), NodeServerError> {
13919    let mut failures = 0_usize;
13920    loop {
13921        if shared.shutdown.load(Ordering::Acquire) {
13922            return Ok(());
13923        }
13924        let permit = tokio::select! {
13925            permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13926                permit.map_err(|_| NodeServerError::PreauthClosed)?
13927            }
13928            _ = shared.shutdown_notify.notified() => return Ok(()),
13929        };
13930        match dial_relay_and_serve(relay, identity.as_deref(), Arc::clone(&shared), permit).await {
13931            Ok(()) => {
13932                // A clean end is the relay hanging up, which it does on
13933                // its own restart. Reconnect immediately rather than
13934                // serving the backoff a peer that is plainly there.
13935                failures = 0;
13936                tracing::info!(node_id = %shared.node_id, %relay, "call-home connection closed; redialling");
13937            }
13938            Err(error) => {
13939                failures = failures.saturating_add(1);
13940                tracing::warn!(
13941                    node_id = %shared.node_id,
13942                    %relay,
13943                    cause = %error,
13944                    attempt = failures,
13945                    "call-home connection failed",
13946                );
13947                let backoff = call_home_backoff(failures);
13948                tokio::select! {
13949                    _ = sleep(backoff) => {}
13950                    _ = shared.shutdown_notify.notified() => return Ok(()),
13951                }
13952            }
13953        }
13954    }
13955}
13956
13957/// Idle time before the first keepalive probe on a call-home socket.
13958const CALL_HOME_KEEPALIVE_IDLE: Duration = Duration::from_secs(15);
13959/// Interval between keepalive probes once the link has gone quiet.
13960const CALL_HOME_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(5);
13961
13962fn call_home_keepalive() -> socket2::TcpKeepalive {
13963    let keepalive = socket2::TcpKeepalive::new()
13964        .with_time(CALL_HOME_KEEPALIVE_IDLE)
13965        .with_interval(CALL_HOME_KEEPALIVE_INTERVAL);
13966    #[cfg(not(windows))]
13967    let keepalive = keepalive.with_retries(3);
13968    keepalive
13969}
13970
13971fn enable_call_home_keepalive(stream: &tokio::net::TcpStream) -> std::io::Result<()> {
13972    socket2::SockRef::from(stream).set_tcp_keepalive(&call_home_keepalive())
13973}
13974
13975/// Same keepalive profile for any node->relay socket (restream uplink).
13976pub(crate) fn enable_tcp_keepalive(stream: &std::net::TcpStream) -> std::io::Result<()> {
13977    socket2::SockRef::from(stream).set_tcp_keepalive(&call_home_keepalive())
13978}
13979
13980/// Backoff for the `attempt`-th consecutive failure (1-based), capped.
13981fn call_home_backoff(attempt: usize) -> Duration {
13982    Duration::from_millis(
13983        CALL_HOME_BACKOFFS_MS[attempt.saturating_sub(1).min(CALL_HOME_BACKOFFS_MS.len() - 1)],
13984    )
13985}
13986
13987
13988/// Dial the call-home relay. Tunnel addresses go through the in-process
13989/// WireGuard dialer when the userspace session is up.
13990async fn dial_call_home(relay: std::net::SocketAddr) -> Result<tokio::net::TcpStream, NodeServerError> {
13991    #[cfg(feature = "wireguard")]
13992    {
13993        let std_stream = tokio::task::spawn_blocking(move || {
13994            gate4agent_node_core::kit_wireguard::tcp_connect(relay)
13995        })
13996        .await
13997        .map_err(|e| NodeServerError::CallHomeTransport(std::io::Error::other(e.to_string())))?
13998        .map_err(NodeServerError::CallHomeTransport)?;
13999        std_stream
14000            .set_nonblocking(true)
14001            .map_err(NodeServerError::CallHomeTransport)?;
14002        return tokio::net::TcpStream::from_std(std_stream).map_err(NodeServerError::CallHomeTransport);
14003    }
14004    #[cfg(not(feature = "wireguard"))]
14005    {
14006        tokio::net::TcpStream::connect(relay)
14007            .await
14008            .map_err(NodeServerError::CallHomeTransport)
14009    }
14010}
14011
14012/// One call-home attempt: connect, name this node, then serve.
14013async fn dial_relay_and_serve(
14014    relay: std::net::SocketAddr,
14015    identity: Option<&hatchery_enroll::NodeIdentity>,
14016    shared: Arc<NodeShared>,
14017    preauth_permit: OwnedSemaphorePermit,
14018) -> Result<(), NodeServerError> {
14019    let mut stream = dial_call_home(relay).await?;
14020    // Nagle off for the same reason the rest of this wire wants it off:
14021    // the traffic is small request/response frames, and coalescing them
14022    // adds latency to every one.
14023    let _ = stream.set_nodelay(true);
14024    // A relay that vanishes without a FIN/RST reaching us (tunnel drop,
14025    // relay-side socket kill whose RST was lost) would otherwise leave this
14026    // socket ESTABLISHED forever: the node mostly reads, so nothing ever
14027    // fails and the reconnect loop never runs. Kernel keepalive turns that
14028    // half-open link into a read error within a bounded time.
14029    if let Err(error) = enable_call_home_keepalive(&stream) {
14030        tracing::warn!(cause = %error, "could not enable TCP keepalive on the call-home socket");
14031    }
14032    gate4agent_node_wire::write_call_home_announce(&mut stream, &shared.node_id)
14033        .await
14034        .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
14035    tracing::info!(node_id = %shared.node_id, %relay, "announced this node to the relay");
14036    let mut session_token = None;
14037    if let Some(identity) = identity {
14038        // An enrolled relay answers the announce with a signed-call-home challenge; a
14039        // token-mode relay sends its wire hello instead. Peek to tell them apart
14040        // without consuming either.
14041        let mut magic = [0_u8; 4];
14042        let peeked = timeout(Duration::from_secs(10), stream.peek(&mut magic))
14043            .await
14044            .map_err(|_| NodeServerError::Handshake("relay sent nothing after the announce".to_owned()))?
14045            .map_err(NodeServerError::CallHomeTransport)?;
14046        if peeked == 4 && &magic == hatchery_enroll::CHALLENGE_MAGIC {
14047            let mut frame = [0_u8; hatchery_enroll::CHALLENGE_LEN];
14048            timeout(Duration::from_secs(5), tokio::io::AsyncReadExt::read_exact(&mut stream, &mut frame))
14049                .await
14050                .map_err(|_| NodeServerError::Handshake("relay challenge timed out".to_owned()))?
14051                .map_err(NodeServerError::CallHomeTransport)?;
14052            let (answer, token) = identity
14053                .answer_challenge(shared.node_id.as_str(), &frame)
14054                .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
14055            tokio::io::AsyncWriteExt::write_all(&mut stream, &answer).await.map_err(NodeServerError::CallHomeTransport)?;
14056            tracing::info!(node_id = %shared.node_id, "answered the relay's signed call-home challenge");
14057            session_token = Some(token);
14058        }
14059    }
14060    serve_connection(stream, shared, session_token, preauth_permit).await
14061}
14062
14063async fn accept_connections_inner(
14064    endpoint: &str,
14065    shared: Arc<NodeShared>,
14066    connections: &mut JoinSet<Result<(), NodeServerError>>,
14067) -> Result<(), NodeServerError> {
14068    let mut listener = OwnerOnlyLocalListener::bind(endpoint).await?;
14069    loop {
14070        while connections.try_join_next().is_some() {}
14071        if shared.shutdown.load(Ordering::Acquire) {
14072            return Ok(());
14073        }
14074        let preauth_permit = tokio::select! {
14075            permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
14076                permit.map_err(|_| NodeServerError::PreauthClosed)?
14077            }
14078            _ = shared.shutdown_notify.notified() => return Ok(()),
14079        };
14080        let server = tokio::select! {
14081            result = listener.accept() => result?,
14082            _ = shared.shutdown_notify.notified() => return Ok(()),
14083        };
14084        let connection_shared = Arc::clone(&shared);
14085        connections.spawn(async move {
14086            serve_connection(server, connection_shared, None, preauth_permit).await
14087        });
14088        if shared.shutdown.load(Ordering::Acquire) {
14089            return Ok(());
14090        }
14091    }
14092}
14093
14094/// Serves one connection, whichever end opened it.
14095///
14096/// Generic over the stream rather than typed to `LocalServerStream`
14097/// because a call-home connection arrives on a `TcpStream` the node itself
14098/// dialled. Nothing in the body ever depended on the concrete type -- it
14099/// reads and writes framed JSON and then `tokio::io::split`s -- so this is
14100/// the whole of what welding "who dials" to "who serves" was costing.
14101async fn serve_connection<S>(
14102    mut pipe: S,
14103    shared: Arc<NodeShared>,
14104    session_token: Option<String>,
14105    preauth_permit: OwnedSemaphorePermit,
14106) -> Result<(), NodeServerError>
14107where
14108    S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
14109{
14110    // A signed call-home connection authenticates with its own per-connection token.
14111    let auth_token: &[u8] = session_token.as_deref().map_or(shared.access_token.as_bytes(), str::as_bytes);
14112    let hello_frame = timeout(
14113        Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
14114        read_json_frame_limited_body_timeout(
14115            &mut pipe,
14116            MAX_NODE_HELLO_FRAME_BYTES,
14117            Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
14118        ),
14119    )
14120    .await
14121    .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
14122    let ClientFrame::Hello(hello) = hello_frame else {
14123        return Err(NodeServerError::Handshake("first frame must be hello".to_owned()));
14124    };
14125    if hello.build_stamp != BUILD_STAMP {
14126        return Err(NodeServerError::Handshake(format!(
14127            "build stamp mismatch: local={BUILD_STAMP} remote={}",
14128            hello.build_stamp,
14129        )));
14130    }
14131    let compatibility = match hello.compatibility.as_ref() {
14132        Some(offer) => {
14133            let mut selected = node_compatibility_support(&shared)?
14134                .negotiate(offer)
14135                .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
14136            project_negotiated_provider_ids(&mut selected);
14137            Some(selected)
14138        }
14139        None => None,
14140    };
14141    let server_nonce = random_nonce().map_err(NodeServerError::Authentication)?;
14142    let server_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
14143        (Some(offer), Some(selected)) => negotiated_auth_proof(
14144            auth_token,
14145            AuthDirection::Server,
14146            hello.role,
14147            &hello.client_nonce,
14148            &server_nonce,
14149            offer,
14150            selected,
14151        ),
14152        (None, None) => auth_proof(
14153            auth_token,
14154            AuthDirection::Server,
14155            hello.role,
14156            &hello.client_nonce,
14157            &server_nonce,
14158        ),
14159        _ => Err("node compatibility authentication state is inconsistent".to_owned()),
14160    }
14161    .map_err(NodeServerError::Authentication)?;
14162    write_json_frame_limited(
14163        &mut pipe,
14164        &ServerFrame::Challenge(ServerChallenge {
14165            build_stamp: BUILD_STAMP.to_owned(),
14166            server_nonce,
14167            server_proof,
14168            compatibility: compatibility.clone(),
14169        }),
14170        MAX_NODE_HELLO_FRAME_BYTES,
14171    )
14172    .await?;
14173    let authentication = timeout(
14174        Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
14175        read_json_frame_limited_body_timeout(
14176            &mut pipe,
14177            MAX_NODE_HELLO_FRAME_BYTES,
14178            Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
14179        ),
14180    )
14181    .await
14182    .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
14183    let ClientFrame::Authenticate(authentication) = authentication else {
14184        return Err(NodeServerError::Handshake("second frame must authenticate the challenge".to_owned()));
14185    };
14186    let expected_client_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
14187        (Some(offer), Some(selected)) => negotiated_auth_proof(
14188            auth_token,
14189            AuthDirection::Client,
14190            hello.role,
14191            &hello.client_nonce,
14192            &server_nonce,
14193            offer,
14194            selected,
14195        ),
14196        (None, None) => auth_proof(
14197            auth_token,
14198            AuthDirection::Client,
14199            hello.role,
14200            &hello.client_nonce,
14201            &server_nonce,
14202        ),
14203        _ => Err("node compatibility authentication state is inconsistent".to_owned()),
14204    }
14205    .map_err(NodeServerError::Authentication)?;
14206    if !proofs_match(&authentication.client_proof, &expected_client_proof) {
14207        return Err(NodeServerError::Handshake("access denied".to_owned()));
14208    }
14209    let selected_capabilities = compatibility
14210        .as_ref()
14211        .map(|selected| selected.capabilities.clone())
14212        .unwrap_or_default();
14213    let include_provider_runtime_status = selected_capabilities.iter().any(|capability| {
14214        capability.as_str() == NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY
14215    });
14216    let include_open_provider_ids = selected_capabilities.iter().any(|capability| {
14217        capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
14218    });
14219    let include_terminal_frame_events = selected_capabilities.iter().any(|capability| {
14220        capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
14221    });
14222    let include_agent_stream_events = selected_capabilities.iter().any(|capability| {
14223        capability.as_str() == NODE_AGENT_STREAM_EVENTS_CAPABILITY
14224    });
14225    let include_spawn_profiles = selected_capabilities.iter().any(|capability| {
14226        capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
14227    });
14228    let include_managed_worktrees = selected_capabilities.iter().any(|capability| {
14229        capability.as_str() == NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY
14230    }) && selected_capabilities.iter().any(|capability| {
14231        capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
14232    });
14233    let include_child_environment_profiles = selected_capabilities.iter().any(|capability| {
14234        capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
14235    });
14236    let include_session_bundles = selected_capabilities.iter().any(|capability| {
14237        capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
14238    });
14239    let include_history_context_packs = selected_capabilities.iter().any(|capability| {
14240        capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
14241    });
14242    let include_agent_progress = selected_capabilities.iter().any(|capability| {
14243        capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
14244    });
14245    let include_session_task_correlation = selected_capabilities.iter().any(|capability| {
14246        capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
14247    });
14248    let include_harness_mcp_proxy = selected_capabilities.iter().any(|capability| {
14249        capability.as_str() == NODE_HARNESS_MCP_READ_PROXY_CAPABILITY
14250    });
14251    let authenticated_permit = Arc::clone(&shared.authenticated_slots)
14252        .try_acquire_owned()
14253        .map_err(|_| NodeServerError::AuthenticatedConnectionLimit)?;
14254    let connection_id = shared.next_connection_id.fetch_add(1, Ordering::AcqRel);
14255    let _release_guard = ControllerReleaseGuard {
14256        shared: Arc::clone(&shared),
14257        connection_id,
14258    };
14259    drop(preauth_permit);
14260    let _authenticated_permit = authenticated_permit;
14261    let mut event_rx = shared.event_tx.subscribe();
14262    let mut terminal_event_rx = terminal_event_subscription(
14263        &shared,
14264        include_terminal_frame_events,
14265    );
14266    let mut agent_stream_event_rx = agent_stream_event_subscription(
14267        &shared,
14268        include_agent_stream_events,
14269    );
14270    let mut harness_mcp_proxy_event_rx = harness_mcp_proxy_event_subscription(
14271        &shared,
14272        include_harness_mcp_proxy,
14273    );
14274    write_json_frame(
14275        &mut pipe,
14276        &ServerFrame::Hello(NodeHello {
14277            build_stamp: BUILD_STAMP.to_owned(),
14278            incarnation_id: shared.incarnation_id,
14279            connection_id,
14280            role: hello.role,
14281            event_sequence: shared.current_sequence(),
14282            controller: shared.controller_state(),
14283            snapshot: snapshot_for_wire(
14284                &shared,
14285                include_provider_runtime_status,
14286                include_open_provider_ids,
14287                include_spawn_profiles,
14288                include_managed_worktrees,
14289                include_child_environment_profiles,
14290                include_session_bundles,
14291                include_history_context_packs,
14292                include_agent_progress,
14293                include_session_task_correlation,
14294            ),
14295            compatibility,
14296        }),
14297    )
14298    .await?;
14299    let (mut reader, mut writer) = tokio::io::split(pipe);
14300    let (frame_tx, mut frame_rx) = mpsc::channel(16);
14301    let reader_task = tokio::spawn(async move {
14302        loop {
14303            let frame = read_json_frame_limited_body_timeout::<_, ClientFrame>(
14304                &mut reader,
14305                MAX_NODE_CLIENT_FRAME_BYTES,
14306                Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
14307            )
14308            .await;
14309            let terminal = frame.is_err();
14310            if frame_tx.send(frame).await.is_err() || terminal {
14311                break;
14312            }
14313        }
14314    });
14315    let _reader_abort = AbortTaskOnDrop(reader_task.abort_handle());
14316    let mut pending_events = Vec::new();
14317    let mut resync_required = false;
14318    let mut discard_events_through = 0_u64;
14319    loop {
14320        // One tick of the serve loop, whether or not it had anything to do.
14321        // Against `connection_events_sent` this separates "busy" from
14322        // "spinning": a loop turning over thousands of times a second while
14323        // sending nothing is not serving, it is burning a core.
14324        shared.connection_loop_iterations.fetch_add(1, Ordering::Relaxed);
14325        let durable_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
14326            .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
14327        for _ in 0..durable_drain_budget {
14328            match event_rx.try_recv() {
14329                Ok(event) => queue_connection_event(
14330                    &mut pending_events,
14331                    event,
14332                    discard_events_through,
14333                ),
14334                Err(broadcast::error::TryRecvError::Empty) => break,
14335                Err(broadcast::error::TryRecvError::Lagged(_)) => {
14336                    resync_required = true;
14337                    break;
14338                }
14339                Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14340            }
14341        }
14342        if let Some(receiver) = terminal_event_rx.as_mut() {
14343            match receiver.try_recv() {
14344                Ok(events) => queue_connection_event_batch(
14345                    &mut pending_events,
14346                    &events,
14347                    discard_events_through,
14348                ),
14349                Err(broadcast::error::TryRecvError::Empty) => {}
14350                Err(broadcast::error::TryRecvError::Lagged(_)) => {
14351                    resync_required = true;
14352                }
14353                Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14354            }
14355        }
14356        if let Some(receiver) = agent_stream_event_rx.as_mut() {
14357            // Loops up to the same remaining-room budget the durable
14358            // `event_rx` drain above uses, rather than one `try_recv` per
14359            // tick: each successful receive here is itself only ONE
14360            // `NodeEventEnvelope` batch (`publish_agent_stream_chunk` always
14361            // sends `vec![envelope]`, never more), so a single-shot drain
14362            // caps this connection's agent-stream throughput at one chunk
14363            // per outer-loop tick regardless of how many are already queued
14364            // -- exactly the kind of self-inflicted stall this crate's own
14365            // durable-event budget exists to avoid.
14366            let agent_stream_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
14367                .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
14368            for _ in 0..agent_stream_drain_budget {
14369                match receiver.try_recv() {
14370                    Ok(events) => queue_connection_event_batch(
14371                        &mut pending_events,
14372                        &events,
14373                        discard_events_through,
14374                    ),
14375                    Err(broadcast::error::TryRecvError::Empty) => break,
14376                    Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
14377                        tracing::warn!(
14378                            connection_id,
14379                            dropped,
14380                            "agent stream event subscription lagged; forcing a resync",
14381                        );
14382                        resync_required = true;
14383                        break;
14384                    }
14385                    Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14386                }
14387            }
14388        }
14389        if let Some(receiver) = harness_mcp_proxy_event_rx.as_mut() {
14390            // Mirrors the agent-stream drain immediately above: each
14391            // successful receive is one single-envelope batch
14392            // (`publish_transient` always sends `vec![envelope]`), so this
14393            // loops up to the same remaining-room budget rather than
14394            // stopping after one `try_recv`.
14395            let harness_mcp_proxy_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
14396                .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
14397            for _ in 0..harness_mcp_proxy_drain_budget {
14398                match receiver.try_recv() {
14399                    Ok(events) => queue_connection_event_batch(
14400                        &mut pending_events,
14401                        &events,
14402                        discard_events_through,
14403                    ),
14404                    Err(broadcast::error::TryRecvError::Empty) => break,
14405                    Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
14406                        tracing::warn!(
14407                            connection_id,
14408                            dropped,
14409                            "harness MCP proxy event subscription lagged; forcing a resync",
14410                        );
14411                        resync_required = true;
14412                        break;
14413                    }
14414                    Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14415                }
14416            }
14417        }
14418        if resync_required {
14419            let event = resync_required_event(&shared);
14420            apply_connection_resync_watermark(
14421                &mut pending_events,
14422                &mut discard_events_through,
14423                event.sequence,
14424            );
14425            write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
14426            resync_required = false;
14427            continue;
14428        }
14429        if !pending_events.is_empty() {
14430            pending_events.sort_unstable_by_key(|event| event.sequence);
14431            let burst_len = connection_event_burst_len(pending_events.len());
14432            shared
14433                .connection_events_sent
14434                .fetch_add(burst_len as u64, Ordering::Relaxed);
14435            for event in pending_events.drain(..burst_len) {
14436                let event = if include_managed_worktrees {
14437                    Some(event)
14438                } else {
14439                    project_event_without_managed_worktrees(event)
14440                };
14441                let event = event.and_then(|event| if include_open_provider_ids {
14442                    Some(event)
14443                } else {
14444                    project_event_legacy_provider_ids(&shared, event)
14445                });
14446                let event = event.map(|event| if include_child_environment_profiles {
14447                    event
14448                } else {
14449                    project_event_without_child_environment_profile(event)
14450                });
14451                let event = event.map(|event| if include_session_bundles {
14452                    event
14453                } else {
14454                    project_event_without_session_bundle(event)
14455                });
14456                let event = event.map(|event| {
14457                    project_event_history_for_wire(event, include_history_context_packs)
14458                });
14459                let event = event.map(|event| if include_history_context_packs {
14460                    event
14461                } else {
14462                    project_event_without_context_pack(event)
14463                });
14464                let event = event.map(|event| if include_session_task_correlation {
14465                    event
14466                } else {
14467                    project_event_without_session_task_binding(event)
14468                });
14469                let exact_harness_controller = hello.role == ClientRole::Operator
14470                    && shared.controller_state().is_some_and(|controller| {
14471                        controller.connection_id == connection_id
14472                    });
14473                let event = event.and_then(|event| {
14474                    if include_harness_mcp_proxy && exact_harness_controller {
14475                        Some(event)
14476                    } else {
14477                        project_event_without_harness_mcp_proxy(event)
14478                    }
14479                });
14480                if let Some(event) = event {
14481                    write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
14482                }
14483            }
14484        }
14485        tokio::select! {
14486            biased;
14487            frame = frame_rx.recv() => {
14488                let frame = match frame {
14489                    Some(frame) => frame,
14490                    None => break,
14491                };
14492                let frame = match frame {
14493                    Ok(frame) => frame,
14494                    Err(FrameError::Io(error)) if matches!(error.kind(), io::ErrorKind::BrokenPipe | io::ErrorKind::UnexpectedEof) => break,
14495                    Err(error) => return Err(error.into()),
14496                };
14497                let ClientFrame::Request(request) = frame else {
14498                    return Err(NodeServerError::Handshake("hello may only be sent once".to_owned()));
14499                };
14500                #[cfg(feature = "fixture")]
14501                let probe_spawn_managed_worktree_v2 = matches!(
14502                    &request.request,
14503                    NodeRequest::SpawnManagedWorktreeV2 { .. }
14504                );
14505                let requires_open_provider_ids =
14506                    request_requires_open_provider_ids(&shared, &request.request);
14507                let requires_child_environment_profile =
14508                    request_requires_child_environment_profile(&shared, &request.request);
14509                let requires_session_bundle =
14510                    request_requires_session_bundle(&shared, &request.request);
14511                let requires_history_context_pack =
14512                    request_requires_history_context_pack(&shared, &request.request);
14513                let mut reply = if requires_open_provider_ids && !include_open_provider_ids {
14514                    ResponseEnvelope {
14515                        request_id: request.request_id,
14516                        result: Err(failure(
14517                            NodeFailureCode::UnsupportedCapability,
14518                            "open provider IDs were not negotiated",
14519                        )),
14520                    }
14521                } else if requires_child_environment_profile
14522                    && !include_child_environment_profiles
14523                {
14524                    ResponseEnvelope {
14525                        request_id: request.request_id,
14526                        result: Err(failure(
14527                            NodeFailureCode::UnsupportedCapability,
14528                            "child environment profiles were not negotiated",
14529                        )),
14530                    }
14531                } else if requires_session_bundle && !include_session_bundles {
14532                    ResponseEnvelope {
14533                        request_id: request.request_id,
14534                        result: Err(failure(
14535                            NodeFailureCode::UnsupportedCapability,
14536                            "session bundles were not negotiated",
14537                        )),
14538                    }
14539                } else if requires_history_context_pack && !include_history_context_packs {
14540                    ResponseEnvelope {
14541                        request_id: request.request_id,
14542                        result: Err(failure(
14543                            NodeFailureCode::UnsupportedCapability,
14544                            "history context packs were not negotiated",
14545                        )),
14546                    }
14547                } else if request_uses_unnegotiated_capability(
14548                    &request.request,
14549                    &selected_capabilities,
14550                )
14551                {
14552                    ResponseEnvelope {
14553                        request_id: request.request_id,
14554                        result: Err(failure(
14555                            NodeFailureCode::UnsupportedCapability,
14556                            "request capability was not negotiated",
14557                        )),
14558                    }
14559                } else {
14560                    process_request(&shared, connection_id, hello.role, request).await
14561                };
14562                #[cfg(feature = "fixture")]
14563                if probe_spawn_managed_worktree_v2 {
14564                    shared
14565                        .fixture_spawn_managed_worktree_v2_failure_probe
14566                        .record(reply.result.as_ref().err().map(|failure| failure.code));
14567                }
14568                if !include_provider_runtime_status {
14569                    clear_response_provider_runtime_status(&mut reply);
14570                }
14571                if !include_managed_worktrees {
14572                    project_response_without_managed_worktrees(&mut reply);
14573                }
14574                if !include_open_provider_ids {
14575                    project_response_legacy_provider_ids(&shared, &mut reply);
14576                }
14577                if !include_child_environment_profiles {
14578                    project_response_without_child_environment_profile(&mut reply);
14579                }
14580                if !include_session_bundles {
14581                    project_response_without_session_bundle(&mut reply);
14582                }
14583                project_response_history_for_wire(
14584                    &mut reply,
14585                    include_history_context_packs,
14586                );
14587                if !include_history_context_packs {
14588                    project_response_without_context_pack(&mut reply);
14589                }
14590                if !include_agent_progress {
14591                    project_response_without_agent_progress(&mut reply);
14592                }
14593                if !include_session_task_correlation {
14594                    project_response_without_session_task_binding(&mut reply);
14595                }
14596                write_json_frame(&mut writer, &ServerFrame::Reply(reply)).await?;
14597            }
14598            _ = tokio::task::yield_now(), if !pending_events.is_empty() => {}
14599            event = event_rx.recv() => {
14600                match event {
14601                    Ok(event) => queue_connection_event(
14602                        &mut pending_events,
14603                        event,
14604                        discard_events_through,
14605                    ),
14606                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14607                    Err(broadcast::error::RecvError::Closed) => break,
14608                }
14609            }
14610            events = receive_terminal_event_batch(&mut terminal_event_rx) => {
14611                match events {
14612                    Ok(events) => queue_connection_event_batch(
14613                        &mut pending_events,
14614                        &events,
14615                        discard_events_through,
14616                    ),
14617                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14618                    Err(broadcast::error::RecvError::Closed) => break,
14619                }
14620            }
14621            events = receive_terminal_event_batch(&mut agent_stream_event_rx) => {
14622                match events {
14623                    Ok(events) => queue_connection_event_batch(
14624                        &mut pending_events,
14625                        &events,
14626                        discard_events_through,
14627                    ),
14628                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14629                    Err(broadcast::error::RecvError::Closed) => break,
14630                }
14631            }
14632            events = receive_terminal_event_batch(&mut harness_mcp_proxy_event_rx) => {
14633                match events {
14634                    Ok(events) => queue_connection_event_batch(
14635                        &mut pending_events,
14636                        &events,
14637                        discard_events_through,
14638                    ),
14639                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14640                    Err(broadcast::error::RecvError::Closed) => break,
14641                }
14642            }
14643            _ = shared.shutdown_notify.notified() => break,
14644        }
14645    }
14646    Ok(())
14647}
14648
14649async fn receive_terminal_event_batch(
14650    receiver: &mut Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>>,
14651) -> Result<Arc<Vec<NodeEventEnvelope>>, broadcast::error::RecvError> {
14652    match receiver {
14653        Some(receiver) => receiver.recv().await,
14654        None => std::future::pending().await,
14655    }
14656}
14657
14658fn terminal_event_subscription(
14659    shared: &NodeShared,
14660    enabled: bool,
14661) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14662    enabled.then(|| shared.terminal_event_tx.subscribe())
14663}
14664
14665/// Mirrors `terminal_event_subscription` exactly, over the agent stream's
14666/// own channel -- see `NodeShared::agent_stream_event_tx`'s own doc for why
14667/// this needs a dedicated channel rather than a flag on the terminal one.
14668fn agent_stream_event_subscription(
14669    shared: &NodeShared,
14670    enabled: bool,
14671) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14672    enabled.then(|| shared.agent_stream_event_tx.subscribe())
14673}
14674
14675/// Mirrors `agent_stream_event_subscription` exactly, over the harness MCP
14676/// proxy's own channel -- see `NodeShared::harness_mcp_proxy_event_tx`'s
14677/// own doc for why this needs a dedicated channel rather than a ride on
14678/// the durable `event_tx`.
14679fn harness_mcp_proxy_event_subscription(
14680    shared: &NodeShared,
14681    enabled: bool,
14682) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14683    enabled.then(|| shared.harness_mcp_proxy_event_tx.subscribe())
14684}
14685
14686fn queue_connection_event(
14687    pending: &mut Vec<NodeEventEnvelope>,
14688    event: NodeEventEnvelope,
14689    discard_through: u64,
14690) {
14691    if event.sequence <= discard_through
14692        && !event.event.requires_harness_mcp_proxy_capability()
14693    {
14694        return;
14695    }
14696    if let NodeEvent::TerminalFrame { address, .. } = &event.event {
14697        if let Some(index) = pending.iter().position(|current| {
14698            matches!(
14699                &current.event,
14700                NodeEvent::TerminalFrame {
14701                    address: current_address,
14702                    ..
14703                } if current_address == address
14704            )
14705        }) {
14706            pending.remove(index);
14707        }
14708    }
14709    pending.push(event);
14710}
14711
14712fn project_event_without_harness_mcp_proxy(
14713    envelope: NodeEventEnvelope,
14714) -> Option<NodeEventEnvelope> {
14715    (!envelope.event.requires_harness_mcp_proxy_capability()).then_some(envelope)
14716}
14717
14718fn queue_connection_event_batch(
14719    pending: &mut Vec<NodeEventEnvelope>,
14720    events: &[NodeEventEnvelope],
14721    discard_through: u64,
14722) {
14723    for event in events {
14724        queue_connection_event(pending, event.clone(), discard_through);
14725    }
14726}
14727
14728fn apply_connection_resync_watermark(
14729    pending: &mut Vec<NodeEventEnvelope>,
14730    discard_through: &mut u64,
14731    marker_sequence: u64,
14732) {
14733    *discard_through = (*discard_through).max(marker_sequence);
14734    pending.retain(|event| event.sequence > *discard_through);
14735}
14736
14737fn connection_event_burst_len(pending_len: usize) -> usize {
14738    pending_len.min(NODE_CONNECTION_EVENT_BURST_MAX)
14739}
14740
14741fn resync_required_event(shared: &NodeShared) -> NodeEventEnvelope {
14742    let history = shared
14743        .history
14744        .lock()
14745        .unwrap_or_else(|poisoned| poisoned.into_inner());
14746    let sequence = history.last_sequence;
14747    let oldest_available_sequence = history.replay_floor_sequence;
14748    NodeEventEnvelope {
14749        sequence,
14750        event: NodeEvent::ResyncRequired {
14751            oldest_available_sequence,
14752        },
14753    }
14754}
14755
14756fn node_compatibility_support(
14757    shared: &NodeShared,
14758) -> Result<NodeCompatibilitySupport, NodeServerError> {
14759    let mut support = node_compatibility_support_for_manifest(
14760        &shared.provider_contracts,
14761        &shared.provider_adapter_contracts,
14762    )?;
14763    if shared
14764        .delivery_store
14765        .lock()
14766        .unwrap_or_else(|poisoned| poisoned.into_inner())
14767        .is_some()
14768    {
14769        support.capabilities.push(
14770            CapabilityId::new(NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY)
14771                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14772        );
14773    }
14774    if shared.harness_mcp_registry.is_some() {
14775        support.capabilities.push(
14776            CapabilityId::new(NODE_HARNESS_MCP_READ_PROXY_CAPABILITY)
14777                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14778        );
14779    }
14780    Ok(support)
14781}
14782
14783fn node_compatibility_support_for_manifest(
14784    provider_contracts: &[ProviderContractSupport],
14785    provider_adapter_contracts: &[ProviderAdapterContractSupport],
14786) -> Result<NodeCompatibilitySupport, NodeServerError> {
14787    Ok(NodeCompatibilitySupport {
14788        build_stamp: BUILD_STAMP.to_owned(),
14789        capabilities: baseline_capabilities()?,
14790        host: platform::host_descriptor().map_err(NodeServerError::Handshake)?,
14791        path_semantics: platform::path_semantics(),
14792        local_transport: platform::local_transport(),
14793        state_schema: StateSchemaSupport {
14794            versions: ProtocolRange::new(NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10)
14795                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14796        },
14797        provider_contracts: provider_contracts.to_vec(),
14798        provider_adapter_contracts: provider_adapter_contracts.to_vec(),
14799    })
14800}
14801
14802fn baseline_capabilities() -> Result<Vec<CapabilityId>, NodeServerError> {
14803    let capabilities = [
14804        NODE_COMPATIBILITY_METADATA_CAPABILITY,
14805        CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
14806        NODE_REPOSITORY_PATH_CAPABILITY,
14807        NODE_WORKSPACE_FILE_READ_CAPABILITY,
14808        NODE_WORKSPACE_FILE_WRITE_CAPABILITY,
14809        NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
14810        NODE_GIT_READ_CAPABILITY,
14811        NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY,
14812        NODE_PROVIDER_ID_OPEN_CAPABILITY,
14813        NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
14814        NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY,
14815        NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
14816        NODE_AGENT_STREAM_EVENTS_CAPABILITY,
14817        NODE_ACP_CONTROL_CAPABILITY,
14818        NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
14819        NODE_SPAWN_PROFILE_REVISION_CAPABILITY,
14820        NODE_WORKTREE_SELECTION_CAPABILITY,
14821        NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
14822        NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
14823        NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
14824        NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
14825        NODE_HISTORY_CONTEXT_PACK_CAPABILITY,
14826        NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
14827        NODE_NATIVE_SESSION_CATALOG_CAPABILITY,
14828        NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
14829        NODE_NATIVE_SESSION_INDEX_CAPABILITY,
14830        NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
14831        NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
14832        NODE_SESSION_TASK_CORRELATION_CAPABILITY,
14833    ]
14834        .into_iter()
14835        .map(|capability| {
14836            CapabilityId::new(capability)
14837                .map_err(|error| NodeServerError::Handshake(error.to_string()))
14838        })
14839        .collect::<Result<Vec<_>, _>>()?;
14840    #[cfg(windows)]
14841    let capabilities = {
14842        let mut capabilities = capabilities;
14843        capabilities.push(
14844            CapabilityId::new(NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY)
14845                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14846        );
14847        capabilities
14848    };
14849    Ok(capabilities)
14850}
14851
14852fn request_uses_unnegotiated_capability(
14853    request: &NodeRequest,
14854    selected_capabilities: &[CapabilityId],
14855) -> bool {
14856    let selected = |required: &str| {
14857        selected_capabilities
14858            .iter()
14859            .any(|capability| capability.as_str() == required)
14860    };
14861    request
14862        .required_capability()
14863        .is_some_and(|required| !selected(required))
14864        || (request.requires_worktree_selection_capability()
14865            && !selected(NODE_WORKTREE_SELECTION_CAPABILITY))
14866        || (request.requires_spawn_spec_defaults_overrides_capability()
14867            && !selected(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY))
14868        || (request.requires_spawn_profile_revision_capability()
14869            && !selected(NODE_SPAWN_PROFILE_REVISION_CAPABILITY))
14870        || (request.requires_child_environment_profile_capability()
14871            && !selected(NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY))
14872        || (request.requires_session_bundle_materialization_capability()
14873            && !selected(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY))
14874        || (request.requires_history_context_pack_capability()
14875            && !selected(NODE_HISTORY_CONTEXT_PACK_CAPABILITY))
14876}
14877
14878fn snapshot_for_wire(
14879    shared: &NodeShared,
14880    include_provider_runtime_status: bool,
14881    include_open_provider_ids: bool,
14882    include_spawn_profiles: bool,
14883    include_managed_worktrees: bool,
14884    include_child_environment_profiles: bool,
14885    include_session_bundles: bool,
14886    include_history_context_packs: bool,
14887    include_agent_progress: bool,
14888    include_session_task_correlation: bool,
14889) -> NodeSnapshot {
14890    let mut snapshot = shared.snapshot();
14891    if !include_provider_runtime_status {
14892        snapshot.provider_runtime_statuses.clear();
14893    }
14894    if !include_open_provider_ids {
14895        project_snapshot_legacy_provider_ids(&mut snapshot);
14896    }
14897    if !include_managed_worktrees {
14898        snapshot.managed_worktrees.clear();
14899        for workspace in &mut snapshot.workspaces {
14900            workspace.managed_worktree_profiles = None;
14901        }
14902    }
14903    if let Some(inventory) = snapshot.launch_inventory.as_mut() {
14904        if !include_spawn_profiles {
14905            inventory.spawn_profiles = None;
14906        }
14907        if !include_session_bundles {
14908            inventory.bundles = None;
14909        }
14910        if inventory.spawn_profiles.is_none()
14911            && inventory.bundles.is_none()
14912            && inventory.network_allowlists.is_none()
14913        {
14914            snapshot.launch_inventory = None;
14915        }
14916    }
14917    if !include_child_environment_profiles {
14918        clear_snapshot_child_environment_profiles(&mut snapshot);
14919    }
14920    if !include_session_bundles {
14921        clear_snapshot_session_bundles(&mut snapshot);
14922    }
14923    project_snapshot_history_for_wire(&mut snapshot, include_history_context_packs);
14924    if !include_history_context_packs {
14925        clear_snapshot_context_packs(&mut snapshot);
14926    }
14927    if !include_agent_progress {
14928        snapshot.agent_progress.clear();
14929    }
14930    if !include_session_task_correlation {
14931        clear_snapshot_session_task_bindings(&mut snapshot);
14932    }
14933    snapshot
14934}
14935
14936fn clear_snapshot_session_task_bindings(snapshot: &mut NodeSnapshot) {
14937    for record in &mut snapshot.session_records {
14938        record.task_binding = None;
14939    }
14940}
14941
14942fn project_event_without_session_task_binding(
14943    mut envelope: NodeEventEnvelope,
14944) -> NodeEventEnvelope {
14945    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14946        record.task_binding = None;
14947    }
14948    envelope
14949}
14950
14951fn project_response_without_session_task_binding(reply: &mut ResponseEnvelope) {
14952    let Ok(response) = reply.result.as_mut() else { return };
14953    match response {
14954        NodeResponse::Snapshot { snapshot, .. } => {
14955            clear_snapshot_session_task_bindings(snapshot);
14956        }
14957        NodeResponse::Resync { snapshot, events, .. } => {
14958            clear_snapshot_session_task_bindings(snapshot);
14959            for event in events {
14960                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14961                    record.task_binding = None;
14962                }
14963            }
14964        }
14965        NodeResponse::SessionRecordUpdated { record }
14966        | NodeResponse::ProviderSessionIndexed { record }
14967        | NodeResponse::NativeSessionIndexed { record, .. }
14968        | NodeResponse::SessionRecordResumed { record, .. } => {
14969            record.task_binding = None;
14970        }
14971        _ => {}
14972    }
14973}
14974
14975fn project_response_without_agent_progress(reply: &mut ResponseEnvelope) {
14976    let Ok(response) = reply.result.as_mut() else { return };
14977    match response {
14978        NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
14979            snapshot.agent_progress.clear();
14980        }
14981        _ => {}
14982    }
14983}
14984
14985fn project_event_without_managed_worktrees(
14986    mut envelope: NodeEventEnvelope,
14987) -> Option<NodeEventEnvelope> {
14988    if matches!(
14989        envelope.event,
14990        NodeEvent::ManagedWorktreeUpserted { .. }
14991            | NodeEvent::ManagedWorktreeRemoved { .. }
14992    ) {
14993        return None;
14994    }
14995    if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
14996        workspace.managed_worktree_profiles = None;
14997    }
14998    Some(envelope)
14999}
15000
15001fn project_response_without_managed_worktrees(reply: &mut ResponseEnvelope) {
15002    if matches!(
15003        reply.result,
15004        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { .. })
15005            | Ok(NodeResponse::ManagedWorktreeCleanup { .. })
15006    ) {
15007        reply.result = Err(failure(
15008            NodeFailureCode::UnsupportedCapability,
15009            "managed worktree capability was not negotiated",
15010        ));
15011        return;
15012    }
15013    let Ok(response) = reply.result.as_mut() else { return };
15014    match response {
15015        NodeResponse::Snapshot { snapshot, .. } => {
15016            snapshot.managed_worktrees.clear();
15017            for workspace in &mut snapshot.workspaces {
15018                workspace.managed_worktree_profiles = None;
15019            }
15020        }
15021        NodeResponse::Resync { snapshot, events, .. } => {
15022            snapshot.managed_worktrees.clear();
15023            for workspace in &mut snapshot.workspaces {
15024                workspace.managed_worktree_profiles = None;
15025            }
15026            *events = std::mem::take(events)
15027                .into_iter()
15028                .filter_map(project_event_without_managed_worktrees)
15029                .collect();
15030        }
15031        NodeResponse::WorkspaceRegistered { workspace }
15032        | NodeResponse::StandaloneWorkspaceCreated { workspace }
15033        | NodeResponse::WorktreeCreated { workspace, .. } => {
15034            workspace.managed_worktree_profiles = None;
15035        }
15036        NodeResponse::WorkspaceInspected { inspection } => {
15037            inspection.git.managed_worktree = None;
15038        }
15039        _ => {}
15040    }
15041}
15042
15043fn project_negotiated_provider_ids(compatibility: &mut NegotiatedNodeCompatibility) {
15044    let includes_open_provider_ids = compatibility.capabilities.iter().any(|capability| {
15045        capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
15046    });
15047    if includes_open_provider_ids {
15048        return;
15049    }
15050    compatibility
15051        .provider_contracts
15052        .retain(|contract| provider_id_is_legacy(&contract.provider));
15053    compatibility
15054        .provider_adapter_contracts
15055        .retain(|contract| provider_id_is_legacy(&contract.provider));
15056}
15057
15058fn request_requires_open_provider_ids(shared: &NodeShared, request: &NodeRequest) -> bool {
15059    if let Some(spec) = match request {
15060        NodeRequest::SpawnSpec { spec }
15061        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
15062        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
15063        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
15064        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
15065        _ => None,
15066    } {
15067        return shared
15068            .resolve_spawn_spec(spec)
15069            .is_ok_and(|resolved| !provider_id_is_legacy(&resolved.provider));
15070    }
15071    request_requires_open_provider_ids_with(
15072        request,
15073        |session| shared.validate_address(session).ok(),
15074        |record_id| shared.record(record_id).ok().map(|record| record.provider),
15075    )
15076}
15077
15078fn request_requires_child_environment_profile(
15079    shared: &NodeShared,
15080    request: &NodeRequest,
15081) -> bool {
15082    if let Some(spec) = match request {
15083        NodeRequest::SpawnSpec { spec }
15084        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
15085        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
15086        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
15087        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
15088        _ => None,
15089    } {
15090        return shared
15091            .resolve_spawn_spec(spec)
15092            .is_ok_and(|resolved| resolved.environment_profile_id.is_some());
15093    }
15094    match request {
15095        NodeRequest::Resume { session, .. }
15096        | NodeRequest::Prompt { session, .. }
15097        | NodeRequest::Paste { session, .. }
15098        | NodeRequest::Input { session, .. }
15099        | NodeRequest::TerminalBytes { session, .. }
15100        | NodeRequest::TerminalControl { session, .. }
15101        | NodeRequest::Resize { session, .. }
15102        | NodeRequest::Interrupt { session }
15103        | NodeRequest::Stop { session, .. }
15104        | NodeRequest::Remove { session }
15105        | NodeRequest::ResolveInteraction { session, .. }
15106        | NodeRequest::SetSessionMode { session, .. }
15107        | NodeRequest::SetSessionConfigOption { session, .. }
15108        | NodeRequest::SetSessionModel { session, .. }
15109        | NodeRequest::DiscoverHistory { session, .. }
15110        | NodeRequest::LoadHistory { session, .. }
15111        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
15112        | NodeRequest::ExportContextPack { session } => shared
15113            .session_bindings
15114            .lock()
15115            .unwrap_or_else(|poisoned| poisoned.into_inner())
15116            .get(&session.session.instance_id)
15117            .filter(|binding| {
15118                binding.workspace_id == session.workspace_id
15119                    && binding.generation == session.session.generation
15120            })
15121            .map_or(true, |binding| binding.environment_profile.is_some()),
15122        NodeRequest::RenameSessionRecord { record_id, .. }
15123        | NodeRequest::SetSessionTask { record_id, .. }
15124        | NodeRequest::ResumeSessionRecord { record_id, .. }
15125        | NodeRequest::ForgetSessionRecord { record_id } => shared
15126            .record(record_id)
15127            .map_or(true, |record| record.environment_profile.is_some()),
15128        NodeRequest::ArmHarnessMcpReservation { .. }
15129        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
15130        | NodeRequest::ActivateHarnessMcpReservation { .. }
15131        | NodeRequest::AbortHarnessMcpReservation { .. }
15132        | NodeRequest::PutHarnessMcpReplyChunk { .. }
15133        | NodeRequest::RejectHarnessMcpCall { .. }
15134        | NodeRequest::Snapshot
15135        | NodeRequest::Resync { .. }
15136        | NodeRequest::BeginDeliveryStage { .. }
15137        | NodeRequest::PutDeliveryBlobChunk { .. }
15138        | NodeRequest::CommitDeliveryStage { .. }
15139        | NodeRequest::AbortDeliveryStage { .. }
15140        | NodeRequest::BrowseHostDirectories { .. }
15141        | NodeRequest::InspectWorkspace { .. }
15142        | NodeRequest::ReadWorkspaceFile { .. }
15143        | NodeRequest::WriteWorkspaceFile { .. }
15144        | NodeRequest::CreateWorkspaceFile { .. }
15145        | NodeRequest::CreateWorkspaceDirectory { .. }
15146        | NodeRequest::ReadGitHistory { .. }
15147        | NodeRequest::ReadGitDiff { .. }
15148        | NodeRequest::AcquireController { .. }
15149        | NodeRequest::ReleaseController
15150        | NodeRequest::Spawn { .. }
15151        | NodeRequest::SpawnWithOptions { .. }
15152        | NodeRequest::SpawnSpec { .. }
15153        | NodeRequest::RegisterWorkspace { .. }
15154        | NodeRequest::CreateStandaloneWorkspace { .. }
15155        | NodeRequest::UnregisterWorkspace { .. }
15156        | NodeRequest::CreateWorktree { .. }
15157        | NodeRequest::RemoveWorktree { .. }
15158        | NodeRequest::SpawnManagedWorktree { .. }
15159        | NodeRequest::SpawnManagedWorktreeV2 { .. }
15160        | NodeRequest::CleanupManagedWorktree { .. }
15161        | NodeRequest::IndexProviderSession { .. }
15162        | NodeRequest::IndexNativeSession { .. }
15163        | NodeRequest::CatalogNativeSessions { .. }
15164        | NodeRequest::PageNativeSessions { .. }
15165        | NodeRequest::PreviewNativeSession { .. }
15166        | NodeRequest::PreviewSessionRecord { .. }
15167        | NodeRequest::ForgetContextPack { .. }
15168        | NodeRequest::ResolveDurableContextPack { .. }
15169        | NodeRequest::ReadContextPack { .. }
15170        | NodeRequest::Shutdown => false,
15171    }
15172}
15173
15174fn request_requires_session_bundle(shared: &NodeShared, request: &NodeRequest) -> bool {
15175    if let Some(spec) = match request {
15176        NodeRequest::SpawnSpec { spec }
15177        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
15178        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
15179        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
15180        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
15181        _ => None,
15182    } {
15183        return shared
15184            .resolve_spawn_spec(spec)
15185            .is_ok_and(|resolved| resolved.bundle_id.is_some());
15186    }
15187    match request {
15188        NodeRequest::Resume { session, .. }
15189        | NodeRequest::Prompt { session, .. }
15190        | NodeRequest::Paste { session, .. }
15191        | NodeRequest::Input { session, .. }
15192        | NodeRequest::TerminalBytes { session, .. }
15193        | NodeRequest::TerminalControl { session, .. }
15194        | NodeRequest::Resize { session, .. }
15195        | NodeRequest::Interrupt { session }
15196        | NodeRequest::Stop { session, .. }
15197        | NodeRequest::Remove { session }
15198        | NodeRequest::ResolveInteraction { session, .. }
15199        | NodeRequest::SetSessionMode { session, .. }
15200        | NodeRequest::SetSessionConfigOption { session, .. }
15201        | NodeRequest::SetSessionModel { session, .. }
15202        | NodeRequest::DiscoverHistory { session, .. }
15203        | NodeRequest::LoadHistory { session, .. }
15204        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
15205        | NodeRequest::ExportContextPack { session } => shared
15206            .session_bindings
15207            .lock()
15208            .unwrap_or_else(|poisoned| poisoned.into_inner())
15209            .get(&session.session.instance_id)
15210            .filter(|binding| {
15211                binding.workspace_id == session.workspace_id
15212                    && binding.generation == session.session.generation
15213            })
15214            .map_or(true, |binding| binding.bundle.is_some()),
15215        NodeRequest::RenameSessionRecord { record_id, .. }
15216        | NodeRequest::SetSessionTask { record_id, .. }
15217        | NodeRequest::ResumeSessionRecord { record_id, .. }
15218        | NodeRequest::ForgetSessionRecord { record_id } => shared
15219            .record(record_id)
15220            .map_or(true, |record| record.bundle.is_some()),
15221        NodeRequest::ArmHarnessMcpReservation { .. }
15222        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
15223        | NodeRequest::ActivateHarnessMcpReservation { .. }
15224        | NodeRequest::AbortHarnessMcpReservation { .. }
15225        | NodeRequest::PutHarnessMcpReplyChunk { .. }
15226        | NodeRequest::RejectHarnessMcpCall { .. }
15227        | NodeRequest::Snapshot
15228        | NodeRequest::Resync { .. }
15229        | NodeRequest::BeginDeliveryStage { .. }
15230        | NodeRequest::PutDeliveryBlobChunk { .. }
15231        | NodeRequest::CommitDeliveryStage { .. }
15232        | NodeRequest::AbortDeliveryStage { .. }
15233        | NodeRequest::BrowseHostDirectories { .. }
15234        | NodeRequest::InspectWorkspace { .. }
15235        | NodeRequest::ReadWorkspaceFile { .. }
15236        | NodeRequest::WriteWorkspaceFile { .. }
15237        | NodeRequest::CreateWorkspaceFile { .. }
15238        | NodeRequest::CreateWorkspaceDirectory { .. }
15239        | NodeRequest::ReadGitHistory { .. }
15240        | NodeRequest::ReadGitDiff { .. }
15241        | NodeRequest::AcquireController { .. }
15242        | NodeRequest::ReleaseController
15243        | NodeRequest::Spawn { .. }
15244        | NodeRequest::SpawnWithOptions { .. }
15245        | NodeRequest::SpawnSpec { .. }
15246        | NodeRequest::RegisterWorkspace { .. }
15247        | NodeRequest::CreateStandaloneWorkspace { .. }
15248        | NodeRequest::UnregisterWorkspace { .. }
15249        | NodeRequest::CreateWorktree { .. }
15250        | NodeRequest::RemoveWorktree { .. }
15251        | NodeRequest::SpawnManagedWorktree { .. }
15252        | NodeRequest::SpawnManagedWorktreeV2 { .. }
15253        | NodeRequest::CleanupManagedWorktree { .. }
15254        | NodeRequest::IndexProviderSession { .. }
15255        | NodeRequest::IndexNativeSession { .. }
15256        | NodeRequest::CatalogNativeSessions { .. }
15257        | NodeRequest::PageNativeSessions { .. }
15258        | NodeRequest::PreviewNativeSession { .. }
15259        | NodeRequest::PreviewSessionRecord { .. }
15260        | NodeRequest::ForgetContextPack { .. }
15261        | NodeRequest::ResolveDurableContextPack { .. }
15262        | NodeRequest::ReadContextPack { .. }
15263        | NodeRequest::Shutdown => false,
15264    }
15265}
15266
15267fn request_requires_history_context_pack(shared: &NodeShared, request: &NodeRequest) -> bool {
15268    if request.requires_history_context_pack_capability() {
15269        return true;
15270    }
15271    if let Some(spec) = match request {
15272        NodeRequest::SpawnSpec { spec }
15273        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
15274        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
15275        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
15276        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
15277        _ => None,
15278    } {
15279        return shared
15280            .resolve_spawn_spec(spec)
15281            .map_or(true, |resolved| resolved.context_id.is_some());
15282    }
15283    match request {
15284        NodeRequest::Resume { session, .. }
15285        | NodeRequest::Prompt { session, .. }
15286        | NodeRequest::Paste { session, .. }
15287        | NodeRequest::Input { session, .. }
15288        | NodeRequest::TerminalBytes { session, .. }
15289        | NodeRequest::TerminalControl { session, .. }
15290        | NodeRequest::Resize { session, .. }
15291        | NodeRequest::Interrupt { session }
15292        | NodeRequest::Stop { session, .. }
15293        | NodeRequest::Remove { session }
15294        | NodeRequest::ResolveInteraction { session, .. }
15295        | NodeRequest::SetSessionMode { session, .. }
15296        | NodeRequest::SetSessionConfigOption { session, .. }
15297        | NodeRequest::SetSessionModel { session, .. } => shared
15298            .session_bindings
15299            .lock()
15300            .unwrap_or_else(|poisoned| poisoned.into_inner())
15301            .get(&session.session.instance_id)
15302            .filter(|binding| {
15303                binding.workspace_id == session.workspace_id
15304                    && binding.generation == session.session.generation
15305            })
15306            .map_or(true, |binding| binding.context.is_some()),
15307        NodeRequest::RenameSessionRecord { record_id, .. }
15308        | NodeRequest::SetSessionTask { record_id, .. }
15309        | NodeRequest::ResumeSessionRecord { record_id, .. }
15310        | NodeRequest::ForgetSessionRecord { record_id } => shared
15311            .record(record_id)
15312            .map_or(true, |record| record.context.is_some()),
15313        NodeRequest::ArmHarnessMcpReservation { .. }
15314        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
15315        | NodeRequest::ActivateHarnessMcpReservation { .. }
15316        | NodeRequest::AbortHarnessMcpReservation { .. }
15317        | NodeRequest::PutHarnessMcpReplyChunk { .. }
15318        | NodeRequest::RejectHarnessMcpCall { .. }
15319        | NodeRequest::Snapshot
15320        | NodeRequest::Resync { .. }
15321        | NodeRequest::BeginDeliveryStage { .. }
15322        | NodeRequest::PutDeliveryBlobChunk { .. }
15323        | NodeRequest::CommitDeliveryStage { .. }
15324        | NodeRequest::AbortDeliveryStage { .. }
15325        | NodeRequest::BrowseHostDirectories { .. }
15326        | NodeRequest::InspectWorkspace { .. }
15327        | NodeRequest::ReadWorkspaceFile { .. }
15328        | NodeRequest::WriteWorkspaceFile { .. }
15329        | NodeRequest::CreateWorkspaceFile { .. }
15330        | NodeRequest::CreateWorkspaceDirectory { .. }
15331        | NodeRequest::ReadGitHistory { .. }
15332        | NodeRequest::ReadGitDiff { .. }
15333        | NodeRequest::AcquireController { .. }
15334        | NodeRequest::ReleaseController
15335        | NodeRequest::Spawn { .. }
15336        | NodeRequest::SpawnWithOptions { .. }
15337        | NodeRequest::SpawnSpec { .. }
15338        | NodeRequest::RegisterWorkspace { .. }
15339        | NodeRequest::CreateStandaloneWorkspace { .. }
15340        | NodeRequest::UnregisterWorkspace { .. }
15341        | NodeRequest::CreateWorktree { .. }
15342        | NodeRequest::RemoveWorktree { .. }
15343        | NodeRequest::SpawnManagedWorktree { .. }
15344        | NodeRequest::SpawnManagedWorktreeV2 { .. }
15345        | NodeRequest::CleanupManagedWorktree { .. }
15346        | NodeRequest::IndexProviderSession { .. }
15347        | NodeRequest::IndexNativeSession { .. }
15348        | NodeRequest::CatalogNativeSessions { .. }
15349        | NodeRequest::PageNativeSessions { .. }
15350        | NodeRequest::PreviewNativeSession { .. }
15351        | NodeRequest::PreviewSessionRecord { .. }
15352        | NodeRequest::DiscoverHistory { .. }
15353        | NodeRequest::LoadHistory { .. }
15354        | NodeRequest::ExportContextPackForSessionRecord { .. }
15355        | NodeRequest::ExportContextPack { .. }
15356        | NodeRequest::ForgetContextPack { .. }
15357        | NodeRequest::ResolveDurableContextPack { .. }
15358        | NodeRequest::ReadContextPack { .. }
15359        | NodeRequest::Shutdown => false,
15360    }
15361}
15362
15363fn request_requires_open_provider_ids_with(
15364    request: &NodeRequest,
15365    provider_for_session: impl Fn(&SessionAddress) -> Option<AgentId>,
15366    provider_for_record: impl Fn(&SessionRecordId) -> Option<AgentId>,
15367) -> bool {
15368    match request {
15369        NodeRequest::Spawn { provider, .. } | NodeRequest::SpawnWithOptions { provider, .. } => !provider_id_is_legacy(provider),
15370        NodeRequest::IndexProviderSession { provider, .. } => !provider_id_is_legacy(provider),
15371        NodeRequest::CatalogNativeSessions { route, .. }
15372        | NodeRequest::PageNativeSessions { route, .. } => {
15373            !provider_id_is_legacy(&route.provider)
15374        }
15375        NodeRequest::PreviewNativeSession { selection, .. }
15376        | NodeRequest::IndexNativeSession { selection, .. } => {
15377            !provider_id_is_legacy(&selection.route.provider)
15378        }
15379        NodeRequest::SpawnSpec { .. }
15380        | NodeRequest::SpawnSpecWithHarnessMcp { .. } => false,
15381        NodeRequest::SpawnManagedWorktree { .. }
15382        | NodeRequest::SpawnManagedWorktreeV2 { .. } => false,
15383        NodeRequest::Resume { session, .. }
15384        | NodeRequest::Prompt { session, .. }
15385        | NodeRequest::Paste { session, .. }
15386        | NodeRequest::Input { session, .. }
15387        | NodeRequest::TerminalBytes { session, .. }
15388        | NodeRequest::TerminalControl { session, .. }
15389        | NodeRequest::Resize { session, .. }
15390        | NodeRequest::Interrupt { session }
15391        | NodeRequest::Stop { session, .. }
15392        | NodeRequest::Remove { session }
15393        | NodeRequest::ResolveInteraction { session, .. }
15394        | NodeRequest::SetSessionMode { session, .. }
15395        | NodeRequest::SetSessionConfigOption { session, .. }
15396        | NodeRequest::SetSessionModel { session, .. }
15397        | NodeRequest::DiscoverHistory { session, .. }
15398        | NodeRequest::LoadHistory { session, .. }
15399        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
15400        | NodeRequest::ExportContextPack { session } => provider_for_session(session)
15401            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
15402        NodeRequest::RenameSessionRecord { record_id, .. }
15403        | NodeRequest::SetSessionTask { record_id, .. }
15404        | NodeRequest::ResumeSessionRecord { record_id, .. }
15405        | NodeRequest::ForgetSessionRecord { record_id } => provider_for_record(record_id)
15406            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
15407        NodeRequest::PreviewSessionRecord { record_id, .. } => provider_for_record(record_id)
15408            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
15409        NodeRequest::ArmHarnessMcpReservation { .. }
15410        | NodeRequest::ActivateHarnessMcpReservation { .. }
15411        | NodeRequest::AbortHarnessMcpReservation { .. }
15412        | NodeRequest::PutHarnessMcpReplyChunk { .. }
15413        | NodeRequest::RejectHarnessMcpCall { .. }
15414        | NodeRequest::Snapshot
15415        | NodeRequest::Resync { .. }
15416        | NodeRequest::BeginDeliveryStage { .. }
15417        | NodeRequest::PutDeliveryBlobChunk { .. }
15418        | NodeRequest::CommitDeliveryStage { .. }
15419        | NodeRequest::AbortDeliveryStage { .. }
15420        | NodeRequest::BrowseHostDirectories { .. }
15421        | NodeRequest::InspectWorkspace { .. }
15422        | NodeRequest::ReadWorkspaceFile { .. }
15423        | NodeRequest::WriteWorkspaceFile { .. }
15424        | NodeRequest::CreateWorkspaceFile { .. }
15425        | NodeRequest::CreateWorkspaceDirectory { .. }
15426        | NodeRequest::ReadGitHistory { .. }
15427        | NodeRequest::ReadGitDiff { .. }
15428        | NodeRequest::AcquireController { .. }
15429        | NodeRequest::ReleaseController
15430        | NodeRequest::RegisterWorkspace { .. }
15431        | NodeRequest::CreateStandaloneWorkspace { .. }
15432        | NodeRequest::UnregisterWorkspace { .. }
15433        | NodeRequest::CreateWorktree { .. }
15434        | NodeRequest::RemoveWorktree { .. }
15435        | NodeRequest::CleanupManagedWorktree { .. }
15436        | NodeRequest::Shutdown => false,
15437        NodeRequest::ForgetContextPack { .. } => true,
15438        // No session/record to cheaply resolve a provider from here either;
15439        // the resolved receipt can carry an open-id provider, so require the
15440        // same capability as ForgetContextPack unconditionally. `ReadContextPack`
15441        // is the same digest-keyed lookup one step further into the same
15442        // catalog, so it inherits the same conservative answer.
15443        NodeRequest::ResolveDurableContextPack { .. }
15444        | NodeRequest::ReadContextPack { .. } => true,
15445    }
15446}
15447
15448fn project_snapshot_legacy_provider_ids(snapshot: &mut NodeSnapshot) {
15449    snapshot.enabled_providers.retain(provider_id_is_legacy);
15450    snapshot.provider_runtime_statuses = ProviderRuntimeStatuses::new(
15451        snapshot
15452            .provider_runtime_statuses
15453            .as_slice()
15454            .iter()
15455            .filter(|status| provider_id_is_legacy(status.provider()))
15456            .cloned(),
15457    )
15458    .expect("a filtered provider runtime projection remains valid");
15459    snapshot
15460        .session_records
15461        .retain(|record| provider_id_is_legacy(&record.provider));
15462    for workspace in &mut snapshot.workspaces {
15463        workspace
15464            .sessions
15465            .retain(|session| provider_id_is_legacy(&session.agent_id));
15466    }
15467}
15468
15469fn clear_snapshot_child_environment_profiles(snapshot: &mut NodeSnapshot) {
15470    for record in &mut snapshot.session_records {
15471        record.environment_profile = None;
15472    }
15473    if let Some(profiles) = snapshot
15474        .launch_inventory
15475        .as_mut()
15476        .and_then(|inventory| inventory.spawn_profiles.as_mut())
15477    {
15478        for profile in profiles {
15479            profile.environment_profile = None;
15480        }
15481    }
15482}
15483
15484fn project_event_without_child_environment_profile(
15485    mut envelope: NodeEventEnvelope,
15486) -> NodeEventEnvelope {
15487    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15488        record.environment_profile = None;
15489    }
15490    envelope
15491}
15492
15493fn project_response_without_child_environment_profile(reply: &mut ResponseEnvelope) {
15494    let contains_environment_profile = match reply.result.as_ref() {
15495        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
15496            receipt.environment_profile.is_some()
15497        }
15498        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15499            receipt.spawn.environment_profile.is_some()
15500        }
15501        _ => false,
15502    };
15503    if contains_environment_profile {
15504        reply.result = Err(failure(
15505            NodeFailureCode::UnsupportedCapability,
15506            "child environment profiles were not negotiated",
15507        ));
15508        return;
15509    }
15510    let Ok(response) = reply.result.as_mut() else {
15511        return;
15512    };
15513    match response {
15514        NodeResponse::Snapshot { snapshot, .. } => {
15515            clear_snapshot_child_environment_profiles(snapshot);
15516        }
15517        NodeResponse::Resync { snapshot, events, .. } => {
15518            clear_snapshot_child_environment_profiles(snapshot);
15519            for event in events {
15520                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15521                    record.environment_profile = None;
15522                }
15523            }
15524        }
15525        NodeResponse::SessionRecordUpdated { record }
15526        | NodeResponse::ProviderSessionIndexed { record }
15527        | NodeResponse::NativeSessionIndexed { record, .. }
15528        | NodeResponse::SessionRecordResumed { record, .. } => {
15529            record.environment_profile = None;
15530        }
15531        NodeResponse::Armed { .. }
15532        | NodeResponse::Spawned { .. }
15533        | NodeResponse::Activated { .. }
15534        | NodeResponse::Aborted { .. }
15535        | NodeResponse::ReplyChunkAccepted { .. }
15536        | NodeResponse::CallRejected { .. }
15537        | NodeResponse::DeliveryStageBegun { .. }
15538        | NodeResponse::DeliveryBlobChunkAccepted { .. }
15539        | NodeResponse::DeliveryCommitted { .. }
15540        | NodeResponse::DeliveryStageAborted { .. }
15541        | NodeResponse::WorkspaceInspected { .. }
15542        | NodeResponse::HostDirectoriesBrowsed { .. }
15543        | NodeResponse::WorkspaceFileRead { .. }
15544        | NodeResponse::WorkspaceFileWritten { .. }
15545        | NodeResponse::WorkspaceFileCreated { .. }
15546        | NodeResponse::WorkspaceDirectoryCreated { .. }
15547        | NodeResponse::GitHistoryRead { .. }
15548        | NodeResponse::GitDiffRead { .. }
15549        | NodeResponse::Controller { .. }
15550        | NodeResponse::SpawnAccepted { .. }
15551        | NodeResponse::SpawnSpecAccepted { .. }
15552        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15553        | NodeResponse::ManagedWorktreeCleanup { .. }
15554        | NodeResponse::NativeSessionsCataloged { .. }
15555        | NodeResponse::NativeSessionsPaged { .. }
15556        | NodeResponse::NativeSessionPreviewed { .. }
15557        | NodeResponse::SessionRecordPreviewed { .. }
15558        | NodeResponse::HistoryDiscovered { .. }
15559        | NodeResponse::HistoryLoaded { .. }
15560        | NodeResponse::ContextPackForSessionRecordExported { .. }
15561        | NodeResponse::ContextPackExported { .. }
15562        | NodeResponse::ContextPackForgotten { .. }
15563        | NodeResponse::DurableContextPackResolved { .. }
15564        | NodeResponse::ContextPackBytesRead { .. }
15565        | NodeResponse::SessionRecordForgotten { .. }
15566        | NodeResponse::WorkspaceRegistered { .. }
15567        | NodeResponse::StandaloneWorkspaceCreated { .. }
15568        | NodeResponse::WorkspaceUnregistered { .. }
15569        | NodeResponse::WorktreeCreated { .. }
15570        | NodeResponse::WorktreeRemoved { .. }
15571        | NodeResponse::Accepted
15572        | NodeResponse::ShuttingDown => {}
15573    }
15574}
15575
15576fn clear_snapshot_session_bundles(snapshot: &mut NodeSnapshot) {
15577    for record in &mut snapshot.session_records {
15578        record.bundle = None;
15579    }
15580}
15581
15582fn project_event_without_session_bundle(
15583    mut envelope: NodeEventEnvelope,
15584) -> NodeEventEnvelope {
15585    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15586        record.bundle = None;
15587    }
15588    envelope
15589}
15590
15591fn project_response_without_session_bundle(reply: &mut ResponseEnvelope) {
15592    let contains_bundle = match reply.result.as_ref() {
15593        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.bundle.is_some(),
15594        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15595            receipt.spawn.bundle.is_some()
15596        }
15597        _ => false,
15598    };
15599    if contains_bundle {
15600        reply.result = Err(failure(
15601            NodeFailureCode::UnsupportedCapability,
15602            "session bundles were not negotiated",
15603        ));
15604        return;
15605    }
15606    let Ok(response) = reply.result.as_mut() else {
15607        return;
15608    };
15609    match response {
15610        NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_session_bundles(snapshot),
15611        NodeResponse::Resync { snapshot, events, .. } => {
15612            clear_snapshot_session_bundles(snapshot);
15613            for event in events {
15614                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15615                    record.bundle = None;
15616                }
15617            }
15618        }
15619        NodeResponse::SessionRecordUpdated { record }
15620        | NodeResponse::ProviderSessionIndexed { record }
15621        | NodeResponse::NativeSessionIndexed { record, .. }
15622        | NodeResponse::SessionRecordResumed { record, .. } => record.bundle = None,
15623        NodeResponse::Armed { .. }
15624        | NodeResponse::Spawned { .. }
15625        | NodeResponse::Activated { .. }
15626        | NodeResponse::Aborted { .. }
15627        | NodeResponse::ReplyChunkAccepted { .. }
15628        | NodeResponse::CallRejected { .. }
15629        | NodeResponse::DeliveryStageBegun { .. }
15630        | NodeResponse::DeliveryBlobChunkAccepted { .. }
15631        | NodeResponse::DeliveryCommitted { .. }
15632        | NodeResponse::DeliveryStageAborted { .. }
15633        | NodeResponse::WorkspaceInspected { .. }
15634        | NodeResponse::HostDirectoriesBrowsed { .. }
15635        | NodeResponse::WorkspaceFileRead { .. }
15636        | NodeResponse::WorkspaceFileWritten { .. }
15637        | NodeResponse::WorkspaceFileCreated { .. }
15638        | NodeResponse::WorkspaceDirectoryCreated { .. }
15639        | NodeResponse::GitHistoryRead { .. }
15640        | NodeResponse::GitDiffRead { .. }
15641        | NodeResponse::Controller { .. }
15642        | NodeResponse::SpawnAccepted { .. }
15643        | NodeResponse::SpawnSpecAccepted { .. }
15644        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15645        | NodeResponse::ManagedWorktreeCleanup { .. }
15646        | NodeResponse::NativeSessionsCataloged { .. }
15647        | NodeResponse::NativeSessionsPaged { .. }
15648        | NodeResponse::NativeSessionPreviewed { .. }
15649        | NodeResponse::SessionRecordPreviewed { .. }
15650        | NodeResponse::HistoryDiscovered { .. }
15651        | NodeResponse::HistoryLoaded { .. }
15652        | NodeResponse::ContextPackForSessionRecordExported { .. }
15653        | NodeResponse::ContextPackExported { .. }
15654        | NodeResponse::ContextPackForgotten { .. }
15655        | NodeResponse::DurableContextPackResolved { .. }
15656        | NodeResponse::ContextPackBytesRead { .. }
15657        | NodeResponse::SessionRecordForgotten { .. }
15658        | NodeResponse::WorkspaceRegistered { .. }
15659        | NodeResponse::StandaloneWorkspaceCreated { .. }
15660        | NodeResponse::WorkspaceUnregistered { .. }
15661        | NodeResponse::WorktreeCreated { .. }
15662        | NodeResponse::WorktreeRemoved { .. }
15663        | NodeResponse::Accepted
15664        | NodeResponse::ShuttingDown => {}
15665    }
15666}
15667
15668fn project_snapshot_history_for_wire(
15669    snapshot: &mut NodeSnapshot,
15670    include_history_metadata: bool,
15671) {
15672    for workspace in &mut snapshot.workspaces {
15673        for session in &mut workspace.sessions {
15674            if include_history_metadata {
15675                session.history.loaded = None;
15676            } else {
15677                session.history = Default::default();
15678            }
15679        }
15680    }
15681}
15682
15683fn project_event_history_for_wire(
15684    mut envelope: NodeEventEnvelope,
15685    include_history_metadata: bool,
15686) -> NodeEventEnvelope {
15687    if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
15688        for session in &mut workspace.sessions {
15689            if include_history_metadata {
15690                session.history.loaded = None;
15691            } else {
15692                session.history = Default::default();
15693            }
15694        }
15695    }
15696    envelope
15697}
15698
15699fn project_response_history_for_wire(
15700    reply: &mut ResponseEnvelope,
15701    include_history_metadata: bool,
15702) {
15703    let Ok(response) = reply.result.as_mut() else {
15704        return;
15705    };
15706    match response {
15707        NodeResponse::Snapshot { snapshot, .. } => {
15708            project_snapshot_history_for_wire(snapshot, include_history_metadata);
15709        }
15710        NodeResponse::Resync {
15711            snapshot, events, ..
15712        } => {
15713            project_snapshot_history_for_wire(snapshot, include_history_metadata);
15714            for event in events {
15715                *event = project_event_history_for_wire(
15716                    event.clone(),
15717                    include_history_metadata,
15718                );
15719            }
15720        }
15721        NodeResponse::WorkspaceRegistered { workspace }
15722        | NodeResponse::StandaloneWorkspaceCreated { workspace }
15723        | NodeResponse::WorktreeCreated { workspace, .. } => {
15724            for session in &mut workspace.sessions {
15725                if include_history_metadata {
15726                    session.history.loaded = None;
15727                } else {
15728                    session.history = Default::default();
15729                }
15730            }
15731        }
15732        _ => {}
15733    }
15734}
15735
15736fn clear_snapshot_context_packs(snapshot: &mut NodeSnapshot) {
15737    for record in &mut snapshot.session_records {
15738        record.context_id = None;
15739        record.context = None;
15740        record.exported_context = None;
15741    }
15742}
15743
15744fn project_event_without_context_pack(
15745    mut envelope: NodeEventEnvelope,
15746) -> NodeEventEnvelope {
15747    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15748        record.context_id = None;
15749        record.context = None;
15750        record.exported_context = None;
15751    }
15752    envelope
15753}
15754
15755fn project_response_without_context_pack(reply: &mut ResponseEnvelope) {
15756    let contains_context = match reply.result.as_ref() {
15757        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.context.is_some(),
15758        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15759            receipt.spawn.context.is_some()
15760        }
15761        Ok(NodeResponse::HistoryDiscovered { .. })
15762        | Ok(NodeResponse::HistoryLoaded { .. })
15763        | Ok(NodeResponse::ContextPackForSessionRecordExported { .. })
15764        | Ok(NodeResponse::ContextPackExported { .. })
15765        | Ok(NodeResponse::ContextPackForgotten { .. })
15766        | Ok(NodeResponse::DurableContextPackResolved { .. }) => true,
15767        _ => false,
15768    };
15769    if contains_context {
15770        reply.result = Err(failure(
15771            NodeFailureCode::UnsupportedCapability,
15772            "history context packs were not negotiated",
15773        ));
15774        return;
15775    }
15776    let Ok(response) = reply.result.as_mut() else {
15777        return;
15778    };
15779    match response {
15780        NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_context_packs(snapshot),
15781        NodeResponse::Resync {
15782            snapshot, events, ..
15783        } => {
15784            clear_snapshot_context_packs(snapshot);
15785            for event in events {
15786                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15787                    record.context_id = None;
15788                    record.context = None;
15789                    record.exported_context = None;
15790                }
15791            }
15792        }
15793        NodeResponse::SessionRecordUpdated { record }
15794        | NodeResponse::ProviderSessionIndexed { record }
15795        | NodeResponse::NativeSessionIndexed { record, .. }
15796        | NodeResponse::SessionRecordResumed { record, .. } => {
15797            record.context_id = None;
15798            record.context = None;
15799            record.exported_context = None;
15800        }
15801        _ => {}
15802    }
15803}
15804
15805fn project_response_legacy_provider_ids(shared: &NodeShared, reply: &mut ResponseEnvelope) {
15806    let contains_open_record = match reply.result.as_ref() {
15807        Ok(NodeResponse::SessionRecordUpdated { record })
15808        | Ok(NodeResponse::ProviderSessionIndexed { record })
15809        | Ok(NodeResponse::SessionRecordResumed { record, .. }) => {
15810            !provider_id_is_legacy(&record.provider)
15811        }
15812        Ok(NodeResponse::NativeSessionIndexed { selection, record }) => {
15813            !provider_id_is_legacy(&selection.route.provider)
15814                || !provider_id_is_legacy(&record.provider)
15815        }
15816        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
15817            !provider_id_is_legacy(&receipt.provider)
15818        }
15819        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15820            !provider_id_is_legacy(&receipt.spawn.provider)
15821        }
15822        Ok(NodeResponse::NativeSessionsCataloged { route, .. })
15823        | Ok(NodeResponse::NativeSessionsPaged { route, .. }) => {
15824            !provider_id_is_legacy(&route.provider)
15825        }
15826        Ok(NodeResponse::NativeSessionPreviewed { selection, .. }) => {
15827            !provider_id_is_legacy(&selection.route.provider)
15828        }
15829        _ => false,
15830    };
15831    if contains_open_record {
15832        reply.result = Err(failure(
15833            NodeFailureCode::UnsupportedCapability,
15834            "open provider IDs were not negotiated",
15835        ));
15836        return;
15837    }
15838    let Ok(response) = reply.result.as_mut() else {
15839        return;
15840    };
15841    match response {
15842        NodeResponse::Snapshot { snapshot, .. } => {
15843            project_snapshot_legacy_provider_ids(snapshot);
15844        }
15845        NodeResponse::Resync { snapshot, events, .. } => {
15846            project_snapshot_legacy_provider_ids(snapshot);
15847            *events = std::mem::take(events)
15848                .into_iter()
15849                .filter_map(|event| project_event_legacy_provider_ids(shared, event))
15850                .collect();
15851        }
15852        NodeResponse::WorkspaceRegistered { workspace }
15853        | NodeResponse::StandaloneWorkspaceCreated { workspace }
15854        | NodeResponse::WorktreeCreated { workspace, .. } => {
15855            workspace
15856                .sessions
15857                .retain(|session| provider_id_is_legacy(&session.agent_id));
15858        }
15859        NodeResponse::Armed { .. }
15860        | NodeResponse::Spawned { .. }
15861        | NodeResponse::Activated { .. }
15862        | NodeResponse::Aborted { .. }
15863        | NodeResponse::ReplyChunkAccepted { .. }
15864        | NodeResponse::CallRejected { .. }
15865        | NodeResponse::DeliveryStageBegun { .. }
15866        | NodeResponse::DeliveryBlobChunkAccepted { .. }
15867        | NodeResponse::DeliveryCommitted { .. }
15868        | NodeResponse::DeliveryStageAborted { .. }
15869        | NodeResponse::WorkspaceInspected { .. }
15870        | NodeResponse::HostDirectoriesBrowsed { .. }
15871        | NodeResponse::WorkspaceFileRead { .. }
15872        | NodeResponse::WorkspaceFileWritten { .. }
15873        | NodeResponse::WorkspaceFileCreated { .. }
15874        | NodeResponse::WorkspaceDirectoryCreated { .. }
15875        | NodeResponse::GitHistoryRead { .. }
15876        | NodeResponse::GitDiffRead { .. }
15877        | NodeResponse::Controller { .. }
15878        | NodeResponse::SpawnAccepted { .. }
15879        | NodeResponse::SpawnSpecAccepted { .. }
15880        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15881        | NodeResponse::ManagedWorktreeCleanup { .. }
15882        | NodeResponse::NativeSessionsCataloged { .. }
15883        | NodeResponse::NativeSessionsPaged { .. }
15884        | NodeResponse::NativeSessionPreviewed { .. }
15885        | NodeResponse::SessionRecordPreviewed { .. }
15886        | NodeResponse::HistoryDiscovered { .. }
15887        | NodeResponse::HistoryLoaded { .. }
15888        | NodeResponse::ContextPackForSessionRecordExported { .. }
15889        | NodeResponse::ContextPackExported { .. }
15890        | NodeResponse::ContextPackForgotten { .. }
15891        | NodeResponse::DurableContextPackResolved { .. }
15892        | NodeResponse::ContextPackBytesRead { .. }
15893        | NodeResponse::SessionRecordUpdated { .. }
15894        | NodeResponse::ProviderSessionIndexed { .. }
15895        | NodeResponse::NativeSessionIndexed { .. }
15896        | NodeResponse::SessionRecordResumed { .. }
15897        | NodeResponse::SessionRecordForgotten { .. }
15898        | NodeResponse::WorkspaceUnregistered { .. }
15899        | NodeResponse::WorktreeRemoved { .. }
15900        | NodeResponse::Accepted
15901        | NodeResponse::ShuttingDown => {}
15902    }
15903}
15904
15905fn project_event_legacy_provider_ids(
15906    shared: &NodeShared,
15907    mut envelope: NodeEventEnvelope,
15908) -> Option<NodeEventEnvelope> {
15909    let include = match &mut envelope.event {
15910        NodeEvent::HarnessMcpReadCall { .. } => true,
15911        NodeEvent::Control { address, .. }
15912        | NodeEvent::AgentStream { address, .. }
15913        | NodeEvent::TerminalFrame { address, .. } => shared
15914            .validate_address(address)
15915            .is_ok_and(|provider| provider_id_is_legacy(&provider)),
15916        NodeEvent::WorkspaceAdded { workspace } => {
15917            workspace
15918                .sessions
15919                .retain(|session| provider_id_is_legacy(&session.agent_id));
15920            true
15921        }
15922        NodeEvent::SessionRecordUpserted { record } => {
15923            provider_id_is_legacy(&record.provider)
15924        }
15925        NodeEvent::SessionRecordHistorySummarized { .. } => false,
15926        NodeEvent::SessionRecordRemoved { .. } => shared
15927            .history
15928            .lock()
15929            .unwrap_or_else(|poisoned| poisoned.into_inner())
15930            .removed_record_providers
15931            .get(&envelope.sequence)
15932            .is_some_and(provider_id_is_legacy),
15933        NodeEvent::ControllerChanged { .. }
15934        | NodeEvent::ManagedWorktreeUpserted { .. }
15935        | NodeEvent::ManagedWorktreeRemoved { .. }
15936        | NodeEvent::WorkspaceRemoved { .. }
15937        | NodeEvent::ResyncRequired { .. } => true,
15938    };
15939    include.then_some(envelope)
15940}
15941
15942fn clear_response_provider_runtime_status(reply: &mut ResponseEnvelope) {
15943    let Ok(response) = reply.result.as_mut() else {
15944        return;
15945    };
15946    match response {
15947        NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
15948            snapshot.provider_runtime_statuses.clear();
15949        }
15950        NodeResponse::Armed { .. }
15951        | NodeResponse::Spawned { .. }
15952        | NodeResponse::Activated { .. }
15953        | NodeResponse::Aborted { .. }
15954        | NodeResponse::ReplyChunkAccepted { .. }
15955        | NodeResponse::CallRejected { .. }
15956        | NodeResponse::DeliveryStageBegun { .. }
15957        | NodeResponse::DeliveryBlobChunkAccepted { .. }
15958        | NodeResponse::DeliveryCommitted { .. }
15959        | NodeResponse::DeliveryStageAborted { .. }
15960        | NodeResponse::WorkspaceInspected { .. }
15961        | NodeResponse::HostDirectoriesBrowsed { .. }
15962        | NodeResponse::WorkspaceFileRead { .. }
15963        | NodeResponse::WorkspaceFileWritten { .. }
15964        | NodeResponse::WorkspaceFileCreated { .. }
15965        | NodeResponse::WorkspaceDirectoryCreated { .. }
15966        | NodeResponse::GitHistoryRead { .. }
15967        | NodeResponse::GitDiffRead { .. }
15968        | NodeResponse::Controller { .. }
15969        | NodeResponse::SpawnAccepted { .. }
15970        | NodeResponse::SpawnSpecAccepted { .. }
15971        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15972        | NodeResponse::ManagedWorktreeCleanup { .. }
15973        | NodeResponse::NativeSessionsCataloged { .. }
15974        | NodeResponse::NativeSessionsPaged { .. }
15975        | NodeResponse::NativeSessionPreviewed { .. }
15976        | NodeResponse::SessionRecordPreviewed { .. }
15977        | NodeResponse::HistoryDiscovered { .. }
15978        | NodeResponse::HistoryLoaded { .. }
15979        | NodeResponse::ContextPackForSessionRecordExported { .. }
15980        | NodeResponse::ContextPackExported { .. }
15981        | NodeResponse::ContextPackForgotten { .. }
15982        | NodeResponse::DurableContextPackResolved { .. }
15983        | NodeResponse::ContextPackBytesRead { .. }
15984        | NodeResponse::SessionRecordUpdated { .. }
15985        | NodeResponse::ProviderSessionIndexed { .. }
15986        | NodeResponse::NativeSessionIndexed { .. }
15987        | NodeResponse::SessionRecordResumed { .. }
15988        | NodeResponse::SessionRecordForgotten { .. }
15989        | NodeResponse::WorkspaceRegistered { .. }
15990        | NodeResponse::StandaloneWorkspaceCreated { .. }
15991        | NodeResponse::WorkspaceUnregistered { .. }
15992        | NodeResponse::WorktreeCreated { .. }
15993        | NodeResponse::WorktreeRemoved { .. }
15994        | NodeResponse::Accepted
15995        | NodeResponse::ShuttingDown => {}
15996    }
15997}
15998
15999struct ControllerReleaseGuard {
16000    shared: Arc<NodeShared>,
16001    connection_id: u64,
16002}
16003
16004impl Drop for ControllerReleaseGuard {
16005    fn drop(&mut self) {
16006        self.shared.release_controller(self.connection_id);
16007    }
16008}
16009
16010struct AbortTaskOnDrop(AbortHandle);
16011
16012impl Drop for AbortTaskOnDrop {
16013    fn drop(&mut self) {
16014        self.0.abort();
16015    }
16016}
16017
16018/// Reads an unsigned env-config override, following the pattern of every
16019/// other bounded knob in this module: missing/unparsable/zero falls back to
16020/// `default`, and the resolved value is always clamped to `max` so a bad
16021/// override cannot reopen a bound this budget exists to enforce.
16022fn env_config_bounded(name: &str, default: u64, max: u64) -> u64 {
16023    std::env::var(name)
16024        .ok()
16025        .and_then(|value| value.parse::<u64>().ok())
16026        .filter(|value| *value > 0)
16027        .unwrap_or(default)
16028        .min(max)
16029}
16030
16031fn workspace_inspection_time_budget_ms() -> u64 {
16032    env_config_bounded(
16033        "GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS",
16034        WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT,
16035        WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX,
16036    )
16037}
16038
16039fn workspace_inspection_entry_cap() -> usize {
16040    env_config_bounded(
16041        "GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP",
16042        WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT as u64,
16043        WORKSPACE_INSPECTION_ENTRY_CAP_MAX as u64,
16044    ) as usize
16045}
16046
16047/// Tracks the shared walk-side budget across the whole recursive walk: a
16048/// wall-clock deadline (shared with the git phase that runs after the walk)
16049/// and a cap on directory entries visited, whether or not they were pushed
16050/// to the response. Cheap to check per entry — one `Instant::now()` and one
16051/// integer compare.
16052struct WorkspaceWalkBudget {
16053    deadline: Instant,
16054    entry_cap: usize,
16055    entries_visited: u64,
16056    time_budget_exceeded: bool,
16057    entry_cap_exceeded: bool,
16058}
16059
16060impl WorkspaceWalkBudget {
16061    fn new(deadline: Instant, entry_cap: usize) -> Self {
16062        Self {
16063            deadline,
16064            entry_cap,
16065            entries_visited: 0,
16066            time_budget_exceeded: false,
16067            entry_cap_exceeded: false,
16068        }
16069    }
16070
16071    fn is_exhausted(&self) -> bool {
16072        self.time_budget_exceeded || self.entry_cap_exceeded
16073    }
16074
16075    /// Records one visited directory entry (pushed to the response or not)
16076    /// and reports whether the walk must stop now.
16077    fn record_visit(&mut self) -> bool {
16078        self.entries_visited += 1;
16079        if self.entries_visited as usize >= self.entry_cap {
16080            self.entry_cap_exceeded = true;
16081        }
16082        if Instant::now() >= self.deadline {
16083            self.time_budget_exceeded = true;
16084        }
16085        self.is_exhausted()
16086    }
16087}
16088
16089fn collect_workspace_entries(
16090    root: &Path,
16091    deadline: Instant,
16092    entry_cap: usize,
16093) -> (Vec<WorkspaceEntry>, bool, WorkspaceWalkBudget) {
16094    let mut entries = Vec::new();
16095    let mut truncated = false;
16096    let mut budget = WorkspaceWalkBudget::new(deadline, entry_cap);
16097    walk_workspace_directory(root, None, 0, &mut entries, &mut truncated, &mut budget);
16098    // DFS pre-order emits `x/child` between prefix-named siblings `x` and
16099    // `x-y` ('-' sorts below '/'), while the bounded operator projections
16100    // require strict global byte order over relative paths.
16101    entries.sort_by(|left, right| left.relative_path.as_utf8().cmp(&right.relative_path.as_utf8()));
16102    (entries, truncated, budget)
16103}
16104
16105/// Builds the additive `WorkspaceInspectionTruncationV1` for an
16106/// `inspect_workspace` response — `None` once the walk and git phase both
16107/// completed inside their shared budget.
16108fn workspace_inspection_truncation(
16109    walk_budget: &WorkspaceWalkBudget,
16110    git_time_budget_exceeded: bool,
16111    elapsed: Duration,
16112) -> Option<WorkspaceInspectionTruncationV1> {
16113    if !walk_budget.time_budget_exceeded
16114        && !walk_budget.entry_cap_exceeded
16115        && !git_time_budget_exceeded
16116    {
16117        return None;
16118    }
16119    Some(WorkspaceInspectionTruncationV1 {
16120        walk_time_budget_exceeded: walk_budget.time_budget_exceeded,
16121        walk_entry_cap_exceeded: walk_budget.entry_cap_exceeded,
16122        git_time_budget_exceeded,
16123        entries_visited: walk_budget.entries_visited,
16124        elapsed_ms: u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX),
16125    })
16126}
16127
16128fn walk_workspace_directory(
16129    directory: &Path,
16130    relative_directory: Option<&RepositoryPath>,
16131    depth: usize,
16132    entries: &mut Vec<WorkspaceEntry>,
16133    truncated: &mut bool,
16134    budget: &mut WorkspaceWalkBudget,
16135) {
16136    if budget.is_exhausted() {
16137        *truncated = true;
16138        return;
16139    }
16140    let read_dir = match std::fs::read_dir(directory) {
16141        Ok(read_dir) => read_dir,
16142        Err(_) => {
16143            *truncated = true;
16144            return;
16145        }
16146    };
16147    let mut children = Vec::new();
16148    for child in read_dir {
16149        match child {
16150            Ok(child) => children.push(child),
16151            Err(_) => *truncated = true,
16152        }
16153    }
16154    children.sort_by_key(|child| child.file_name());
16155    for child in children {
16156        if budget.record_visit() {
16157            *truncated = true;
16158            return;
16159        }
16160        if entries.len() >= WORKSPACE_TREE_MAX_ENTRIES {
16161            *truncated = true;
16162            return;
16163        }
16164        let file_type = match child.file_type() {
16165            Ok(file_type) => file_type,
16166            Err(_) => {
16167                *truncated = true;
16168                continue;
16169            }
16170        };
16171        if file_type.is_symlink() {
16172            continue;
16173        }
16174        let name = match child.file_name().into_string() {
16175            Ok(name) => name,
16176            Err(_) => {
16177                *truncated = true;
16178                continue;
16179            }
16180        };
16181        if file_type.is_dir() && is_skipped_workspace_directory(&name, &child.path()) {
16182            continue;
16183        }
16184        let relative_path = match relative_directory {
16185            None => windows_repository_path(name),
16186            Some(parent) => parent
16187                .as_utf8()
16188                .and_then(|parent| windows_repository_path(format!("{parent}/{name}"))),
16189        };
16190        let Some(relative_path) = relative_path else {
16191            *truncated = true;
16192            continue;
16193        };
16194        let kind = if file_type.is_dir() {
16195            WorkspaceEntryKind::Directory
16196        } else if file_type.is_file() {
16197            WorkspaceEntryKind::File
16198        } else {
16199            continue;
16200        };
16201        entries.push(WorkspaceEntry {
16202            relative_path: relative_path.clone(),
16203            kind,
16204        });
16205        if file_type.is_dir() {
16206            if depth + 1 < WORKSPACE_TREE_MAX_DEPTH {
16207                walk_workspace_directory(
16208                    &child.path(),
16209                    Some(&relative_path),
16210                    depth + 1,
16211                    entries,
16212                    truncated,
16213                    budget,
16214                );
16215            } else {
16216                *truncated = true;
16217            }
16218        }
16219    }
16220}
16221
16222fn windows_repository_path(value: String) -> Option<RepositoryPath> {
16223    if value.contains('\\') {
16224        return None;
16225    }
16226    RepositoryPath::utf8(value).ok()
16227}
16228
16229fn windows_repository_path_from_bytes(value: &[u8]) -> Option<RepositoryPath> {
16230    windows_repository_path(std::str::from_utf8(value).ok()?.to_owned())
16231}
16232
16233/// Excludes a directory from the workspace tree walk. The three literal
16234/// names are the common convention-based cases; the `CACHEDIR.TAG` check
16235/// beneath them catches every build-output directory regardless of name
16236/// (cargo writes `CACHEDIR.TAG` into every `--target-dir` it creates, so
16237/// this covers `target-a2`, `target-a2-tui`, and any future renamed build
16238/// directory without maintaining a name heuristic list). The name check
16239/// runs first and returns early so a directory rejected by name never pays
16240/// for the extra `CACHEDIR.TAG` stat.
16241fn is_skipped_workspace_directory(name: &str, path: &Path) -> bool {
16242    if name.eq_ignore_ascii_case(".git")
16243        || name.eq_ignore_ascii_case("target")
16244        || name.eq_ignore_ascii_case("node_modules")
16245    {
16246        return true;
16247    }
16248    path.join("CACHEDIR.TAG").is_file()
16249}
16250
16251fn context_pack_source_status_is_usable(status: &SessionStatus) -> bool {
16252    matches!(
16253        status,
16254        SessionStatus::Running | SessionStatus::Exited { exit_code: Some(0) }
16255    )
16256}
16257
16258#[derive(Clone, Debug, Eq, PartialEq)]
16259enum ContextPackRepositoryHead {
16260    NotRepository,
16261    Unborn,
16262    Commit(String),
16263}
16264
16265#[derive(Clone, Debug, Eq, PartialEq)]
16266struct ContextPackRepositoryObservation {
16267    head: ContextPackRepositoryHead,
16268    git: GitSnapshot,
16269    files: Vec<ContextPackRepositoryFileSource>,
16270}
16271
16272fn stable_context_pack_repository(
16273    first: ContextPackRepositoryObservation,
16274    second: ContextPackRepositoryObservation,
16275) -> Result<ContextPackRepository, NodeFailure> {
16276    if first != second {
16277        return Err(failure(
16278            NodeFailureCode::ContextPackMaterializationFailed,
16279            "context repository changed during bounded capture",
16280        ));
16281    }
16282    Ok(ContextPackRepository::from_sources(
16283        &second.git,
16284        second.files,
16285    ))
16286}
16287
16288/// Appends a "skipped: budget exhausted" diagnostic and returns `true` once
16289/// (and only once) the shared walk+git `deadline` has already passed. Every
16290/// remaining phase in `inspect_git_workspace` checks this immediately
16291/// before issuing its own git call, so a budget spent by the walk (or by an
16292/// earlier git call) skips the rest of the chain instead of paying for it
16293/// anyway.
16294fn git_budget_exhausted(snapshot: &mut GitSnapshot, deadline: Instant, stage: &str) -> bool {
16295    if Instant::now() < deadline {
16296        return false;
16297    }
16298    append_git_diagnostic(
16299        snapshot,
16300        &format!("{stage} skipped: workspace inspection time budget exhausted"),
16301    );
16302    true
16303}
16304
16305/// Deadline-aware sibling of `run_git_bounded`, used only by
16306/// `inspect_git_workspace`'s six-call chain: caps the per-call timeout to
16307/// whatever remains of the shared `deadline` (never more than
16308/// `GIT_COMMAND_TIMEOUT_MS`), so a slow walk phase that already spent most
16309/// of the budget cannot still hand a full per-call timeout to every git
16310/// probe behind it. `inspect_git_workspace` already guards each call site
16311/// with `git_budget_exhausted`, so `deadline` is guaranteed not to have
16312/// passed yet here.
16313async fn run_git_bounded_with_deadline(
16314    root: &str,
16315    arguments: &[&str],
16316    output_limit: usize,
16317    deadline: Instant,
16318) -> io::Result<GitCommandOutput> {
16319    let remaining = deadline.checked_duration_since(Instant::now());
16320    let timeout_ms = remaining
16321        .and_then(|remaining| u64::try_from(remaining.as_millis()).ok())
16322        .unwrap_or(0)
16323        .clamp(1, GIT_COMMAND_TIMEOUT_MS);
16324    run_git_read_bounded(root, arguments, output_limit, timeout_ms).await
16325}
16326
16327/// Runs the bounded six-call git probe chain used by `inspect_workspace`.
16328/// `deadline` is the same absolute instant the preceding directory walk was
16329/// bounded by, so time already spent walking counts against what remains
16330/// for git enrichment. Returns the resulting `GitSnapshot` plus whether the
16331/// shared budget (not an individual per-call timeout) is what cut the git
16332/// phase short.
16333async fn inspect_git_workspace(root: &str, deadline: Instant) -> (GitSnapshot, bool) {
16334    let mut snapshot = GitSnapshot {
16335        is_repository: false,
16336        branch: None,
16337        status: Vec::new(),
16338        recent_commits: Vec::new(),
16339        worktrees: Vec::new(),
16340        managed_worktree: None,
16341        truncated: false,
16342        diagnostic: None,
16343    };
16344    if git_budget_exhausted(&mut snapshot, deadline, "git repository probe") {
16345        return (snapshot, true);
16346    }
16347    let repository = match run_git_bounded_with_deadline(
16348        root,
16349        &["rev-parse", "--is-inside-work-tree"],
16350        4 * 1_024,
16351        deadline,
16352    )
16353    .await
16354    {
16355        Ok(output) => output,
16356        Err(error) => {
16357            append_git_diagnostic(
16358                &mut snapshot,
16359                &format!("git inspection unavailable: {error}"),
16360            );
16361            return (snapshot, false);
16362        }
16363    };
16364    snapshot.truncated |= repository.truncated;
16365    if repository.timed_out {
16366        append_git_diagnostic(&mut snapshot, "git repository probe timed out");
16367        return (snapshot, false);
16368    }
16369    if !repository.success
16370        || String::from_utf8_lossy(&repository.stdout).trim() != "true"
16371    {
16372        let stderr = String::from_utf8_lossy(&repository.stderr);
16373        if !stderr.to_ascii_lowercase().contains("not a git repository") {
16374            append_git_diagnostic(&mut snapshot, stderr.trim());
16375        }
16376        return (snapshot, false);
16377    }
16378    snapshot.is_repository = true;
16379
16380    if git_budget_exhausted(&mut snapshot, deadline, "git branch query") {
16381        return (snapshot, true);
16382    }
16383    match run_git_bounded_with_deadline(root, &["branch", "--show-current"], 4 * 1_024, deadline).await {
16384        Ok(output) => {
16385            snapshot.truncated |= output.truncated;
16386            if output.timed_out {
16387                append_git_diagnostic(&mut snapshot, "git branch query timed out");
16388            } else if output.success {
16389                let branch = String::from_utf8_lossy(&output.stdout).trim().to_owned();
16390                if !branch.is_empty() {
16391                    snapshot.branch = Some(truncate_git_field(
16392                        &branch,
16393                        MAX_REPOSITORY_PATH_BYTES,
16394                        &mut snapshot.truncated,
16395                    ));
16396                }
16397            }
16398        }
16399        Err(error) => append_git_diagnostic(
16400            &mut snapshot,
16401            &format!("git branch query failed: {error}"),
16402        ),
16403    }
16404    if snapshot.branch.is_none() && !git_budget_exhausted(&mut snapshot, deadline, "git detached-HEAD probe") {
16405        if let Ok(output) = run_git_bounded_with_deadline(root, &["rev-parse", "--short", "HEAD"], 4 * 1_024, deadline).await {
16406            snapshot.truncated |= output.truncated;
16407            if output.success && !output.timed_out {
16408                let head = String::from_utf8_lossy(&output.stdout).trim().to_owned();
16409                if !head.is_empty() {
16410                    snapshot.branch = Some(format!("detached:{head}"));
16411                }
16412            }
16413        }
16414    }
16415
16416    if git_budget_exhausted(&mut snapshot, deadline, "git status") {
16417        return (snapshot, true);
16418    }
16419    match run_git_bounded_with_deadline(
16420        root,
16421        &[
16422            "status",
16423            "--porcelain=v1",
16424            "-z",
16425            "--renames",
16426            "--untracked-files=normal",
16427            "--",
16428            ".",
16429        ],
16430        GIT_OUTPUT_MAX_BYTES,
16431        deadline,
16432    )
16433    .await
16434    {
16435        Ok(output) => {
16436            snapshot.truncated |= output.truncated;
16437            if output.timed_out {
16438                append_git_diagnostic(&mut snapshot, "git status timed out");
16439            } else if output.success {
16440                parse_git_status(&output.stdout, &mut snapshot);
16441            } else {
16442                append_git_diagnostic(
16443                    &mut snapshot,
16444                    String::from_utf8_lossy(&output.stderr).trim(),
16445                );
16446            }
16447        }
16448        Err(error) => append_git_diagnostic(
16449            &mut snapshot,
16450            &format!("git status failed: {error}"),
16451        ),
16452    }
16453
16454    if git_budget_exhausted(&mut snapshot, deadline, "git log") {
16455        return (snapshot, true);
16456    }
16457    let log_limit = (GIT_COMMIT_MAX_ENTRIES + 1).to_string();
16458    match run_git_bounded_with_deadline(
16459        root,
16460        &["log", "-n", &log_limit, "--pretty=format:%H%x1f%s", "--", "."],
16461        16 * 1_024,
16462        deadline,
16463    )
16464    .await
16465    {
16466        Ok(output) => {
16467            snapshot.truncated |= output.truncated;
16468            if output.timed_out {
16469                append_git_diagnostic(&mut snapshot, "git log timed out");
16470            } else if output.success {
16471                parse_git_commits(&output.stdout, &mut snapshot);
16472            } else {
16473                let stderr = String::from_utf8_lossy(&output.stderr);
16474                if !stderr.to_ascii_lowercase().contains("does not have any commits") {
16475                    append_git_diagnostic(&mut snapshot, stderr.trim());
16476                }
16477            }
16478        }
16479        Err(error) => append_git_diagnostic(
16480            &mut snapshot,
16481            &format!("git log failed: {error}"),
16482        ),
16483    }
16484
16485    if git_budget_exhausted(&mut snapshot, deadline, "git worktree list") {
16486        return (snapshot, true);
16487    }
16488    match list_git_worktrees_with_deadline(root, deadline).await {
16489        Ok(worktrees) => {
16490            snapshot.worktrees = worktrees.into_iter().map(protocol_worktree).collect()
16491        }
16492        Err(error) => append_git_diagnostic(
16493            &mut snapshot,
16494            &format!("git worktree list failed: {}", error.message),
16495        ),
16496    }
16497    (snapshot, false)
16498}
16499
16500fn parse_git_status(output: &[u8], snapshot: &mut GitSnapshot) {
16501    let mut remaining = output;
16502    while !remaining.is_empty() {
16503        if snapshot.status.len() >= GIT_STATUS_MAX_ENTRIES {
16504            snapshot.truncated = true;
16505            break;
16506        }
16507        let Some(record_end) = remaining.iter().position(|byte| *byte == 0) else {
16508            snapshot.truncated = true;
16509            break;
16510        };
16511        let record = &remaining[..record_end];
16512        remaining = &remaining[record_end + 1..];
16513        let has_previous_path = record
16514            .get(..2)
16515            .is_some_and(|status| status.iter().any(|byte| matches!(*byte, b'R' | b'C')));
16516        let previous_path_bytes = if has_previous_path {
16517            let Some(previous_end) = remaining.iter().position(|byte| *byte == 0) else {
16518                snapshot.truncated = true;
16519                break;
16520            };
16521            let previous = &remaining[..previous_end];
16522            remaining = &remaining[previous_end + 1..];
16523            Some(previous)
16524        } else {
16525            None
16526        };
16527        if record.len() < 4
16528            || record[2] != b' '
16529            || !is_git_status_code(record[0])
16530            || !is_git_status_code(record[1])
16531        {
16532            snapshot.truncated = true;
16533            continue;
16534        }
16535        let Some(path) = windows_repository_path_from_bytes(&record[3..]) else {
16536            snapshot.truncated = true;
16537            continue;
16538        };
16539        let previous_path = match previous_path_bytes {
16540            Some(previous) => {
16541                let Some(previous) = windows_repository_path_from_bytes(previous) else {
16542                    snapshot.truncated = true;
16543                    continue;
16544                };
16545                Some(previous)
16546            }
16547            None => None,
16548        };
16549        snapshot.status.push(GitStatusEntry {
16550            index_status: (record[0] as char).to_string(),
16551            worktree_status: (record[1] as char).to_string(),
16552            path,
16553            previous_path,
16554        });
16555    }
16556}
16557
16558fn is_git_status_code(byte: u8) -> bool {
16559    matches!(byte, b' ' | b'M' | b'T' | b'A' | b'D' | b'R' | b'C' | b'U' | b'?' | b'!')
16560}
16561
16562fn parse_git_commits(output: &[u8], snapshot: &mut GitSnapshot) {
16563    for line in String::from_utf8_lossy(output).lines() {
16564        let Some((id, summary)) = line.split_once('\u{1f}') else {
16565            continue;
16566        };
16567        if snapshot.recent_commits.len() >= GIT_COMMIT_MAX_ENTRIES {
16568            snapshot.truncated = true;
16569            break;
16570        }
16571        let id = truncate_git_field(id, 64, &mut snapshot.truncated);
16572        let summary = truncate_git_field(summary, 512, &mut snapshot.truncated);
16573        snapshot.recent_commits.push(GitCommitSummary { id, summary });
16574    }
16575}
16576
16577fn truncate_git_field(value: &str, max_bytes: usize, truncated: &mut bool) -> String {
16578    if value.len() <= max_bytes {
16579        return value.to_owned();
16580    }
16581    *truncated = true;
16582    let mut boundary = max_bytes;
16583    while boundary > 0 && !value.is_char_boundary(boundary) {
16584        boundary -= 1;
16585    }
16586    value[..boundary].to_owned()
16587}
16588
16589fn append_git_diagnostic(snapshot: &mut GitSnapshot, message: &str) {
16590    if message.is_empty() {
16591        return;
16592    }
16593    let normalized = message
16594        .chars()
16595        .map(|character| if character.is_control() { ' ' } else { character })
16596        .collect::<String>();
16597    let mut diagnostic = snapshot.diagnostic.take().unwrap_or_default();
16598    if !diagnostic.is_empty() {
16599        diagnostic.push_str("; ");
16600    }
16601    diagnostic.push_str(normalized.trim());
16602    snapshot.diagnostic = Some(truncate_git_field(
16603        &diagnostic,
16604        GIT_DIAGNOSTIC_MAX_BYTES,
16605        &mut snapshot.truncated,
16606    ));
16607}
16608
16609async fn run_git_bounded(
16610    root: &str,
16611    arguments: &[&str],
16612    output_limit: usize,
16613) -> io::Result<GitCommandOutput> {
16614    run_git_read_bounded(root, arguments, output_limit, GIT_COMMAND_TIMEOUT_MS).await
16615}
16616
16617async fn read_git_history_bounded(
16618    root: &str,
16619    path: Option<&RepositoryPath>,
16620    before: Option<&GitObjectId>,
16621    limit: u16,
16622) -> Result<GitHistoryPage, NodeFailure> {
16623    let path = path
16624        .map(|path| {
16625            path.as_utf8().ok_or_else(|| {
16626                failure(
16627                    NodeFailureCode::InvalidRepositoryPath,
16628                    "git history path must use UTF-8",
16629                )
16630            })
16631        })
16632        .transpose()?
16633        .unwrap_or(".");
16634    let count = (usize::from(limit) + 1).to_string();
16635    let start = before
16636        .map(|revision| format!("{}^", revision.as_str()))
16637        .unwrap_or_else(|| "HEAD".to_owned());
16638    let arguments = [
16639        "--no-pager",
16640        "log",
16641        "--no-decorate",
16642        "--date=iso-strict",
16643        "--pretty=format:%H%x1f%P%x1f%s%x1f%an%x1f%ae%x1f%aI%x1f%cn%x1f%ce%x1f%cI%x1e",
16644        "-n",
16645        count.as_str(),
16646        start.as_str(),
16647        "--",
16648        path,
16649    ];
16650    let output = run_git_bounded(root, &arguments, 256 * 1_024)
16651        .await
16652        .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git history command failed"))?;
16653    if output.timed_out {
16654        return Err(failure(NodeFailureCode::GitReadTimedOut, "git history exceeded its bounded deadline"));
16655    }
16656    if !output.success {
16657        return Err(failure(NodeFailureCode::GitReadFailed, "git history could not be read"));
16658    }
16659    let mut commits = parse_git_history(&output.stdout);
16660    let has_more = commits.len() > usize::from(limit) || output.truncated;
16661    commits.truncate(usize::from(limit));
16662    let next_before = has_more.then(|| commits.last().map(|commit| commit.id.clone())).flatten();
16663    Ok(GitHistoryPage { commits, next_before, truncated: output.truncated })
16664}
16665
16666fn parse_git_history(output: &[u8]) -> Vec<GitCommitDetails> {
16667    String::from_utf8_lossy(output)
16668        .split('\u{1e}')
16669        .filter_map(|record| {
16670            let fields = record.trim_matches(['\r', '\n']).split('\u{1f}').collect::<Vec<_>>();
16671            if fields.len() != 9 {
16672                return None;
16673            }
16674            let id = GitObjectId::new(fields[0].to_owned()).ok()?;
16675            let parents = if fields[1].is_empty() {
16676                Vec::new()
16677            } else {
16678                fields[1]
16679                    .split(' ')
16680                    .map(|value| GitObjectId::new(value.to_owned()))
16681                    .collect::<Result<Vec<_>, _>>()
16682                    .ok()?
16683            };
16684            Some(GitCommitDetails {
16685                id,
16686                parents,
16687                subject: bounded_git_text(fields[2], 512),
16688                author_name: bounded_git_text(fields[3], 256),
16689                author_email: bounded_git_text(fields[4], 320),
16690                authored_at: bounded_git_text(fields[5], 64),
16691                committer_name: bounded_git_text(fields[6], 256),
16692                committer_email: bounded_git_text(fields[7], 320),
16693                committed_at: bounded_git_text(fields[8], 64),
16694                signature_status: GitSignatureStatus::NoSignature,
16695                signer: None,
16696            })
16697        })
16698        .take(usize::from(MAX_GIT_HISTORY_COMMITS) + 1)
16699        .collect()
16700}
16701
16702fn bounded_git_text(value: &str, max_bytes: usize) -> String {
16703    let normalized = value
16704        .chars()
16705        .map(|character| if character.is_control() { ' ' } else { character })
16706        .collect::<String>();
16707    if normalized.len() <= max_bytes {
16708        return normalized;
16709    }
16710    let mut boundary = max_bytes;
16711    while boundary > 0 && !normalized.is_char_boundary(boundary) {
16712        boundary -= 1;
16713    }
16714    normalized[..boundary].to_owned()
16715}
16716
16717async fn read_git_diff_bounded(root: &str, request: GitDiffRequest) -> Result<GitDiff, NodeFailure> {
16718    let path = request.path.as_ref().map(|path| {
16719        path.as_utf8()
16720            .ok_or_else(|| failure(NodeFailureCode::InvalidRepositoryPath, "git diff path must use UTF-8"))
16721    }).transpose()?;
16722    let mut arguments = match &request.mode {
16723        GitDiffMode::Working => vec!["--no-pager", "-c", "diff.external=", "diff", "--no-ext-diff", "--no-textconv"],
16724        GitDiffMode::Staged => vec!["--no-pager", "-c", "diff.external=", "diff", "--cached", "--no-ext-diff", "--no-textconv"],
16725        GitDiffMode::Commit { .. } => vec!["--no-pager", "-c", "diff.external=", "show", "--format=fuller", "--no-ext-diff", "--no-textconv"],
16726    };
16727    if let GitDiffMode::Commit { revision } = &request.mode {
16728        arguments.push(revision.as_str());
16729    }
16730    arguments.push("--");
16731    if let Some(path) = path {
16732        arguments.push(path);
16733    }
16734    let output = run_git_bounded(root, &arguments, MAX_GIT_DIFF_BYTES)
16735        .await
16736        .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git diff command failed"))?;
16737    if output.timed_out {
16738        return Err(failure(NodeFailureCode::GitReadTimedOut, "git diff exceeded its bounded deadline"));
16739    }
16740    if !output.success {
16741        return Err(failure(NodeFailureCode::GitReadFailed, "git diff could not be read"));
16742    }
16743    Ok(GitDiff {
16744        mode: request.mode,
16745        path: request.path,
16746        text: String::from_utf8_lossy(&output.stdout).into_owned(),
16747        truncated: output.truncated,
16748    })
16749}
16750
16751async fn process_request(shared: &NodeShared, connection_id: u64, role: ClientRole, envelope: RequestEnvelope) -> ResponseEnvelope {
16752    let result = process_request_inner(shared, connection_id, role, envelope.request).await;
16753    ResponseEnvelope { request_id: envelope.request_id, result }
16754}
16755
16756async fn process_request_inner(shared: &NodeShared, connection_id: u64, role: ClientRole, request: NodeRequest) -> Result<NodeResponse, NodeFailure> {
16757    if !request.harness_mcp_contract_is_valid_at(unix_time_ms()) {
16758        return Err(failure(NodeFailureCode::InvalidRequest, "harness MCP request is invalid or stale"));
16759    }
16760    let read_only = matches!(
16761        &request,
16762        NodeRequest::Snapshot
16763            | NodeRequest::Resync { .. }
16764            | NodeRequest::BrowseHostDirectories { .. }
16765            | NodeRequest::InspectWorkspace { .. }
16766            | NodeRequest::ReadWorkspaceFile { .. }
16767            | NodeRequest::ReadGitHistory { .. }
16768            | NodeRequest::ReadGitDiff { .. }
16769            | NodeRequest::CatalogNativeSessions { .. }
16770            | NodeRequest::PageNativeSessions { .. }
16771            | NodeRequest::PreviewNativeSession { .. }
16772            | NodeRequest::PreviewSessionRecord { .. }
16773    );
16774    let _mutation_guard = if read_only {
16775        None
16776    } else {
16777        let guard = shared.mutation_gate.lock().await;
16778        if shared.shutdown.load(Ordering::Acquire) {
16779            return Err(failure(NodeFailureCode::ShuttingDown, "node shutdown has begun"));
16780        }
16781        Some(guard)
16782    };
16783    match request {
16784        NodeRequest::Snapshot => Ok(NodeResponse::Snapshot {
16785            event_sequence: shared.current_sequence(),
16786            controller: shared.controller_state(),
16787            snapshot: shared.snapshot(),
16788        }),
16789        NodeRequest::Resync { after_sequence } => Ok(shared.resync(after_sequence)),
16790        NodeRequest::ArmHarnessMcpReservation {
16791            reservation_id,
16792            activation_digest,
16793            spawn_spec,
16794            launch,
16795            expires_at_unix_ms,
16796        } => {
16797            shared.require_controller(connection_id, role)?;
16798            let registry = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16799                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16800            })?;
16801            let expires_at_unix_ms = registry
16802                .arm(reservation_id.clone(), activation_digest.clone(), spawn_spec, launch, expires_at_unix_ms)
16803                .await
16804                .map_err(harness_mcp_failure)?;
16805            Ok(NodeResponse::Armed { reservation_id, activation_digest, expires_at_unix_ms })
16806        }
16807        NodeRequest::SpawnSpecWithHarnessMcp {
16808            reservation_id,
16809            activation_digest,
16810            spec,
16811            deadline_unix_ms,
16812        } => {
16813            shared.require_controller(connection_id, role)?;
16814            let receipt = shared
16815                .spawn_from_spec_with_harness_mcp(
16816                    reservation_id.clone(), activation_digest.clone(), spec, deadline_unix_ms,
16817                )
16818                .await?;
16819            Ok(NodeResponse::Spawned { reservation_id, activation_digest, receipt })
16820        }
16821        NodeRequest::ActivateHarnessMcpReservation {
16822            reservation_id,
16823            activation_digest,
16824            record_id,
16825            session,
16826        } => {
16827            shared.require_controller(connection_id, role)?;
16828            let provider_root_pid = shared.handle.snapshot().sessions.iter().find(|current| {
16829                current.instance_id == session.session.instance_id
16830                    && current.generation == session.session.generation
16831            }).and_then(|current| current.process_id).ok_or_else(|| {
16832                failure(NodeFailureCode::BindingMismatch, "provider root process is unavailable")
16833            })?;
16834            shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16835                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16836            })?.activate(
16837                &reservation_id, &activation_digest, &record_id, &session, provider_root_pid,
16838            ).map_err(harness_mcp_failure)?;
16839            Ok(NodeResponse::Activated { reservation_id, activation_digest, record_id, session })
16840        }
16841        NodeRequest::AbortHarnessMcpReservation { reservation_id, activation_digest } => {
16842            shared.require_controller(connection_id, role)?;
16843            let instance_id = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16844                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16845            })?.abort(&reservation_id, &activation_digest).map_err(harness_mcp_failure)?;
16846            if let Some(instance_id) = instance_id {
16847                SessionLifecycleHook::on_harness_reservation_aborted(&*shared, instance_id);
16848            }
16849            Ok(NodeResponse::Aborted { reservation_id, activation_digest })
16850        }
16851        NodeRequest::PutHarnessMcpReplyChunk {
16852            reservation_id, activation_digest, record_id, session, call_id,
16853            offset, final_chunk, chunk_hex,
16854        } => {
16855            shared.require_controller(connection_id, role)?;
16856            let (next_offset, completed) = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16857                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16858            })?.put_reply_chunk(
16859                &reservation_id, &activation_digest, &record_id, &session, &call_id,
16860                offset, final_chunk, &chunk_hex,
16861            ).map_err(harness_mcp_failure)?;
16862            Ok(NodeResponse::ReplyChunkAccepted {
16863                reservation_id, activation_digest, record_id, session, call_id,
16864                next_offset, completed,
16865            })
16866        }
16867        NodeRequest::RejectHarnessMcpCall {
16868            reservation_id, activation_digest, record_id, session, call_id, reason,
16869        } => {
16870            shared.require_controller(connection_id, role)?;
16871            shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16872                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16873            })?.reject_call(
16874                &reservation_id, &activation_digest, &record_id, &session, &call_id, reason,
16875            ).map_err(harness_mcp_failure)?;
16876            Ok(NodeResponse::CallRejected {
16877                reservation_id, activation_digest, record_id, session, call_id,
16878            })
16879        }
16880        NodeRequest::BeginDeliveryStage { manifest } => {
16881            shared.require_controller(connection_id, role)?;
16882            shared.begin_delivery_stage(manifest)
16883        }
16884        NodeRequest::PutDeliveryBlobChunk {
16885            stage_id,
16886            blob_digest,
16887            offset,
16888            chunk_hex,
16889        } => {
16890            shared.require_controller(connection_id, role)?;
16891            shared.put_delivery_blob_chunk(stage_id, blob_digest, offset, chunk_hex)
16892        }
16893        NodeRequest::CommitDeliveryStage { stage_id } => {
16894            shared.require_controller(connection_id, role)?;
16895            let receipt = shared.commit_delivery_stage(stage_id)?;
16896            Ok(NodeResponse::DeliveryCommitted { receipt })
16897        }
16898        NodeRequest::AbortDeliveryStage { stage_id } => {
16899            shared.require_controller(connection_id, role)?;
16900            shared.abort_delivery_stage(&stage_id)?;
16901            Ok(NodeResponse::DeliveryStageAborted { stage_id })
16902        }
16903        NodeRequest::BrowseHostDirectories { directory, after } => {
16904            let listing = shared.browse_host_directories(directory, after).await?;
16905            Ok(NodeResponse::HostDirectoriesBrowsed { listing })
16906        }
16907        NodeRequest::InspectWorkspace { workspace_id } => {
16908            let inspection = shared.inspect_workspace(workspace_id).await?;
16909            Ok(NodeResponse::WorkspaceInspected { inspection })
16910        }
16911        NodeRequest::ReadWorkspaceFile { workspace_id, path } => {
16912            let file = shared.read_workspace_file(workspace_id, path).await?;
16913            Ok(NodeResponse::WorkspaceFileRead { file })
16914        }
16915        NodeRequest::WriteWorkspaceFile { workspace_id, path, expected_revision, text } => {
16916            shared.require_controller(connection_id, role)?;
16917            let file = shared
16918                .write_workspace_file(workspace_id, path, expected_revision, text)
16919                .await?;
16920            Ok(NodeResponse::WorkspaceFileWritten { file })
16921        }
16922        NodeRequest::CreateWorkspaceFile { workspace_id, path } => {
16923            shared.require_controller(connection_id, role)?;
16924            let file = shared.create_workspace_file(workspace_id, path).await?;
16925            Ok(NodeResponse::WorkspaceFileCreated { file })
16926        }
16927        NodeRequest::CreateWorkspaceDirectory { workspace_id, path } => {
16928            shared.require_controller(connection_id, role)?;
16929            let entry = shared
16930                .create_workspace_directory(workspace_id.clone(), path)
16931                .await?;
16932            Ok(NodeResponse::WorkspaceDirectoryCreated { workspace_id, entry })
16933        }
16934        NodeRequest::ReadGitHistory { workspace_id, path, before, limit } => {
16935            if role != ClientRole::Operator {
16936                return Err(failure(NodeFailureCode::ObserverReadOnly, "git history requires an operator connection"));
16937            }
16938            if !(1..=MAX_GIT_HISTORY_COMMITS).contains(&limit) {
16939                return Err(failure(NodeFailureCode::InvalidRequest, "git history limit is invalid"));
16940            }
16941            let page = shared
16942                .read_git_history(workspace_id.clone(), path, before, limit)
16943                .await?;
16944            Ok(NodeResponse::GitHistoryRead { workspace_id, page })
16945        }
16946        NodeRequest::ReadGitDiff { workspace_id, request } => {
16947            if role != ClientRole::Operator {
16948                return Err(failure(NodeFailureCode::ObserverReadOnly, "git diff requires an operator connection"));
16949            }
16950            let diff = shared.read_git_diff(workspace_id.clone(), request).await?;
16951            Ok(NodeResponse::GitDiffRead { workspace_id, diff })
16952        }
16953        NodeRequest::CatalogNativeSessions { route, limit } => {
16954            if role != ClientRole::Operator {
16955                return Err(failure(
16956                    NodeFailureCode::ObserverReadOnly,
16957                    "native session catalog requires an operator connection",
16958                ));
16959            }
16960            if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit) {
16961                return Err(failure(
16962                    NodeFailureCode::InvalidRequest,
16963                    "native session catalog limit is invalid",
16964                ));
16965            }
16966            let (entries, summary) = shared
16967                .catalog_native_sessions(route.clone(), limit)
16968                .await?;
16969            Ok(NodeResponse::NativeSessionsCataloged {
16970                route,
16971                entries,
16972                summary: Some(summary),
16973            })
16974        }
16975        NodeRequest::PageNativeSessions {
16976            route,
16977            window,
16978            catalog_revision,
16979            recent_cutoff_unix_ms,
16980            after_selection_id,
16981            limit,
16982        } => {
16983            if role != ClientRole::Operator {
16984                return Err(failure(
16985                    NodeFailureCode::ObserverReadOnly,
16986                    "native session catalog paging requires an operator connection",
16987                ));
16988            }
16989            if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit)
16990                || after_selection_id
16991                    .as_deref()
16992                    .is_some_and(|cursor| validate_candidate_id(cursor).is_err())
16993            {
16994                return Err(failure(
16995                    NodeFailureCode::InvalidRequest,
16996                    "native session catalog page request is invalid",
16997                ));
16998            }
16999            let page = shared
17000                .page_native_sessions(
17001                    route.clone(),
17002                    window,
17003                    catalog_revision,
17004                    recent_cutoff_unix_ms,
17005                    after_selection_id,
17006                    limit,
17007                )
17008                .await?;
17009            Ok(NodeResponse::NativeSessionsPaged {
17010                route,
17011                page,
17012            })
17013        }
17014        NodeRequest::PreviewNativeSession {
17015            selection,
17016            message_limit,
17017        } => {
17018            if role != ClientRole::Operator {
17019                return Err(failure(
17020                    NodeFailureCode::ObserverReadOnly,
17021                    "native session preview requires an operator connection",
17022                ));
17023            }
17024            if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
17025                .contains(&message_limit)
17026                || selection.validate().is_err()
17027            {
17028                return Err(failure(
17029                    NodeFailureCode::InvalidRequest,
17030                    "native session preview request is invalid",
17031                ));
17032            }
17033            let preview = shared
17034                .preview_native_session(selection.clone(), message_limit)
17035                .await?;
17036            Ok(NodeResponse::NativeSessionPreviewed {
17037                selection,
17038                preview,
17039            })
17040        }
17041        NodeRequest::IndexNativeSession {
17042            selection,
17043            display_name,
17044        } => {
17045            shared.require_controller(connection_id, role)?;
17046            let record = shared
17047                .index_native_session(selection.clone(), display_name)
17048                .await?;
17049            Ok(NodeResponse::NativeSessionIndexed { selection, record })
17050        }
17051        NodeRequest::PreviewSessionRecord {
17052            record_id,
17053            message_limit,
17054        } => {
17055            if role != ClientRole::Operator {
17056                return Err(failure(
17057                    NodeFailureCode::ObserverReadOnly,
17058                    "native session preview requires an operator connection",
17059                ));
17060            }
17061            if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
17062                .contains(&message_limit)
17063            {
17064                return Err(failure(
17065                    NodeFailureCode::InvalidRequest,
17066                    "native session preview request is invalid",
17067                ));
17068            }
17069            let record = shared.record(&record_id)?;
17070            let working_directory = shared.workspace_root(&record.workspace_id)?;
17071            if !platform::roots_equal(
17072                &working_directory,
17073                windows_path_text(&record.canonical_root),
17074            ) {
17075                return Err(failure(
17076                    NodeFailureCode::SessionRecordConflict,
17077                    "managed session workspace root changed; refusing to preview another directory",
17078                ));
17079            }
17080            let identity = record.provider_session.clone().ok_or_else(|| {
17081                failure(
17082                    NodeFailureCode::BackendOperationFailed,
17083                    "native session preview is unavailable for this session record",
17084                )
17085            })?;
17086            if identity.key != ProviderSessionKey::SessionId {
17087                return Err(failure(
17088                    NodeFailureCode::BackendOperationFailed,
17089                    "native session preview is unavailable for this session identity",
17090                ));
17091            }
17092            let preview = shared
17093                .preview_native_session_by_session_id(
17094                    record.workspace_id.clone(), record.provider.clone(), identity.id.clone(), message_limit,
17095                )
17096                .await?;
17097            if preview.session_id != identity.id {
17098                return Err(failure(
17099                    NodeFailureCode::SessionRecordConflict,
17100                    "native session preview identity changed while loading",
17101                ));
17102            }
17103            shared.revalidate_session_record_preview(&record, &identity)?;
17104            let preview: gate4agent_types::SessionRecordPreview = preview.into();
17105            shared.publish(NodeEvent::SessionRecordHistorySummarized {
17106                record_id: record_id.clone(),
17107                summary: SessionHistorySummaryV1::from(&preview),
17108            });
17109            Ok(NodeResponse::SessionRecordPreviewed {
17110                record_id,
17111                preview,
17112            })
17113        }
17114        NodeRequest::AcquireController { lease_ms } => {
17115            let controller = shared.acquire_controller(connection_id, role, lease_ms)?;
17116            Ok(NodeResponse::Controller { controller: Some(controller) })
17117        }
17118        NodeRequest::ReleaseController => {
17119            shared.release_controller(connection_id);
17120            Ok(NodeResponse::Controller { controller: shared.controller_state() })
17121        }
17122        NodeRequest::RegisterWorkspace { workspace_id, root } => {
17123            shared.require_controller(connection_id, role)?;
17124            let root = require_windows_path(root)?;
17125            shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
17126            let workspace = shared.register_workspace(workspace_id, root).await?;
17127            Ok(NodeResponse::WorkspaceRegistered { workspace })
17128        }
17129        NodeRequest::CreateStandaloneWorkspace {
17130            workspace_id,
17131            root,
17132            initial_branch,
17133        } => {
17134            shared.require_controller(connection_id, role)?;
17135            let root = require_windows_path(root)?;
17136            shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
17137            let workspace = shared
17138                .create_standalone_workspace(workspace_id, root, initial_branch)
17139                .await?;
17140            Ok(NodeResponse::StandaloneWorkspaceCreated { workspace })
17141        }
17142        NodeRequest::UnregisterWorkspace { workspace_id } => {
17143            shared.require_controller(connection_id, role)?;
17144            shared.reject_managed_reservation(Some(&workspace_id), None)?;
17145            shared.unregister_workspace(&workspace_id)?;
17146            Ok(NodeResponse::WorkspaceUnregistered { workspace_id })
17147        }
17148        NodeRequest::CreateWorktree {
17149            source_workspace_id,
17150            workspace_id,
17151            target_root,
17152            branch,
17153            base,
17154        } => {
17155            shared.require_controller(connection_id, role)?;
17156            let target_root = require_windows_path(target_root)?;
17157            shared.reject_managed_reservation(Some(&workspace_id), Some(&target_root))?;
17158            let (worktree, workspace) = shared
17159                .create_worktree(
17160                    source_workspace_id,
17161                    workspace_id,
17162                    target_root,
17163                    branch,
17164                    base,
17165                )
17166                .await?;
17167            Ok(NodeResponse::WorktreeCreated { worktree, workspace })
17168        }
17169        NodeRequest::RemoveWorktree {
17170            source_workspace_id,
17171            target_root,
17172        } => {
17173            shared.require_controller(connection_id, role)?;
17174            let native_target_root = require_windows_path(target_root.clone())?;
17175            shared.reject_managed_reservation(None, Some(&native_target_root))?;
17176            let workspace_id = shared
17177                .remove_worktree(source_workspace_id, native_target_root)
17178                .await?;
17179            Ok(NodeResponse::WorktreeRemoved {
17180                target_root,
17181                workspace_id,
17182            })
17183        }
17184        NodeRequest::Spawn { workspace_id, provider, mode, terminal_size, initial_prompt } => {
17185            shared.require_controller(connection_id, role)?;
17186            if !terminal_size.is_valid() {
17187                return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
17188            }
17189            if let Some(prompt) = initial_prompt.as_deref() {
17190                validate_node_text("spawn initial prompt", prompt)?;
17191            }
17192            match shared
17193                .spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt)
17194                .await
17195            {
17196                Ok(session) => {
17197                    shared.mark_spawn_provisional(session.clone());
17198                    Ok(NodeResponse::SpawnAccepted { session })
17199                }
17200                Err(err) => {
17201                    tracing::warn!(
17202                        %provider,
17203                        %workspace_id,
17204                        ?mode,
17205                        code = ?err.code,
17206                        message = %err.message,
17207                        "session spawn refused"
17208                    );
17209                    Err(err)
17210                }
17211            }
17212        }
17213        NodeRequest::SpawnWithOptions { workspace_id, provider, mode, terminal_size, initial_prompt, options } => {
17214            shared.require_controller(connection_id, role)?;
17215            if !terminal_size.is_valid() {
17216                return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
17217            }
17218            if let Some(prompt) = initial_prompt.as_deref() {
17219                validate_node_text("spawn initial prompt", prompt)?;
17220            }
17221            let plan = backend_paths::plan_launch_options(
17222                provider.as_str(),
17223                mode,
17224                options.model.is_some(),
17225                options.effort.is_some(),
17226            )
17227            .map_err(|r| {
17228                tracing::warn!(%provider, reason = %r.message, "launch option path refused");
17229                failure(NodeFailureCode::InvalidRequest, "launch option path refused")
17230            })?;
17231            let selection = spawn_selection(&options)?;
17232            let nick_model = selection.as_ref().map(|s| s.model.clone());
17233            let pty_selection = match plan {
17234                backend_paths::LaunchOptionsPlan::PtyFlags => selection,
17235                _ => None,
17236            };
17237            let spawned = SPAWN_ORIGIN.scope(options.origin.clone(), async {
17238                SPAWN_PARENT.scope(options.parent_session_id.clone(), async {
17239                    SPAWN_NICK_MODEL.scope(nick_model, SPAWN_SESSION_OPTIONS
17240                        .scope(pty_selection, shared.spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt)))
17241                        .await
17242                }).await
17243            }).await;
17244            match spawned {
17245                Ok(session) => {
17246                    if plan == backend_paths::LaunchOptionsPlan::AcpAfterSpawn {
17247                        shared
17248                            .pending_acp_options
17249                            .lock()
17250                            .unwrap_or_else(|p| p.into_inner())
17251                            .insert(session.session.instance_id, (options.model.clone(), options.effort.clone()));
17252                    }
17253                    shared.mark_spawn_provisional(session.clone());
17254                    tracing::info!(%provider, model = ?options.model, effort = ?options.effort, "session spawned with launch options");
17255                    Ok(NodeResponse::SpawnAccepted { session })
17256                }
17257                Err(err) => {
17258                    tracing::warn!(%provider, %workspace_id, code = ?err.code, message = %err.message, "session spawn with options refused");
17259                    Err(err)
17260                }
17261            }
17262        }
17263        NodeRequest::SpawnSpec { spec } => {
17264            shared.require_controller(connection_id, role)?;
17265            let receipt = shared.spawn_from_spec(spec).await?;
17266            Ok(NodeResponse::SpawnSpecAccepted { receipt })
17267        }
17268        NodeRequest::SpawnManagedWorktree { request } => {
17269            shared.require_controller(connection_id, role)?;
17270            let receipt = shared.spawn_managed_worktree(request).await?;
17271            Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
17272        }
17273        NodeRequest::SpawnManagedWorktreeV2 { request } => {
17274            shared.require_controller(connection_id, role)?;
17275            let receipt = shared.spawn_managed_worktree_v2(request).await?;
17276            Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
17277        }
17278        NodeRequest::CleanupManagedWorktree { lease_id } => {
17279            shared.require_controller(connection_id, role)?;
17280            let lease = shared.cleanup_managed_worktree(&lease_id, true).await?;
17281            Ok(NodeResponse::ManagedWorktreeCleanup { lease })
17282        }
17283        NodeRequest::DiscoverHistory { session, limit } => {
17284            controlled_session(shared, connection_id, role, &session)?;
17285            let candidates = shared.discover_history(&session, limit).await?;
17286            Ok(NodeResponse::HistoryDiscovered {
17287                session,
17288                candidates,
17289            })
17290        }
17291        NodeRequest::LoadHistory {
17292            session,
17293            candidate_id,
17294        } => {
17295            controlled_session(shared, connection_id, role, &session)?;
17296            let loaded = shared
17297                .load_history(&session, candidate_id)
17298                .await?;
17299            Ok(NodeResponse::HistoryLoaded {
17300                session,
17301                session_id: loaded.session_id,
17302                message_count: loaded.message_count,
17303                completed_turn_count: loaded.completed_turn_count,
17304            })
17305        }
17306        NodeRequest::ExportContextPackForSessionRecord { record_id, session } => {
17307            shared.require_controller(connection_id, role)?;
17308            let context = shared
17309                .export_context_pack_for_session_record(&record_id, &session, false)
17310                .await?;
17311            Ok(NodeResponse::ContextPackForSessionRecordExported {
17312                record_id,
17313                session,
17314                context,
17315            })
17316        }
17317        NodeRequest::ExportContextPack { session } => {
17318            controlled_session(shared, connection_id, role, &session)?;
17319            let context = shared.export_context_pack(&session).await?;
17320            Ok(NodeResponse::ContextPackExported { context })
17321        }
17322        NodeRequest::ForgetContextPack { context_id } => {
17323            shared.require_controller(connection_id, role)?;
17324            shared.forget_context_pack(&context_id)?;
17325            Ok(NodeResponse::ContextPackForgotten { context_id })
17326        }
17327        NodeRequest::ResolveDurableContextPack { context_id } => {
17328            // Read-only, idempotent, no controller lease required: this lets
17329            // a caller preflight "is this durably exported pack still here"
17330            // without holding the Node's mutation controller.
17331            let context = shared
17332                .context_catalog
17333                .read()
17334                .unwrap_or_else(|poisoned| poisoned.into_inner())
17335                .get(&context_id)
17336                .map(|pack| pack.receipt().clone())
17337                .ok_or_else(|| {
17338                    failure(
17339                        NodeFailureCode::UnknownContextPack,
17340                        "context pack is not present",
17341                    )
17342                })?;
17343            Ok(NodeResponse::DurableContextPackResolved { context })
17344        }
17345        NodeRequest::ReadContextPack { digest } => {
17346            // Read-only fetch of the pack's own bytes, keyed by content digest
17347            // rather than the caller's `SpawnContextId`: same no-controller-lease
17348            // rationale as `ResolveDurableContextPack` above, one step further
17349            // down the same catalog.
17350            let (id, byte_len, bytes) = shared
17351                .context_catalog
17352                .read()
17353                .unwrap_or_else(|poisoned| poisoned.into_inner())
17354                .get_by_digest(&digest)
17355                .map(|pack| {
17356                    (pack.receipt().id.clone(), pack.receipt().byte_len, pack.bytes().to_vec())
17357                })
17358                .ok_or_else(|| {
17359                    failure(
17360                        NodeFailureCode::UnknownContextPack,
17361                        &format!("context pack {digest} not on this node"),
17362                    )
17363                })?;
17364            tracing::debug!(
17365                digest = digest.as_str(),
17366                byte_len,
17367                "read context pack bytes",
17368            );
17369            Ok(NodeResponse::ContextPackBytesRead {
17370                pack: ContextPackBytesRead {
17371                    digest,
17372                    id,
17373                    byte_len,
17374                    bytes,
17375                },
17376            })
17377        }
17378        NodeRequest::Resume { session, terminal_size, initial_prompt } => {
17379            let provider = controlled_session(shared, connection_id, role, &session)?;
17380            if let Some(prompt) = initial_prompt.as_deref() {
17381                validate_node_text("resume initial prompt", prompt)?;
17382            }
17383            let runtime_requirement = if initial_prompt.is_some() {
17384                ProviderRuntimeRequirement::ResumeWithPrompt
17385            } else {
17386                ProviderRuntimeRequirement::Resume
17387            };
17388            shared.require_session_runtime_policy(&session, runtime_requirement)?;
17389            let runtime_policy = shared
17390                .admit_provider_runtime(&provider, runtime_requirement)
17391                .await?;
17392            let working_directory = shared.bound_workspace_root(&session)?;
17393            let request = ResumeLaunchRequest {
17394                working_directory,
17395                terminal_size,
17396                initial_prompt,
17397            };
17398            request
17399                .validate()
17400                .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
17401            let command = shared.prepare_command(ControlCommand::Resume {
17402                instance_id: session.session.instance_id,
17403                target: ResumeTarget::CurrentProvider,
17404                runtime_policy,
17405                request,
17406            });
17407            shared.arm_resume(&session, command.id, runtime_policy)?;
17408            let dispatch = shared.dispatch_envelope(command);
17409            if let Err(error) = dispatch {
17410                shared.clear_armed_resume(&session);
17411                return Err(error);
17412            }
17413            Ok(NodeResponse::Accepted)
17414        }
17415        NodeRequest::RenameSessionRecord {
17416            record_id,
17417            display_name,
17418        } => {
17419            shared.require_controller(connection_id, role)?;
17420            let record = shared.rename_session_record(&record_id, display_name)?;
17421            Ok(NodeResponse::SessionRecordUpdated { record })
17422        }
17423        NodeRequest::SetSessionTask {
17424            record_id,
17425            expected_revision,
17426            target,
17427        } => {
17428            shared.require_controller(connection_id, role)?;
17429            let record = shared.set_session_task(&record_id, expected_revision, target)?;
17430            Ok(NodeResponse::SessionRecordUpdated { record })
17431        }
17432        NodeRequest::IndexProviderSession {
17433            workspace_id,
17434            provider,
17435            identity,
17436            display_name,
17437        } => {
17438            shared.require_controller(connection_id, role)?;
17439            let record = shared.index_provider_session(
17440                workspace_id,
17441                provider,
17442                identity,
17443                display_name,
17444            )?;
17445            Ok(NodeResponse::ProviderSessionIndexed { record })
17446        }
17447        NodeRequest::ResumeSessionRecord {
17448            record_id,
17449            terminal_size,
17450            initial_prompt,
17451        } => {
17452            shared.require_controller(connection_id, role)?;
17453            if let Some(prompt) = initial_prompt.as_deref() {
17454                validate_node_text("managed resume initial prompt", prompt)?;
17455            }
17456            let (record, session) = shared
17457                .resume_session_record(
17458                    &record_id,
17459                    terminal_size,
17460                    initial_prompt,
17461                )
17462                .await?;
17463            Ok(NodeResponse::SessionRecordResumed { record, session })
17464        }
17465        NodeRequest::ForgetSessionRecord { record_id } => {
17466            shared.require_controller(connection_id, role)?;
17467            shared.forget_session_record(&record_id).await?;
17468            Ok(NodeResponse::SessionRecordForgotten { record_id })
17469        }
17470        NodeRequest::Prompt { session, text } => {
17471            let agent_id = controlled_session(shared, connection_id, role, &session)?;
17472            validate_node_text("prompt", &text)?;
17473            shared.require_session_runtime_policy(
17474                &session,
17475                ProviderRuntimeRequirement::SemanticPrompt,
17476            )?;
17477            let framing = prompt_framing(&agent_id);
17478            shared
17479                .dispatch_input_bounded(
17480                    &session,
17481                    InputAction::SubmitPrompt(PromptPayload { text, framing }),
17482                )
17483                .await?;
17484            Ok(NodeResponse::Accepted)
17485        }
17486        NodeRequest::Paste { session, text } => {
17487            controlled_session(shared, connection_id, role, &session)?;
17488            validate_node_text("paste", &text)?;
17489            shared.require_session_runtime_policy(
17490                &session,
17491                ProviderRuntimeRequirement::SemanticPrompt,
17492            )?;
17493            shared
17494                .dispatch_input_bounded(
17495                    &session,
17496                    InputAction::InsertDraft(PromptPayload {
17497                        text,
17498                        framing: PromptFraming::BracketedPaste,
17499                    }),
17500                )
17501                .await?;
17502            Ok(NodeResponse::Accepted)
17503        }
17504        NodeRequest::Input { session, text } => {
17505            controlled_session(shared, connection_id, role, &session)?;
17506            validate_node_text("terminal input", &text)?;
17507            shared
17508                .dispatch_input_bounded(
17509                    &session,
17510                    InputAction::TerminalText(TerminalText { text }),
17511                )
17512                .await?;
17513            Ok(NodeResponse::Accepted)
17514        }
17515        NodeRequest::TerminalBytes { session, bytes } => {
17516            controlled_session(shared, connection_id, role, &session)?;
17517            shared.sessionize_spawn(session.session.instance_id);
17518            let action = terminal_bytes_action(bytes)?;
17519            shared
17520                .dispatch_input_bounded(&session, action)
17521                .await?;
17522            Ok(NodeResponse::Accepted)
17523        }
17524        NodeRequest::TerminalControl { session, control } => {
17525            controlled_session(shared, connection_id, role, &session)?;
17526            shared
17527                .dispatch_input_bounded(&session, InputAction::TerminalControl(control))
17528                .await?;
17529            Ok(NodeResponse::Accepted)
17530        }
17531        NodeRequest::Resize { session, size } => {
17532            controlled_session(shared, connection_id, role, &session)?;
17533            shared.sessionize_spawn(session.session.instance_id);
17534            if !size.is_valid() {
17535                return Err(failure(NodeFailureCode::InvalidRequest, "terminal size is invalid"));
17536            }
17537            shared.dispatch_resize_bounded(&session, size).await?;
17538            Ok(NodeResponse::Accepted)
17539        }
17540        NodeRequest::Interrupt { session } => {
17541            controlled_session(shared, connection_id, role, &session)?;
17542            shared
17543                .dispatch_input_bounded(
17544                    &session,
17545                    InputAction::TerminalControl(TerminalControl::Interrupt),
17546                )
17547                .await?;
17548            Ok(NodeResponse::Accepted)
17549        }
17550        NodeRequest::Stop { session, force } => {
17551            controlled_session(shared, connection_id, role, &session)?;
17552            shared.sessionize_spawn(session.session.instance_id);
17553            shared.dispatch(ControlCommand::Stop { instance_id: session.session.instance_id, force })?;
17554            shared.retire_session_grok_leader(session.session.instance_id);
17555            Ok(NodeResponse::Accepted)
17556        }
17557        NodeRequest::Remove { session } => {
17558            controlled_session(shared, connection_id, role, &session)?;
17559            shared.remove_session(&session).await?;
17560            Ok(NodeResponse::Accepted)
17561        }
17562        NodeRequest::ResolveInteraction { session, correlation_id, response } => {
17563            shared.require_controller(connection_id, role)?;
17564            let snapshot = shared.require_acp_session(&session)?;
17565            let interaction = snapshot
17566                .provider
17567                .interactions
17568                .iter()
17569                .find(|interaction| {
17570                    interaction_correlation(
17571                        session.session.instance_id,
17572                        session.session.generation,
17573                        interaction.id.0,
17574                    ) == correlation_id
17575                })
17576                .ok_or_else(|| failure(
17577                    NodeFailureCode::InvalidRequest,
17578                    "unknown interaction correlation",
17579                ))?;
17580            if interaction.status != ProviderInteractionStatus::Pending {
17581                return Err(failure(
17582                    NodeFailureCode::BackendOperationFailed,
17583                    "interaction has already been resolved",
17584                ));
17585            }
17586            response
17587                .validate_for(interaction.interaction_kind)
17588                .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
17589            let interaction_id = interaction.id;
17590            let response_kind = response.kind();
17591            let response_outcome = response.outcome();
17592            shared.dispatch(ControlCommand::ResolveInteraction {
17593                instance_id: session.session.instance_id,
17594                generation: session.session.generation,
17595                interaction_id,
17596                response,
17597            })?;
17598            // The one line that lets an operator compare what THIS node
17599            // recorded against what the provider itself reports doing --
17600            // measured live on a kimi run where our side resolved `true`
17601            // while the agent reported "rejected" and neither layer had a
17602            // line to line up against the other.
17603            tracing::info!(
17604                node_id = %shared.node_id,
17605                session = ?session.session,
17606                correlation_id = %correlation_id,
17607                interaction_id = interaction_id.0,
17608                response_kind = ?response_kind,
17609                outcome = ?response_outcome,
17610                "interaction resolved",
17611            );
17612            Ok(NodeResponse::Accepted)
17613        }
17614        NodeRequest::SetSessionMode { session, mode_id } => {
17615            shared.require_controller(connection_id, role)?;
17616            shared.require_acp_session(&session)?;
17617            shared.dispatch(ControlCommand::SetSessionMode {
17618                instance_id: session.session.instance_id,
17619                mode_id,
17620            })?;
17621            Ok(NodeResponse::Accepted)
17622        }
17623        NodeRequest::SetSessionConfigOption {
17624            session,
17625            option_id,
17626            value_json,
17627        } => {
17628            shared.require_controller(connection_id, role)?;
17629            shared.require_acp_session(&session)?;
17630            shared.dispatch(ControlCommand::SetSessionConfigOption {
17631                instance_id: session.session.instance_id,
17632                option_id,
17633                value_json,
17634            })?;
17635            Ok(NodeResponse::Accepted)
17636        }
17637        NodeRequest::SetSessionModel { session, model_id } => {
17638            shared.require_controller(connection_id, role)?;
17639            shared.require_acp_session(&session)?;
17640            shared.dispatch(ControlCommand::SetSessionModel {
17641                instance_id: session.session.instance_id,
17642                model_id,
17643            })?;
17644            Ok(NodeResponse::Accepted)
17645        }
17646        NodeRequest::Shutdown => {
17647            shared.require_controller(connection_id, role)?;
17648            shared.begin_shutdown_locked().await?;
17649            Ok(NodeResponse::ShuttingDown)
17650        }
17651    }
17652}
17653
17654fn controlled_session(shared: &NodeShared, connection_id: u64, role: ClientRole, session: &SessionAddress) -> Result<AgentId, NodeFailure> {
17655    shared.require_controller(connection_id, role)?;
17656    shared.validate_address(session)
17657}
17658
17659/// Selects the sole history candidate matching `provider_session_id` when
17660/// the managed record carries a verified identity, or the sole candidate in
17661/// the discovered set at all when it does not (see
17662/// `export_context_pack_for_session_record_inner`'s identity match). Either
17663/// way, more than one match refuses instead of guessing which one is exact.
17664fn select_history_candidate(
17665    candidates: &[HistoryCandidateSummary],
17666    provider_session_id: Option<&str>,
17667) -> Result<String, NodeFailure> {
17668    let mut matches = candidates.iter().filter(|candidate| match provider_session_id {
17669        Some(id) => candidate.session_id_hint == id,
17670        None => true,
17671    });
17672    let candidate = matches.next().ok_or_else(|| {
17673        failure(
17674            NodeFailureCode::SessionRecordConflict,
17675            if provider_session_id.is_some() {
17676                "no history candidate matches the managed provider session"
17677            } else {
17678                "no history candidate exists for the managed session"
17679            },
17680        )
17681    })?;
17682    if matches.next().is_some() {
17683        return Err(failure(
17684            NodeFailureCode::SessionRecordConflict,
17685            if provider_session_id.is_some() {
17686                "history identity is ambiguous for the managed provider session"
17687            } else {
17688                "history is ambiguous without a verified provider session identity"
17689            },
17690        ));
17691    }
17692    Ok(candidate.id.clone())
17693}
17694
17695fn resolved_spawn_profile_summary(
17696    profile: &SpawnProfileDefaults,
17697    environment_profiles: &BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>,
17698) -> Option<SpawnProfileSummary> {
17699    let environment_profile = match profile.environment_profile_id.as_ref() {
17700        None => None,
17701        Some(profile_id) => {
17702            let binding = environment_profiles.get(profile_id)?;
17703            if binding.provider != profile.provider
17704                || binding.native_profile_id(profile.mode).is_none()
17705            {
17706                return None;
17707            }
17708            Some(ResolvedEnvironmentProfileReceipt {
17709                profile_id: binding.id.clone(),
17710                profile_revision: binding.revision.clone(),
17711                network_allowlist: None,
17712                browser_profile_id: None,
17713            })
17714        }
17715    };
17716    Some(SpawnProfileSummary {
17717        id: profile.profile_id.clone(),
17718        revision: profile.revision.clone(),
17719        environment_profile,
17720    })
17721}
17722
17723/// True if `current` is an acceptable version of `expected` at export
17724/// revalidation/commit time.
17725///
17726/// When `allow_clean_detachment` is `false` (the on-demand,
17727/// `NodeRequest::ExportContextPackForSessionRecord` API path), `current` must
17728/// be byte-identical to `expected` and still actively bound to `session` —
17729/// the original, unchanged invariant.
17730///
17731/// When `true` (the reactive auto-export-at-exit path, `§2.1`), `current` may
17732/// ALSO be the exact Live -> Dormant transition that the same clean exit this
17733/// export reacts to produces on its own: `state` Live -> Dormant,
17734/// `active_session` `Some(session)` -> `None`, `updated_at_unix_ms`
17735/// advancing. Every other field — including `provider_session`, `context`,
17736/// `bundle`, `task_binding` — must still match exactly. A record that changed
17737/// in any other way (rebind, a different active session, an identity
17738/// conflict, capacity eviction) is rejected either way; this widens nothing
17739/// about which sessions are eligible, only how the record's own expected
17740/// detachment is tolerated mid-export.
17741fn session_record_export_target_matches(
17742    expected: &ManagedSessionRecord,
17743    current: &ManagedSessionRecord,
17744    session: &SessionAddress,
17745    allow_clean_detachment: bool,
17746) -> bool {
17747    if current == expected && current.active_session.as_ref() == Some(session) {
17748        return current.state == ManagedSessionState::Live;
17749    }
17750    allow_clean_detachment
17751        && current.state == ManagedSessionState::Dormant
17752        && current.active_session.is_none()
17753        && current.record_id == expected.record_id
17754        && current.display_name == expected.display_name
17755        && current.provider == expected.provider
17756        && current.mode == expected.mode
17757        && current.workspace_id == expected.workspace_id
17758        && current.canonical_root == expected.canonical_root
17759        && current.provider_session == expected.provider_session
17760        && current.environment_profile == expected.environment_profile
17761        && current.bundle == expected.bundle
17762        && current.context_id == expected.context_id
17763        && current.context == expected.context
17764        && current.exported_context == expected.exported_context
17765        && current.task_binding == expected.task_binding
17766        && current.created_at_unix_ms == expected.created_at_unix_ms
17767        && current.last_error == expected.last_error
17768}
17769
17770fn session_record_context_export_binding_is_exact(
17771    expected: &ManagedSessionRecord,
17772    current: &ManagedSessionRecord,
17773    identity: Option<&ProviderSessionIdentity>,
17774    session: &SessionAddress,
17775    binding: &SessionBinding,
17776    provider: &AgentId,
17777    current_root: &str,
17778    allow_clean_detachment: bool,
17779) -> bool {
17780    session_record_export_target_matches(expected, current, session, allow_clean_detachment)
17781        && current.provider_session.as_ref() == identity
17782        && current.workspace_id == session.workspace_id
17783        && provider == &current.provider
17784        && binding.workspace_id == session.workspace_id
17785        && binding.generation == session.session.generation
17786        && binding.record_id.as_ref() == Some(&current.record_id)
17787        && platform::roots_equal(
17788            current_root,
17789            windows_path_text(&current.canonical_root),
17790        )
17791}
17792
17793fn session_record_context_export_source_is_usable(
17794    record: &ManagedSessionRecord,
17795    status: &SessionStatus,
17796    allow_clean_detachment: bool,
17797) -> bool {
17798    (record.state == ManagedSessionState::Live
17799        || (allow_clean_detachment && record.state == ManagedSessionState::Dormant))
17800        && context_pack_source_status_is_usable(status)
17801}
17802
17803fn prompt_framing(_agent_id: &AgentId) -> PromptFraming {
17804    PromptFraming::BracketedPaste
17805}
17806
17807fn validate_node_text(field: &str, text: &str) -> Result<(), NodeFailure> {
17808    if text.len() > MAX_NODE_TEXT_BYTES {
17809        return Err(failure(
17810            NodeFailureCode::InvalidRequest,
17811            &format!(
17812                "{field} length {} exceeds the {MAX_NODE_TEXT_BYTES}-byte node limit",
17813                text.len(),
17814            ),
17815        ));
17816    }
17817    Ok(())
17818}
17819
17820fn validate_terminal_bytes(bytes: &[u8]) -> Result<(), NodeFailure> {
17821    if bytes.is_empty() || bytes.len() > MAX_NODE_TERMINAL_BYTES {
17822        return Err(failure(
17823            NodeFailureCode::InvalidRequest,
17824            &format!(
17825                "terminal byte sequence must contain 1..={MAX_NODE_TERMINAL_BYTES} bytes; received {}",
17826                bytes.len(),
17827            ),
17828        ));
17829    }
17830    Ok(())
17831}
17832
17833fn terminal_bytes_action(bytes: Vec<u8>) -> Result<InputAction, NodeFailure> {
17834    validate_terminal_bytes(&bytes)?;
17835    Ok(InputAction::TerminalBytes(bytes))
17836}
17837
17838fn validate_workspace_request_root(root: &str) -> Result<(), NodeFailure> {
17839    if root.is_empty()
17840        || root.len() > MAX_WORKSPACE_ROOT_BYTES
17841        || root.chars().any(char::is_control)
17842    {
17843        return Err(failure(
17844            NodeFailureCode::InvalidWorkspaceRoot,
17845            &format!(
17846                "workspace root must contain 1..={MAX_WORKSPACE_ROOT_BYTES} bytes and no control characters",
17847            ),
17848        ));
17849    }
17850    Ok(())
17851}
17852
17853fn canonical_standalone_workspace_candidate(
17854    workspace_id: &WorkspaceId,
17855    root: &str,
17856) -> Result<String, NodeServerError> {
17857    let path = Path::new(root);
17858    match std::fs::symlink_metadata(path) {
17859        Ok(_) => WorkspaceConfig::new(workspace_id.clone(), path)
17860            .map(|workspace| workspace.canonical_root),
17861        Err(error) if error.kind() == io::ErrorKind::NotFound => {
17862            if !path.is_absolute() {
17863                return Err(NodeServerError::InvalidWorkspaceRoot {
17864                    workspace_id: workspace_id.clone(),
17865                    path: root.to_owned(),
17866                    message: "path is not absolute".to_owned(),
17867                });
17868            }
17869            let name = path.file_name().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17870                workspace_id: workspace_id.clone(),
17871                path: root.to_owned(),
17872                message: "path has no standalone directory name".to_owned(),
17873            })?;
17874            let parent = path.parent().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17875                workspace_id: workspace_id.clone(),
17876                path: root.to_owned(),
17877                message: "path has no parent directory".to_owned(),
17878            })?;
17879            let canonical = std::fs::canonicalize(parent)
17880                .map(|parent| parent.join(name))
17881                .map_err(|error| NodeServerError::InvalidWorkspaceRoot {
17882                    workspace_id: workspace_id.clone(),
17883                    path: root.to_owned(),
17884                    message: error.to_string(),
17885                })?;
17886            let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
17887                NodeServerError::InvalidWorkspaceRoot {
17888                    workspace_id: workspace_id.clone(),
17889                    path: path.to_string_lossy().into_owned(),
17890                    message: "canonical path is not valid Unicode".to_owned(),
17891                }
17892            })?;
17893            let canonical_root = platform::normalize_canonical_root(canonical_root);
17894            validate_workspace_root(workspace_id, &canonical_root)?;
17895            Ok(canonical_root)
17896        }
17897        Err(error) => Err(NodeServerError::InvalidWorkspaceRoot {
17898            workspace_id: workspace_id.clone(),
17899            path: root.to_owned(),
17900            message: error.to_string(),
17901        }),
17902    }
17903}
17904
17905fn validate_git_revision(field: &str, revision: Option<&str>) -> Result<(), NodeFailure> {
17906    let Some(revision) = revision else {
17907        return Ok(());
17908    };
17909    if revision.is_empty()
17910        || revision.len() > MAX_REPOSITORY_PATH_BYTES
17911        || revision.chars().any(char::is_control)
17912    {
17913        return Err(failure(
17914            NodeFailureCode::InvalidRequest,
17915            &format!(
17916                "{field} must contain 1..={MAX_REPOSITORY_PATH_BYTES} bytes and no control characters",
17917            ),
17918        ));
17919    }
17920    Ok(())
17921}
17922
17923fn git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17924    let code = match error.kind {
17925        GitWorktreeErrorKind::Invalid => NodeFailureCode::InvalidRequest,
17926        GitWorktreeErrorKind::NotRepository => NodeFailureCode::NotGitRepository,
17927        GitWorktreeErrorKind::Conflict => NodeFailureCode::WorktreeConflict,
17928        GitWorktreeErrorKind::Protected => NodeFailureCode::WorktreeProtected,
17929        GitWorktreeErrorKind::Dirty => NodeFailureCode::WorktreeDirty,
17930        GitWorktreeErrorKind::Locked => NodeFailureCode::WorktreeLocked,
17931        GitWorktreeErrorKind::Failed => NodeFailureCode::BackendOperationFailed,
17932    };
17933    failure(code, &error.message)
17934}
17935
17936fn standalone_workspace_failure(error: StandaloneWorkspaceError) -> NodeFailure {
17937    let code = match error.kind {
17938        StandaloneWorkspaceErrorKind::InvalidRoot => NodeFailureCode::InvalidWorkspaceRoot,
17939        StandaloneWorkspaceErrorKind::InvalidInitialBranch => NodeFailureCode::InvalidRequest,
17940        StandaloneWorkspaceErrorKind::GitInitializationFailed => {
17941            NodeFailureCode::BackendOperationFailed
17942        }
17943        StandaloneWorkspaceErrorKind::RecoveryRequired => {
17944            NodeFailureCode::StandaloneWorkspaceRecoveryRequired
17945        }
17946    };
17947    failure(code, error.message)
17948}
17949
17950fn managed_git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17951    let (code, message) = match error.kind {
17952        GitWorktreeErrorKind::Invalid => (
17953            NodeFailureCode::InvalidRequest,
17954            "managed Git input is invalid",
17955        ),
17956        GitWorktreeErrorKind::NotRepository => (
17957            NodeFailureCode::NotGitRepository,
17958            "managed source is not a Git repository",
17959        ),
17960        GitWorktreeErrorKind::Conflict => (
17961            NodeFailureCode::WorktreeConflict,
17962            "managed Git identity conflicts with existing repository state",
17963        ),
17964        GitWorktreeErrorKind::Protected => (
17965            NodeFailureCode::ManagedWorktreeOwnershipConflict,
17966            "managed worktree ownership validation failed",
17967        ),
17968        GitWorktreeErrorKind::Dirty => (
17969            NodeFailureCode::WorktreeDirty,
17970            "managed worktree contains uncommitted changes",
17971        ),
17972        GitWorktreeErrorKind::Locked => (
17973            NodeFailureCode::WorktreeLocked,
17974            "managed worktree is locked",
17975        ),
17976        GitWorktreeErrorKind::Failed => (
17977            NodeFailureCode::BackendOperationFailed,
17978            "managed Git operation failed",
17979        ),
17980    };
17981    NodeFailure { code, message: message.to_owned() }
17982}
17983
17984fn managed_spawn_receipt(
17985    resolved: &ResolvedSpawnSpec,
17986    incarnation_id: NodeIncarnationId,
17987    session: SessionAddress,
17988    lease: ManagedWorktreeLeaseSnapshot,
17989    environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
17990    bundle: Option<ResolvedBundleReceipt>,
17991    context: Option<ResolvedContextPackReceipt>,
17992) -> ManagedWorktreeSpawnReceipt {
17993    let mut managed = resolved.clone();
17994    managed.target.worktree_id = Some(lease.workspace_id.clone());
17995    ManagedWorktreeSpawnReceipt {
17996        spawn: managed.receipt_with_materialization(
17997            incarnation_id,
17998            session,
17999            environment_profile,
18000            bundle,
18001            context,
18002        ),
18003        lease,
18004    }
18005}
18006
18007fn validate_selected_worktree(
18008    source_root: &str,
18009    selected_root: &str,
18010    worktrees: &[NativeGitWorktreeSnapshot],
18011) -> Result<(), NodeFailure> {
18012    if !worktrees
18013        .iter()
18014        .any(|worktree| worktree_paths_equal(&worktree.path, source_root) && !worktree.is_bare)
18015    {
18016        return Err(failure(
18017            NodeFailureCode::WorktreeProtected,
18018            "source workspace is not an authoritative non-bare Git worktree root",
18019        ));
18020    }
18021    if !worktrees.iter().any(|worktree| {
18022        worktree_paths_equal(&worktree.path, selected_root)
18023            && !worktree.is_main
18024            && !worktree.is_bare
18025    }) {
18026        return Err(failure(
18027            NodeFailureCode::WorktreeProtected,
18028            "selected workspace is not an eligible Git worktree in the source repository",
18029        ));
18030    }
18031    Ok(())
18032}
18033
18034fn workspace_file_failure(error: WorkspaceFileReadError) -> NodeFailure {
18035    let code = match error.kind() {
18036        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
18037        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
18038            NodeFailureCode::RepositoryPathUnsafe
18039        }
18040        WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
18041        WorkspaceFileReadErrorKind::NotRegularFile => {
18042            NodeFailureCode::RepositoryFileNotRegular
18043        }
18044        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
18045            NodeFailureCode::RepositoryFileReadFailed
18046        }
18047        WorkspaceFileReadErrorKind::RevisionConflict => {
18048            NodeFailureCode::RepositoryFileRevisionConflict
18049        }
18050        WorkspaceFileReadErrorKind::AlreadyExists
18051        | WorkspaceFileReadErrorKind::ParentNotFound
18052        | WorkspaceFileReadErrorKind::ParentNotDirectory
18053        | WorkspaceFileReadErrorKind::Canceled => {
18054            NodeFailureCode::RepositoryFileReadFailed
18055        }
18056    };
18057    failure(code, "repository file read failed")
18058}
18059
18060fn workspace_file_write_failure(error: WorkspaceFileReadError) -> NodeFailure {
18061    let code = match error.kind() {
18062        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
18063        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
18064            NodeFailureCode::RepositoryPathUnsafe
18065        }
18066        WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
18067        WorkspaceFileReadErrorKind::NotRegularFile => {
18068            NodeFailureCode::RepositoryFileNotRegular
18069        }
18070        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
18071            NodeFailureCode::RepositoryFileWriteFailed
18072        }
18073        WorkspaceFileReadErrorKind::RevisionConflict => {
18074            NodeFailureCode::RepositoryFileRevisionConflict
18075        }
18076        WorkspaceFileReadErrorKind::AlreadyExists
18077        | WorkspaceFileReadErrorKind::ParentNotFound
18078        | WorkspaceFileReadErrorKind::ParentNotDirectory
18079        | WorkspaceFileReadErrorKind::Canceled => {
18080            NodeFailureCode::RepositoryFileWriteFailed
18081        }
18082    };
18083    failure(code, "repository file write failed")
18084}
18085
18086async fn settle_workspace_entry_create<T>(
18087    mut task: tokio::task::JoinHandle<Result<T, WorkspaceFileReadError>>,
18088    commit_state: Arc<AtomicU8>,
18089    deadline: Duration,
18090    timeout_message: &'static str,
18091    task_failure_message: &'static str,
18092) -> Result<T, NodeFailure> {
18093    let joined = match timeout(deadline, &mut task).await {
18094        Ok(joined) => joined,
18095        Err(_) => {
18096            if commit_state
18097                .compare_exchange(
18098                    WORKSPACE_ENTRY_CREATE_PENDING,
18099                    WORKSPACE_ENTRY_CREATE_CANCELED,
18100                    Ordering::AcqRel,
18101                    Ordering::Acquire,
18102                )
18103                .is_ok()
18104            {
18105                return Err(failure(
18106                    NodeFailureCode::RepositoryEntryCreateTimedOut,
18107                    timeout_message,
18108                ));
18109            }
18110            debug_assert_eq!(
18111                commit_state.load(Ordering::Acquire),
18112                WORKSPACE_ENTRY_CREATE_COMMITTING,
18113            );
18114            task.await
18115        }
18116    };
18117    joined
18118        .map_err(|_| {
18119            failure(
18120                NodeFailureCode::RepositoryEntryCreateFailed,
18121                task_failure_message,
18122            )
18123        })?
18124        .map_err(workspace_entry_create_failure)
18125}
18126
18127fn workspace_entry_create_failure(error: WorkspaceFileReadError) -> NodeFailure {
18128    let code = match error.kind() {
18129        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
18130        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
18131            NodeFailureCode::RepositoryPathUnsafe
18132        }
18133        WorkspaceFileReadErrorKind::AlreadyExists => {
18134            NodeFailureCode::RepositoryEntryAlreadyExists
18135        }
18136        WorkspaceFileReadErrorKind::ParentNotFound | WorkspaceFileReadErrorKind::NotFound => {
18137            NodeFailureCode::RepositoryParentNotFound
18138        }
18139        WorkspaceFileReadErrorKind::ParentNotDirectory
18140        | WorkspaceFileReadErrorKind::NotRegularFile => {
18141            NodeFailureCode::RepositoryParentNotDirectory
18142        }
18143        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
18144            NodeFailureCode::RepositoryEntryCreateFailed
18145        }
18146        WorkspaceFileReadErrorKind::RevisionConflict => {
18147            NodeFailureCode::RepositoryEntryCreateFailed
18148        }
18149        WorkspaceFileReadErrorKind::Canceled => {
18150            NodeFailureCode::RepositoryEntryCreateTimedOut
18151        }
18152    };
18153    failure(code, "repository entry create failed")
18154}
18155
18156fn workspace_file_revision(bytes: &[u8]) -> WorkspaceFileRevision {
18157    let value = digest(&SHA256, bytes)
18158        .as_ref()
18159        .iter()
18160        .map(|byte| format!("{byte:02x}"))
18161        .collect();
18162    WorkspaceFileRevision::new(value).expect("SHA-256 produces a valid workspace revision")
18163}
18164
18165fn managed_spawn_request_digest_v2(
18166    request: &ManagedWorktreeSpawnRequestV2,
18167) -> Result<String, NodeFailure> {
18168    let encoded = serde_json::to_vec(request).map_err(|_| {
18169        failure(
18170            NodeFailureCode::InvalidRequest,
18171            "managed worktree V2 request could not be canonicalized",
18172        )
18173    })?;
18174    let mut value = String::with_capacity(71);
18175    value.push_str("sha256:");
18176    for byte in digest(&SHA256, &encoded).as_ref() {
18177        use std::fmt::Write as _;
18178        write!(&mut value, "{byte:02x}").expect("writing to a String cannot fail");
18179    }
18180    Ok(value)
18181}
18182
18183fn native_session_preview_failure(error: NativeSessionPreviewError) -> NodeFailure {
18184    let code = match error {
18185        NativeSessionPreviewError::InvalidLimit => NodeFailureCode::InvalidRequest,
18186        NativeSessionPreviewError::WorkspaceUnavailable => NodeFailureCode::UnknownWorkspace,
18187        NativeSessionPreviewError::StaleCatalog => NodeFailureCode::StaleNativeSessionCatalog,
18188        NativeSessionPreviewError::UnsupportedProvider
18189        | NativeSessionPreviewError::PreviewUnavailable
18190        | NativeSessionPreviewError::SessionNotFound
18191        | NativeSessionPreviewError::AmbiguousSession => {
18192            NodeFailureCode::BackendOperationFailed
18193        }
18194    };
18195    failure(code, "native session preview failed")
18196}
18197
18198fn host_directory_failure(error: HostDirectoryBrowseError) -> NodeFailure {
18199    let code = match error.kind() {
18200        HostDirectoryBrowseErrorKind::Invalid => NodeFailureCode::HostDirectoryInvalid,
18201        HostDirectoryBrowseErrorKind::ReadFailed => NodeFailureCode::HostDirectoryReadFailed,
18202    };
18203    failure(code, "host directory browse failed")
18204}
18205
18206fn delivery_failure(error: DeliveryStoreError) -> NodeFailure {
18207    let code = match error {
18208        DeliveryStoreError::InvalidManifest => NodeFailureCode::DeliveryManifestInvalid,
18209        DeliveryStoreError::UnknownStage => NodeFailureCode::UnknownDeliveryStage,
18210        DeliveryStoreError::Capacity | DeliveryStoreError::StageConflict => {
18211            NodeFailureCode::DeliveryStageConflict
18212        }
18213        DeliveryStoreError::UnexpectedBlob => NodeFailureCode::DeliveryBlobUnexpected,
18214        DeliveryStoreError::ChunkOutOfOrder | DeliveryStoreError::ChunkOverflow => {
18215            NodeFailureCode::DeliveryChunkOutOfOrder
18216        }
18217        DeliveryStoreError::BlobDigestMismatch => {
18218            NodeFailureCode::DeliveryBlobDigestMismatch
18219        }
18220        DeliveryStoreError::BundleDigestMismatch => {
18221            NodeFailureCode::DeliveryBundleDigestMismatch
18222        }
18223        DeliveryStoreError::StageIncomplete => NodeFailureCode::DeliveryStageIncomplete,
18224        DeliveryStoreError::Unavailable
18225        | DeliveryStoreError::Corrupt
18226        | DeliveryStoreError::Storage(_) => NodeFailureCode::DeliveryStageStorageFailed,
18227    };
18228    failure(code, "delivery operation failed")
18229}
18230
18231fn validate_workspace_root(
18232    workspace_id: &WorkspaceId,
18233    canonical_root: &str,
18234) -> Result<(), NodeServerError> {
18235    #[cfg(windows)]
18236    if canonical_root.starts_with(r"\\") {
18237        return Err(NodeServerError::InvalidWorkspaceRoot {
18238            workspace_id: workspace_id.clone(),
18239            path: canonical_root.to_owned(),
18240            message: "UNC workspace roots are unsupported by Windows PTY providers".to_owned(),
18241        });
18242    }
18243    if !platform::workspace_root_supported(canonical_root)
18244        || canonical_root.len() > WORKING_DIRECTORY_MAX_BYTES
18245    {
18246        return Err(NodeServerError::InvalidWorkspaceRoot {
18247            workspace_id: workspace_id.clone(),
18248            path: canonical_root.to_owned(),
18249            message: "canonical path must be absolute, bounded, and free of control characters"
18250                .to_owned(),
18251        });
18252    }
18253    Ok(())
18254}
18255
18256fn failure(code: NodeFailureCode, _message: &str) -> NodeFailure {
18257    NodeFailure {
18258        code,
18259        message: node_failure_category(code).to_owned(),
18260    }
18261}
18262
18263fn harness_mcp_failure(error: HarnessMcpProxyError) -> NodeFailure {
18264    let code = match error {
18265        HarnessMcpProxyError::Unavailable => NodeFailureCode::HarnessMcpUnavailable,
18266        HarnessMcpProxyError::NotFound => NodeFailureCode::ReservationNotFound,
18267        HarnessMcpProxyError::Conflict => NodeFailureCode::ReservationConflict,
18268        HarnessMcpProxyError::Expired => NodeFailureCode::ReservationExpired,
18269        HarnessMcpProxyError::BindingMismatch => NodeFailureCode::BindingMismatch,
18270        HarnessMcpProxyError::NotActivated => NodeFailureCode::NotActivated,
18271        HarnessMcpProxyError::CallNotFound => NodeFailureCode::CallNotFound,
18272        HarnessMcpProxyError::ChunkOutOfOrder => NodeFailureCode::ChunkOutOfOrder,
18273        HarnessMcpProxyError::ResponseTooLarge => NodeFailureCode::ResponseTooLarge,
18274    };
18275    failure(code, "harness MCP proxy operation failed")
18276}
18277
18278/// Numbers each harness-MCP overlay this process constructs, in
18279/// construction order, so two overlays built back to back in one node process
18280/// still get distinct trace files.
18281static NEXT_HARNESS_MCP_TRACE_SEQUENCE: AtomicU64 = AtomicU64::new(1);
18282
18283/// Builds the optional trace environment entry of one harness-MCP overlay.
18284/// The caller's launch description names both the entry and the node-process
18285/// variable that opts in: when that variable holds a non-empty directory, the
18286/// entry names a trace file under it. Returns `None` when the launch has no
18287/// trace, or the directory is unset, empty, or not valid Unicode. Never creates
18288/// the directory; that is the operator's job.
18289fn harness_mcp_trace_env_entry(launch: &HarnessMcpLaunchV1) -> Option<(OsString, OsString)> {
18290    let trace = launch.trace.as_ref()?;
18291    let trace_dir = std::env::var(&trace.dir_env).ok()?;
18292    if trace_dir.is_empty() {
18293        return None;
18294    }
18295    let sequence = NEXT_HARNESS_MCP_TRACE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
18296    let path = std::path::Path::new(&trace_dir).join(format!("harness-mcp-{sequence}.trace"));
18297    Some((OsString::from(trace.env.as_str()), path.into_os_string()))
18298}
18299
18300fn spawn_deadline_remaining(deadline: Instant) -> Result<Duration, NodeFailure> {
18301    let remaining = deadline.saturating_duration_since(Instant::now());
18302    if remaining.is_zero() {
18303        Err(failure(
18304            NodeFailureCode::SpawnDeadlineExceeded,
18305            "spawn deadline elapsed",
18306        ))
18307    } else {
18308        Ok(remaining)
18309    }
18310}
18311
18312fn spawn_dispatch_timeout(deadline: Option<Instant>) -> Result<Duration, NodeFailure> {
18313    let bounded = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
18314    match deadline {
18315        Some(deadline) => Ok(spawn_deadline_remaining(deadline)?.min(bounded)),
18316        None => Ok(bounded),
18317    }
18318}
18319
18320fn spawn_dispatch_error(error: NodeFailure, deadline: Option<Instant>) -> NodeFailure {
18321    if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
18322        failure(
18323            NodeFailureCode::SpawnDeadlineExceeded,
18324            "spawn dispatch exceeded the spawn deadline",
18325        )
18326    } else {
18327        error
18328    }
18329}
18330
18331fn spawn_runtime_capabilities(
18332    capabilities: &SpawnRequiredCapabilities,
18333) -> Result<Vec<ProviderRuntimeCapability>, NodeFailure> {
18334    capabilities
18335        .iter()
18336        .map(|capability| match capability.as_str() {
18337            SPAWN_RUNTIME_RAW_PTY_LIFECYCLE => Ok(ProviderRuntimeCapability::RawPtyLifecycle),
18338            SPAWN_RUNTIME_SEMANTIC_READINESS => Ok(ProviderRuntimeCapability::SemanticReadiness),
18339            SPAWN_RUNTIME_STRUCTURED_PROMPT => Ok(ProviderRuntimeCapability::StructuredPrompt),
18340            SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY => {
18341                Ok(ProviderRuntimeCapability::ProviderSessionIdentity)
18342            }
18343            SPAWN_RUNTIME_SEMANTIC_RESUME => Ok(ProviderRuntimeCapability::SemanticResume),
18344            _ => Err(failure(
18345                NodeFailureCode::UnsupportedSpawnCapability,
18346                "spawn required capability is unknown",
18347            )),
18348        })
18349        .collect()
18350}
18351
18352fn persistence_failure(_error: io::Error) -> NodeFailure {
18353    NodeFailure {
18354        code: NodeFailureCode::BackendOperationFailed,
18355        message: DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned(),
18356    }
18357}
18358
18359fn node_failure_category(code: NodeFailureCode) -> &'static str {
18360    match code {
18361        NodeFailureCode::InvalidRequest => "invalid-request",
18362        NodeFailureCode::UnsupportedCapability => "unsupported-capability",
18363        NodeFailureCode::HarnessMcpUnavailable => "harness-mcp-unavailable",
18364        NodeFailureCode::ReservationNotFound => "reservation-not-found",
18365        NodeFailureCode::ReservationConflict => "reservation-conflict",
18366        NodeFailureCode::ReservationExpired => "reservation-expired",
18367        NodeFailureCode::BindingMismatch => "binding-mismatch",
18368        NodeFailureCode::NotActivated => "not-activated",
18369        NodeFailureCode::CallNotFound => "call-not-found",
18370        NodeFailureCode::ChunkOutOfOrder => "chunk-out-of-order",
18371        NodeFailureCode::ResponseTooLarge => "response-too-large",
18372        NodeFailureCode::DeliveryManifestInvalid => "delivery-manifest-invalid",
18373        NodeFailureCode::UnknownDeliveryStage => "unknown-delivery-stage",
18374        NodeFailureCode::DeliveryStageConflict => "delivery-stage-conflict",
18375        NodeFailureCode::DeliveryBlobUnexpected => "delivery-blob-unexpected",
18376        NodeFailureCode::DeliveryChunkOutOfOrder => "delivery-chunk-out-of-order",
18377        NodeFailureCode::DeliveryBlobDigestMismatch => "delivery-blob-digest-mismatch",
18378        NodeFailureCode::DeliveryBundleDigestMismatch => "delivery-bundle-digest-mismatch",
18379        NodeFailureCode::DeliveryStageIncomplete => "delivery-stage-incomplete",
18380        NodeFailureCode::DeliveryStageStorageFailed => "delivery-stage-storage-failed",
18381        NodeFailureCode::Unauthorized => "unauthorized",
18382        NodeFailureCode::ObserverReadOnly => "observer-read-only",
18383        NodeFailureCode::ControllerBusy => "controller-busy",
18384        NodeFailureCode::ControllerRequired => "controller-required",
18385        NodeFailureCode::UnknownWorkspace => "unknown-workspace",
18386        NodeFailureCode::HostDirectoryInvalid => "host-directory-invalid",
18387        NodeFailureCode::HostDirectoryReadFailed => "host-directory-read-failed",
18388        NodeFailureCode::HostDirectoryReadTimedOut => "host-directory-read-timed-out",
18389        NodeFailureCode::InvalidRepositoryPath => "invalid-repository-path",
18390        NodeFailureCode::RepositoryFileNotFound => "repository-file-not-found",
18391        NodeFailureCode::RepositoryFileNotRegular => "repository-file-not-regular",
18392        NodeFailureCode::RepositoryPathUnsafe => "repository-path-unsafe",
18393        NodeFailureCode::RepositoryFileReadTimedOut => "repository-file-read-timed-out",
18394        NodeFailureCode::RepositoryFileReadFailed => "repository-file-read-failed",
18395        NodeFailureCode::RepositoryFileWriteTimedOut => "repository-file-write-timed-out",
18396        NodeFailureCode::RepositoryFileWriteFailed => "repository-file-write-failed",
18397        NodeFailureCode::RepositoryFileRevisionConflict => "repository-file-revision-conflict",
18398        NodeFailureCode::RepositoryEntryAlreadyExists => "repository-entry-already-exists",
18399        NodeFailureCode::RepositoryParentNotFound => "repository-parent-not-found",
18400        NodeFailureCode::RepositoryParentNotDirectory => "repository-parent-not-directory",
18401        NodeFailureCode::RepositoryEntryCreateTimedOut => "repository-entry-create-timed-out",
18402        NodeFailureCode::RepositoryEntryCreateFailed => "repository-entry-create-failed",
18403        NodeFailureCode::GitReadTimedOut => "git-read-timed-out",
18404        NodeFailureCode::GitReadFailed => "git-read-failed",
18405        NodeFailureCode::UnknownSpawnProfile => "unknown-spawn-profile",
18406        NodeFailureCode::SpawnProfileRevisionMismatch => "spawn-profile-revision-mismatch",
18407        NodeFailureCode::SpawnTargetMismatch => "spawn-target-mismatch",
18408        NodeFailureCode::SpawnIdempotencyConflict => "spawn-idempotency-conflict",
18409        NodeFailureCode::SpawnIdempotencyCapacity => "spawn-idempotency-capacity",
18410        NodeFailureCode::SpawnDeadlineExceeded => "spawn-deadline-exceeded",
18411        NodeFailureCode::UnsupportedSpawnCapability => "unsupported-spawn-capability",
18412        NodeFailureCode::UnsupportedTransport => "unsupported-transport",
18413        NodeFailureCode::TurnInFlight => "turn-in-flight",
18414        NodeFailureCode::UnknownEnvironmentProfile => "unknown-environment-profile",
18415        NodeFailureCode::UnknownNetworkAllowlist => "unknown-network-allowlist",
18416        NodeFailureCode::UnsupportedNetworkAllowlistMapping => {
18417            "unsupported-network-allowlist-mapping"
18418        }
18419        NodeFailureCode::BrowserStationProbeUnavailable => {
18420            "browser-station-probe-unavailable"
18421        }
18422        NodeFailureCode::BrowserStationUnreachable => "browser-station-unreachable",
18423        NodeFailureCode::BrowserStationProfileBusy => "browser-station-profile-busy",
18424        NodeFailureCode::EnvironmentProfileBindingMismatch => {
18425            "environment-profile-binding-mismatch"
18426        }
18427        NodeFailureCode::UnknownBundle => "unknown-bundle",
18428        NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
18429        NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
18430        NodeFailureCode::UnknownContextPack => "unknown-context-pack",
18431        NodeFailureCode::ContextPackBusy => "context-pack-busy",
18432        NodeFailureCode::ContextPackMaterializationFailed => {
18433            "context-pack-materialization-failed"
18434        }
18435        NodeFailureCode::InvalidWorkspaceRoot => "invalid-workspace-root",
18436        NodeFailureCode::DuplicateWorkspaceId => "duplicate-workspace-id",
18437        NodeFailureCode::DuplicateWorkspaceRoot => "duplicate-workspace-root",
18438        NodeFailureCode::WorkspaceBusy => "workspace-busy",
18439        NodeFailureCode::LastWorkspace => "last-workspace",
18440        NodeFailureCode::NotGitRepository => "not-git-repository",
18441        NodeFailureCode::WorktreeConflict => "worktree-conflict",
18442        NodeFailureCode::WorktreeProtected => "worktree-protected",
18443        NodeFailureCode::WorktreeDirty => "worktree-dirty",
18444        NodeFailureCode::WorktreeLocked => "worktree-locked",
18445        NodeFailureCode::UnknownManagedWorktreeLease => "unknown-managed-worktree-lease",
18446        NodeFailureCode::ManagedWorktreeBusy => "managed-worktree-busy",
18447        NodeFailureCode::ManagedWorktreeOwnershipConflict => "managed-worktree-ownership-conflict",
18448        NodeFailureCode::ManagedWorktreeProfileRevisionMismatch => {
18449            "managed-worktree-profile-revision-mismatch"
18450        }
18451        NodeFailureCode::ManagedWorktreeRecoveryRequired => "managed-worktree-recovery-required",
18452        NodeFailureCode::StandaloneWorkspaceRecoveryRequired => {
18453            "standalone-workspace-recovery-required"
18454        }
18455        NodeFailureCode::UnknownSession => "unknown-session",
18456        NodeFailureCode::UnknownSessionRecord => "unknown-session-record",
18457        NodeFailureCode::SessionRecordNotResumable => "session-record-not-resumable",
18458        NodeFailureCode::SessionRecordBusy => "session-record-busy",
18459        NodeFailureCode::SessionRecordConflict => "session-record-conflict",
18460        NodeFailureCode::SessionWorkspaceMismatch => "session-workspace-mismatch",
18461        NodeFailureCode::WorkspaceRegistrationRequired => {
18462            "workspace-registration-required"
18463        }
18464        NodeFailureCode::StaleNativeSessionCatalog => "stale-native-session-catalog",
18465        NodeFailureCode::StaleGeneration => "stale-generation",
18466        NodeFailureCode::BackendBusy => "backend-busy",
18467        NodeFailureCode::BackendDisconnected => "backend-disconnected",
18468        NodeFailureCode::BackendOperationFailed => "backend-operation-failed",
18469        NodeFailureCode::ShuttingDown => "shutting-down",
18470    }
18471}
18472
18473fn durable_state_server_error(error: io::Error, category: &'static str) -> NodeServerError {
18474    NodeServerError::DurableState(io::Error::new(error.kind(), category))
18475}
18476
18477fn durable_state_load_error(error: io::Error) -> NodeServerError {
18478    let category = match session_registry::state_load_refusal(&error) {
18479        Some(session_registry::StateLoadRefusal::UnsupportedSchema) => {
18480            DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR
18481        }
18482        Some(session_registry::StateLoadRefusal::PathSemanticsUnsupported) => {
18483            DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR
18484        }
18485        Some(session_registry::StateLoadRefusal::NodeIdentityMismatch) => {
18486            DURABLE_STATE_CONFLICT_ERROR
18487        }
18488        _ => DURABLE_STATE_LOAD_FAILED_ERROR,
18489    };
18490    durable_state_server_error(error, category)
18491}
18492
18493fn unix_time_ms() -> u64 {
18494    std::time::SystemTime::now()
18495        .duration_since(std::time::UNIX_EPOCH)
18496        .unwrap_or_default()
18497        .as_millis()
18498        .min(u64::MAX as u128) as u64
18499}
18500
18501fn detached_record_state(record: &ManagedSessionRecord) -> ManagedSessionState {
18502    if record.provider_session.is_some() {
18503        ManagedSessionState::Dormant
18504    } else {
18505        ManagedSessionState::Unavailable
18506    }
18507}
18508
18509fn active_record_state(
18510    identity_admitted: bool,
18511    provider_session_is_known: bool,
18512) -> ManagedSessionState {
18513    if identity_admitted && !provider_session_is_known {
18514        ManagedSessionState::IdentityPending
18515    } else {
18516        ManagedSessionState::Live
18517    }
18518}
18519
18520fn managed_worktree_record_holders(
18521    records: &[ManagedSessionRecord],
18522) -> Vec<(SessionRecordId, WorkspaceId)> {
18523    records
18524        .iter()
18525        .filter(|record| record.provider_session.is_some())
18526        .map(|record| (record.record_id.clone(), record.workspace_id.clone()))
18527        .collect()
18528}
18529
18530fn managed_spawn_record_ownership(
18531    binding: &SessionBinding,
18532    effective_runtime_policy: ProviderRuntimePolicy,
18533) -> (Option<SessionRecordId>, Option<SessionRecordId>) {
18534    debug_assert_eq!(binding.runtime_policy, effective_runtime_policy);
18535    if effective_runtime_policy.provider_session_identity {
18536        (binding.record_id.clone(), None)
18537    } else {
18538        (None, binding.record_id.clone())
18539    }
18540}
18541
18542#[derive(Debug, Error)]
18543pub enum NodeServerError {
18544    #[error("node endpoint must be a bounded absolute local transport path")]
18545    InvalidEndpoint,
18546    #[error("node access token must contain 1..=4096 bytes")]
18547    InvalidAccessToken,
18548    #[error("node HTTP observer must listen on a loopback address: {0}")]
18549    InvalidApiListen(std::net::SocketAddr),
18550    #[error("node browser bridge must listen on a loopback address: {0}")]
18551    InvalidBridgeListen(std::net::SocketAddr),
18552    #[error("node browser bridge token must contain 1..=4096 bytes")]
18553    InvalidBridgeToken,
18554    #[error("bridge underlay requires --bridge-listen (local HTTP+WS door)")]
18555    BridgeUnderlayRequiresBridgeListen,
18556    #[error("bridge underlay requires GATE4AGENT_BRIDGE_TOKEN (application barrier)")]
18557    BridgeUnderlayRequiresBridgeToken,
18558    #[error("mesh underlay auth token must contain 1..=4096 bytes")]
18559    InvalidBridgeUnderlayToken,
18560    #[error("mesh underlay transport key must be 32 bytes (64 hex digits)")]
18561    InvalidBridgeUnderlayKey,
18562    #[error("node call-home relay must be loopback or a concrete C2 tunnel address with a nonzero port: {0}")]
18563    InvalidCallHome(std::net::SocketAddr),
18564    #[error("node could not reach its call-home relay: {0}")]
18565    CallHomeTransport(#[source] std::io::Error),
18566    #[error("node durable state path must be an absolute file path: {0}")]
18567    InvalidStatePath(String),
18568    #[error("harness MCP helper must be an exact reviewed absolute regular file")]
18569    InvalidHarnessMcpHelper,
18570    #[error("network allowlist catalog path must be an absolute regular file: {0}")]
18571    InvalidNetworkAllowlistCatalogPath(String),
18572    #[error("network allowlist catalog could not be read: {0}")]
18573    NetworkAllowlistCatalogIo(#[source] std::io::Error),
18574    #[error("network allowlist catalog line {line}: {message}")]
18575    InvalidNetworkAllowlistCatalogEntry { line: usize, message: String },
18576    #[error("network allowlist catalog: {0}")]
18577    InvalidNetworkAllowlistCatalog(String),
18578    #[error("network allowlist catalog exceeds the {max}-entry limit")]
18579    NetworkAllowlistCatalogCapacity { max: usize },
18580    #[error("the local state directory is unavailable; supply an explicit state path")]
18581    LocalStateDirectoryUnavailable,
18582    #[error("the local runtime directory is unavailable; supply an explicit endpoint")]
18583    LocalRuntimeDirectoryUnavailable,
18584    #[error("node durable state failed: {0}")]
18585    DurableState(io::Error),
18586    #[error("node requires at least one configured workspace")]
18587    NoWorkspaces,
18588    #[error("workspace '{workspace_id}' root '{path}' is invalid: {message}")]
18589    InvalidWorkspaceRoot {
18590        workspace_id: WorkspaceId,
18591        path: String,
18592        message: String,
18593    },
18594    #[error("duplicate workspace ID: {0}")]
18595    DuplicateWorkspaceId(WorkspaceId),
18596    #[error("workspaces '{first}' and '{second}' resolve to the same root '{root}'")]
18597    DuplicateWorkspaceRoot {
18598        first: WorkspaceId,
18599        second: WorkspaceId,
18600        root: String,
18601    },
18602    #[error("workspace '{0}' uses managed worktrees but has no local profile")]
18603    ManagedWorktreeProfileRequired(WorkspaceId),
18604    #[error("workspace '{0}' has managed worktree profiles but is not in managed mode")]
18605    ManagedWorktreeProfileModeMismatch(WorkspaceId),
18606    #[error("workspace '{workspace_id}' has duplicate managed worktree profile '{profile_id}'")]
18607    DuplicateManagedWorktreeProfile {
18608        workspace_id: WorkspaceId,
18609        profile_id: WorktreeProfileId,
18610    },
18611    #[error("workspace '{workspace_id}' exceeds the {max}-profile managed worktree limit")]
18612    ManagedWorktreeProfileCapacity {
18613        workspace_id: WorkspaceId,
18614        max: usize,
18615    },
18616    #[error("workspace '{workspace_id}' managed worktree profile '{profile_id}' is invalid: {message}")]
18617    InvalidManagedWorktreeProfile {
18618        workspace_id: WorkspaceId,
18619        profile_id: WorktreeProfileId,
18620        message: String,
18621    },
18622    #[error("duplicate node environment profile: {0}")]
18623    DuplicateEnvironmentProfile(SpawnEnvironmentProfileId),
18624    #[error("node environment profile provider is unavailable: {0}")]
18625    EnvironmentProfileProviderUnavailable(AgentId),
18626    #[error("node environment profile capacity is {max}")]
18627    EnvironmentProfileCapacity { max: usize },
18628    #[error("node environment profiles reuse native profile ID '{0}'")]
18629    DuplicateNativeEnvironmentProfile(String),
18630    #[error("node native environment profile is invalid: {0}")]
18631    NativeEnvironmentProfile(String),
18632    #[error("node session-environment materialization root must be absolute")]
18633    InvalidSessionEnvironmentRoot,
18634    #[error("node session-environment materializer is unavailable")]
18635    SessionEnvironmentMaterializerRequired,
18636    #[error("node session-environment materializer failed to initialize")]
18637    SessionEnvironmentMaterializer,
18638    #[error("node bundle catalog is invalid: {0}")]
18639    BundleCatalog(String),
18640    #[error("node delivery store failed to initialize: {0}")]
18641    DeliveryStore(String),
18642    #[error("node context pack store failed to initialize")]
18643    ContextPackStore,
18644    #[error("active agent registry failed: {0}")]
18645    Registry(String),
18646    #[error("node provider contract manifest is invalid: {0}")]
18647    ProviderContractManifest(String),
18648    #[error("named pipe I/O failed: {0}")]
18649    Io(#[from] io::Error),
18650    #[error("node HTTP observer failed: {0}")]
18651    HttpApi(io::Error),
18652    #[error("node browser bridge failed: {0}")]
18653    BridgeApi(io::Error),
18654    #[error(transparent)]
18655    Frame(#[from] FrameError),
18656    #[error("node handshake failed: {0}")]
18657    Handshake(String),
18658    #[error("node authentication frame was not received before the bounded deadline")]
18659    AuthenticationTimedOut,
18660    #[error("node authentication primitive failed: {0}")]
18661    Authentication(String),
18662    #[error("node incarnation identity generation failed: {0}")]
18663    IncarnationIdentity(String),
18664    #[error("node pre-authentication limiter is closed")]
18665    PreauthClosed,
18666    #[error("node authenticated connection limit was reached")]
18667    AuthenticatedConnectionLimit,
18668    #[error("node Ctrl+C/CtrlBreak signal stream closed")]
18669    SignalStreamClosed,
18670    #[error("node stop-all dispatch failed: {0:?}")]
18671    ShutdownDispatch(NodeFailure),
18672    #[error("node shutdown timed out with {active_native_sessions} physical sessions retained")]
18673    ShutdownTimedOut { active_native_sessions: usize },
18674}
18675
18676impl From<crate::network_allowlist_catalog::NetworkAllowlistCatalogError> for NodeServerError {
18677    fn from(error: crate::network_allowlist_catalog::NetworkAllowlistCatalogError) -> Self {
18678        use crate::network_allowlist_catalog::NetworkAllowlistCatalogError as E;
18679        match error {
18680            E::InvalidPath(path) => Self::InvalidNetworkAllowlistCatalogPath(path),
18681            E::Io(error) => Self::NetworkAllowlistCatalogIo(error),
18682            E::InvalidEntry { line, message } => {
18683                Self::InvalidNetworkAllowlistCatalogEntry { line, message }
18684            }
18685            E::InvalidCatalog(message) => Self::InvalidNetworkAllowlistCatalog(message),
18686            E::Capacity { max } => Self::NetworkAllowlistCatalogCapacity { max },
18687        }
18688    }
18689}
18690
18691#[cfg(test)]
18692mod agent_stream_blocked_tests {
18693    use super::*;
18694    use gate4agent_types::{
18695        AdapterId, AdapterVerification, HostRequestOutcome as ProviderHostRequestOutcome,
18696        ProviderSource,
18697    };
18698
18699    fn provider_source(family: AdapterFamily, adapter: &str) -> ProviderSource {
18700        ProviderSource {
18701            family,
18702            binding: AdapterBinding::new(
18703                AdapterId::new(adapter).unwrap(),
18704                "fixture/v1",
18705                AdapterVerification::SyntheticFixture,
18706            )
18707            .unwrap(),
18708        }
18709    }
18710
18711    fn provider_control_event_at(
18712        family: AdapterFamily,
18713        adapter: &str,
18714        source_sequence: u64,
18715        provider_event: ProviderEvent,
18716    ) -> ControlEvent {
18717        ControlEvent {
18718            sequence: 41,
18719            command_id: None,
18720            instance_id: AgentInstanceId(7),
18721            generation: SessionGeneration(3),
18722            event: ControlEventKind::ProviderEvent {
18723                sequence: 8,
18724                source: provider_source(family, adapter),
18725                source_sequence,
18726                event: provider_event,
18727            },
18728        }
18729    }
18730
18731    fn provider_control_event(family: AdapterFamily, provider_event: ProviderEvent) -> ControlEvent {
18732        provider_control_event_at(family, "fixture", 9, provider_event)
18733    }
18734
18735    fn denied_host_request(
18736        by: ProviderHostDecisionAuthority,
18737        reason: Option<String>,
18738    ) -> ControlEvent {
18739        provider_control_event_at(
18740            AdapterFamily::Acp,
18741            "claude-code",
18742            2,
18743            ProviderEvent::HostRequestObserved {
18744                method: "terminal/create".to_owned(),
18745                params_json: String::new(),
18746                decision: ProviderHostRequestDecision::Denied { by },
18747                outcome: ProviderHostRequestOutcome::Executed,
18748                reason,
18749            },
18750        )
18751    }
18752
18753    fn blocked(
18754        event: &ControlEvent,
18755    ) -> (
18756        Option<String>,
18757        String,
18758        BlockAuthorityV1,
18759        Option<String>,
18760        String,
18761        Option<String>,
18762    ) {
18763        let chunk = agent_stream_chunk(event).expect("expected a Blocked chunk");
18764        chunk.validate().expect("minted chunk must validate");
18765        let AgentStreamChunkKindV1::Blocked {
18766            correlation_id,
18767            tool_class,
18768            authority,
18769            reason_kind,
18770            reason,
18771            help,
18772        } = chunk.kind
18773        else {
18774            panic!("expected AgentStreamChunkKindV1::Blocked");
18775        };
18776        (correlation_id, tool_class, authority, reason_kind, reason, help)
18777    }
18778
18779    /// Pins the fleet and every adapter each member declares: the fleet is
18780    /// exactly what `active_registry` says, and no member declares a hook
18781    /// adapter (lifecycle hooks are retired).
18782    #[test]
18783    fn the_enabled_provider_matrix_is_exact() {
18784        let registry = active_registry().unwrap();
18785        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
18786        assert_eq!(
18787            providers
18788                .iter()
18789                .map(|contract| contract.provider.as_str())
18790                .collect::<Vec<_>>(),
18791            // `shell` is the plain PTY provider (operator terminals and smoke tests).
18792            vec!["shell", "claude", "codex", "grok", "kimi"],
18793        );
18794        assert!(adapters.iter().all(|contract| {
18795            !matches!(contract.family, AdapterFamily::Hook | AdapterFamily::ManagedHook)
18796        }));
18797    }
18798
18799    /// A gate-denied host request, WITH its reason text carried all the way
18800    /// from the ACP host, mints a `Blocked` chunk naming the gate.
18801    #[test]
18802    fn gate_denied_host_request_mints_a_blocked_chunk() {
18803        let gate_text =
18804            "blocked by dangerous-command gate: rule=filesystem-wipe, argument=rm -rf /";
18805        let event = denied_host_request(
18806            ProviderHostDecisionAuthority::Gate,
18807            Some(gate_text.to_owned()),
18808        );
18809        let chunk = agent_stream_chunk(&event).expect("expected a Blocked chunk");
18810        assert_eq!(chunk.source_sequence, 2);
18811        let (correlation_id, tool_class, authority, reason_kind, reason, help) = blocked(&event);
18812        assert_eq!(authority, BlockAuthorityV1::HostGate);
18813        assert_eq!(reason, gate_text);
18814        assert_eq!(correlation_id, None);
18815        assert_eq!(reason_kind, None);
18816        assert_eq!(help, None);
18817        assert_eq!(tool_class, "Write");
18818    }
18819
18820    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.";
18821
18822    /// The owner's classifier sample, verbatim, arriving as a
18823    /// `tool_call_update`-derived `ProviderEvent::ToolCompleted` with
18824    /// `non_execution_kind: "permission-rule"` -- mints a `Blocked` chunk with
18825    /// `authority: ProviderPermissionRule`, the `Reason:` sentence as `reason`
18826    /// and the guidance tail as `help`.
18827    #[test]
18828    fn classifier_sample_permission_rule_mints_a_blocked_chunk() {
18829        let event = provider_control_event(
18830            AdapterFamily::Acp,
18831            ProviderEvent::ToolCompleted {
18832                id: "toolu_9".to_owned(),
18833                output: CLASSIFIER_SAMPLE.to_owned(),
18834                is_error: true,
18835                duration_ms: None,
18836                agent_id: None,
18837                non_execution_kind: Some("permission-rule".to_owned()),
18838            },
18839        );
18840        let (correlation_id, _, authority, reason_kind, reason, help) = blocked(&event);
18841        assert_eq!(authority, BlockAuthorityV1::ProviderPermissionRule);
18842        assert_eq!(reason, "Blocked by classifier");
18843        assert_eq!(reason_kind, Some("permission-rule".to_owned()));
18844        assert!(help.as_deref().is_some_and(|help| help.starts_with("If you have other tasks")));
18845        assert!(correlation_id.is_some(), "a tool-correlated block must carry a correlation id");
18846    }
18847
18848    /// A `session/prompt` RPC error with `data.codexErrorInfo:
18849    /// "usageLimitExceeded"` (measured live 2026-09-05) mints a `Blocked` chunk
18850    /// with `authority: ProviderQuota`, the message verbatim as `reason` and the
18851    /// vendor code as `reason_kind`.
18852    #[test]
18853    fn codex_quota_rpc_error_mints_a_blocked_chunk() {
18854        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.";
18855        let event = provider_control_event(
18856            AdapterFamily::Acp,
18857            ProviderEvent::SessionEnded {
18858                result: "JSON-RPC error -32603: Internal error".to_owned(),
18859                cost_usd: None,
18860                is_error: true,
18861                stop_reason: Some(ProviderStopReason::ProviderError {
18862                    code: -32603,
18863                    message: message.to_owned(),
18864                    vendor_code: Some("usageLimitExceeded".to_owned()),
18865                }),
18866            },
18867        );
18868        let (_, tool_class, authority, reason_kind, reason, _) = blocked(&event);
18869        assert_eq!(authority, BlockAuthorityV1::ProviderQuota);
18870        assert_eq!(reason, message);
18871        assert_eq!(reason_kind, Some("usageLimitExceeded".to_owned()));
18872        assert_eq!(tool_class, "Session");
18873    }
18874
18875    /// ACP's `stopReason: "refusal"` mints a `Blocked` chunk with
18876    /// `authority: ProviderRefusal`.
18877    #[test]
18878    fn session_end_stop_reason_refusal_mints_a_blocked_chunk() {
18879        let event = provider_control_event(
18880            AdapterFamily::Acp,
18881            ProviderEvent::SessionEnded {
18882                result: "refusal".to_owned(),
18883                cost_usd: None,
18884                is_error: true,
18885                stop_reason: Some(ProviderStopReason::Refusal),
18886            },
18887        );
18888        let (_, _, authority, _, _, _) = blocked(&event);
18889        assert_eq!(authority, BlockAuthorityV1::ProviderRefusal);
18890    }
18891
18892    /// A failed tool completion with no typed `non_execution_kind` -- or one
18893    /// that only says the turn stopped -- is a plain execution failure, never a
18894    /// block; nor is an agent's own narrative claiming one.
18895    #[test]
18896    fn failures_and_narrative_without_a_typed_block_mint_no_blocked_chunk() {
18897        for non_execution_kind in [None, Some("interrupted".to_owned()), Some("cancelled".to_owned())] {
18898            let event = provider_control_event(
18899                AdapterFamily::Acp,
18900                ProviderEvent::ToolCompleted {
18901                    id: "toolu_1".to_owned(),
18902                    output: "command exited with status 1".to_owned(),
18903                    is_error: true,
18904                    duration_ms: Some(12),
18905                    agent_id: None,
18906                    non_execution_kind,
18907                },
18908            );
18909            assert!(agent_stream_chunk(&event).is_none());
18910        }
18911        let narrative = provider_control_event(
18912            AdapterFamily::Acp,
18913            ProviderEvent::ToolCompleted {
18914                id: "toolu_2".to_owned(),
18915                output: "the harness blocked the write, approval needed".to_owned(),
18916                is_error: false,
18917                duration_ms: Some(3),
18918                agent_id: None,
18919                non_execution_kind: None,
18920            },
18921        );
18922        assert!(agent_stream_chunk(&narrative).is_none());
18923    }
18924
18925    /// `Granted`/`Deferred` decisions never mint a `Blocked` chunk, whatever the
18926    /// execution outcome: a granted request that failed while running was
18927    /// still authorized.
18928    #[test]
18929    fn granted_and_deferred_host_requests_never_mint_a_blocked_chunk() {
18930        for (decision, outcome) in [
18931            (
18932                ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18933                ProviderHostRequestOutcome::Executed,
18934            ),
18935            (ProviderHostRequestDecision::Deferred, ProviderHostRequestOutcome::Executed),
18936            (
18937                ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18938                ProviderHostRequestOutcome::Failed {
18939                    error: "terminal/create spawn failed: os error 3".to_owned(),
18940                },
18941            ),
18942        ] {
18943            let event = provider_control_event_at(
18944                AdapterFamily::Acp,
18945                "claude-code",
18946                3,
18947                ProviderEvent::HostRequestObserved {
18948                    method: "fs/read_text_file".to_owned(),
18949                    params_json: String::new(),
18950                    decision: decision.clone(),
18951                    outcome,
18952                    reason: None,
18953                },
18954            );
18955            assert!(agent_stream_chunk(&event).is_none(), "{decision:?}");
18956        }
18957    }
18958
18959    /// A deferred `session/request_permission` resolved by an operator or an
18960    /// expired deadline carries no reason text -- the chunk falls back to a
18961    /// categorical sentence naming the authority rather than inventing detail.
18962    #[test]
18963    fn denied_host_request_with_no_reason_falls_back_to_a_categorical_sentence() {
18964        for (by, expected_authority, expected_reason) in [
18965            (
18966                ProviderHostDecisionAuthority::Operator,
18967                BlockAuthorityV1::Operator,
18968                "declined by the operator",
18969            ),
18970            (
18971                ProviderHostDecisionAuthority::DeadlinePolicy,
18972                BlockAuthorityV1::HostDeadline,
18973                "denied by host policy after the operator deadline expired",
18974            ),
18975        ] {
18976            let (_, _, authority, _, reason, _) = blocked(&denied_host_request(by, None));
18977            assert_eq!(authority, expected_authority);
18978            assert_eq!(reason, expected_reason);
18979        }
18980    }
18981
18982    /// An over-long reason is cut to the wire bound BEFORE the chunk is built,
18983    /// at a safe UTF-8 boundary -- `validate` rejects an over-long value
18984    /// outright rather than truncating.
18985    #[test]
18986    fn overlong_blocked_reason_is_truncated_to_the_wire_bound() {
18987        let oversized = "x".repeat(MAX_ACP_BLOCKED_REASON_BYTES + 500);
18988        let (_, _, _, _, reason, _) = blocked(&denied_host_request(
18989            ProviderHostDecisionAuthority::Policy,
18990            Some(oversized),
18991        ));
18992        assert!(reason.len() <= MAX_ACP_BLOCKED_REASON_BYTES);
18993    }
18994}
18995
18996#[cfg(test)]
18997mod standalone_capability_platform_tests {
18998    use super::*;
18999
19000    #[test]
19001    fn standalone_workspace_capability_requires_windows_identity_guards() {
19002        let capabilities = baseline_capabilities().unwrap();
19003        let advertised = capabilities.iter().any(|capability| {
19004            capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
19005        });
19006        assert_eq!(advertised, cfg!(windows));
19007
19008        let request = NodeRequest::CreateStandaloneWorkspace {
19009            workspace_id: WorkspaceId::new("standalone-platform").unwrap(),
19010            root: OpaqueHostPath::utf8("standalone-platform-root".to_owned()).unwrap(),
19011            initial_branch: None,
19012        };
19013        assert_eq!(
19014            request_uses_unnegotiated_capability(&request, &capabilities),
19015            !cfg!(windows),
19016        );
19017    }
19018}
19019
19020
19021#[cfg(test)]
19022mod station_knobs_resolve_tests {
19023    use super::*;
19024    use gate4agent_catalog::EnvMutation;
19025    use gate4agent_runtime_native::{
19026        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
19027        NativeLaunchProfileId,
19028    };
19029    use std::ffi::OsString;
19030    use std::sync::Arc;
19031
19032    struct EmptyEnvironmentResolver;
19033
19034    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19035        fn resolve_child_environment(
19036            &self,
19037        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19038            Ok(vec![EnvMutation {
19039                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19040                value: None,
19041            }])
19042        }
19043    }
19044
19045    fn agent(value: &str) -> AgentId {
19046        AgentId::new(value).unwrap()
19047    }
19048
19049    fn station_knobs_fixture() -> (NodeShared, crate::protocol::SpawnProfileDefaults) {
19050        let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
19051        let profile_revision =
19052            crate::protocol::SpawnEnvironmentProfileRevision::new("local-claude-r1").unwrap();
19053        let spawn_defaults = crate::protocol::SpawnProfileDefaults {
19054            profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
19055            revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
19056            provider: agent("claude"),
19057            mode: SessionMode::Pty,
19058            terminal_size: gate4agent_types::TerminalSize {
19059                rows: 24,
19060                columns: 80,
19061            },
19062            prompt: None,
19063            bundle_id: None,
19064            context_id: None,
19065            environment_profile_id: Some(profile_id.clone()),
19066        };
19067        let spawn_profiles = SpawnProfileRegistry::new([spawn_defaults.clone()]).unwrap();
19068        let catalog = active_registry().unwrap();
19069        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19070        let control = runtime.native_launch_profile_control();
19071        let node_profile = NodeEnvironmentProfile::new(
19072            profile_id.clone(),
19073            profile_revision,
19074            agent("claude"),
19075            [NativeLaunchProfile::new(
19076                NativeLaunchProfileId::new("local-claude-pty").unwrap(),
19077                agent("claude"),
19078                TransportKind::Pty,
19079                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19080                Arc::new(EmptyEnvironmentResolver),
19081            )
19082            .unwrap()],
19083        )
19084        .unwrap();
19085        let (binding, native_profiles, _materialization) = node_profile.into_parts();
19086        for native_profile in native_profiles {
19087            runtime.upsert_native_launch_profile(native_profile).unwrap();
19088        }
19089        let workspace = WorkspaceConfig::new(
19090            WorkspaceId::new("primary").unwrap(),
19091            std::env::current_dir().unwrap(),
19092        )
19093        .unwrap();
19094        let shared = NodeShared::new_with_incarnation(
19095            handle,
19096            "fixture-token".to_owned(),
19097            NodeId::new("node-station-knobs-test").unwrap(),
19098            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19099            vec![workspace],
19100            vec![agent("claude")],
19101            ProviderRuntimeStatuses::default(),
19102            None,
19103            Vec::new(),
19104            Vec::new(),
19105            spawn_profiles,
19106            Some(control),
19107            None,
19108            None,
19109            None,
19110            Vec::new(),
19111            Vec::new(),
19112            Vec::new(),
19113            Vec::new(),
19114            None,
19115            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19116        );
19117        shared
19118            .environment_profiles
19119            .write()
19120            .unwrap_or_else(|poisoned| poisoned.into_inner())
19121            .insert(binding.id.clone(), binding);
19122        (shared, spawn_defaults)
19123    }
19124
19125    /// Empty-default catalog refuses unknown network_allowlist ids.
19126    /// Feature-off dig2-station-probe keeps browser_profile stub echo.
19127    #[test]
19128    fn resolve_environment_profile_refuses_unknown_network_allowlist() {
19129        let (shared, defaults) = station_knobs_fixture();
19130        let unknown = crate::protocol::SpawnNetworkAllowlistId::new("not-registered").unwrap();
19131        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
19132        let spec = crate::protocol::SpawnSpec {
19133            target: crate::protocol::SpawnTarget {
19134                node_id: shared.node_id.clone(),
19135                workspace_id: WorkspaceId::new("primary").unwrap(),
19136                worktree_id: None,
19137            },
19138            profile_id: defaults.profile_id.clone(),
19139            expected_profile_revision: defaults.revision.clone(),
19140            overrides: crate::protocol::SpawnOverrides {
19141                network_allowlist: Some(unknown),
19142                browser_profile_id: Some(browser),
19143                ..crate::protocol::SpawnOverrides::default()
19144            },
19145            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19146            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19147                "station-knobs-unknown-allowlist",
19148            )
19149            .unwrap(),
19150            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19151        };
19152        let resolved = spec.resolve(&defaults).unwrap();
19153        let failure = shared
19154            .resolve_environment_profile(&resolved)
19155            .expect_err("unknown allowlist must refuse");
19156        assert_eq!(failure.code, NodeFailureCode::UnknownNetworkAllowlist);
19157    }
19158
19159    /// Registered network_allowlist (+ browser_profile_id when feature off)
19160    /// echo onto the env-profile receipt. Empty catalog still allows omit
19161    /// (None). Empty/whitespace refuse is type-level. Feature-off
19162    /// dig2-station-probe keeps stub browser echo; feature-on refuse is
19163    /// covered by `…_refuses_browser_profile_when_station_probe_fails`.
19164    #[test]
19165    fn resolve_environment_profile_echoes_registered_station_network_and_browser_knobs() {
19166        let (shared, defaults) = station_knobs_fixture();
19167        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19168        #[cfg(not(feature = "dig2-station-probe"))]
19169        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
19170        shared
19171            .network_allowlist_catalog
19172            .write()
19173            .unwrap_or_else(|poisoned| poisoned.into_inner())
19174            .insert_id(allowlist.clone())
19175            .unwrap();
19176        let spec = crate::protocol::SpawnSpec {
19177            target: crate::protocol::SpawnTarget {
19178                node_id: shared.node_id.clone(),
19179                workspace_id: WorkspaceId::new("primary").unwrap(),
19180                worktree_id: None,
19181            },
19182            profile_id: defaults.profile_id.clone(),
19183            expected_profile_revision: defaults.revision.clone(),
19184            overrides: crate::protocol::SpawnOverrides {
19185                network_allowlist: Some(allowlist.clone()),
19186                #[cfg(not(feature = "dig2-station-probe"))]
19187                browser_profile_id: Some(browser.clone()),
19188                ..crate::protocol::SpawnOverrides::default()
19189            },
19190            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19191            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-echo")
19192                .unwrap(),
19193            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19194        };
19195        let resolved = spec.resolve(&defaults).unwrap();
19196        assert_eq!(resolved.network_allowlist.as_ref(), Some(&allowlist));
19197        #[cfg(not(feature = "dig2-station-probe"))]
19198        assert_eq!(resolved.browser_profile_id.as_ref(), Some(&browser));
19199        let receipt = shared
19200            .resolve_environment_profile(&resolved)
19201            .expect("resolve ok")
19202            .expect("env profile present");
19203        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
19204        #[cfg(not(feature = "dig2-station-probe"))]
19205        assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
19206        #[cfg(feature = "dig2-station-probe")]
19207        assert_eq!(receipt.browser_profile_id, None);
19208        assert_eq!(receipt.profile_id.as_str(), "local-claude");
19209
19210        let omitted = crate::protocol::SpawnSpec {
19211            overrides: crate::protocol::SpawnOverrides::default(),
19212            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-omit")
19213                .unwrap(),
19214            ..spec
19215        };
19216        let resolved_omit = omitted.resolve(&defaults).unwrap();
19217        let bare = shared
19218            .resolve_environment_profile(&resolved_omit)
19219            .expect("resolve ok")
19220            .expect("env profile present");
19221        assert_eq!(bare.network_allowlist, None);
19222        assert_eq!(bare.browser_profile_id, None);
19223    }
19224
19225    /// Feature `dig2-station-probe`: browser_profile_id set refuses when the
19226    /// cheap station probe cannot succeed. On non-Windows (this CI host) that
19227    /// is `BrowserStationProbeUnavailable`. Never cookies on C2. Lease map is
19228    /// unit-tested separately (probe refuses before lease on Linux).
19229    #[cfg(feature = "dig2-station-probe")]
19230    #[test]
19231    fn resolve_environment_profile_refuses_browser_profile_when_station_probe_fails() {
19232        let (shared, defaults) = station_knobs_fixture();
19233        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
19234        let spec = crate::protocol::SpawnSpec {
19235            target: crate::protocol::SpawnTarget {
19236                node_id: shared.node_id.clone(),
19237                workspace_id: WorkspaceId::new("primary").unwrap(),
19238                worktree_id: None,
19239            },
19240            profile_id: defaults.profile_id.clone(),
19241            expected_profile_revision: defaults.revision.clone(),
19242            overrides: crate::protocol::SpawnOverrides {
19243                browser_profile_id: Some(browser),
19244                ..crate::protocol::SpawnOverrides::default()
19245            },
19246            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19247            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19248                "station-knobs-probe-refuse",
19249            )
19250            .unwrap(),
19251            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19252        };
19253        let resolved = spec.resolve(&defaults).unwrap();
19254        let failure = shared
19255            .resolve_environment_profile(&resolved)
19256            .expect_err("browser_profile with failing probe must refuse");
19257        #[cfg(windows)]
19258        {
19259            assert!(
19260                matches!(
19261                    failure.code,
19262                    NodeFailureCode::BrowserStationUnreachable
19263                        | NodeFailureCode::BrowserStationProbeUnavailable
19264                ),
19265                "unexpected code {:?}",
19266                failure.code
19267            );
19268        }
19269        #[cfg(not(windows))]
19270        {
19271            assert_eq!(
19272                failure.code,
19273                NodeFailureCode::BrowserStationProbeUnavailable
19274            );
19275        }
19276    }
19277
19278    /// Feature on: omitting browser_profile_id still resolves (no probe).
19279    #[cfg(feature = "dig2-station-probe")]
19280    #[test]
19281    fn resolve_environment_profile_skips_station_probe_when_browser_profile_omitted() {
19282        let (shared, defaults) = station_knobs_fixture();
19283        let spec = crate::protocol::SpawnSpec {
19284            target: crate::protocol::SpawnTarget {
19285                node_id: shared.node_id.clone(),
19286                workspace_id: WorkspaceId::new("primary").unwrap(),
19287                worktree_id: None,
19288            },
19289            profile_id: defaults.profile_id.clone(),
19290            expected_profile_revision: defaults.revision.clone(),
19291            overrides: crate::protocol::SpawnOverrides::default(),
19292            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19293            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19294                "station-knobs-probe-omit",
19295            )
19296            .unwrap(),
19297            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19298        };
19299        let resolved = spec.resolve(&defaults).unwrap();
19300        let receipt = shared
19301            .resolve_environment_profile(&resolved)
19302            .expect("omit browser_profile must not probe")
19303            .expect("env profile present");
19304        assert_eq!(receipt.browser_profile_id, None);
19305    }
19306
19307    /// Feature dig2-station-probe: exclusive BrowserStationLease on NodeShared
19308    /// refuses a second holder for the same opaque profile id; release on
19309    /// remove_binding allows reacquire. Never ImportSession/cookies on C2.
19310    #[cfg(feature = "dig2-station-probe")]
19311    #[test]
19312    fn browser_station_lease_exclusive_and_clears_on_remove_binding() {
19313        let (shared, _defaults) = station_knobs_fixture();
19314        let profile = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
19315        let workspace_id = WorkspaceId::new("primary").unwrap();
19316        let address_a = SessionAddress {
19317            workspace_id: workspace_id.clone(),
19318            session: SessionKey {
19319                instance_id: AgentInstanceId(101),
19320                generation: SessionGeneration(1),
19321            },
19322        };
19323        let address_b = SessionAddress {
19324            workspace_id: workspace_id.clone(),
19325            session: SessionKey {
19326                instance_id: AgentInstanceId(102),
19327                generation: SessionGeneration(1),
19328            },
19329        };
19330        let guard_a = crate::dig2_station_lease::acquire_lease(
19331            &shared.browser_station_leases,
19332            profile.clone(),
19333            address_a.session.instance_id,
19334            1,
19335        )
19336        .expect("first lease");
19337        let busy = crate::dig2_station_lease::acquire_lease(
19338            &shared.browser_station_leases,
19339            profile.clone(),
19340            address_b.session.instance_id,
19341            2,
19342        )
19343        .expect_err("second holder must refuse");
19344        assert!(matches!(
19345            busy,
19346            crate::dig2_station_lease::BrowserStationLeaseError::ProfileBusy { .. }
19347        ));
19348        // Simulate a bound session so remove_binding clears the lease.
19349        shared.bind_session_with_policy(
19350            &address_a,
19351            ProviderRuntimePolicy::raw_pty(),
19352            None,
19353        );
19354        guard_a.retain();
19355        assert!(shared.remove_binding(&address_a).is_some());
19356        crate::dig2_station_lease::acquire_lease(
19357            &shared.browser_station_leases,
19358            profile,
19359            address_b.session.instance_id,
19360            3,
19361        )
19362        .expect("reacquire after clear")
19363        .retain();
19364        assert!(
19365            crate::dig2_station_lease::release_lease_for_holder(
19366                &shared.browser_station_leases,
19367                address_b.session.instance_id,
19368            )
19369        );
19370    }
19371
19372    /// Feature-off: no browser_station_leases field; stub echo documented in
19373    /// resolve_environment_profile_echoes_registered_station_network_and_browser_knobs.
19374    #[cfg(not(feature = "dig2-station-probe"))]
19375    #[test]
19376    fn browser_station_lease_feature_off_keeps_stub_without_lease_table() {
19377        let (shared, defaults) = station_knobs_fixture();
19378        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
19379        let spec = crate::protocol::SpawnSpec {
19380            target: crate::protocol::SpawnTarget {
19381                node_id: shared.node_id.clone(),
19382                workspace_id: WorkspaceId::new("primary").unwrap(),
19383                worktree_id: None,
19384            },
19385            profile_id: defaults.profile_id.clone(),
19386            expected_profile_revision: defaults.revision.clone(),
19387            overrides: crate::protocol::SpawnOverrides {
19388                browser_profile_id: Some(browser.clone()),
19389                ..crate::protocol::SpawnOverrides::default()
19390            },
19391            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19392            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("lease-stub-off")
19393                .unwrap(),
19394            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19395        };
19396        let resolved = spec.resolve(&defaults).unwrap();
19397        let receipt = shared
19398            .resolve_environment_profile(&resolved)
19399            .expect("stub resolve")
19400            .expect("env profile");
19401        assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
19402    }
19403
19404
19405    /// Startup file loader registers ids into the empty-default catalog.
19406    /// Feature-off dig2 probe stays stub. Never secrets on C2.
19407    #[test]
19408    fn network_allowlist_catalog_loads_from_temp_file() {
19409        let unique = std::time::SystemTime::now()
19410            .duration_since(std::time::UNIX_EPOCH)
19411            .unwrap()
19412            .as_nanos();
19413        let path = std::env::temp_dir().join(format!(
19414            "gate4agent-node-allowlist-config-{}-{unique}.txt",
19415            std::process::id(),
19416        ));
19417        std::fs::write(
19418            &path,
19419            "# opaque station allowlist ids\negress-default\nlab-egress\n",
19420        )
19421        .unwrap();
19422        let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
19423            .expect("temp catalog file must load");
19424        let _ = std::fs::remove_file(&path);
19425        assert_eq!(loaded.len(), 2);
19426        let (shared, defaults) = station_knobs_fixture();
19427        {
19428            let mut catalog = shared
19429                .network_allowlist_catalog
19430                .write()
19431                .unwrap_or_else(|poisoned| poisoned.into_inner());
19432            *catalog = loaded;
19433        }
19434        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19435        let spec = crate::protocol::SpawnSpec {
19436            target: crate::protocol::SpawnTarget {
19437                node_id: shared.node_id.clone(),
19438                workspace_id: WorkspaceId::new("primary").unwrap(),
19439                worktree_id: None,
19440            },
19441            profile_id: defaults.profile_id.clone(),
19442            expected_profile_revision: defaults.revision.clone(),
19443            overrides: crate::protocol::SpawnOverrides {
19444                network_allowlist: Some(allowlist.clone()),
19445                ..crate::protocol::SpawnOverrides::default()
19446            },
19447            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19448            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19449                "station-knobs-catalog-file",
19450            )
19451            .unwrap(),
19452            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19453        };
19454        let resolved = spec.resolve(&defaults).unwrap();
19455        let receipt = shared
19456            .resolve_environment_profile(&resolved)
19457            .expect("registered via file load")
19458            .expect("env profile present");
19459        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
19460    }
19461
19462    /// Snapshot launch inventory echoes registered catalog ids (opaque).
19463    /// Dig2 station probe is orthogonal (feature `dig2-station-probe`). Never secrets on C2.
19464    #[test]
19465    fn snapshot_launch_inventory_lists_network_allowlist_catalog_ids() {
19466        let (shared, _defaults) = station_knobs_fixture();
19467        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19468        shared
19469            .network_allowlist_catalog
19470            .write()
19471            .unwrap_or_else(|poisoned| poisoned.into_inner())
19472            .insert_id(allowlist.clone())
19473            .unwrap();
19474        let snapshot = shared.snapshot();
19475        let inventory = snapshot
19476            .launch_inventory
19477            .expect("launch inventory present");
19478        let ids = inventory
19479            .network_allowlists
19480            .expect("non-empty catalog surfaces ids");
19481        assert_eq!(ids, vec![allowlist]);
19482    }
19483
19484    /// Track B: registered id with provider_native codex_network_access refuses
19485    /// clearly for Claude (fixture provider). Unknown id still UnknownNetworkAllowlist.
19486    /// Never secrets on C2. Codex Moderate maps to `-c` overlay; RO/FullAuto refuse.
19487    #[test]
19488    fn resolve_environment_profile_refuses_unsupported_provider_native_mapping() {
19489        let (shared, defaults) = station_knobs_fixture();
19490        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-only").unwrap();
19491        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19492            id: allowlist.clone(),
19493            permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
19494                protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19495                peer: "127.0.0.1:443".to_owned(),
19496            }],
19497            provider_native: Some(
19498                crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
19499                    codex_network_access: Some(true),
19500                },
19501            ),
19502        };
19503        shared
19504            .network_allowlist_catalog
19505            .write()
19506            .unwrap_or_else(|poisoned| poisoned.into_inner())
19507            .insert(entry)
19508            .unwrap();
19509        let spec = crate::protocol::SpawnSpec {
19510            target: crate::protocol::SpawnTarget {
19511                node_id: shared.node_id.clone(),
19512                workspace_id: WorkspaceId::new("primary").unwrap(),
19513                worktree_id: None,
19514            },
19515            profile_id: defaults.profile_id.clone(),
19516            expected_profile_revision: defaults.revision.clone(),
19517            overrides: crate::protocol::SpawnOverrides {
19518                network_allowlist: Some(allowlist),
19519                ..crate::protocol::SpawnOverrides::default()
19520            },
19521            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19522            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19523                "station-knobs-unsupported-mapping",
19524            )
19525            .unwrap(),
19526            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19527        };
19528        let resolved = spec.resolve(&defaults).unwrap();
19529        assert_eq!(resolved.provider.as_str(), "claude");
19530        let failure = shared
19531            .resolve_environment_profile(&resolved)
19532            .expect_err("claude + codex_network_access must refuse");
19533        assert_eq!(
19534            failure.code,
19535            NodeFailureCode::UnsupportedNetworkAllowlistMapping
19536        );
19537    }
19538
19539    /// Codex Moderate + codex_network_access yields `-c` overlay argv.
19540    /// ReadOnly / FullAuto refuse UnsupportedNetworkAllowlistMapping.
19541    /// Never secrets on C2.
19542    #[test]
19543    fn resolve_codex_network_access_overlay_moderate_only() {
19544        let (shared, defaults) = station_knobs_fixture();
19545        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-net").unwrap();
19546        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19547            id: allowlist.clone(),
19548            permits: Vec::new(),
19549            provider_native: Some(
19550                crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
19551                    codex_network_access: Some(true),
19552                },
19553            ),
19554        };
19555        shared
19556            .network_allowlist_catalog
19557            .write()
19558            .unwrap_or_else(|poisoned| poisoned.into_inner())
19559            .insert(entry)
19560            .unwrap();
19561
19562        let overlay = shared
19563            .resolve_codex_network_access_overlay_args(
19564                &agent("codex"),
19565                SessionMode::Pty,
19566                ApprovalLevel::Moderate,
19567                Some(&allowlist),
19568            )
19569            .expect("Moderate Codex maps network_access");
19570        assert_eq!(
19571            overlay,
19572            vec![
19573                std::ffi::OsString::from("-c"),
19574                std::ffi::OsString::from("sandbox_workspace_write.network_access=true"),
19575            ]
19576        );
19577
19578        let acp = shared
19579            .resolve_codex_network_access_overlay_args(
19580                &agent("codex"),
19581                SessionMode::Acp,
19582                ApprovalLevel::Moderate,
19583                Some(&allowlist),
19584            )
19585            .unwrap();
19586        assert_eq!(acp, overlay);
19587
19588        let inline = shared
19589            .resolve_codex_network_access_overlay_args(
19590                &agent("codex"),
19591                SessionMode::Inline,
19592                ApprovalLevel::Moderate,
19593                Some(&allowlist),
19594            )
19595            .unwrap_err();
19596        assert_eq!(
19597            inline.code,
19598            NodeFailureCode::UnsupportedNetworkAllowlistMapping
19599        );
19600
19601        for level in [ApprovalLevel::ReadOnly, ApprovalLevel::FullAuto] {
19602            let spec = crate::protocol::SpawnSpec {
19603                target: crate::protocol::SpawnTarget {
19604                    node_id: shared.node_id.clone(),
19605                    workspace_id: WorkspaceId::new("primary").unwrap(),
19606                    worktree_id: None,
19607                },
19608                profile_id: defaults.profile_id.clone(),
19609                expected_profile_revision: defaults.revision.clone(),
19610                overrides: crate::protocol::SpawnOverrides {
19611                    provider: crate::protocol::SpawnOverride::Set {
19612                        value: agent("codex"),
19613                    },
19614                    approval_level: Some(level),
19615                    network_allowlist: Some(allowlist.clone()),
19616                    ..crate::protocol::SpawnOverrides::default()
19617                },
19618                deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19619                idempotency_key: crate::protocol::SpawnIdempotencyKey::new(format!(
19620                    "codex-net-refuse-{}",
19621                    match level {
19622                        ApprovalLevel::ReadOnly => "ro",
19623                        ApprovalLevel::FullAuto => "fa",
19624                        _ => "other",
19625                    }
19626                ))
19627                .unwrap(),
19628                required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19629            };
19630            let resolved = spec.resolve(&defaults).unwrap();
19631            let failure = shared
19632                .resolve_environment_profile(&resolved)
19633                .expect_err("RO/FullAuto must refuse");
19634            assert_eq!(
19635                failure.code,
19636                NodeFailureCode::UnsupportedNetworkAllowlistMapping
19637            );
19638        }
19639    }
19640
19641    /// Claude PTY + catalog permits → ProviderHome `--settings` sidecar with
19642    /// sandbox.network.allowedDomains. ACP / missing ProviderHome refuse.
19643    /// Never secrets on C2; does not invent provider_native Claude keys.
19644    #[test]
19645    fn resolve_claude_settings_network_overlay_pty_writes_sidecar() {
19646        let (shared, _defaults) = station_knobs_fixture();
19647        let allowlist =
19648            crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-domains").unwrap();
19649        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19650            id: allowlist.clone(),
19651            permits: vec![
19652                crate::network_allowlist_catalog::NetworkPermitSketch {
19653                    protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19654                    peer: "github.com:443".to_owned(),
19655                },
19656                crate::network_allowlist_catalog::NetworkPermitSketch {
19657                    protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19658                    peer: "*.npmjs.org".to_owned(),
19659                },
19660            ],
19661            provider_native: None,
19662        };
19663        shared
19664            .network_allowlist_catalog
19665            .write()
19666            .unwrap_or_else(|poisoned| poisoned.into_inner())
19667            .insert(entry)
19668            .unwrap();
19669
19670        let unique = std::time::SystemTime::now()
19671            .duration_since(std::time::UNIX_EPOCH)
19672            .unwrap()
19673            .as_nanos();
19674        let provider_home = std::env::temp_dir().join(format!(
19675            "gate4agent-claude-provider-home-{}-{unique}",
19676            std::process::id(),
19677        ));
19678        std::fs::create_dir_all(&provider_home).unwrap();
19679
19680        let overlay = shared
19681            .resolve_network_allowlist_overlay_args(
19682                &agent("claude"),
19683                SessionMode::Pty,
19684                ApprovalLevel::Moderate,
19685                Some(&allowlist),
19686                Some(provider_home.as_path()),
19687            )
19688            .expect("Claude PTY settings overlay on unix");
19689        let settings_path = provider_home.join(
19690            crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
19691        );
19692        assert_eq!(
19693            overlay,
19694            vec![
19695                std::ffi::OsString::from("--settings"),
19696                std::ffi::OsString::from(settings_path.as_os_str()),
19697            ]
19698        );
19699        let body = std::fs::read_to_string(&settings_path).unwrap();
19700        let value: serde_json::Value = serde_json::from_str(&body).unwrap();
19701        assert_eq!(value["sandbox"]["enabled"], true);
19702        assert_eq!(
19703            value["sandbox"]["network"]["allowedDomains"],
19704            serde_json::json!(["github.com", "*.npmjs.org"])
19705        );
19706        assert!(settings_path.starts_with(&provider_home));
19707
19708        let acp = shared
19709            .resolve_network_allowlist_overlay_args(
19710                &agent("claude"),
19711                SessionMode::Acp,
19712                ApprovalLevel::Moderate,
19713                Some(&allowlist),
19714                Some(provider_home.as_path()),
19715            )
19716            .expect_err("Claude ACP must refuse settings overlay");
19717        assert_eq!(
19718            acp.code,
19719            NodeFailureCode::UnsupportedNetworkAllowlistMapping
19720        );
19721
19722        let no_home = shared
19723            .resolve_network_allowlist_overlay_args(
19724                &agent("claude"),
19725                SessionMode::Pty,
19726                ApprovalLevel::Moderate,
19727                Some(&allowlist),
19728                None,
19729            )
19730            .expect_err("Claude overlay requires ProviderHome");
19731        assert_eq!(
19732            no_home.code,
19733            NodeFailureCode::UnsupportedNetworkAllowlistMapping
19734        );
19735
19736        let bare = crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-bare").unwrap();
19737        shared
19738            .network_allowlist_catalog
19739            .write()
19740            .unwrap_or_else(|poisoned| poisoned.into_inner())
19741            .insert_id(bare.clone())
19742            .unwrap();
19743        let empty = shared
19744            .resolve_network_allowlist_overlay_args(
19745                &agent("claude"),
19746                SessionMode::Pty,
19747                ApprovalLevel::Moderate,
19748                Some(&bare),
19749                Some(provider_home.as_path()),
19750            )
19751            .unwrap();
19752        assert!(empty.is_empty());
19753
19754        let _ = std::fs::remove_dir_all(&provider_home);
19755    }
19756
19757    /// Kimi / Grok with permits stay without invented argv overlays.
19758    #[test]
19759    fn resolve_kimi_grok_network_overlay_stays_empty_without_native() {
19760        let (shared, _defaults) = station_knobs_fixture();
19761        let allowlist =
19762            crate::protocol::SpawnNetworkAllowlistId::new("egress-kimi-permits").unwrap();
19763        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19764            id: allowlist.clone(),
19765            permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
19766                protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19767                peer: "example.com:443".to_owned(),
19768            }],
19769            provider_native: None,
19770        };
19771        shared
19772            .network_allowlist_catalog
19773            .write()
19774            .unwrap_or_else(|poisoned| poisoned.into_inner())
19775            .insert(entry)
19776            .unwrap();
19777        for provider in ["kimi", "grok"] {
19778            let overlay = shared
19779                .resolve_network_allowlist_overlay_args(
19780                    &agent(provider),
19781                    SessionMode::Pty,
19782                    ApprovalLevel::Moderate,
19783                    Some(&allowlist),
19784                    None,
19785                )
19786                .unwrap();
19787            assert!(
19788                overlay.is_empty(),
19789                "{provider} must not invent network argv from permits"
19790            );
19791        }
19792    }
19793
19794    /// Track B: JSON v2 catalog file loads permits; inventory still ids only.
19795    #[test]
19796    fn network_allowlist_catalog_loads_json_v2_and_lists_ids_only() {
19797        let unique = std::time::SystemTime::now()
19798            .duration_since(std::time::UNIX_EPOCH)
19799            .unwrap()
19800            .as_nanos();
19801        let path = std::env::temp_dir().join(format!(
19802            "gate4agent-node-allowlist-json-{}-{unique}.json",
19803            std::process::id(),
19804        ));
19805        std::fs::write(
19806            &path,
19807            r#"{
19808              "schema_version": 2,
19809              "entries": [
19810                {
19811                  "id": "egress-default",
19812                  "permits": [{ "protocol": "tcp", "peer": "127.0.0.1:443" }]
19813                },
19814                { "id": "lab-egress" }
19815              ]
19816            }"#,
19817        )
19818        .unwrap();
19819        let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
19820            .expect("json v2 catalog must load");
19821        let _ = std::fs::remove_file(&path);
19822        assert_eq!(loaded.len(), 2);
19823        let entry = loaded
19824            .get(&crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap())
19825            .unwrap();
19826        assert_eq!(entry.permits.len(), 1);
19827        let (shared, defaults) = station_knobs_fixture();
19828        {
19829            let mut catalog = shared
19830                .network_allowlist_catalog
19831                .write()
19832                .unwrap_or_else(|poisoned| poisoned.into_inner());
19833            *catalog = loaded;
19834        }
19835        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19836        let spec = crate::protocol::SpawnSpec {
19837            target: crate::protocol::SpawnTarget {
19838                node_id: shared.node_id.clone(),
19839                workspace_id: WorkspaceId::new("primary").unwrap(),
19840                worktree_id: None,
19841            },
19842            profile_id: defaults.profile_id.clone(),
19843            expected_profile_revision: defaults.revision.clone(),
19844            overrides: crate::protocol::SpawnOverrides {
19845                network_allowlist: Some(allowlist.clone()),
19846                ..crate::protocol::SpawnOverrides::default()
19847            },
19848            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19849            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19850                "station-knobs-catalog-json",
19851            )
19852            .unwrap(),
19853            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19854        };
19855        let resolved = spec.resolve(&defaults).unwrap();
19856        let receipt = shared
19857            .resolve_environment_profile(&resolved)
19858            .expect("permits-only entry ok for claude")
19859            .expect("env profile present");
19860        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
19861        let inventory = shared
19862            .snapshot()
19863            .launch_inventory
19864            .expect("launch inventory present");
19865        let ids = inventory.network_allowlists.expect("ids surface");
19866        assert_eq!(
19867            ids,
19868            vec![
19869                crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap(),
19870                crate::protocol::SpawnNetworkAllowlistId::new("lab-egress").unwrap(),
19871            ]
19872        );
19873    }
19874
19875
19876}
19877
19878
19879#[cfg(test)]
19880mod workspace_environment_collision_tests {
19881    use super::*;
19882    use crate::session_environment::{
19883        NodeSessionMaterializationProfile, NodeSessionPathBinding, NodeSessionPathClass,
19884    };
19885    use gate4agent_catalog::EnvMutation;
19886    use gate4agent_runtime_native::{
19887        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
19888        NativeLaunchProfileId,
19889    };
19890    use std::ffi::OsString;
19891    use std::sync::Arc;
19892
19893    struct EmptyEnvironmentResolver;
19894
19895    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19896        fn resolve_child_environment(
19897            &self,
19898        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19899            Ok(vec![EnvMutation {
19900                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19901                value: None,
19902            }])
19903        }
19904    }
19905
19906    fn agent(value: &str) -> AgentId {
19907        AgentId::new(value).unwrap()
19908    }
19909
19910    /// Two Claude ProviderHome profiles (`CLAUDE_CONFIG_DIR` A vs B) on one node.
19911    fn claude_config_dir_collision_fixture() -> (
19912        NodeShared,
19913        ResolvedEnvironmentProfileReceipt,
19914        ResolvedEnvironmentProfileReceipt,
19915    ) {
19916        let catalog = active_registry().unwrap();
19917        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19918        let control = runtime.native_launch_profile_control();
19919        let workspace = WorkspaceConfig::new(
19920            WorkspaceId::new("primary").unwrap(),
19921            std::env::current_dir().unwrap(),
19922        )
19923        .unwrap();
19924        let shared = NodeShared::new_with_incarnation(
19925            handle,
19926            "fixture-token".to_owned(),
19927            NodeId::new("node-env-collision-test").unwrap(),
19928            NodeIncarnationId::from_bytes([3; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19929            vec![workspace],
19930            vec![agent("claude")],
19931            ProviderRuntimeStatuses::default(),
19932            None,
19933            Vec::new(),
19934            Vec::new(),
19935            SpawnProfileRegistry::default(),
19936            Some(control),
19937            None,
19938            None,
19939            None,
19940            Vec::new(),
19941            Vec::new(),
19942            Vec::new(),
19943            Vec::new(),
19944            None,
19945            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19946        );
19947
19948        let mut receipts = Vec::new();
19949        for (suffix, native_suffix) in [("a", "a-pty"), ("b", "b-pty")] {
19950            let profile_id =
19951                SpawnEnvironmentProfileId::new(format!("claude-config-dir-{suffix}")).unwrap();
19952            let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(format!(
19953                "claude-config-dir-{suffix}-r1"
19954            ))
19955            .unwrap();
19956            let materialization = NodeSessionMaterializationProfile::new(
19957                Vec::new(),
19958                vec![NodeSessionPathBinding::new(
19959                    "CLAUDE_CONFIG_DIR",
19960                    NodeSessionPathClass::ProviderHome,
19961                )
19962                .unwrap()],
19963                Vec::new(),
19964            )
19965            .unwrap();
19966            let node_profile = NodeEnvironmentProfile::new_with_materialization(
19967                profile_id.clone(),
19968                profile_revision.clone(),
19969                agent("claude"),
19970                [NativeLaunchProfile::new(
19971                    NativeLaunchProfileId::new(format!("claude-config-dir-{native_suffix}"))
19972                        .unwrap(),
19973                    agent("claude"),
19974                    TransportKind::Pty,
19975                    vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19976                    Arc::new(EmptyEnvironmentResolver),
19977                )
19978                .unwrap()],
19979                Some(materialization),
19980            )
19981            .unwrap();
19982            let (binding, native_profiles, materialization) = node_profile.into_parts();
19983            for native_profile in native_profiles {
19984                runtime.upsert_native_launch_profile(native_profile).unwrap();
19985            }
19986            shared
19987                .environment_profiles
19988                .write()
19989                .unwrap_or_else(|poisoned| poisoned.into_inner())
19990                .insert(binding.id.clone(), binding);
19991            shared
19992                .environment_materialization_profiles
19993                .write()
19994                .unwrap_or_else(|poisoned| poisoned.into_inner())
19995                .insert(profile_id.clone(), materialization.unwrap());
19996            receipts.push(ResolvedEnvironmentProfileReceipt {
19997                profile_id,
19998                profile_revision,
19999                network_allowlist: None,
20000                browser_profile_id: None,
20001            });
20002        }
20003        let profile_b = receipts.pop().unwrap();
20004        let profile_a = receipts.pop().unwrap();
20005        (shared, profile_a, profile_b)
20006    }
20007
20008    #[test]
20009    fn refuse_distinct_claude_config_dir_profiles_on_same_workspace_cwd() {
20010        let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
20011        let primary = WorkspaceId::new("primary").unwrap();
20012        let worktree = WorkspaceId::new("worktree-b").unwrap();
20013        let address = SessionAddress {
20014            workspace_id: primary.clone(),
20015            session: SessionKey {
20016                instance_id: AgentInstanceId(41),
20017                generation: SessionGeneration(1),
20018            },
20019        };
20020        shared.bind_session_with_policy(
20021            &address,
20022            ProviderRuntimePolicy::raw_pty(),
20023            Some(profile_a.clone()),
20024        );
20025
20026        let refused = shared
20027            .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_b))
20028            .unwrap_err();
20029        assert_eq!(refused.code, NodeFailureCode::WorkspaceBusy);
20030
20031        // Some vs None on the same cwd is also a collision.
20032        let refused_none = shared
20033            .ensure_workspace_environment_profile_compatible(&primary, None)
20034            .unwrap_err();
20035        assert_eq!(refused_none.code, NodeFailureCode::WorkspaceBusy);
20036
20037        // Same profile on the same workspace remains allowed.
20038        shared
20039            .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_a))
20040            .unwrap();
20041
20042        // Distinct workspace id (managed-worktree parallel path) allows profile B.
20043        shared
20044            .ensure_workspace_environment_profile_compatible(&worktree, Some(&profile_b))
20045            .unwrap();
20046    }
20047
20048    #[tokio::test]
20049    async fn spawn_session_refuses_conflicting_environment_profile_before_mutation() {
20050        let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
20051        let primary = WorkspaceId::new("primary").unwrap();
20052        let address = SessionAddress {
20053            workspace_id: primary.clone(),
20054            session: SessionKey {
20055                instance_id: AgentInstanceId(41),
20056                generation: SessionGeneration(1),
20057            },
20058        };
20059        shared.bind_session_with_policy(
20060            &address,
20061            ProviderRuntimePolicy::raw_pty(),
20062            Some(profile_a),
20063        );
20064
20065        let failure = shared
20066            .spawn_session_with_deadline(
20067                primary,
20068                agent("claude"),
20069                SessionMode::Pty,
20070                gate4agent_types::TerminalSize {
20071                    rows: 24,
20072                    columns: 80,
20073                },
20074                None,
20075                Some(profile_b),
20076                None,
20077                None,
20078                None,
20079                None,
20080                SpawnRecordPolicy::ProviderIdentityOnly,
20081                Some(Instant::now() + Duration::from_secs(30)),
20082                &[],
20083                None,
20084                ApprovalLevel::default(),
20085                None,
20086                None,
20087            )
20088            .await
20089            .unwrap_err();
20090        assert_eq!(failure.code, NodeFailureCode::WorkspaceBusy);
20091        // Guard runs before instance id mint / bind.
20092        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
20093        assert!(shared.handle.snapshot().sessions.is_empty());
20094    }
20095}
20096
20097#[cfg(test)]
20098mod spawn_characterization_tests {
20099    use super::*;
20100    use crate::session_environment::{
20101        NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
20102        NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
20103        NodeSessionPathClass,
20104    };
20105    use gate4agent_catalog::EnvMutation;
20106    use gate4agent_runtime_native::{
20107        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
20108        NativeLaunchProfile, NativeLaunchProfileId,
20109    };
20110    use std::ffi::OsString;
20111    use std::sync::Arc;
20112
20113    struct EmptyEnvironmentResolver;
20114
20115    struct FixtureSecretResolver {
20116        deny: Arc<AtomicBool>,
20117    }
20118
20119    impl NodeSecretResolver for FixtureSecretResolver {
20120        fn resolve(
20121            &self,
20122            _reference: &NodeSecretReference,
20123        ) -> Result<NodeSecretValue, NodeSecretResolveError> {
20124            if self.deny.load(Ordering::Acquire) {
20125                Err(NodeSecretResolveError::Denied)
20126            } else {
20127                NodeSecretValue::text("fixture-secret")
20128                    .map_err(|_| NodeSecretResolveError::Unavailable)
20129            }
20130        }
20131    }
20132
20133    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
20134        fn resolve_child_environment(
20135            &self,
20136        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
20137            Ok(vec![EnvMutation {
20138                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
20139                value: None,
20140            }])
20141        }
20142    }
20143
20144    fn agent(value: &str) -> AgentId {
20145        AgentId::new(value).unwrap()
20146    }
20147
20148    fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
20149        let unique = std::time::SystemTime::now()
20150            .duration_since(std::time::UNIX_EPOCH)
20151            .unwrap()
20152            .as_nanos();
20153        std::env::temp_dir().join(format!(
20154            "gate4agent-node-{label}-{}-{unique}",
20155            std::process::id(),
20156        ))
20157    }
20158
20159    fn materialization_test_shared(
20160        deny_secret: bool,
20161    ) -> (
20162        NodeShared,
20163        NativeRuntime,
20164        ResolvedEnvironmentProfileReceipt,
20165        PathBuf,
20166        Arc<AtomicBool>,
20167    ) {
20168        let workspace = WorkspaceConfig::new(
20169            WorkspaceId::new("primary").unwrap(),
20170            std::env::current_dir().unwrap(),
20171        )
20172        .unwrap();
20173        materialization_test_shared_with_workspace(deny_secret, workspace)
20174    }
20175
20176    fn materialization_test_shared_with_workspace(
20177        deny_secret: bool,
20178        workspace: WorkspaceConfig,
20179    ) -> (
20180        NodeShared,
20181        NativeRuntime,
20182        ResolvedEnvironmentProfileReceipt,
20183        PathBuf,
20184        Arc<AtomicBool>,
20185    ) {
20186        let root = temporary_workspace_root(if deny_secret {
20187            "materialization-denied"
20188        } else {
20189            "materialization-ready"
20190        });
20191        std::fs::create_dir_all(&root).unwrap();
20192        let materialization_root = root.join("session-environments");
20193        let deny = Arc::new(AtomicBool::new(deny_secret));
20194        let resolver = Arc::new(FixtureSecretResolver {
20195            deny: Arc::clone(&deny),
20196        });
20197        let materializer = SessionEnvironmentMaterializer::new(
20198            materialization_root,
20199            resolver,
20200        )
20201        .unwrap();
20202        let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
20203        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20204            "materialized-claude-r1",
20205        )
20206        .unwrap();
20207        let materialization = NodeSessionMaterializationProfile::new(
20208            vec![NodeSessionEnvironmentMutation::SetSecret {
20209                key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
20210                reference: NodeSecretReference::new("fixture-token").unwrap(),
20211            }],
20212            vec![NodeSessionPathBinding::new(
20213                "GATE4AGENT_PROVIDER_HOME",
20214                NodeSessionPathClass::ProviderHome,
20215            )
20216            .unwrap()],
20217            vec![NodeSessionFile::secret(
20218                NodeSessionPathClass::Config,
20219                "auth/token",
20220                NodeSecretReference::new("fixture-token").unwrap(),
20221            )
20222            .unwrap()],
20223        )
20224        .unwrap();
20225        let catalog = active_registry().unwrap();
20226        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20227        let control = runtime.native_launch_profile_control();
20228        let node_profile = NodeEnvironmentProfile::new_with_materialization(
20229            profile_id.clone(),
20230            profile_revision.clone(),
20231            agent("claude"),
20232            [NativeLaunchProfile::new(
20233                NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
20234                agent("claude"),
20235                TransportKind::Pty,
20236                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20237                Arc::new(EmptyEnvironmentResolver),
20238            )
20239            .unwrap()],
20240            Some(materialization),
20241        )
20242        .unwrap();
20243        let (binding, native_profiles, materialization) = node_profile.into_parts();
20244        for native_profile in native_profiles {
20245            runtime.upsert_native_launch_profile(native_profile).unwrap();
20246        }
20247        let shared = NodeShared::new_with_incarnation(
20248            handle,
20249            "fixture-token".to_owned(),
20250            NodeId::new("node-materialization-test").unwrap(),
20251            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20252            vec![workspace],
20253            vec![agent("claude")],
20254            ProviderRuntimeStatuses::default(),
20255            None,
20256            Vec::new(),
20257            Vec::new(),
20258            SpawnProfileRegistry::default(),
20259            Some(control),
20260            None,
20261            Some(materializer),
20262            None,
20263            Vec::new(),
20264            Vec::new(),
20265            Vec::new(),
20266            Vec::new(),
20267            None,
20268            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20269        );
20270        shared
20271            .environment_profiles
20272            .write()
20273            .unwrap_or_else(|poisoned| poisoned.into_inner())
20274            .insert(binding.id.clone(), binding);
20275        shared
20276            .environment_materialization_profiles
20277            .write()
20278            .unwrap_or_else(|poisoned| poisoned.into_inner())
20279            .insert(profile_id.clone(), materialization.unwrap());
20280        (
20281            shared,
20282            runtime,
20283            ResolvedEnvironmentProfileReceipt {
20284                profile_id,
20285                profile_revision,
20286                network_allowlist: None,
20287                browser_profile_id: None,
20288            },
20289            root,
20290            deny,
20291        )
20292    }
20293
20294
20295    // ---- Characterization of the spawn preparation block (pre-SpawnPreparer) ----
20296
20297    async fn characterization_spawn(
20298        shared: &NodeShared,
20299        mode: SessionMode,
20300        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
20301        required: &[ProviderRuntimeCapability],
20302        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
20303    ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
20304        shared
20305            .spawn_session_with_deadline(
20306                WorkspaceId::new("primary").unwrap(),
20307                agent("claude"),
20308                mode,
20309                gate4agent_types::TerminalSize { rows: 24, columns: 80 },
20310                None,
20311                environment_profile,
20312                None,
20313                None,
20314                None,
20315                Some(ProviderRuntimePolicy::raw_pty()),
20316                SpawnRecordPolicy::ProviderIdentityOnly,
20317                Some(Instant::now() + Duration::from_secs(30)),
20318                required,
20319                harness_mcp,
20320                ApprovalLevel::default(),
20321                None,
20322                None,
20323            )
20324            .await
20325    }
20326
20327    fn assert_no_session_state(shared: &NodeShared) {
20328        assert!(shared.handle.snapshot().sessions.is_empty());
20329        assert!(shared
20330            .session_bindings
20331            .lock()
20332            .unwrap_or_else(|poisoned| poisoned.into_inner())
20333            .is_empty());
20334        assert!(shared
20335            .materializations
20336            .lock()
20337            .unwrap_or_else(|poisoned| poisoned.into_inner())
20338            .is_empty());
20339    }
20340
20341    #[tokio::test]
20342    async fn spawn_unadmitted_required_capability_refuses_before_id_mint() {
20343        let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
20344        let error = characterization_spawn(
20345            &shared,
20346            SessionMode::Pty,
20347            None,
20348            &[ProviderRuntimeCapability::SemanticReadiness],
20349            None,
20350        )
20351        .await
20352        .unwrap_err();
20353        assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
20354        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
20355        assert_no_session_state(&shared);
20356        drop(shared);
20357        drop(runtime);
20358        std::fs::remove_dir_all(root).unwrap();
20359    }
20360
20361    #[tokio::test]
20362    async fn spawn_denied_environment_materialization_fails_after_id_mint_with_no_state() {
20363        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
20364        let error = characterization_spawn(&shared, SessionMode::Pty, Some(receipt), &[], None)
20365            .await
20366            .unwrap_err();
20367        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
20368        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 2);
20369        assert_no_session_state(&shared);
20370        drop(shared);
20371        drop(runtime);
20372        std::fs::remove_dir_all(root).unwrap();
20373    }
20374
20375    #[test]
20376    fn remove_binding_returns_binding_and_is_idempotent_for_environment_session() {
20377        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20378        let address = SessionAddress {
20379            workspace_id: WorkspaceId::new("primary").unwrap(),
20380            session: SessionKey {
20381                instance_id: AgentInstanceId(77),
20382                generation: SessionGeneration(1),
20383            },
20384        };
20385        shared.bind_session_with_policy(&address, ProviderRuntimePolicy::raw_pty(), Some(receipt));
20386        let removed = shared.remove_binding(&address).expect("binding removed");
20387        assert!(removed.environment_profile.is_some());
20388        assert!(shared.remove_binding(&address).is_none());
20389        assert_no_session_state(&shared);
20390        drop(shared);
20391        drop(runtime);
20392        std::fs::remove_dir_all(root).unwrap();
20393    }
20394
20395    async fn characterization_prepared_harness(provider: &str) -> PreparedHarnessMcpSpawn {
20396        use crate::harness_mcp_proxy::{HarnessMcpProxyRegistry, ReviewedHarnessMcpProgram};
20397        use crate::protocol::{HarnessMcpActivationDigest, HarnessMcpLaunchV1, HarnessMcpReservationId};
20398        let helper = ReviewedHarnessMcpProgram::review(std::env::current_exe().unwrap())
20399            .expect("review current test executable as fixture helper");
20400        let registry = HarnessMcpProxyRegistry::new(helper);
20401        let spec = crate::protocol::SpawnSpec {
20402            target: crate::protocol::SpawnTarget {
20403                node_id: NodeId::new("node-characterization").unwrap(),
20404                workspace_id: WorkspaceId::new("primary").unwrap(),
20405                worktree_id: None,
20406            },
20407            profile_id: crate::protocol::SpawnProfileId::new("claude").unwrap(),
20408            expected_profile_revision: crate::protocol::SpawnProfileRevision::new("r1").unwrap(),
20409            overrides: crate::protocol::SpawnOverrides::default(),
20410            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
20411            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("characterization-harness").unwrap(),
20412            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
20413        };
20414        let reservation = HarnessMcpReservationId::new(format!("hmcpres_{}", "c".repeat(24))).unwrap();
20415        let digest = HarnessMcpActivationDigest::new(format!("sha256:{}", "c".repeat(64))).unwrap();
20416        let launch = HarnessMcpLaunchV1 {
20417            server_name: "fixture-harness".to_owned(),
20418            args: vec!["--session-proxy".to_owned()],
20419            endpoint_env: "FIXTURE_SESSION_ENDPOINT".to_owned(),
20420            token_env: "FIXTURE_SESSION_TOKEN".to_owned(),
20421            program_env: "FIXTURE_MCP_PROGRAM".to_owned(),
20422            trace: None,
20423            scrub_env: Vec::new(),
20424        };
20425        let expires = unix_time_ms().saturating_add(30_000);
20426        registry.arm(reservation.clone(), digest.clone(), spec.clone(), launch, expires).await.unwrap();
20427        registry
20428            .prepare_spawn(&reservation, &digest, &spec, expires, agent(provider))
20429            .unwrap()
20430    }
20431
20432    #[tokio::test]
20433    async fn spawn_harness_overlay_refuses_inline_and_provider_mismatch_with_no_state() {
20434        let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
20435        let prepared = characterization_prepared_harness("claude").await;
20436        let error = characterization_spawn(&shared, SessionMode::Inline, None, &[], Some(&prepared))
20437            .await
20438            .unwrap_err();
20439        // Inline is stopped by runtime admission before the harness check runs.
20440        assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
20441        let mismatched = characterization_prepared_harness("codex").await;
20442        let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&mismatched))
20443            .await
20444            .unwrap_err();
20445        assert_eq!(error.code, NodeFailureCode::BindingMismatch);
20446        assert_no_session_state(&shared);
20447        drop(shared);
20448        drop(runtime);
20449        std::fs::remove_dir_all(root).unwrap();
20450    }
20451
20452    #[tokio::test]
20453    async fn spawn_harness_overlay_without_launch_control_is_unavailable_with_no_state() {
20454        let (mut shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
20455        shared.native_launch_profile_control = None;
20456        let prepared = characterization_prepared_harness("claude").await;
20457        let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&prepared))
20458            .await
20459            .unwrap_err();
20460        assert_eq!(error.code, NodeFailureCode::HarnessMcpUnavailable);
20461        assert_no_session_state(&shared);
20462        drop(shared);
20463        drop(runtime);
20464        std::fs::remove_dir_all(root).unwrap();
20465    }
20466}
20467
20468#[cfg(all(test, windows))]
20469mod tests {
20470    use super::*;
20471    use gate4agent_types::ProviderActivity;
20472    use crate::session_environment::{
20473        NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
20474        NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
20475        NodeSessionPathClass,
20476    };
20477    use gate4agent_catalog::EnvMutation;
20478    use gate4agent_runtime_native::{
20479        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
20480        NativeLaunchProfile, NativeLaunchProfileId,
20481    };
20482    use std::ffi::OsString;
20483    use std::sync::Arc;
20484
20485    struct EmptyEnvironmentResolver;
20486
20487    struct FixtureSecretResolver {
20488        deny: Arc<AtomicBool>,
20489    }
20490
20491    impl NodeSecretResolver for FixtureSecretResolver {
20492        fn resolve(
20493            &self,
20494            _reference: &NodeSecretReference,
20495        ) -> Result<NodeSecretValue, NodeSecretResolveError> {
20496            if self.deny.load(Ordering::Acquire) {
20497                Err(NodeSecretResolveError::Denied)
20498            } else {
20499                NodeSecretValue::text("fixture-secret")
20500                    .map_err(|_| NodeSecretResolveError::Unavailable)
20501            }
20502        }
20503    }
20504
20505    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
20506        fn resolve_child_environment(
20507            &self,
20508        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
20509            Ok(vec![EnvMutation {
20510                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
20511                value: None,
20512            }])
20513        }
20514    }
20515
20516    fn agent(value: &str) -> AgentId {
20517        AgentId::new(value).unwrap()
20518    }
20519
20520    fn terminal_test_shared_with_mode(mode: WorktreeServiceMode) -> NodeShared {
20521        let catalog = active_registry().unwrap();
20522        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20523        let workspace = WorkspaceConfig::new(
20524            WorkspaceId::new("primary").unwrap(),
20525            std::env::current_dir().unwrap(),
20526        )
20527        .unwrap()
20528        .with_worktree_service_mode(mode);
20529        NodeShared::new(
20530            handle,
20531            "fixture-token".to_owned(),
20532            NodeId::new("node-terminal-test").unwrap(),
20533            vec![workspace],
20534            vec![agent("claude")],
20535        )
20536    }
20537
20538    fn terminal_test_shared() -> NodeShared {
20539        terminal_test_shared_with_mode(WorktreeServiceMode::Manual)
20540    }
20541
20542    #[cfg(feature = "fixture")]
20543    #[test]
20544    fn managed_worktree_v2_failure_probe_keeps_only_the_latest_code() {
20545        let probe = SpawnManagedWorktreeV2FailureProbe::default();
20546        assert_eq!(probe.latest_code(), None);
20547
20548        probe.record(Some(NodeFailureCode::ManagedWorktreeOwnershipConflict));
20549        assert_eq!(
20550            probe.latest_code(),
20551            Some(NodeFailureCode::ManagedWorktreeOwnershipConflict),
20552        );
20553
20554        probe.record(Some(NodeFailureCode::SpawnDeadlineExceeded));
20555        assert_eq!(
20556            probe.latest_code(),
20557            Some(NodeFailureCode::SpawnDeadlineExceeded),
20558        );
20559
20560        probe.record(None);
20561        assert_eq!(probe.latest_code(), None);
20562    }
20563
20564    #[test]
20565    fn launch_snapshot_is_authoritative_bounded_and_path_free() {
20566        let source = temporary_workspace_root("launch-inventory-source");
20567        let allocation = temporary_workspace_root("launch-inventory-allocation");
20568        std::fs::create_dir_all(&source).unwrap();
20569        std::fs::create_dir_all(&allocation).unwrap();
20570        let workspace_id = WorkspaceId::new("primary").unwrap();
20571        let mut workspace = WorkspaceConfig::new(workspace_id.clone(), &source)
20572            .unwrap()
20573            .with_worktree_service_mode(WorktreeServiceMode::Managed);
20574        for index in 0..MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE {
20575            workspace = workspace
20576                .with_managed_worktree_profile(
20577                    ManagedWorktreeProfile::new(
20578                        WorktreeProfileId::new(format!("profile-{index}")).unwrap(),
20579                        crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
20580                        &allocation,
20581                        "gate4agent",
20582                        "HEAD",
20583                        ManagedWorktreeRetention::RemoveWhenReleased,
20584                    )
20585                    .unwrap(),
20586                )
20587                .unwrap();
20588        }
20589        let overflow = workspace.clone().with_managed_worktree_profile(
20590            ManagedWorktreeProfile::new(
20591                WorktreeProfileId::new("overflow").unwrap(),
20592                crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
20593                &allocation,
20594                "gate4agent",
20595                "HEAD",
20596                ManagedWorktreeRetention::Retain,
20597            )
20598            .unwrap(),
20599        );
20600        assert!(matches!(
20601            overflow,
20602            Err(NodeServerError::ManagedWorktreeProfileCapacity {
20603                max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
20604                ..
20605            }),
20606        ));
20607
20608        let catalog = active_registry().unwrap();
20609        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20610        let shared = NodeShared::new(
20611            handle,
20612            "fixture-token".to_owned(),
20613            NodeId::new("node-launch-inventory").unwrap(),
20614            vec![workspace],
20615            vec![agent("claude")],
20616        );
20617        let snapshot = shared.snapshot();
20618        let launch = snapshot.launch_inventory.as_ref().unwrap();
20619        assert_eq!(launch.spawn_profiles.as_ref().unwrap().len(), 1);
20620        assert!(launch.bundles.as_ref().unwrap().is_empty());
20621        let profiles = snapshot.workspaces[0]
20622            .managed_worktree_profiles
20623            .as_ref()
20624            .unwrap();
20625        assert_eq!(
20626            snapshot.workspaces[0].worktree_service_mode,
20627            Some(WorktreeServiceMode::Managed),
20628        );
20629        assert_eq!(
20630            profiles.profiles.len(),
20631            MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
20632        );
20633        let public_inventory = serde_json::to_string(&(launch, profiles)).unwrap();
20634        for forbidden in ["allocation_root", "branch_prefix", "base", "prompt", "environment", "home"] {
20635            assert!(!public_inventory.contains(forbidden), "launch inventory leaked {forbidden}");
20636        }
20637        let legacy = snapshot_for_wire(
20638            &shared,
20639            true,
20640            true,
20641            false,
20642            false,
20643            true,
20644            false,
20645            true,
20646            false,
20647            true,
20648        );
20649        assert!(legacy.launch_inventory.is_none());
20650        assert!(legacy.workspaces[0].managed_worktree_profiles.is_none());
20651
20652        drop(shared);
20653        std::fs::remove_dir_all(source).unwrap();
20654        std::fs::remove_dir_all(allocation).unwrap();
20655    }
20656
20657    #[test]
20658    fn agent_progress_projection_omits_sensitive_provider_payloads_and_is_capability_gated() {
20659        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20660            capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
20661        }));
20662        let source = gate4agent_types::ProviderSource {
20663            family: AdapterFamily::PtySemantic,
20664            binding: AdapterBinding::new(
20665                gate4agent_types::AdapterId::new("claude").unwrap(),
20666                "fixture/v1",
20667                gate4agent_types::AdapterVerification::SyntheticFixture,
20668            )
20669            .unwrap(),
20670        };
20671        let provider = ProviderSnapshot {
20672            sequence: 17,
20673            session: Some(ProviderSessionIdentity {
20674                key: ProviderSessionKey::SessionId,
20675                id: "PRIVATE_PROVIDER_SESSION".to_owned(),
20676                transcript_path: Some(r"C:\PRIVATE_PROVIDER_PATH".to_owned()),
20677            }),
20678            model: Some("PRIVATE_MODEL".to_owned()),
20679            tools: vec!["PRIVATE_TOOL_INVENTORY".to_owned()],
20680            completed_turns: 3,
20681            usage: gate4agent_types::TokenUsage {
20682                input_tokens: 10,
20683                output_tokens: 20,
20684                cache_read_tokens: 30,
20685                cache_write_tokens: 40,
20686                reasoning_tokens: 50,
20687                context_window: Some(200_000),
20688            },
20689            lead_activity: ProviderActivity::WaitingForInput,
20690            activity: ProviderActivity::WaitingForInput,
20691            current_prompt: Some("PRIVATE_CURRENT_PROMPT".to_owned()),
20692            active_tools: (0..9)
20693                .map(|index| gate4agent_types::ActiveProviderTool {
20694                    id: format!("private-tool-id-{index}"),
20695                    name: if index == 0 {
20696                        format!("  Read\u{0000}{}", "x".repeat(80))
20697                    } else {
20698                        format!("Tool-{index}")
20699                    },
20700                    input_json: "PRIVATE_TOOL_INPUT_JSON".to_owned(),
20701                })
20702                .collect(),
20703            interactions: vec![gate4agent_types::ProviderInteraction {
20704                id: gate4agent_types::ProviderInteractionId(17),
20705                source: source.clone(),
20706                provider_request_id: Some("PRIVATE_PROVIDER_REQUEST".to_owned()),
20707                interaction_kind: ProviderInteractionKind::Approval,
20708                tool_name: " Approve\u{0007}Tool ".to_owned(),
20709                prompt: "PRIVATE_INTERACTION_PROMPT".to_owned(),
20710                agent_id: Some("PRIVATE_INTERACTION_AGENT".to_owned()),
20711                resume_lead_activity: Some(ProviderActivity::Working),
20712                status: ProviderInteractionStatus::Pending,
20713            }],
20714            subagents: vec![gate4agent_types::ProviderSubagent {
20715                source: source.clone(),
20716                provider_agent_id: "PRIVATE_SUBAGENT_ID".to_owned(),
20717                agent_type: Some("PRIVATE_SUBAGENT_TYPE".to_owned()),
20718                description: Some("PRIVATE_SUBAGENT_DESCRIPTION".to_owned()),
20719            }],
20720            sources: vec![gate4agent_types::ProviderSourceCursor {
20721                source,
20722                sequence: 17,
20723                gap_count: 2,
20724                stale: true,
20725            }],
20726            last_event: Some(ProviderEvent::Error {
20727                message: "PRIVATE_PROVIDER_ERROR".to_owned(),
20728            }),
20729            gap_count: 2,
20730            stale: true,
20731        };
20732        let address = terminal_address(3);
20733        let entry = agent_progress_from_provider_snapshot(address.clone(), &provider).unwrap();
20734        assert_eq!(entry.address, address);
20735        assert_eq!(entry.progress.provider_sequence, 17);
20736        assert_eq!(entry.progress.active_tool_count, 9);
20737        assert_eq!(
20738            entry.progress.active_tool_labels.len(),
20739            MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS,
20740        );
20741        assert_eq!(entry.progress.subagent_count, 1);
20742        assert_eq!(entry.progress.last_event_kind, Some(AgentProgressEventKindV1::Error));
20743        assert!(entry.progress.truncated);
20744        let encoded = serde_json::to_string(&entry).unwrap();
20745        for forbidden in [
20746            "PRIVATE_PROVIDER_SESSION",
20747            "PRIVATE_PROVIDER_PATH",
20748            "PRIVATE_MODEL",
20749            "PRIVATE_TOOL_INVENTORY",
20750            "PRIVATE_CURRENT_PROMPT",
20751            "PRIVATE_TOOL_INPUT_JSON",
20752            "PRIVATE_PROVIDER_REQUEST",
20753            "PRIVATE_INTERACTION_PROMPT",
20754            "PRIVATE_INTERACTION_AGENT",
20755            "PRIVATE_SUBAGENT_ID",
20756            "PRIVATE_SUBAGENT_TYPE",
20757            "PRIVATE_SUBAGENT_DESCRIPTION",
20758            "PRIVATE_PROVIDER_ERROR",
20759            "input_json",
20760            "current_prompt",
20761            "session_id",
20762        ] {
20763            assert!(!encoded.contains(forbidden), "agent progress leaked {forbidden}");
20764        }
20765
20766        let snapshot = NodeSnapshot {
20767            node_id: NodeId::new("node-progress-test").unwrap(),
20768            enabled_providers: vec![agent("claude")],
20769            provider_runtime_statuses: ProviderRuntimeStatuses::default(),
20770            workspaces: Vec::new(),
20771            session_records: Vec::new(),
20772            managed_worktrees: Vec::new(),
20773            launch_inventory: None,
20774            agent_progress: vec![entry],
20775        };
20776        let mut legacy_reply = ResponseEnvelope {
20777            request_id: 1,
20778            result: Ok(NodeResponse::Snapshot {
20779                event_sequence: 0,
20780                controller: None,
20781                snapshot: snapshot.clone(),
20782            }),
20783        };
20784        project_response_without_agent_progress(&mut legacy_reply);
20785        let Ok(NodeResponse::Snapshot { snapshot: legacy, .. }) = legacy_reply.result else {
20786            panic!("agent progress projection changed response kind");
20787        };
20788        assert!(legacy.agent_progress.is_empty());
20789        assert_eq!(snapshot.agent_progress.len(), 1);
20790    }
20791
20792    fn terminal_address(generation: u64) -> SessionAddress {
20793        SessionAddress {
20794            workspace_id: WorkspaceId::new("primary").unwrap(),
20795            session: SessionKey {
20796                instance_id: AgentInstanceId(41),
20797                generation: SessionGeneration(generation),
20798            },
20799        }
20800    }
20801
20802    fn spawn_spec_fixture() -> SpawnSpec {
20803        SpawnSpec {
20804            target: crate::protocol::SpawnTarget {
20805                node_id: NodeId::new("node-terminal-test").unwrap(),
20806                workspace_id: WorkspaceId::new("primary").unwrap(),
20807                worktree_id: None,
20808            },
20809            profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
20810            expected_profile_revision: crate::protocol::SpawnProfileRevision::new("builtin-v1")
20811                .unwrap(),
20812            overrides: crate::protocol::SpawnOverrides::default(),
20813            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
20814            idempotency_key: SpawnIdempotencyKey::new("spawn-spec-fixture").unwrap(),
20815            required_capabilities: SpawnRequiredCapabilities::default(),
20816        }
20817    }
20818
20819    fn environment_profile_test_shared(
20820    ) -> (NodeShared, NativeRuntime, ResolvedEnvironmentProfileReceipt) {
20821        let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
20822        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20823            "local-claude-r1",
20824        )
20825        .unwrap();
20826        let spawn_profiles = SpawnProfileRegistry::new([
20827            crate::protocol::SpawnProfileDefaults {
20828                profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
20829                revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
20830                provider: agent("claude"),
20831                mode: SessionMode::Pty,
20832                terminal_size: gate4agent_types::TerminalSize {
20833                    rows: 24,
20834                    columns: 80,
20835                },
20836                prompt: None,
20837                bundle_id: None,
20838                context_id: None,
20839                environment_profile_id: Some(profile_id.clone()),
20840            },
20841        ])
20842        .unwrap();
20843        let catalog = active_registry().unwrap();
20844        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20845        let control = runtime.native_launch_profile_control();
20846        let node_profile = NodeEnvironmentProfile::new(
20847            profile_id.clone(),
20848            profile_revision.clone(),
20849            agent("claude"),
20850            [NativeLaunchProfile::new(
20851                NativeLaunchProfileId::new("local-claude-pty").unwrap(),
20852                agent("claude"),
20853                TransportKind::Pty,
20854                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20855                Arc::new(EmptyEnvironmentResolver),
20856            )
20857            .unwrap()],
20858        )
20859        .unwrap();
20860        let (binding, native_profiles, _materialization) = node_profile.into_parts();
20861        for native_profile in native_profiles {
20862            runtime.upsert_native_launch_profile(native_profile).unwrap();
20863        }
20864        let workspace = WorkspaceConfig::new(
20865            WorkspaceId::new("primary").unwrap(),
20866            std::env::current_dir().unwrap(),
20867        )
20868        .unwrap();
20869        let shared = NodeShared::new_with_incarnation(
20870            handle,
20871            "fixture-token".to_owned(),
20872            NodeId::new("node-terminal-test").unwrap(),
20873            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20874            vec![workspace],
20875            vec![agent("claude")],
20876            ProviderRuntimeStatuses::default(),
20877            None,
20878            Vec::new(),
20879            Vec::new(),
20880            spawn_profiles,
20881            Some(control),
20882            None,
20883            None,
20884            None,
20885            Vec::new(),
20886            Vec::new(),
20887            Vec::new(),
20888            Vec::new(),
20889            None,
20890            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20891        );
20892        shared
20893            .environment_profiles
20894            .write()
20895            .unwrap_or_else(|poisoned| poisoned.into_inner())
20896            .insert(binding.id.clone(), binding);
20897        (
20898            shared,
20899            runtime,
20900            ResolvedEnvironmentProfileReceipt {
20901                profile_id,
20902                profile_revision,
20903                network_allowlist: None,
20904                browser_profile_id: None,
20905            },
20906        )
20907    }
20908
20909    fn materialization_test_shared(
20910        deny_secret: bool,
20911    ) -> (
20912        NodeShared,
20913        NativeRuntime,
20914        ResolvedEnvironmentProfileReceipt,
20915        PathBuf,
20916        Arc<AtomicBool>,
20917    ) {
20918        let workspace = WorkspaceConfig::new(
20919            WorkspaceId::new("primary").unwrap(),
20920            std::env::current_dir().unwrap(),
20921        )
20922        .unwrap();
20923        materialization_test_shared_with_workspace(deny_secret, workspace)
20924    }
20925
20926    fn materialization_test_shared_with_workspace(
20927        deny_secret: bool,
20928        workspace: WorkspaceConfig,
20929    ) -> (
20930        NodeShared,
20931        NativeRuntime,
20932        ResolvedEnvironmentProfileReceipt,
20933        PathBuf,
20934        Arc<AtomicBool>,
20935    ) {
20936        let root = temporary_workspace_root(if deny_secret {
20937            "materialization-denied"
20938        } else {
20939            "materialization-ready"
20940        });
20941        std::fs::create_dir_all(&root).unwrap();
20942        let materialization_root = root.join("session-environments");
20943        let deny = Arc::new(AtomicBool::new(deny_secret));
20944        let resolver = Arc::new(FixtureSecretResolver {
20945            deny: Arc::clone(&deny),
20946        });
20947        let materializer = SessionEnvironmentMaterializer::new(
20948            materialization_root,
20949            resolver,
20950        )
20951        .unwrap();
20952        let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
20953        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20954            "materialized-claude-r1",
20955        )
20956        .unwrap();
20957        let materialization = NodeSessionMaterializationProfile::new(
20958            vec![NodeSessionEnvironmentMutation::SetSecret {
20959                key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
20960                reference: NodeSecretReference::new("fixture-token").unwrap(),
20961            }],
20962            vec![NodeSessionPathBinding::new(
20963                "GATE4AGENT_PROVIDER_HOME",
20964                NodeSessionPathClass::ProviderHome,
20965            )
20966            .unwrap()],
20967            vec![NodeSessionFile::secret(
20968                NodeSessionPathClass::Config,
20969                "auth/token",
20970                NodeSecretReference::new("fixture-token").unwrap(),
20971            )
20972            .unwrap()],
20973        )
20974        .unwrap();
20975        let catalog = active_registry().unwrap();
20976        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20977        let control = runtime.native_launch_profile_control();
20978        let node_profile = NodeEnvironmentProfile::new_with_materialization(
20979            profile_id.clone(),
20980            profile_revision.clone(),
20981            agent("claude"),
20982            [NativeLaunchProfile::new(
20983                NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
20984                agent("claude"),
20985                TransportKind::Pty,
20986                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20987                Arc::new(EmptyEnvironmentResolver),
20988            )
20989            .unwrap()],
20990            Some(materialization),
20991        )
20992        .unwrap();
20993        let (binding, native_profiles, materialization) = node_profile.into_parts();
20994        for native_profile in native_profiles {
20995            runtime.upsert_native_launch_profile(native_profile).unwrap();
20996        }
20997        let shared = NodeShared::new_with_incarnation(
20998            handle,
20999            "fixture-token".to_owned(),
21000            NodeId::new("node-materialization-test").unwrap(),
21001            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
21002            vec![workspace],
21003            vec![agent("claude")],
21004            ProviderRuntimeStatuses::default(),
21005            None,
21006            Vec::new(),
21007            Vec::new(),
21008            SpawnProfileRegistry::default(),
21009            Some(control),
21010            None,
21011            Some(materializer),
21012            None,
21013            Vec::new(),
21014            Vec::new(),
21015            Vec::new(),
21016            Vec::new(),
21017            None,
21018            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
21019        );
21020        shared
21021            .environment_profiles
21022            .write()
21023            .unwrap_or_else(|poisoned| poisoned.into_inner())
21024            .insert(binding.id.clone(), binding);
21025        shared
21026            .environment_materialization_profiles
21027            .write()
21028            .unwrap_or_else(|poisoned| poisoned.into_inner())
21029            .insert(profile_id.clone(), materialization.unwrap());
21030        (
21031            shared,
21032            runtime,
21033            ResolvedEnvironmentProfileReceipt {
21034                profile_id,
21035                profile_revision,
21036                network_allowlist: None,
21037                browser_profile_id: None,
21038            },
21039            root,
21040            deny,
21041        )
21042    }
21043
21044    #[cfg(feature = "fixture")]
21045    #[derive(Clone, Copy)]
21046    enum CodexHomeProfile {
21047        Missing,
21048        WrongClass,
21049        Exact,
21050    }
21051
21052    #[cfg(feature = "fixture")]
21053    fn codex_bundle_test_shared(
21054        home_profile: CodexHomeProfile,
21055    ) -> (
21056        NodeShared,
21057        NativeRuntime,
21058        ResolvedEnvironmentProfileReceipt,
21059        NodeBundle,
21060        PathBuf,
21061    ) {
21062        const ROOT_MANIFEST: &[u8] = br#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"review-tools"}"#;
21063        const CLAUDE_MANIFEST: &[u8] =
21064            br#"{"name":"review-tools","version":"1.0.0","description":"Review helpers"}"#;
21065        const SKILL: &[u8] = b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n";
21066        const DIGEST: &str =
21067            "sha256:941f20868a6ef49b4329bf1bb1368515763f5b64d8279b05cd9700966083d707";
21068
21069        let (shared, mut runtime, _claude_receipt, root, _deny) =
21070            materialization_test_shared(false);
21071        let profile_id = SpawnEnvironmentProfileId::new(match home_profile {
21072            CodexHomeProfile::Missing => "codex-home-missing",
21073            CodexHomeProfile::WrongClass => "codex-home-wrong",
21074            CodexHomeProfile::Exact => "codex-home-exact",
21075        })
21076        .unwrap();
21077        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
21078            "codex-home-r1",
21079        )
21080        .unwrap();
21081        let materialization = match home_profile {
21082            CodexHomeProfile::Missing => None,
21083            CodexHomeProfile::WrongClass | CodexHomeProfile::Exact => {
21084                Some(NodeSessionMaterializationProfile::new(
21085                    Vec::new(),
21086                    vec![NodeSessionPathBinding::new(
21087                        "CODEX_HOME",
21088                        if matches!(home_profile, CodexHomeProfile::Exact) {
21089                            NodeSessionPathClass::ProviderHome
21090                        } else {
21091                            NodeSessionPathClass::Config
21092                        },
21093                    )
21094                    .unwrap()],
21095                    Vec::new(),
21096                )
21097                .unwrap())
21098            }
21099        };
21100        let node_profile = NodeEnvironmentProfile::new_with_materialization(
21101            profile_id.clone(),
21102            profile_revision.clone(),
21103            agent("codex"),
21104            [NativeLaunchProfile::new(
21105                NativeLaunchProfileId::new(format!("{profile_id}-pty")).unwrap(),
21106                agent("codex"),
21107                TransportKind::Pty,
21108                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
21109                Arc::new(EmptyEnvironmentResolver),
21110            )
21111            .unwrap()],
21112            materialization,
21113        )
21114        .unwrap();
21115        let (binding, native_profiles, materialization) = node_profile.into_parts();
21116        for native_profile in native_profiles {
21117            runtime.upsert_native_launch_profile(native_profile).unwrap();
21118        }
21119        shared
21120            .environment_profiles
21121            .write()
21122            .unwrap_or_else(|poisoned| poisoned.into_inner())
21123            .insert(binding.id.clone(), binding);
21124        if let Some(materialization) = materialization {
21125            shared
21126                .environment_materialization_profiles
21127                .write()
21128                .unwrap_or_else(|poisoned| poisoned.into_inner())
21129                .insert(profile_id.clone(), materialization);
21130        }
21131
21132        let bundle_source = root.join(format!("bundle-source-{profile_id}"));
21133        std::fs::create_dir_all(bundle_source.join(".claude-plugin")).unwrap();
21134        std::fs::create_dir_all(bundle_source.join("skills/review-code")).unwrap();
21135        std::fs::write(bundle_source.join("plugin.json"), ROOT_MANIFEST).unwrap();
21136        std::fs::write(
21137            bundle_source.join(".claude-plugin/plugin.json"),
21138            CLAUDE_MANIFEST,
21139        )
21140        .unwrap();
21141        std::fs::write(bundle_source.join("skills/review-code/SKILL.md"), SKILL).unwrap();
21142        crate::bundle_catalog::protect_bundle_source_tree_fixture(&bundle_source).unwrap();
21143        let bundle = NodeBundle::new(
21144            crate::protocol::SpawnBundleId::new("review-tools").unwrap(),
21145            crate::protocol::SpawnBundleRevision::new("review-tools-r1").unwrap(),
21146            crate::protocol::SpawnBundleDigest::new(DIGEST).unwrap(),
21147            &bundle_source,
21148        )
21149        .unwrap();
21150        *shared
21151            .bundle_catalog
21152            .write()
21153            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21154            BundleCatalog::new([bundle.clone()]).unwrap();
21155        (
21156            shared,
21157            runtime,
21158            ResolvedEnvironmentProfileReceipt {
21159                profile_id,
21160                profile_revision,
21161                network_allowlist: None,
21162                browser_profile_id: None,
21163            },
21164            bundle,
21165            root,
21166        )
21167    }
21168
21169    #[cfg(feature = "fixture")]
21170    #[test]
21171    fn codex_bundle_preflight_rejects_missing_or_wrong_home_before_materialization() {
21172        for home_profile in [CodexHomeProfile::Missing, CodexHomeProfile::WrongClass] {
21173            let (shared, runtime, receipt, bundle, root) =
21174                codex_bundle_test_shared(home_profile);
21175            let error = shared
21176                .bundle_layout(
21177                    &agent("codex"),
21178                    SessionMode::Pty,
21179                    Some(&receipt),
21180                    &bundle,
21181                )
21182                .unwrap_err();
21183            assert_eq!(error.code, NodeFailureCode::BundleBindingMismatch);
21184            assert!(shared
21185                .materializations
21186                .lock()
21187                .unwrap_or_else(|poisoned| poisoned.into_inner())
21188                .is_empty());
21189            let materialization_root = root.join("session-environments");
21190            assert!(!std::fs::read_dir(&materialization_root)
21191                .unwrap()
21192                .filter_map(Result::ok)
21193                .any(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())));
21194            drop(shared);
21195            drop(runtime);
21196            let _ = std::fs::remove_dir_all(root);
21197        }
21198    }
21199
21200    #[cfg(feature = "fixture")]
21201    #[test]
21202    fn codex_bundle_exact_home_profile_materializes_only_the_skill_tree() {
21203        let (shared, runtime, receipt, bundle, root) =
21204            codex_bundle_test_shared(CodexHomeProfile::Exact);
21205        let address = SessionAddress {
21206            workspace_id: WorkspaceId::new("primary").unwrap(),
21207            session: SessionKey {
21208                instance_id: AgentInstanceId(310),
21209                generation: SessionGeneration(1),
21210            },
21211        };
21212        let (overlay, guard) = shared
21213            .prepare_session_materialization(
21214                &address,
21215                &agent("codex"),
21216                SessionMode::Pty,
21217                Some(&receipt),
21218                Some(&bundle.receipt()),
21219                None,
21220                None,
21221            )
21222            .unwrap()
21223            .unwrap();
21224        assert!(matches!(overlay, Some(PreparedNativeLaunchOverlay::Instance(_))));
21225        let ownership = shared
21226            .materializations
21227            .lock()
21228            .unwrap_or_else(|poisoned| poisoned.into_inner())
21229            .get(guard.id().unwrap())
21230            .unwrap()
21231            .clone();
21232        let (profile, _, _) = shared
21233            .materialization_profile(
21234                &agent("codex"),
21235                SessionMode::Pty,
21236                Some(&receipt),
21237                Some(&bundle.receipt()),
21238                None,
21239            )
21240            .unwrap()
21241            .unwrap();
21242        let environment = shared
21243            .session_environment_materializer
21244            .as_ref()
21245            .unwrap()
21246            .resolve_environment(&ownership, &profile)
21247            .unwrap();
21248        assert!(environment.iter().any(|mutation| {
21249            mutation.key == OsString::from("CODEX_HOME")
21250                && mutation.value.as_deref() == Some(ownership.provider_home().as_os_str())
21251        }));
21252        assert_eq!(
21253            std::fs::read(ownership.provider_home().join("skills/review-code/SKILL.md"))
21254                .unwrap(),
21255            b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n",
21256        );
21257        assert!(std::fs::read_dir(ownership.bundle_root())
21258            .unwrap()
21259            .next()
21260            .is_none());
21261        drop(guard);
21262        drop(shared);
21263        drop(runtime);
21264        let _ = std::fs::remove_dir_all(root);
21265    }
21266
21267    #[cfg(feature = "fixture")]
21268    #[test]
21269    fn codex_cold_record_resume_revalidates_the_isolated_skill_tree() {
21270        let (shared, runtime, receipt, bundle, root) =
21271            codex_bundle_test_shared(CodexHomeProfile::Exact);
21272        let bundle_receipt = bundle.receipt();
21273        let address = SessionAddress {
21274            workspace_id: WorkspaceId::new("primary").unwrap(),
21275            session: SessionKey {
21276                instance_id: AgentInstanceId(311),
21277                generation: SessionGeneration(1),
21278            },
21279        };
21280        let (_overlay, guard) = shared
21281            .prepare_session_materialization(
21282                &address,
21283                &agent("codex"),
21284                SessionMode::Pty,
21285                Some(&receipt),
21286                Some(&bundle_receipt),
21287                None,
21288                None,
21289            )
21290            .unwrap()
21291            .unwrap();
21292        let materialization_id = guard.id().unwrap().clone();
21293        let skill_path = shared
21294            .materializations
21295            .lock()
21296            .unwrap_or_else(|poisoned| poisoned.into_inner())
21297            .get(&materialization_id)
21298            .unwrap()
21299            .provider_home()
21300            .join("skills/review-code/SKILL.md");
21301        let record_id = shared
21302            .bind_spawn_session_with_materialization(
21303                &address,
21304                agent("codex"),
21305                SessionMode::Pty,
21306                ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap(),
21307                SpawnRecordPolicy::ProviderIdentityOnly,
21308                Some(receipt.clone()),
21309                Some(bundle_receipt.clone()),
21310                None,
21311                Some(materialization_id.clone()),
21312            )
21313            .unwrap()
21314            .unwrap();
21315        guard.retain();
21316        std::fs::write(skill_path, b"changed-after-record-bind").unwrap();
21317
21318        let error = match shared.resolve_record_materialization(
21319                &record_id,
21320                &agent("codex"),
21321                SessionMode::Pty,
21322                Some(&receipt),
21323                Some(&bundle_receipt),
21324                None,
21325            ) {
21326                Ok(_) => panic!("changed Codex skill tree unexpectedly revalidated"),
21327                Err(error) => error,
21328            };
21329        assert_eq!(error.code, NodeFailureCode::BundleMaterializationFailed);
21330        assert_eq!(
21331            shared
21332                .materializations
21333                .lock()
21334                .unwrap_or_else(|poisoned| poisoned.into_inner())
21335                .get(&materialization_id)
21336                .unwrap()
21337                .state(),
21338            MaterializationState::RecoveryRequired,
21339        );
21340        drop(shared);
21341        drop(runtime);
21342        let _ = std::fs::remove_dir_all(root);
21343    }
21344
21345    fn bind_record_owned_materialization_fixture(
21346        shared: &NodeShared,
21347        receipt: &ResolvedEnvironmentProfileReceipt,
21348        instance_id: u64,
21349    ) -> (SessionAddress, SessionRecordId, MaterializationId, PathBuf) {
21350        let address = SessionAddress {
21351            workspace_id: WorkspaceId::new("primary").unwrap(),
21352            session: SessionKey {
21353                instance_id: AgentInstanceId(instance_id),
21354                generation: SessionGeneration(1),
21355            },
21356        };
21357        let (overlay, materialization_guard) = shared
21358            .prepare_session_materialization(
21359                &address,
21360                &agent("claude"),
21361                SessionMode::Pty,
21362                Some(receipt),
21363                None,
21364                None,
21365                None,
21366            )
21367            .unwrap()
21368            .unwrap();
21369        let materialization_id = materialization_guard.id().unwrap().clone();
21370        let materialization_root = shared
21371            .materializations
21372            .lock()
21373            .unwrap_or_else(|poisoned| poisoned.into_inner())
21374            .get(&materialization_id)
21375            .unwrap()
21376            .root()
21377            .to_path_buf();
21378        let selection = shared
21379            .select_environment_profile(
21380                address.session.instance_id,
21381                &agent("claude"),
21382                SessionMode::Pty,
21383                Some(receipt),
21384            )
21385            .unwrap()
21386            .unwrap();
21387        assert!(shared
21388            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21389            .unwrap()
21390            .is_none());
21391        let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
21392        let record_id = shared
21393            .bind_spawn_session_with_materialization(
21394                &address,
21395                agent("claude"),
21396                SessionMode::Pty,
21397                policy,
21398                SpawnRecordPolicy::ProviderIdentityOnly,
21399                Some(receipt.clone()),
21400                None,
21401                None,
21402                Some(materialization_id.clone()),
21403            )
21404            .unwrap()
21405            .unwrap();
21406        selection.retain();
21407        materialization_guard.retain();
21408        (address, record_id, materialization_id, materialization_root)
21409    }
21410
21411    #[test]
21412    fn child_environment_profile_capability_is_state_aware_and_legacy_safe() {
21413        let (shared, _runtime, environment_profile) = environment_profile_test_shared();
21414        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21415            capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
21416        }));
21417        let support = node_compatibility_support_for_manifest(&[], &[]).unwrap();
21418        assert_eq!(support.state_schema.versions.maximum(), NODE_STATE_SCHEMA_V10);
21419        let mut spec = spawn_spec_fixture();
21420        spec.expected_profile_revision =
21421            crate::protocol::SpawnProfileRevision::new("test-r1").unwrap();
21422        assert!(matches!(
21423            &spec.overrides.environment_profile_id,
21424            crate::protocol::SpawnOverride::Inherit,
21425        ));
21426        assert!(!NodeRequest::SpawnSpec { spec: spec.clone() }
21427            .requires_child_environment_profile_capability());
21428        assert!(request_requires_child_environment_profile(
21429            &shared,
21430            &NodeRequest::SpawnSpec { spec: spec.clone() },
21431        ));
21432
21433        let address = terminal_address(1);
21434        shared.bind_session_with_policy(
21435            &address,
21436            ProviderRuntimePolicy::raw_pty(),
21437            Some(environment_profile.clone()),
21438        );
21439        assert!(request_requires_child_environment_profile(
21440            &shared,
21441            &NodeRequest::Input {
21442                session: address.clone(),
21443                text: "x".to_owned(),
21444            },
21445        ));
21446
21447        let mut record = record("claude", "profile-record");
21448        record.active_session = Some(address.clone());
21449        record.environment_profile = Some(environment_profile.clone());
21450        let record_id = record.record_id.clone();
21451        shared.insert_record(record.clone()).unwrap();
21452        assert!(request_requires_child_environment_profile(
21453            &shared,
21454            &NodeRequest::ResumeSessionRecord {
21455                record_id,
21456                terminal_size: gate4agent_types::TerminalSize {
21457                    rows: 24,
21458                    columns: 80,
21459                },
21460                initial_prompt: None,
21461            },
21462        ));
21463
21464        let snapshot = snapshot_for_wire(
21465            &shared, false, true, true, true, false, false, false, false, true,
21466        );
21467        assert_eq!(snapshot.session_records.len(), 1);
21468        assert!(snapshot.session_records[0].environment_profile.is_none());
21469        let event = project_event_without_child_environment_profile(NodeEventEnvelope {
21470            sequence: 1,
21471            event: NodeEvent::SessionRecordUpserted { record },
21472        });
21473        let NodeEvent::SessionRecordUpserted { record } = event.event else {
21474            panic!("record projection changed the event kind");
21475        };
21476        assert!(record.environment_profile.is_none());
21477
21478        let resolved = shared.resolve_spawn_spec(&spec).unwrap();
21479        let mut reply = ResponseEnvelope {
21480            request_id: 1,
21481            result: Ok(NodeResponse::SpawnSpecAccepted {
21482                receipt: resolved.receipt_with_environment(
21483                    shared.incarnation_id,
21484                    address,
21485                    Some(environment_profile),
21486                ),
21487            }),
21488        };
21489        project_response_without_child_environment_profile(&mut reply);
21490        assert_eq!(
21491            reply.result.unwrap_err().code,
21492            NodeFailureCode::UnsupportedCapability,
21493        );
21494    }
21495
21496    #[test]
21497    fn spawn_profile_environment_authority_is_exact_and_legacy_safe() {
21498        let (shared, _runtime, environment_profile) = environment_profile_test_shared();
21499        let snapshot = shared.snapshot();
21500        let profiles = snapshot
21501            .launch_inventory
21502            .as_ref()
21503            .and_then(|inventory| inventory.spawn_profiles.as_ref())
21504            .unwrap();
21505        assert_eq!(profiles.len(), 1);
21506        assert_eq!(profiles[0].environment_profile, Some(environment_profile));
21507        assert!(snapshot.requires_child_environment_profile_capability());
21508
21509        let mut legacy = snapshot.clone();
21510        clear_snapshot_child_environment_profiles(&mut legacy);
21511        assert!(legacy
21512            .launch_inventory
21513            .as_ref()
21514            .unwrap()
21515            .spawn_profiles
21516            .as_ref()
21517            .unwrap()[0]
21518            .environment_profile
21519            .is_none());
21520        assert!(!legacy.requires_child_environment_profile_capability());
21521
21522        let profile = shared.spawn_profiles.iter().next().unwrap().clone();
21523        let bindings = shared
21524            .environment_profiles
21525            .read()
21526            .unwrap_or_else(|poisoned| poisoned.into_inner())
21527            .clone();
21528        let mut provider_mismatch = bindings.clone();
21529        provider_mismatch
21530            .values_mut()
21531            .next()
21532            .unwrap()
21533            .provider = agent("codex");
21534        assert!(resolved_spawn_profile_summary(&profile, &provider_mismatch).is_none());
21535        let mut mode_mismatch = profile;
21536        mode_mismatch.mode = SessionMode::Inline;
21537        assert!(resolved_spawn_profile_summary(&mode_mismatch, &bindings).is_none());
21538
21539        shared
21540            .environment_profiles
21541            .write()
21542            .unwrap_or_else(|poisoned| poisoned.into_inner())
21543            .clear();
21544        assert!(shared
21545            .snapshot()
21546            .launch_inventory
21547            .unwrap()
21548            .spawn_profiles
21549            .unwrap()
21550            .is_empty());
21551    }
21552
21553    #[test]
21554    fn session_bundle_capability_is_state_aware_and_legacy_safe() {
21555        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21556            capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
21557        }));
21558        let shared = terminal_test_shared();
21559        let bundle = ResolvedBundleReceipt {
21560            id: crate::protocol::SpawnBundleId::new("review-bundle").unwrap(),
21561            revision: crate::protocol::SpawnBundleRevision::new("r1").unwrap(),
21562            digest: crate::protocol::SpawnBundleDigest::new(format!(
21563                "sha256:{}",
21564                "0".repeat(64),
21565            ))
21566            .unwrap(),
21567        };
21568        let mut bundled = record("claude", "bundle-record");
21569        bundled.bundle = Some(bundle.clone());
21570        let record_id = bundled.record_id.clone();
21571        shared.insert_record(bundled.clone()).unwrap();
21572        assert!(request_requires_session_bundle(
21573            &shared,
21574            &NodeRequest::ForgetSessionRecord {
21575                record_id: record_id.clone(),
21576            },
21577        ));
21578
21579        let snapshot = snapshot_for_wire(
21580            &shared, false, true, true, true, true, false, false, false, true,
21581        );
21582        assert!(snapshot.session_records[0].bundle.is_none());
21583        let launch = snapshot.launch_inventory.as_ref().unwrap();
21584        assert!(launch.spawn_profiles.is_some());
21585        assert!(launch.bundles.is_none());
21586        let event = project_event_without_session_bundle(NodeEventEnvelope {
21587            sequence: 1,
21588            event: NodeEvent::SessionRecordUpserted { record: bundled },
21589        });
21590        let NodeEvent::SessionRecordUpserted { record } = event.event else {
21591            panic!("record projection changed the event kind");
21592        };
21593        assert!(record.bundle.is_none());
21594
21595        let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
21596        let mut reply = ResponseEnvelope {
21597            request_id: 1,
21598            result: Ok(NodeResponse::SpawnSpecAccepted {
21599                receipt: resolved.receipt_with_materialization(
21600                    shared.incarnation_id,
21601                    terminal_address(17),
21602                    None,
21603                    Some(bundle),
21604                    None,
21605                ),
21606            }),
21607        };
21608        project_response_without_session_bundle(&mut reply);
21609        assert_eq!(
21610            reply.result.unwrap_err().code,
21611            NodeFailureCode::UnsupportedCapability,
21612        );
21613    }
21614
21615    #[test]
21616    fn history_context_pack_capability_redacts_loaded_messages_and_legacy_metadata() {
21617        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21618            capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
21619        }));
21620        let context_id = SpawnContextId::new("context-review").unwrap();
21621        let context = ResolvedContextPackReceipt {
21622            id: context_id.clone(),
21623            digest: crate::protocol::SpawnContextDigest::new(format!(
21624                "sha256:{}",
21625                "c".repeat(64),
21626            ))
21627            .unwrap(),
21628            lineage: ContextPackLineageReceipt {
21629                source_node_id: NodeId::new("node-source").unwrap(),
21630                source_session: terminal_address(1),
21631                source_provider: agent("kimi"),
21632            },
21633            source_message_count: 2,
21634            retained_message_count: 2,
21635            byte_len: 256,
21636            truncated: false,
21637        };
21638        assert!(context.is_valid());
21639        let mut bound_record = record("codex", "context-record");
21640        bound_record.context_id = Some(context_id);
21641        bound_record.context = Some(context.clone());
21642        let session = gate4agent_types::SessionSnapshot {
21643            instance_id: AgentInstanceId(91),
21644            agent_id: agent("kimi"),
21645            transport: TransportKind::Pty,
21646            generation: SessionGeneration(3),
21647            status: SessionStatus::Running,
21648            pending_operation: None,
21649            pending_input: None,
21650            process_id: Some(9001),
21651            terminal_size: None,
21652            terminal_frame: None,
21653            terminal_stale: None,
21654            session_options: None,
21655            capabilities: gate4agent_types::CapabilitySnapshot::default(),
21656            history: gate4agent_types::HistorySnapshot {
21657                pending: None,
21658                candidates: vec![HistoryCandidateSummary {
21659                    id: "candidate-opaque".to_owned(),
21660                    session_id_hint: "session-hint".to_owned(),
21661                    modified_at_unix_ms: Some(1),
21662                }],
21663                loaded_candidate_id: Some("candidate-opaque".to_owned()),
21664                loaded: Some(HistorySessionRecord {
21665                    session_id: "private-vendor-session".to_owned(),
21666                    title: Some("private title".to_owned()),
21667                    cwd: Some(r"C:\private\history-root".to_owned()),
21668                    model: Some("private-model".to_owned()),
21669                    message_count: 2,
21670                    completed_turn_count: None,
21671                    total_tokens: 19,
21672                    messages: vec![
21673                        gate4agent_types::HistoryMessageRecord {
21674                            role: gate4agent_types::HistoryMessageRole::User,
21675                            text: "F7_PRIVATE_HISTORY_MESSAGE".to_owned(),
21676                        },
21677                        gate4agent_types::HistoryMessageRecord {
21678                            role: gate4agent_types::HistoryMessageRole::Assistant,
21679                            text: "private answer".to_owned(),
21680                        },
21681                    ],
21682                }),
21683                last_error: None,
21684            },
21685            resume: gate4agent_types::ResumeSnapshot::default(),
21686            foreground: gate4agent_types::ForegroundSnapshot::default(),
21687            provider: gate4agent_types::ProviderSnapshot::default(),
21688            screen_state: gate4agent_types::PtyScreenState::default(),
21689        };
21690        let original = NodeSnapshot {
21691            node_id: NodeId::new("node-terminal-test").unwrap(),
21692            enabled_providers: vec![agent("kimi"), agent("codex")],
21693            provider_runtime_statuses: ProviderRuntimeStatuses::default(),
21694            workspaces: vec![WorkspaceSnapshot {
21695                workspace_id: WorkspaceId::new("primary").unwrap(),
21696                canonical_root: opaque_windows_path(
21697                    std::env::current_dir().unwrap().to_string_lossy().into_owned(),
21698                ),
21699                sessions: vec![session],
21700                worktree_service_mode: None,
21701                managed_worktree_profiles: None,
21702            }],
21703            session_records: vec![bound_record],
21704            managed_worktrees: Vec::new(),
21705            launch_inventory: None,
21706            agent_progress: Vec::new(),
21707        };
21708
21709        let mut negotiated = original.clone();
21710        project_snapshot_history_for_wire(&mut negotiated, true);
21711        assert_eq!(negotiated.workspaces[0].sessions[0].history.candidates.len(), 1);
21712        assert!(negotiated.workspaces[0].sessions[0].history.loaded.is_none());
21713        assert_eq!(negotiated.session_records[0].context.as_ref(), Some(&context));
21714        assert!(!serde_json::to_string(&negotiated)
21715            .unwrap()
21716            .contains("F7_PRIVATE_HISTORY_MESSAGE"));
21717
21718        let mut legacy = original;
21719        project_snapshot_history_for_wire(&mut legacy, false);
21720        clear_snapshot_context_packs(&mut legacy);
21721        assert_eq!(
21722            legacy.workspaces[0].sessions[0].history,
21723            gate4agent_types::HistorySnapshot::default(),
21724        );
21725        assert!(legacy.session_records[0].context_id.is_none());
21726        assert!(legacy.session_records[0].context.is_none());
21727        let encoded = serde_json::to_string(&legacy).unwrap();
21728        assert!(!encoded.contains("F7_PRIVATE_HISTORY_MESSAGE"));
21729        assert!(!encoded.contains("context-review"));
21730
21731        let mut reply = ResponseEnvelope {
21732            request_id: 7,
21733            result: Ok(NodeResponse::ContextPackExported { context }),
21734        };
21735        project_response_without_context_pack(&mut reply);
21736        assert_eq!(
21737            reply.result.unwrap_err().code,
21738            NodeFailureCode::UnsupportedCapability,
21739        );
21740    }
21741
21742    #[test]
21743    fn forgetting_unbound_context_pack_evicts_replay_but_retained_record_is_busy() {
21744        let shared = terminal_test_shared();
21745        let history = HistorySessionRecord {
21746            session_id: "fixture-history".to_owned(),
21747            title: None,
21748            cwd: None,
21749            model: None,
21750            message_count: 1,
21751            completed_turn_count: None,
21752            total_tokens: 0,
21753            messages: vec![gate4agent_types::HistoryMessageRecord {
21754                role: gate4agent_types::HistoryMessageRole::User,
21755                text: "bounded handoff".to_owned(),
21756            }],
21757        };
21758        let pack = NodeContextPack::export(
21759            ContextPackLineageReceipt {
21760                source_node_id: shared.node_id.clone(),
21761                source_session: terminal_address(1),
21762                source_provider: agent("claude"),
21763            },
21764            &history,
21765        )
21766        .unwrap();
21767        let context = shared
21768            .context_catalog
21769            .write()
21770            .unwrap_or_else(|poisoned| poisoned.into_inner())
21771            .insert(pack)
21772            .unwrap();
21773        let mut retained = record("codex", "context-retained-record");
21774        retained.context_id = Some(context.id.clone());
21775        retained.context = Some(context.clone());
21776        let record_id = retained.record_id.clone();
21777        shared.insert_record(retained).unwrap();
21778        assert_eq!(
21779            shared.forget_context_pack(&context.id).unwrap_err().code,
21780            NodeFailureCode::ContextPackBusy,
21781        );
21782        assert!(shared
21783            .context_catalog
21784            .read()
21785            .unwrap_or_else(|poisoned| poisoned.into_inner())
21786            .get(&context.id)
21787            .is_some());
21788        shared
21789            .session_records
21790            .lock()
21791            .unwrap_or_else(|poisoned| poisoned.into_inner())
21792            .records
21793            .remove(&record_id);
21794
21795        let mut spec = spawn_spec_fixture();
21796        spec.overrides.context_id = crate::protocol::SpawnOverride::Set {
21797            value: context.id.clone(),
21798        };
21799        let resolved = shared.resolve_spawn_spec(&spec).unwrap();
21800        let receipt = resolved.receipt_with_materialization(
21801            shared.incarnation_id,
21802            terminal_address(2),
21803            None,
21804            None,
21805            Some(context.clone()),
21806        );
21807        assert!(receipt.context_binding_is_valid());
21808        shared.remember_spawn_spec(spec.clone(), Ok(receipt), Instant::now());
21809
21810        shared.forget_context_pack(&context.id).unwrap();
21811        assert!(shared
21812            .context_catalog
21813            .read()
21814            .unwrap_or_else(|poisoned| poisoned.into_inner())
21815            .get(&context.id)
21816            .is_none());
21817        assert!(shared
21818            .replay_spawn_spec(&spec, Instant::now())
21819            .unwrap()
21820            .is_none());
21821    }
21822
21823    #[test]
21824    fn environment_profile_selection_is_owned_by_the_exact_session_binding() {
21825        let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
21826        let address = terminal_address(1);
21827        let selection = shared
21828            .select_environment_profile(
21829                address.session.instance_id,
21830                &agent("claude"),
21831                SessionMode::Pty,
21832                Some(&environment_profile),
21833            )
21834            .unwrap()
21835            .unwrap();
21836        shared.bind_session_with_policy(
21837            &address,
21838            ProviderRuntimePolicy::raw_pty(),
21839            Some(environment_profile.clone()),
21840        );
21841        selection.retain();
21842        let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21843        assert!(matches!(
21844            runtime.remove_native_launch_profile(&native_id),
21845            Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21846        ));
21847        assert!(shared.remove_binding(&address).is_some());
21848        assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21849
21850        let mismatched = ResolvedEnvironmentProfileReceipt {
21851            profile_id: environment_profile.profile_id,
21852            profile_revision: crate::protocol::SpawnEnvironmentProfileRevision::new(
21853                "local-claude-r2",
21854            )
21855            .unwrap(),
21856            network_allowlist: None,
21857            browser_profile_id: None,
21858        };
21859        let error = match shared.select_environment_profile(
21860                AgentInstanceId(42),
21861                &agent("claude"),
21862                SessionMode::Pty,
21863                Some(&mismatched),
21864            ) {
21865            Ok(_) => panic!("mismatched environment profile revision was selected"),
21866            Err(error) => error,
21867        };
21868        assert_eq!(
21869            error.code,
21870            NodeFailureCode::EnvironmentProfileBindingMismatch,
21871        );
21872        assert!(!shared
21873            .session_bindings
21874            .lock()
21875            .unwrap_or_else(|poisoned| poisoned.into_inner())
21876            .contains_key(&AgentInstanceId(42)));
21877    }
21878
21879    #[test]
21880    fn combined_environment_and_bundle_overlay_clears_with_its_binding() {
21881        let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
21882        let address = terminal_address(1);
21883        let selection = shared
21884            .select_environment_profile(
21885                address.session.instance_id,
21886                &agent("claude"),
21887                SessionMode::Pty,
21888                Some(&environment_profile),
21889            )
21890            .unwrap()
21891            .unwrap();
21892        let overlay = NativeInstanceLaunchOverlay::new(
21893            agent("claude"),
21894            TransportKind::Pty,
21895            vec![EnvMutation {
21896                key: OsString::from("GATE4AGENT_SESSION_TOKEN"),
21897                value: Some(OsString::from("fixture-secret")),
21898            }],
21899            vec![
21900                OsString::from("--plugin-dir"),
21901                std::env::current_dir().unwrap().into_os_string(),
21902            ],
21903        )
21904        .unwrap();
21905        let instance_overlay = shared
21906            .install_prepared_launch_overlay(
21907                address.session.instance_id,
21908                PreparedNativeLaunchOverlay::Instance(overlay),
21909            )
21910            .unwrap()
21911            .unwrap();
21912        let bundle = ResolvedBundleReceipt {
21913            id: crate::protocol::SpawnBundleId::new("combined-bundle").unwrap(),
21914            revision: crate::protocol::SpawnBundleRevision::new("combined-r1").unwrap(),
21915            digest: crate::protocol::SpawnBundleDigest::new(format!(
21916                "sha256:{}",
21917                "0".repeat(64),
21918            ))
21919            .unwrap(),
21920        };
21921        shared.bind_session_with_materialization(
21922            &address,
21923            ProviderRuntimePolicy::raw_pty(),
21924            Some(environment_profile),
21925            Some(bundle),
21926            None,
21927            None,
21928        );
21929        selection.retain();
21930        instance_overlay.retain();
21931
21932        let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21933        assert!(matches!(
21934            runtime.remove_native_launch_profile(&native_id),
21935            Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21936        ));
21937        assert!(shared.remove_binding(&address).is_some());
21938        assert!(!shared
21939            .native_launch_profile_control
21940            .as_ref()
21941            .unwrap()
21942            .clear_native_instance_launch_overlay(address.session.instance_id));
21943        assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21944    }
21945
21946    #[test]
21947    fn session_environment_materialization_failure_is_pre_child_and_durable() {
21948        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
21949        let address = SessionAddress {
21950            workspace_id: WorkspaceId::new("primary").unwrap(),
21951            session: SessionKey {
21952                instance_id: AgentInstanceId(201),
21953                generation: SessionGeneration(1),
21954            },
21955        };
21956        let error = match shared.prepare_session_materialization(
21957            &address,
21958            &agent("claude"),
21959            SessionMode::Pty,
21960            Some(&receipt),
21961            None,
21962            None,
21963            None,
21964        ) {
21965            Ok(_) => panic!("denied local reference unexpectedly materialized"),
21966            Err(error) => error,
21967        };
21968        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21969        assert!(shared.handle.snapshot().sessions.is_empty());
21970        assert!(shared
21971            .session_bindings
21972            .lock()
21973            .unwrap_or_else(|poisoned| poisoned.into_inner())
21974            .is_empty());
21975        assert!(shared
21976            .materializations
21977            .lock()
21978            .unwrap_or_else(|poisoned| poisoned.into_inner())
21979            .is_empty());
21980        drop(shared);
21981        drop(runtime);
21982        std::fs::remove_dir_all(root).unwrap();
21983    }
21984
21985    #[test]
21986    fn session_environment_binding_retains_on_stop_and_cleans_after_remove_reap() {
21987        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21988        let address = SessionAddress {
21989            workspace_id: WorkspaceId::new("primary").unwrap(),
21990            session: SessionKey {
21991                instance_id: AgentInstanceId(202),
21992                generation: SessionGeneration(1),
21993            },
21994        };
21995        let (overlay, materialization_guard) = shared
21996            .prepare_session_materialization(
21997                &address,
21998                &agent("claude"),
21999                SessionMode::Pty,
22000                Some(&receipt),
22001                None,
22002                None,
22003                None,
22004            )
22005            .unwrap()
22006            .unwrap();
22007        let materialization_id = materialization_guard.id().unwrap().clone();
22008        let materialization_root = shared
22009            .materializations
22010            .lock()
22011            .unwrap_or_else(|poisoned| poisoned.into_inner())
22012            .get(&materialization_id)
22013            .unwrap()
22014            .root()
22015            .to_path_buf();
22016        let selection = shared
22017            .select_environment_profile(
22018                address.session.instance_id,
22019                &agent("claude"),
22020                SessionMode::Pty,
22021                Some(&receipt),
22022            )
22023            .unwrap()
22024            .unwrap();
22025        assert!(shared
22026            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
22027            .unwrap()
22028            .is_none());
22029        shared.bind_session_with_materialization(
22030            &address,
22031            ProviderRuntimePolicy::raw_pty(),
22032            Some(receipt),
22033            None,
22034            None,
22035            Some(materialization_id),
22036        );
22037        selection.retain();
22038        materialization_guard.retain();
22039
22040        assert!(materialization_root.is_dir());
22041        assert!(shared
22042            .session_bindings
22043            .lock()
22044            .unwrap_or_else(|poisoned| poisoned.into_inner())
22045            .contains_key(&address.session.instance_id));
22046
22047        let removed = shared.remove_binding(&address).unwrap();
22048        shared
22049            .cleanup_session_owned_materialization(&address, &removed)
22050            .unwrap();
22051        assert!(!materialization_root.exists());
22052        assert!(shared
22053            .materializations
22054            .lock()
22055            .unwrap_or_else(|poisoned| poisoned.into_inner())
22056            .is_empty());
22057        drop(shared);
22058        drop(runtime);
22059        std::fs::remove_dir_all(root).unwrap();
22060    }
22061
22062    #[tokio::test]
22063    async fn record_owned_session_environment_reuses_on_resume_and_forget_cleans() {
22064        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
22065        let address = SessionAddress {
22066            workspace_id: WorkspaceId::new("primary").unwrap(),
22067            session: SessionKey {
22068                instance_id: AgentInstanceId(203),
22069                generation: SessionGeneration(1),
22070            },
22071        };
22072        let (overlay, materialization_guard) = shared
22073            .prepare_session_materialization(
22074                &address,
22075                &agent("claude"),
22076                SessionMode::Pty,
22077                Some(&receipt),
22078                None,
22079                None,
22080                None,
22081            )
22082            .unwrap()
22083            .unwrap();
22084        let materialization_id = materialization_guard.id().unwrap().clone();
22085        let materialization_root = shared
22086            .materializations
22087            .lock()
22088            .unwrap_or_else(|poisoned| poisoned.into_inner())
22089            .get(&materialization_id)
22090            .unwrap()
22091            .root()
22092            .to_path_buf();
22093        let selection = shared
22094            .select_environment_profile(
22095                address.session.instance_id,
22096                &agent("claude"),
22097                SessionMode::Pty,
22098                Some(&receipt),
22099            )
22100            .unwrap()
22101            .unwrap();
22102        assert!(shared
22103            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
22104            .unwrap()
22105            .is_none());
22106        let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
22107        let record_id = shared
22108            .bind_spawn_session_with_materialization(
22109                &address,
22110                agent("claude"),
22111                SessionMode::Pty,
22112                policy,
22113                SpawnRecordPolicy::ProviderIdentityOnly,
22114                Some(receipt.clone()),
22115                None,
22116                None,
22117                Some(materialization_id.clone()),
22118            )
22119            .unwrap()
22120            .unwrap();
22121        selection.retain();
22122        materialization_guard.retain();
22123        assert_eq!(
22124            shared
22125                .materializations
22126                .lock()
22127                .unwrap_or_else(|poisoned| poisoned.into_inner())
22128                .get(&materialization_id)
22129                .unwrap()
22130                .owner(),
22131            &MaterializationOwner::Record {
22132                record_id: record_id.clone(),
22133            },
22134        );
22135
22136        let removed = shared.remove_binding(&address).unwrap();
22137        shared
22138            .cleanup_session_owned_materialization(&address, &removed)
22139            .unwrap();
22140        assert!(materialization_root.is_dir());
22141        assert!(shared
22142            .resolve_record_materialization(
22143                &record_id,
22144                &agent("claude"),
22145                SessionMode::Pty,
22146                Some(&receipt),
22147                None,
22148                None,
22149            )
22150            .unwrap()
22151            .is_some());
22152        {
22153            let mut records = shared
22154                .session_records
22155                .lock()
22156                .unwrap_or_else(|poisoned| poisoned.into_inner());
22157            let record = records.records.get_mut(&record_id).unwrap();
22158            record.active_session = None;
22159            record.state = ManagedSessionState::Unavailable;
22160        }
22161        shared.forget_session_record(&record_id).await.unwrap();
22162        assert!(!materialization_root.exists());
22163        assert!(shared
22164            .materializations
22165            .lock()
22166            .unwrap_or_else(|poisoned| poisoned.into_inner())
22167            .is_empty());
22168        drop(shared);
22169        drop(runtime);
22170        std::fs::remove_dir_all(root).unwrap();
22171    }
22172
22173    #[test]
22174    fn record_materialization_revalidation_failure_persists_recovery_required() {
22175        let (mut shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
22176        let state_path = root.join("node-state-v6.json");
22177        shared.state_path = Some(state_path.clone());
22178        let (_address, _record_id, materialization_id, materialization_root) =
22179            bind_record_owned_materialization_fixture(&shared, &receipt, 205);
22180        std::fs::write(
22181            materialization_root.join(".gate4agent-materialization-owner"),
22182            b"tampered",
22183        )
22184        .unwrap();
22185
22186        let error = match shared.resolve_record_materialization(
22187            &_record_id,
22188            &agent("claude"),
22189            SessionMode::Pty,
22190            Some(&receipt),
22191            None,
22192            None,
22193        ) {
22194            Ok(_) => panic!("tampered owner marker unexpectedly revalidated"),
22195            Err(error) => error,
22196        };
22197        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
22198        assert_eq!(
22199            shared
22200                .materializations
22201                .lock()
22202                .unwrap_or_else(|poisoned| poisoned.into_inner())
22203                .get(&materialization_id)
22204                .unwrap()
22205                .state(),
22206            MaterializationState::RecoveryRequired,
22207        );
22208        let loaded = session_registry::load(Some(&state_path), &shared.node_id).unwrap();
22209        assert_eq!(loaded.materializations.len(), 1);
22210        assert_eq!(
22211            loaded.materializations[0].state(),
22212            MaterializationState::RecoveryRequired,
22213        );
22214        drop(shared);
22215        drop(runtime);
22216        std::fs::remove_dir_all(root).unwrap();
22217    }
22218
22219    #[test]
22220    fn transient_record_materialization_resolution_failure_remains_ready() {
22221        let (shared, runtime, receipt, root, deny) = materialization_test_shared(false);
22222        let (_address, record_id, materialization_id, _materialization_root) =
22223            bind_record_owned_materialization_fixture(&shared, &receipt, 206);
22224        deny.store(true, Ordering::Release);
22225
22226        let error = match shared.resolve_record_materialization(
22227            &record_id,
22228            &agent("claude"),
22229            SessionMode::Pty,
22230            Some(&receipt),
22231            None,
22232            None,
22233        ) {
22234            Ok(_) => panic!("denied transient resolver unexpectedly succeeded"),
22235            Err(error) => error,
22236        };
22237        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
22238        assert_eq!(
22239            shared
22240                .materializations
22241                .lock()
22242                .unwrap_or_else(|poisoned| poisoned.into_inner())
22243                .get(&materialization_id)
22244                .unwrap()
22245                .state(),
22246            MaterializationState::Ready,
22247        );
22248        drop(shared);
22249        drop(runtime);
22250        std::fs::remove_dir_all(root).unwrap();
22251    }
22252
22253    #[test]
22254    fn startup_missing_record_materialization_is_not_resumable() {
22255        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
22256        let record_id = SessionRecordId::new("sr-missing-materialization").unwrap();
22257        shared
22258            .insert_record(ManagedSessionRecord {
22259                record_id: record_id.clone(),
22260                display_name: "missing materialization".to_owned(),
22261                provider: agent("claude"),
22262                mode: SessionMode::Pty,
22263                state: ManagedSessionState::Dormant,
22264                workspace_id: WorkspaceId::new("primary").unwrap(),
22265                canonical_root: opaque_windows_path(
22266                    std::env::current_dir().unwrap().to_string_lossy().into_owned(),
22267                ),
22268                provider_session: Some(gate4agent_types::ProviderSessionIdentity {
22269                    key: gate4agent_types::ProviderSessionKey::SessionId,
22270                    id: "provider-session-missing-materialization".to_owned(),
22271                    transcript_path: None,
22272                }),
22273                active_session: None,
22274                environment_profile: Some(receipt),
22275                bundle: None,
22276                context_id: None,
22277                context: None,
22278                exported_context: None,
22279                task_binding: None,
22280                created_at_unix_ms: 1,
22281                updated_at_unix_ms: 1,
22282                last_error: None,
22283            })
22284            .unwrap();
22285
22286        shared.reconcile_materializations();
22287        let record = shared.record(&record_id).unwrap();
22288        assert_eq!(record.state, ManagedSessionState::Unavailable);
22289        assert!(record.provider_session.is_none());
22290        assert_eq!(
22291            record.last_error.as_deref(),
22292            Some("environment-profile-unavailable"),
22293        );
22294        drop(shared);
22295        drop(runtime);
22296        std::fs::remove_dir_all(root).unwrap();
22297    }
22298
22299    #[tokio::test]
22300    async fn managed_worktree_cleanup_waits_for_session_environment_cleanup() {
22301        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
22302        let lease = install_managed_lease(&shared);
22303        let address = SessionAddress {
22304            workspace_id: WorkspaceId::new("primary").unwrap(),
22305            session: SessionKey {
22306                instance_id: AgentInstanceId(204),
22307                generation: SessionGeneration(1),
22308            },
22309        };
22310        let (overlay, materialization_guard) = shared
22311            .prepare_session_materialization(
22312                &address,
22313                &agent("claude"),
22314                SessionMode::Pty,
22315                Some(&receipt),
22316                None,
22317                None,
22318                Some(lease.lease_id.clone()),
22319            )
22320            .unwrap()
22321            .unwrap();
22322        let materialization_id = materialization_guard.id().unwrap().clone();
22323        let materialization_root = shared
22324            .materializations
22325            .lock()
22326            .unwrap_or_else(|poisoned| poisoned.into_inner())
22327            .get(&materialization_id)
22328            .unwrap()
22329            .root()
22330            .to_path_buf();
22331        let selection = shared
22332            .select_environment_profile(
22333                address.session.instance_id,
22334                &agent("claude"),
22335                SessionMode::Pty,
22336                Some(&receipt),
22337            )
22338            .unwrap()
22339            .unwrap();
22340        assert!(shared
22341            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
22342            .unwrap()
22343            .is_none());
22344        shared.bind_session_with_materialization(
22345            &address,
22346            ProviderRuntimePolicy::raw_pty(),
22347            Some(receipt),
22348            None,
22349            None,
22350            Some(materialization_id),
22351        );
22352        selection.retain();
22353        materialization_guard.retain();
22354
22355        let blocked = shared
22356            .cleanup_managed_worktree(&lease.lease_id, false)
22357            .await
22358            .unwrap_err();
22359        assert_eq!(blocked.code, NodeFailureCode::ManagedWorktreeRecoveryRequired);
22360        assert!(materialization_root.is_dir());
22361        let removed = shared.remove_binding(&address).unwrap();
22362        shared
22363            .cleanup_session_owned_materialization(&address, &removed)
22364            .unwrap();
22365        assert!(!materialization_root.exists());
22366        drop(shared);
22367        drop(runtime);
22368        std::fs::remove_dir_all(root).unwrap();
22369    }
22370
22371    #[tokio::test]
22372    async fn raw_managed_record_is_inventory_only_and_does_not_block_session_cleanup() {
22373        let git_root = temporary_workspace_root("raw-managed-inventory");
22374        let source = git_root.join("source");
22375        let allocation = git_root.join("allocation");
22376        std::fs::create_dir_all(&source).unwrap();
22377        std::fs::create_dir_all(&allocation).unwrap();
22378        run_test_git(&source, &["init"]);
22379        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
22380        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
22381        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22382        run_test_git(&source, &["add", "seed.txt"]);
22383        run_test_git(&source, &["commit", "-m", "seed"]);
22384        let profile = ManagedWorktreeProfile::new(
22385            WorktreeProfileId::new("default").unwrap(),
22386            crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22387            &allocation,
22388            "gate4agent",
22389            "HEAD",
22390            ManagedWorktreeRetention::RemoveWhenReleased,
22391        )
22392        .unwrap();
22393        let workspace = WorkspaceConfig::new(
22394            WorkspaceId::new("primary").unwrap(),
22395            &source,
22396        )
22397        .unwrap()
22398        .with_worktree_service_mode(WorktreeServiceMode::Managed)
22399        .with_managed_worktree_profile(profile.clone())
22400        .unwrap();
22401        let (shared, mut runtime, receipt, materialization_fixture_root, _deny) =
22402            materialization_test_shared_with_workspace(false, workspace);
22403        let target = PathBuf::from(profile.allocation_root()).join("mw-raw-inventory");
22404        let source_text = source.to_string_lossy().into_owned();
22405        let target_text = target.to_string_lossy().into_owned();
22406        let created = create_git_worktree(
22407            &source_text,
22408            &target_text,
22409            "gate4agent/mw-raw-inventory",
22410            Some("HEAD"),
22411        )
22412        .await
22413        .unwrap();
22414        let lease = ManagedWorktreeLeaseRecord {
22415            lease_id: ManagedWorktreeLeaseId::new("mw-raw-inventory").unwrap(),
22416            source_workspace_id: WorkspaceId::new("primary").unwrap(),
22417            workspace_id: WorkspaceId::new("managed-raw-inventory").unwrap(),
22418            profile_id: profile.profile_id().clone(),
22419            profile_revision: profile.revision().clone(),
22420            target_root: created.path.clone(),
22421            branch: "gate4agent/mw-raw-inventory".to_owned(),
22422            base_commit: created.head.clone(),
22423            expected_head: Some(created.head.clone()),
22424            retention: ManagedWorktreeRetention::RemoveWhenReleased,
22425            state: ManagedWorktreeLeaseState::Ready,
22426            session_holders: Vec::new(),
22427            record_holders: Vec::new(),
22428            cleanup_failure: None,
22429            created_at_unix_ms: 1,
22430            updated_at_unix_ms: 1,
22431        };
22432        shared
22433            .workspaces
22434            .write()
22435            .unwrap_or_else(|poisoned| poisoned.into_inner())
22436            .insert(lease.workspace_id.clone(), created.path.clone());
22437        *shared
22438            .managed_worktrees
22439            .lock()
22440            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22441            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
22442        let address = SessionAddress {
22443            workspace_id: lease.workspace_id.clone(),
22444            session: SessionKey {
22445                instance_id: AgentInstanceId(205),
22446                generation: SessionGeneration(1),
22447            },
22448        };
22449        let (overlay, materialization_guard) = shared
22450            .prepare_session_materialization(
22451                &address,
22452                &agent("claude"),
22453                SessionMode::Pty,
22454                Some(&receipt),
22455                None,
22456                None,
22457                Some(lease.lease_id.clone()),
22458            )
22459            .unwrap()
22460            .unwrap();
22461        let materialization_id = materialization_guard.id().unwrap().clone();
22462        let materialization_root = shared
22463            .materializations
22464            .lock()
22465            .unwrap_or_else(|poisoned| poisoned.into_inner())
22466            .get(&materialization_id)
22467            .unwrap()
22468            .root()
22469            .to_path_buf();
22470        let selection = shared
22471            .select_environment_profile(
22472                address.session.instance_id,
22473                &agent("claude"),
22474                SessionMode::Pty,
22475                Some(&receipt),
22476            )
22477            .unwrap()
22478            .unwrap();
22479        assert!(shared
22480            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
22481            .unwrap()
22482            .is_none());
22483        let record_id = shared
22484            .bind_spawn_session_with_materialization(
22485                &address,
22486                agent("claude"),
22487                SessionMode::Pty,
22488                ProviderRuntimePolicy::raw_pty(),
22489                SpawnRecordPolicy::Always,
22490                Some(receipt.clone()),
22491                None,
22492                None,
22493                Some(materialization_id.clone()),
22494            )
22495            .unwrap()
22496            .unwrap();
22497        selection.retain();
22498        materialization_guard.retain();
22499        let record = shared.record(&record_id).unwrap();
22500        assert_eq!(record.state, ManagedSessionState::Live);
22501        assert!(record.provider_session.is_none());
22502        assert!(record.environment_profile.is_none());
22503        assert!(record.bundle.is_none());
22504        assert!(record.context_id.is_none());
22505        assert!(record.context.is_none());
22506        assert_eq!(record.active_session.as_ref(), Some(&address));
22507        assert_eq!(
22508            shared
22509                .materializations
22510                .lock()
22511                .unwrap_or_else(|poisoned| poisoned.into_inner())
22512                .get(&materialization_id)
22513                .unwrap()
22514                .owner(),
22515            &MaterializationOwner::Session {
22516                incarnation_id: shared.incarnation_id,
22517                instance_id: address.session.instance_id,
22518                generation: address.session.generation,
22519            },
22520        );
22521        {
22522            let mut bindings = shared
22523                .session_bindings
22524                .lock()
22525                .unwrap_or_else(|poisoned| poisoned.into_inner());
22526            bindings
22527                .get_mut(&address.session.instance_id)
22528                .unwrap()
22529                .managed_worktree_lease_id = Some(lease.lease_id.clone());
22530        }
22531        let lease_snapshot = shared
22532            .managed_worktrees
22533            .lock()
22534            .unwrap_or_else(|poisoned| poisoned.into_inner())
22535            .bind_session(
22536                &lease.lease_id,
22537                ManagedWorktreeSessionHolder {
22538                    incarnation_id: shared.incarnation_id,
22539                    instance_id: address.session.instance_id,
22540                    generation: address.session.generation,
22541                },
22542                None,
22543                unix_time_ms(),
22544            )
22545            .unwrap();
22546        assert_eq!(lease_snapshot.active_session_count, 1);
22547        assert_eq!(lease_snapshot.managed_record_count, 0);
22548        shared.persist_state().unwrap();
22549        assert!(shared
22550            .managed_worktrees
22551            .lock()
22552            .unwrap_or_else(|poisoned| poisoned.into_inner())
22553            .get(&lease.lease_id)
22554            .unwrap()
22555            .record_holders
22556            .is_empty());
22557        shared
22558            .handle
22559            .dispatch(shared.prepare_command(ControlCommand::Register {
22560                instance_id: address.session.instance_id,
22561                agent_id: agent("claude"),
22562                transport: TransportKind::Pty,
22563            }))
22564            .unwrap();
22565        runtime.tick().await;
22566        shared.reconcile_managed_record(
22567            &record_id,
22568            &address,
22569            &ControlEvent {
22570                sequence: 1,
22571                command_id: None,
22572                instance_id: address.session.instance_id,
22573                generation: address.session.generation,
22574                event: ControlEventKind::Removed,
22575            },
22576            false,
22577            false,
22578        );
22579        let remove = shared.remove_session(&address);
22580        let drive_runtime = async {
22581            runtime.tick().await;
22582        };
22583        let (removed, ()) = tokio::join!(remove, drive_runtime);
22584        removed.unwrap();
22585
22586        assert!(!materialization_root.exists());
22587        assert!(!Path::new(&created.path).exists());
22588        assert_eq!(
22589            shared
22590                .managed_worktrees
22591                .lock()
22592                .unwrap_or_else(|poisoned| poisoned.into_inner())
22593                .get(&lease.lease_id)
22594                .unwrap()
22595                .state,
22596            ManagedWorktreeLeaseState::Removed,
22597        );
22598        let renamed = shared
22599            .rename_session_record(&record_id, "raw managed inventory".to_owned())
22600            .unwrap();
22601        assert_eq!(renamed.record_id, record_id);
22602        assert_eq!(renamed.display_name, "raw managed inventory");
22603        assert_eq!(renamed.state, ManagedSessionState::Unavailable);
22604        assert!(renamed.active_session.is_none());
22605
22606        drop(shared);
22607        drop(runtime);
22608        std::fs::remove_dir_all(materialization_fixture_root).unwrap();
22609        std::fs::remove_dir_all(git_root).unwrap();
22610    }
22611
22612    fn install_managed_lease(shared: &NodeShared) -> ManagedWorktreeLeaseRecord {
22613        let root = std::env::current_dir().unwrap()
22614            .join("managed-test-target")
22615            .to_string_lossy()
22616            .into_owned();
22617        let lease = ManagedWorktreeLeaseRecord {
22618            lease_id: ManagedWorktreeLeaseId::new("mw-test").unwrap(),
22619            source_workspace_id: WorkspaceId::new("primary").unwrap(),
22620            workspace_id: WorkspaceId::new("managed-test").unwrap(),
22621            profile_id: WorktreeProfileId::new("default").unwrap(),
22622            profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22623            target_root: root.clone(),
22624            branch: "gate4agent/mw-test".to_owned(),
22625            base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
22626            expected_head: Some("0123456789abcdef0123456789abcdef01234567".to_owned()),
22627            retention: ManagedWorktreeRetention::RemoveWhenReleased,
22628            state: ManagedWorktreeLeaseState::Ready,
22629            session_holders: Vec::new(),
22630            record_holders: Vec::new(),
22631            cleanup_failure: None,
22632            created_at_unix_ms: 1,
22633            updated_at_unix_ms: 1,
22634        };
22635        shared.workspaces.write()
22636            .unwrap_or_else(|poisoned| poisoned.into_inner())
22637            .insert(lease.workspace_id.clone(), root);
22638        *shared.managed_worktrees.lock()
22639            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22640            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
22641        lease
22642    }
22643
22644    #[cfg(feature = "fixture")]
22645    #[test]
22646    fn monitoring_fixture_managed_spawn_retains_effective_record_holder_and_converges_live() {
22647        let mut shared = terminal_test_shared();
22648        shared.fixture_semantic_hook_policy = true;
22649        let lease = install_managed_lease(&shared);
22650        let address = SessionAddress {
22651            workspace_id: lease.workspace_id.clone(),
22652            session: SessionKey {
22653                instance_id: AgentInstanceId(206),
22654                generation: SessionGeneration(1),
22655            },
22656        };
22657        let pre_admission_policy = ProviderRuntimePolicy::raw_pty();
22658        let effective_runtime_policy =
22659            shared.effective_spawn_runtime_policy(pre_admission_policy);
22660        assert_ne!(effective_runtime_policy, pre_admission_policy);
22661        assert!(effective_runtime_policy.provider_session_identity);
22662
22663        let record_id = shared
22664            .bind_spawn_session(
22665                &address,
22666                agent("claude"),
22667                SessionMode::Pty,
22668                effective_runtime_policy,
22669                SpawnRecordPolicy::Always,
22670                None,
22671            )
22672            .unwrap()
22673            .unwrap();
22674        assert_eq!(
22675            shared.record(&record_id).unwrap().state,
22676            ManagedSessionState::IdentityPending,
22677        );
22678        let (record_holder, raw_inventory_record_id) = {
22679            let mut bindings = shared
22680                .session_bindings
22681                .lock()
22682                .unwrap_or_else(|poisoned| poisoned.into_inner());
22683            let binding = bindings.get_mut(&address.session.instance_id).unwrap();
22684            binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
22685            managed_spawn_record_ownership(binding, effective_runtime_policy)
22686        };
22687        assert_eq!(record_holder.as_ref(), Some(&record_id));
22688        assert!(raw_inventory_record_id.is_none());
22689        let snapshot = shared
22690            .managed_worktrees
22691            .lock()
22692            .unwrap_or_else(|poisoned| poisoned.into_inner())
22693            .bind_session(
22694                &lease.lease_id,
22695                ManagedWorktreeSessionHolder {
22696                    incarnation_id: shared.incarnation_id,
22697                    instance_id: address.session.instance_id,
22698                    generation: address.session.generation,
22699                },
22700                record_holder,
22701                unix_time_ms(),
22702            )
22703            .unwrap();
22704        assert_eq!(snapshot.managed_record_count, 1);
22705        assert_eq!(
22706            shared
22707                .managed_worktrees
22708                .lock()
22709                .unwrap_or_else(|poisoned| poisoned.into_inner())
22710                .get(&lease.lease_id)
22711                .unwrap()
22712                .record_holders,
22713            vec![record_id.clone()],
22714        );
22715
22716        let identity = ProviderSessionIdentity {
22717            key: ProviderSessionKey::SessionId,
22718            id: "monitoring-fixture-managed-session".to_owned(),
22719            transcript_path: None,
22720        };
22721        shared.publish_control(ControlEvent {
22722            sequence: 1,
22723            command_id: None,
22724            instance_id: address.session.instance_id,
22725            generation: address.session.generation,
22726            event: ControlEventKind::ProviderEvent {
22727                sequence: 1,
22728                source: gate4agent_types::ProviderSource {
22729                    family: AdapterFamily::Hook,
22730                    binding: AdapterBinding::new(
22731                        gate4agent_types::AdapterId::new("claude-code").unwrap(),
22732                        "fixture/v1",
22733                        gate4agent_types::AdapterVerification::SyntheticFixture,
22734                    )
22735                    .unwrap(),
22736                },
22737                source_sequence: 1,
22738                event: ProviderEvent::SessionIdentityObserved {
22739                    identity: identity.clone(),
22740                },
22741            },
22742        });
22743
22744        let live = shared.record(&record_id).unwrap();
22745        assert_eq!(live.state, ManagedSessionState::Live);
22746        assert_eq!(live.provider_session.as_ref(), Some(&identity));
22747        assert_eq!(live.active_session.as_ref(), Some(&address));
22748        assert_eq!(
22749            shared
22750                .managed_worktrees
22751                .lock()
22752                .unwrap_or_else(|poisoned| poisoned.into_inner())
22753                .get(&lease.lease_id)
22754                .unwrap()
22755                .record_holders,
22756            vec![record_id],
22757        );
22758    }
22759
22760    fn managed_v2_test_shared(
22761        allocation_root: &Path,
22762        state_path: Option<PathBuf>,
22763        profile_revision: &str,
22764    ) -> NodeShared {
22765        let profile = ManagedWorktreeProfile::new(
22766            WorktreeProfileId::new("default").unwrap(),
22767            WorktreeProfileRevision::new(profile_revision).unwrap(),
22768            allocation_root,
22769            "gate4agent",
22770            "HEAD",
22771            ManagedWorktreeRetention::RemoveWhenReleased,
22772        )
22773        .unwrap();
22774        let workspace = WorkspaceConfig::new(
22775            WorkspaceId::new("primary").unwrap(),
22776            std::env::current_dir().unwrap(),
22777        )
22778        .unwrap()
22779        .with_worktree_service_mode(WorktreeServiceMode::Managed)
22780        .with_managed_worktree_profile(profile)
22781        .unwrap();
22782        let catalog = active_registry().unwrap();
22783        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22784        let mut shared = NodeShared::new(
22785            handle,
22786            "fixture-token".to_owned(),
22787            NodeId::new("node-terminal-test").unwrap(),
22788            vec![workspace],
22789            vec![agent("claude")],
22790        );
22791        shared.state_path = state_path;
22792        shared
22793    }
22794
22795    fn managed_v2_request(idempotency_key: &str) -> ManagedWorktreeSpawnRequestV2 {
22796        let mut spawn_spec = spawn_spec_fixture();
22797        spawn_spec.idempotency_key = SpawnIdempotencyKey::new(idempotency_key).unwrap();
22798        ManagedWorktreeSpawnRequestV2 {
22799            spawn_spec,
22800            worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22801            expected_profile_revision: WorktreeProfileRevision::new("v1").unwrap(),
22802        }
22803    }
22804
22805    #[test]
22806    fn managed_spawn_receipt_rebinds_allocated_worktree_and_roundtrips_wire() {
22807        let shared = terminal_test_shared();
22808        let mut lease = install_managed_lease(&shared);
22809        let session = SessionAddress {
22810            workspace_id: lease.workspace_id.clone(),
22811            session: SessionKey {
22812                instance_id: AgentInstanceId(73),
22813                generation: SessionGeneration(4),
22814            },
22815        };
22816        lease.state = ManagedWorktreeLeaseState::InUse;
22817        lease.session_holders.push(ManagedWorktreeSessionHolder {
22818            incarnation_id: shared.incarnation_id,
22819            instance_id: session.session.instance_id,
22820            generation: session.session.generation,
22821        });
22822        let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
22823        assert!(resolved.target.worktree_id.is_none());
22824
22825        let receipt = managed_spawn_receipt(
22826            &resolved,
22827            shared.incarnation_id,
22828            session.clone(),
22829            lease.snapshot(),
22830            None,
22831            None,
22832            None,
22833        );
22834        assert!(resolved.target.worktree_id.is_none());
22835        assert_eq!(receipt.spawn.target.workspace_id, lease.source_workspace_id);
22836        assert_eq!(receipt.spawn.target.worktree_id.as_ref(), Some(&lease.workspace_id));
22837        assert_eq!(receipt.spawn.session, session);
22838        assert_eq!(receipt.lease.workspace_id, lease.workspace_id);
22839        let encoded = serde_json::to_vec(&receipt).unwrap();
22840        let decoded: ManagedWorktreeSpawnReceipt = serde_json::from_slice(&encoded).unwrap();
22841        assert_eq!(decoded, receipt);
22842    }
22843
22844    #[tokio::test]
22845    async fn managed_spawn_v2_rejects_profile_revision_before_allocation() {
22846        let root = temporary_workspace_root("managed-v2-revision");
22847        let allocation = root.join("allocation");
22848        std::fs::create_dir_all(&allocation).unwrap();
22849        let shared = managed_v2_test_shared(
22850            &allocation,
22851            Some(root.join("node-state.json")),
22852            "v1",
22853        );
22854        let mut request = managed_v2_request("managed-v2-revision");
22855        request.expected_profile_revision = WorktreeProfileRevision::new("v2").unwrap();
22856
22857        let failure = shared.spawn_managed_worktree_v2(request).await.unwrap_err();
22858
22859        assert_eq!(
22860            failure.code,
22861            NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22862        );
22863        assert!(shared
22864            .managed_worktrees
22865            .lock()
22866            .unwrap_or_else(|poisoned| poisoned.into_inner())
22867            .records()
22868            .is_empty());
22869        assert!(shared
22870            .managed_spawn_replays
22871            .lock()
22872            .unwrap_or_else(|poisoned| poisoned.into_inner())
22873            .is_empty());
22874        std::fs::remove_dir_all(root).unwrap();
22875    }
22876
22877    #[test]
22878    fn managed_spawn_v2_concurrent_same_key_has_one_durable_reservation() {
22879        let root = temporary_workspace_root("managed-v2-concurrent");
22880        let allocation = root.join("allocation");
22881        std::fs::create_dir_all(&allocation).unwrap();
22882        let shared = Arc::new(managed_v2_test_shared(
22883            &allocation,
22884            Some(root.join("node-state.json")),
22885            "v1",
22886        ));
22887        let request = managed_v2_request("managed-v2-concurrent");
22888        let barrier = Arc::new(std::sync::Barrier::new(3));
22889        let mut joins = Vec::new();
22890        for _ in 0..2 {
22891            let shared = Arc::clone(&shared);
22892            let request = request.clone();
22893            let barrier = Arc::clone(&barrier);
22894            joins.push(std::thread::spawn(move || {
22895                let digest = managed_spawn_request_digest_v2(&request).unwrap();
22896                barrier.wait();
22897                shared.acquire_durable_managed_spawn_v2(&request, digest)
22898            }));
22899        }
22900        barrier.wait();
22901        let results = joins
22902            .into_iter()
22903            .map(|join| join.join().unwrap())
22904            .collect::<Vec<_>>();
22905        assert_eq!(
22906            results
22907                .iter()
22908                .filter(|result| {
22909                    matches!(result, Ok(DurableManagedSpawnReplayDecision::Reserved))
22910                })
22911                .count(),
22912            1,
22913        );
22914        assert_eq!(
22915            results
22916                .iter()
22917                .filter(|result| {
22918                    matches!(result, Err(error) if error.code == NodeFailureCode::BackendBusy)
22919                })
22920                .count(),
22921            1,
22922        );
22923        assert_eq!(
22924            shared
22925                .managed_spawn_replays
22926                .lock()
22927                .unwrap_or_else(|poisoned| poisoned.into_inner())
22928                .len(),
22929            1,
22930        );
22931        assert!(shared
22932            .managed_worktrees
22933            .lock()
22934            .unwrap_or_else(|poisoned| poisoned.into_inner())
22935            .records()
22936            .is_empty());
22937        drop(shared);
22938        std::fs::remove_dir_all(root).unwrap();
22939    }
22940
22941    #[tokio::test]
22942    async fn managed_spawn_v2_linked_pending_reopen_fails_closed_without_reallocation() {
22943        let root = temporary_workspace_root("managed-v2-linked-pending");
22944        let allocation = root.join("allocation");
22945        std::fs::create_dir_all(&allocation).unwrap();
22946        let state_path = root.join("node-state.json");
22947        let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22948        let lease = install_managed_lease(&shared);
22949        let request = managed_v2_request("managed-v2-linked-pending");
22950        assert!(matches!(
22951            shared
22952                .acquire_durable_managed_spawn_v2(
22953                    &request,
22954                    managed_spawn_request_digest_v2(&request).unwrap(),
22955                )
22956                .unwrap(),
22957            DurableManagedSpawnReplayDecision::Reserved,
22958        ));
22959        {
22960            let _transaction = shared
22961                .state_transaction
22962                .lock()
22963                .unwrap_or_else(|poisoned| poisoned.into_inner());
22964            shared
22965                .link_durable_managed_spawn_v2_lease_locked(
22966                    &request.spawn_spec.idempotency_key,
22967                    &lease.lease_id,
22968                )
22969                .unwrap();
22970            shared.persist_state_locked().unwrap();
22971        }
22972        drop(shared);
22973
22974        let mut loaded = session_registry::load(
22975            Some(&state_path),
22976            &NodeId::new("node-terminal-test").unwrap(),
22977        )
22978        .unwrap();
22979        let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22980        *reopened
22981            .managed_worktrees
22982            .lock()
22983            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22984            ManagedWorktreeRegistry::from_records(
22985                std::mem::take(&mut loaded.managed_worktrees),
22986                std::mem::take(&mut loaded.managed_worktree_tombstones),
22987            )
22988            .unwrap();
22989        *reopened
22990            .managed_spawn_replays
22991            .lock()
22992            .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22993            .managed_spawn_replays
22994            .into_iter()
22995            .map(|record| (record.idempotency_key.clone(), record))
22996            .collect();
22997        let lease_count = reopened
22998            .managed_worktrees
22999            .lock()
23000            .unwrap_or_else(|poisoned| poisoned.into_inner())
23001            .records()
23002            .len();
23003
23004        let failure = reopened
23005            .spawn_managed_worktree_v2(request)
23006            .await
23007            .unwrap_err();
23008
23009        assert_eq!(
23010            failure.code,
23011            NodeFailureCode::ManagedWorktreeRecoveryRequired,
23012        );
23013        assert_eq!(
23014            reopened
23015                .managed_worktrees
23016                .lock()
23017                .unwrap_or_else(|poisoned| poisoned.into_inner())
23018                .records()
23019                .len(),
23020            lease_count,
23021        );
23022        std::fs::remove_dir_all(root).unwrap();
23023    }
23024
23025    #[tokio::test]
23026    async fn managed_spawn_v2_replays_exact_receipt_after_durable_reopen_and_conflicts() {
23027        let root = temporary_workspace_root("managed-v2-reopen");
23028        let allocation = root.join("allocation");
23029        std::fs::create_dir_all(&allocation).unwrap();
23030        let state_path = root.join("node-state.json");
23031        let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
23032        let lease = install_managed_lease(&shared);
23033        let request = managed_v2_request("managed-v2-reopen");
23034        let session = SessionAddress {
23035            workspace_id: lease.workspace_id.clone(),
23036            session: SessionKey {
23037                instance_id: AgentInstanceId(91),
23038                generation: SessionGeneration(1),
23039            },
23040        };
23041        let snapshot = {
23042            let mut registry = shared
23043                .managed_worktrees
23044                .lock()
23045                .unwrap_or_else(|poisoned| poisoned.into_inner());
23046            let current = registry.get_mut(&lease.lease_id).unwrap();
23047            current.state = ManagedWorktreeLeaseState::InUse;
23048            current.session_holders.push(ManagedWorktreeSessionHolder {
23049                incarnation_id: shared.incarnation_id,
23050                instance_id: session.session.instance_id,
23051                generation: session.session.generation,
23052            });
23053            current.snapshot()
23054        };
23055        let resolved = shared.resolve_spawn_spec(&request.spawn_spec).unwrap();
23056        let receipt = managed_spawn_receipt(
23057            &resolved,
23058            shared.incarnation_id,
23059            session,
23060            snapshot,
23061            None,
23062            None,
23063            None,
23064        );
23065        assert!(matches!(
23066            shared
23067                .acquire_durable_managed_spawn_v2(
23068                    &request,
23069                    managed_spawn_request_digest_v2(&request).unwrap(),
23070                )
23071                .unwrap(),
23072            DurableManagedSpawnReplayDecision::Reserved,
23073        ));
23074        {
23075            let _transaction = shared
23076                .state_transaction
23077                .lock()
23078                .unwrap_or_else(|poisoned| poisoned.into_inner());
23079            shared
23080                .link_durable_managed_spawn_v2_lease_locked(
23081                    &request.spawn_spec.idempotency_key,
23082                    &lease.lease_id,
23083                )
23084                .unwrap();
23085            shared.persist_state_locked().unwrap();
23086        }
23087        shared
23088            .commit_durable_managed_spawn_v2(&request, receipt.clone())
23089            .unwrap();
23090        let legacy_same_key = ManagedWorktreeSpawnRequest {
23091            spawn_spec: request.spawn_spec.clone(),
23092            worktree_profile_id: request.worktree_profile_id.clone(),
23093        };
23094        shared.remember_managed_spawn_attempt(
23095            Some(&request.spawn_spec.idempotency_key),
23096            legacy_same_key.clone(),
23097            Ok(receipt.clone()),
23098            Instant::now(),
23099        );
23100        assert!(shared
23101            .replay_managed_spawn(&legacy_same_key, Instant::now())
23102            .unwrap()
23103            .is_none());
23104        drop(shared);
23105
23106        let mut loaded = session_registry::load(
23107            Some(&state_path),
23108            &NodeId::new("node-terminal-test").unwrap(),
23109        )
23110        .unwrap();
23111        assert_eq!(loaded.managed_spawn_replays.len(), 1);
23112        let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
23113        *reopened
23114            .managed_worktrees
23115            .lock()
23116            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
23117            ManagedWorktreeRegistry::from_records(
23118                std::mem::take(&mut loaded.managed_worktrees),
23119                std::mem::take(&mut loaded.managed_worktree_tombstones),
23120            )
23121            .unwrap();
23122        *reopened
23123            .managed_spawn_replays
23124            .lock()
23125            .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
23126            .managed_spawn_replays
23127            .into_iter()
23128            .map(|record| (record.idempotency_key.clone(), record))
23129            .collect();
23130        let lease_count_before = reopened
23131            .managed_worktrees
23132            .lock()
23133            .unwrap_or_else(|poisoned| poisoned.into_inner())
23134            .records()
23135            .len();
23136
23137        assert_eq!(
23138            reopened
23139                .spawn_managed_worktree_v2(request.clone())
23140                .await
23141                .unwrap(),
23142            receipt,
23143        );
23144        assert_eq!(
23145            reopened
23146                .managed_worktrees
23147                .lock()
23148                .unwrap_or_else(|poisoned| poisoned.into_inner())
23149                .records()
23150                .len(),
23151            lease_count_before,
23152        );
23153        let mut changed = request;
23154        changed.spawn_spec.deadline_ms = crate::protocol::SpawnDeadlineMs::new(29_000).unwrap();
23155        let failure = reopened
23156            .spawn_managed_worktree_v2(changed)
23157            .await
23158            .unwrap_err();
23159        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
23160        assert_eq!(
23161            reopened
23162                .managed_worktrees
23163                .lock()
23164                .unwrap_or_else(|poisoned| poisoned.into_inner())
23165                .records()
23166                .len(),
23167            lease_count_before,
23168        );
23169        let mut new_key = managed_v2_request("managed-v2-new-after-profile-change");
23170        new_key.expected_profile_revision = WorktreeProfileRevision::new("v1").unwrap();
23171        let failure = reopened
23172            .spawn_managed_worktree_v2(new_key)
23173            .await
23174            .unwrap_err();
23175        assert_eq!(
23176            failure.code,
23177            NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
23178        );
23179        assert_eq!(
23180            reopened
23181                .managed_worktrees
23182                .lock()
23183                .unwrap_or_else(|poisoned| poisoned.into_inner())
23184                .records()
23185                .len(),
23186            lease_count_before,
23187        );
23188        assert_eq!(
23189            reopened
23190                .managed_spawn_replays
23191                .lock()
23192                .unwrap_or_else(|poisoned| poisoned.into_inner())
23193                .len(),
23194            1,
23195        );
23196        std::fs::remove_dir_all(root).unwrap();
23197    }
23198
23199    #[test]
23200    fn legacy_managed_spawn_idempotency_remains_transient_and_exact() {
23201        let shared = terminal_test_shared();
23202        let request = ManagedWorktreeSpawnRequest {
23203            spawn_spec: spawn_spec_fixture(),
23204            worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
23205        };
23206        let expected = failure(NodeFailureCode::BackendBusy, "legacy fixture failure");
23207        shared.remember_managed_spawn(
23208            request.clone(),
23209            Err(expected.clone()),
23210            Instant::now(),
23211        );
23212
23213        assert_eq!(
23214            shared
23215                .replay_managed_spawn(&request, Instant::now())
23216                .unwrap()
23217                .unwrap(),
23218            Err(expected),
23219        );
23220        assert!(shared
23221            .managed_spawn_replays
23222            .lock()
23223            .unwrap_or_else(|poisoned| poisoned.into_inner())
23224            .is_empty());
23225    }
23226
23227    #[tokio::test]
23228    async fn ordinary_spawn_and_spawn_spec_cannot_bypass_managed_lease_holder_fence() {
23229        let shared = terminal_test_shared();
23230        let lease = install_managed_lease(&shared);
23231        let raw = shared.spawn_session(
23232            lease.workspace_id.clone(),
23233            agent("claude"),
23234            SessionMode::Pty,
23235            gate4agent_types::TerminalSize { rows: 24, columns: 80 },
23236            None,
23237        ).await.unwrap_err();
23238        assert_eq!(raw.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
23239        let mut spec = spawn_spec_fixture();
23240        spec.target.workspace_id = lease.workspace_id.clone();
23241        let specified = shared.spawn_from_spec(spec).await.unwrap_err();
23242        assert_eq!(specified.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
23243        let mut selected = spawn_spec_fixture();
23244        selected.idempotency_key = SpawnIdempotencyKey::new("managed-selected-bypass").unwrap();
23245        selected.target.worktree_id = Some(lease.workspace_id);
23246        let selected = shared.spawn_from_spec(selected).await.unwrap_err();
23247        assert_eq!(selected.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
23248        assert!(shared.handle.snapshot().sessions.is_empty());
23249    }
23250
23251    #[test]
23252    fn legacy_projection_omits_managed_snapshot_events_and_path_bearing_diagnostics() {
23253        let shared = terminal_test_shared();
23254        let lease = install_managed_lease(&shared);
23255        let snapshot = snapshot_for_wire(
23256            &shared, true, true, true, false, true, true, false, false, true,
23257        );
23258        assert!(snapshot.managed_worktrees.is_empty());
23259        assert!(snapshot.workspaces.iter().all(|workspace| {
23260            workspace.managed_worktree_profiles.is_none()
23261        }));
23262        let mut reply = ResponseEnvelope {
23263            request_id: 1,
23264            result: Ok(NodeResponse::Resync {
23265                event_sequence: 1,
23266                oldest_available_sequence: 1,
23267                snapshot: shared.snapshot(),
23268                events: vec![NodeEventEnvelope {
23269                    sequence: 1,
23270                    event: NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() },
23271                }],
23272            }),
23273        };
23274        project_response_without_managed_worktrees(&mut reply);
23275        let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
23276            panic!("projection changed the response kind");
23277        };
23278        assert!(snapshot.managed_worktrees.is_empty());
23279        assert!(events.is_empty());
23280        let mut git = git_snapshot_for_parser();
23281        git.branch = Some(lease.branch.clone());
23282        git.managed_worktree = Some(crate::protocol::ManagedWorktreeGitScope {
23283            lease_id: lease.lease_id.clone(),
23284            source_workspace_id: lease.source_workspace_id.clone(),
23285            branch: lease.branch.clone(),
23286            base_commit: GitObjectId::new(lease.base_commit.clone()).unwrap(),
23287            active_session_count: 1,
23288            managed_record_count: 0,
23289        });
23290        let mut inspection_reply = ResponseEnvelope {
23291            request_id: 2,
23292            result: Ok(NodeResponse::WorkspaceInspected {
23293                inspection: WorkspaceInspection {
23294                    workspace_id: lease.workspace_id.clone(),
23295                    entries: Vec::new(),
23296                    tree_truncated: false,
23297                    git,
23298                    truncation: None,
23299                },
23300            }),
23301        };
23302        project_response_without_managed_worktrees(&mut inspection_reply);
23303        let Ok(NodeResponse::WorkspaceInspected { inspection }) = inspection_reply.result else {
23304            panic!("projection changed the inspection response kind");
23305        };
23306        assert!(inspection.git.managed_worktree.is_none());
23307        let failure = managed_git_worktree_failure(GitWorktreeError {
23308            kind: GitWorktreeErrorKind::Failed,
23309            message: r"git failed at C:\private\managed-target".to_owned(),
23310        });
23311        assert!(!failure.message.contains("private"));
23312        assert!(!failure.message.contains("managed-target"));
23313    }
23314
23315    #[tokio::test]
23316    async fn restart_reconciliation_retains_exact_owned_worktree_and_explicit_cleanup_removes_it() {
23317        let root = temporary_workspace_root("managed-restart");
23318        let source = root.join("source");
23319        let allocation = root.join("allocation");
23320        std::fs::create_dir_all(&source).unwrap();
23321        std::fs::create_dir_all(&allocation).unwrap();
23322        run_test_git(&source, &["init"]);
23323        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
23324        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
23325        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
23326        run_test_git(&source, &["add", "seed.txt"]);
23327        run_test_git(&source, &["commit", "-m", "seed"]);
23328        let profile = ManagedWorktreeProfile::new(
23329            WorktreeProfileId::new("default").unwrap(),
23330            crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
23331            &allocation,
23332            "gate4agent",
23333            "HEAD",
23334            ManagedWorktreeRetention::Retain,
23335        ).unwrap();
23336        let workspace = WorkspaceConfig::new(
23337            WorkspaceId::new("primary").unwrap(),
23338            &source,
23339        ).unwrap()
23340            .with_worktree_service_mode(WorktreeServiceMode::Managed)
23341            .with_managed_worktree_profile(profile.clone()).unwrap();
23342        let target = PathBuf::from(profile.allocation_root()).join("mw-restart");
23343        let source_text = workspace.canonical_root().to_owned();
23344        let target_text = target.to_string_lossy().into_owned();
23345        let created = create_git_worktree(
23346            &source_text,
23347            &target_text,
23348            "gate4agent/mw-restart",
23349            Some("HEAD"),
23350        ).await.unwrap();
23351        let lease = ManagedWorktreeLeaseRecord {
23352            lease_id: ManagedWorktreeLeaseId::new("mw-restart").unwrap(),
23353            source_workspace_id: WorkspaceId::new("primary").unwrap(),
23354            workspace_id: WorkspaceId::new("managed-restart").unwrap(),
23355            profile_id: profile.profile_id().clone(),
23356            profile_revision: profile.revision().clone(),
23357            target_root: created.path.clone(),
23358            branch: "gate4agent/mw-restart".to_owned(),
23359            base_commit: created.head.clone(),
23360            expected_head: None,
23361            retention: ManagedWorktreeRetention::Retain,
23362            state: ManagedWorktreeLeaseState::Allocating,
23363            session_holders: Vec::new(),
23364            record_holders: Vec::new(),
23365            cleanup_failure: None,
23366            created_at_unix_ms: 1,
23367            updated_at_unix_ms: 1,
23368        };
23369        let catalog = active_registry().unwrap();
23370        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23371        let shared = NodeShared::new_with_incarnation(
23372            handle,
23373            "fixture-token".to_owned(),
23374            NodeId::new("node-managed-restart").unwrap(),
23375            NodeIncarnationId::from_bytes([7; crate::protocol::NODE_INCARNATION_ID_BYTES]),
23376            vec![workspace],
23377            vec![agent("claude")],
23378            ProviderRuntimeStatuses::default(),
23379            None,
23380            Vec::new(),
23381            Vec::new(),
23382            SpawnProfileRegistry::default(),
23383            None,
23384            None,
23385            None,
23386            None,
23387            Vec::new(),
23388            vec![lease.clone()],
23389            Vec::new(),
23390            Vec::new(),
23391            None,
23392            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
23393        );
23394        shared.reconcile_managed_worktrees().await;
23395        let retained = shared.managed_worktrees.lock()
23396            .unwrap_or_else(|poisoned| poisoned.into_inner())
23397            .get(&lease.lease_id).unwrap().clone();
23398        assert_eq!(retained.state, ManagedWorktreeLeaseState::Retained);
23399        assert!(shared.workspaces.read()
23400            .unwrap_or_else(|poisoned| poisoned.into_inner())
23401            .contains_key(&lease.workspace_id));
23402        let removed = shared.cleanup_managed_worktree(&lease.lease_id, true).await.unwrap();
23403        assert_eq!(removed.state, ManagedWorktreeLeaseState::Removed);
23404        assert!(!Path::new(&created.path).exists());
23405        std::fs::remove_dir_all(root).unwrap();
23406    }
23407
23408    fn run_test_git(root: &Path, arguments: &[&str]) {
23409        let output = std::process::Command::new("git")
23410            .arg("-C").arg(root).args(arguments).output().unwrap();
23411        assert!(
23412            output.status.success(),
23413            "git {:?} failed: {}",
23414            arguments,
23415            String::from_utf8_lossy(&output.stderr),
23416        );
23417    }
23418
23419    #[tokio::test]
23420    async fn spawn_spec_validation_fails_before_session_mutation() {
23421        let shared = terminal_test_shared();
23422        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23423            capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
23424        }));
23425        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23426            capability.as_str() == NODE_SPAWN_PROFILE_REVISION_CAPABILITY
23427        }));
23428        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23429            capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
23430        }));
23431
23432        let mut target_mismatch = spawn_spec_fixture();
23433        target_mismatch.target.node_id = NodeId::new("other-node").unwrap();
23434        let failure = shared.spawn_from_spec(target_mismatch).await.unwrap_err();
23435        assert_eq!(failure.code, NodeFailureCode::SpawnTargetMismatch);
23436
23437        let mut unknown_profile = spawn_spec_fixture();
23438        unknown_profile.idempotency_key = SpawnIdempotencyKey::new("unknown-profile").unwrap();
23439        unknown_profile.profile_id = crate::protocol::SpawnProfileId::new("missing").unwrap();
23440        let failure = shared.spawn_from_spec(unknown_profile).await.unwrap_err();
23441        assert_eq!(failure.code, NodeFailureCode::UnknownSpawnProfile);
23442
23443        let mut revision_mismatch = spawn_spec_fixture();
23444        revision_mismatch.idempotency_key =
23445            SpawnIdempotencyKey::new("profile-revision-mismatch").unwrap();
23446        revision_mismatch.expected_profile_revision =
23447            crate::protocol::SpawnProfileRevision::new("test-r2").unwrap();
23448        let failure = shared.spawn_from_spec(revision_mismatch).await.unwrap_err();
23449        assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
23450
23451        let mut unsupported_materializer = spawn_spec_fixture();
23452        unsupported_materializer.idempotency_key =
23453            SpawnIdempotencyKey::new("unsupported-materializer").unwrap();
23454        unsupported_materializer.overrides.bundle_id = crate::protocol::SpawnOverride::Set {
23455            value: crate::protocol::SpawnBundleId::new("bundle-v1").unwrap(),
23456        };
23457        let failure = shared
23458            .spawn_from_spec(unsupported_materializer)
23459            .await
23460            .unwrap_err();
23461        assert_eq!(failure.code, NodeFailureCode::UnknownBundle);
23462
23463        let failure = shared
23464            .spawn_session_with_deadline(
23465                WorkspaceId::new("primary").unwrap(),
23466                agent("claude"),
23467                SessionMode::Pty,
23468                gate4agent_types::TerminalSize {
23469                    rows: 24,
23470                    columns: 80,
23471                },
23472                None,
23473                None,
23474                None,
23475                None,
23476                None,
23477                None,
23478                SpawnRecordPolicy::ProviderIdentityOnly,
23479                Some(Instant::now()),
23480                &[],
23481                None,
23482                ApprovalLevel::default(),
23483                None,
23484                None,
23485            )
23486            .await
23487            .unwrap_err();
23488        assert_eq!(failure.code, NodeFailureCode::SpawnDeadlineExceeded);
23489        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23490        assert!(shared.handle.snapshot().sessions.is_empty());
23491    }
23492
23493    #[test]
23494    fn worktree_selection_requires_its_separate_negotiated_capability() {
23495        let mut spec = spawn_spec_fixture();
23496        spec.target.worktree_id = Some(WorkspaceId::new("selected-worktree").unwrap());
23497        let request = NodeRequest::SpawnSpec { spec };
23498        let spawn_spec_capability =
23499            CapabilityId::new(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY).unwrap();
23500        assert!(request_uses_unnegotiated_capability(
23501            &request,
23502            &[spawn_spec_capability.clone()],
23503        ));
23504        let worktree_capability =
23505            CapabilityId::new(NODE_WORKTREE_SELECTION_CAPABILITY).unwrap();
23506        assert!(request_uses_unnegotiated_capability(
23507            &request,
23508            &[spawn_spec_capability.clone(), worktree_capability.clone()],
23509        ));
23510        let profile_revision_capability =
23511            CapabilityId::new(NODE_SPAWN_PROFILE_REVISION_CAPABILITY).unwrap();
23512        assert!(request_uses_unnegotiated_capability(
23513            &request,
23514            &[
23515                spawn_spec_capability.clone(),
23516                worktree_capability.clone(),
23517                profile_revision_capability.clone(),
23518            ],
23519        ));
23520        // Default `SpawnOverrides` leaves `bundle_id`/`context_id` at
23521        // `Inherit`, not `Clear` — the profile's own defaults might still
23522        // materialize a bundle or a context pack, so both capabilities stay
23523        // conservatively required until negotiated (see
23524        // `requires_session_bundle_materialization_capability` /
23525        // `requires_history_context_pack_capability`).
23526        let bundle_materialization_capability =
23527            CapabilityId::new(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY).unwrap();
23528        assert!(request_uses_unnegotiated_capability(
23529            &request,
23530            &[
23531                spawn_spec_capability.clone(),
23532                worktree_capability.clone(),
23533                profile_revision_capability.clone(),
23534                bundle_materialization_capability.clone(),
23535            ],
23536        ));
23537        let history_context_pack_capability =
23538            CapabilityId::new(NODE_HISTORY_CONTEXT_PACK_CAPABILITY).unwrap();
23539        assert!(!request_uses_unnegotiated_capability(
23540            &request,
23541            &[
23542                spawn_spec_capability,
23543                worktree_capability,
23544                profile_revision_capability,
23545                bundle_materialization_capability,
23546                history_context_pack_capability,
23547            ],
23548        ));
23549    }
23550
23551    #[tokio::test]
23552    async fn spawn_spec_idempotency_replays_receipt_and_conflicts_before_session_mutation() {
23553        let mut shared = terminal_test_shared();
23554        let spec = spawn_spec_fixture();
23555        let receipt = shared
23556            .resolve_spawn_spec(&spec)
23557            .unwrap()
23558            .receipt(shared.incarnation_id, terminal_address(1));
23559        shared.remember_spawn_spec(spec.clone(), Ok(receipt.clone()), Instant::now());
23560
23561        let mut revised = shared.spawn_profiles.get(&spec.profile_id).unwrap().clone();
23562        revised.revision = crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
23563        shared.spawn_profiles = SpawnProfileRegistry::new([revised]).unwrap();
23564
23565        assert_eq!(shared.spawn_from_spec(spec.clone()).await.unwrap(), receipt);
23566
23567        let mut revision_conflicting = spec.clone();
23568        revision_conflicting.expected_profile_revision =
23569            crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
23570        let failure = shared.spawn_from_spec(revision_conflicting).await.unwrap_err();
23571        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
23572
23573        let mut stale_cache_miss = spec.clone();
23574        stale_cache_miss.idempotency_key =
23575            SpawnIdempotencyKey::new("stale-profile-cache-miss").unwrap();
23576        let failure = shared.spawn_from_spec(stale_cache_miss).await.unwrap_err();
23577        assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
23578
23579        let mut conflicting = spec;
23580        conflicting.deadline_ms = crate::protocol::SpawnDeadlineMs::new(31_000).unwrap();
23581        let failure = shared.spawn_from_spec(conflicting).await.unwrap_err();
23582        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
23583        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23584        assert!(shared.handle.snapshot().sessions.is_empty());
23585    }
23586
23587    #[tokio::test]
23588    async fn off_worktree_service_rejects_selection_and_mutation_before_git() {
23589        let shared = terminal_test_shared_with_mode(WorktreeServiceMode::Off);
23590        let mut spec = spawn_spec_fixture();
23591        spec.target.worktree_id = Some(WorkspaceId::new("unregistered-worktree").unwrap());
23592        let failure = shared.spawn_from_spec(spec).await.unwrap_err();
23593        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23594
23595        let failure = shared
23596            .create_worktree(
23597                WorkspaceId::new("primary").unwrap(),
23598                WorkspaceId::new("new-worktree").unwrap(),
23599                "not-an-absolute-path".to_owned(),
23600                "invalid branch".to_owned(),
23601                None,
23602            )
23603            .await
23604            .unwrap_err();
23605        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23606
23607        let failure = shared
23608            .remove_worktree(
23609                WorkspaceId::new("primary").unwrap(),
23610                "not-an-absolute-path".to_owned(),
23611            )
23612            .await
23613            .unwrap_err();
23614        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23615        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23616        assert!(shared.handle.snapshot().sessions.is_empty());
23617    }
23618
23619    #[tokio::test]
23620    async fn manual_worktree_registration_failure_removes_the_created_worktree() {
23621        let root = temporary_workspace_root("manual-worktree-compensation");
23622        let source = root.join("source");
23623        let target = root.join("target");
23624        let invalid_state_parent = root.join("state-parent-is-a-file");
23625        let invalid_state_path = invalid_state_parent.join("state.json");
23626        std::fs::create_dir_all(&source).unwrap();
23627        std::fs::write(&invalid_state_parent, "not a directory").unwrap();
23628        run_test_git(&source, &["init"]);
23629        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
23630        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
23631        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
23632        run_test_git(&source, &["add", "seed.txt"]);
23633        run_test_git(&source, &["commit", "-m", "seed"]);
23634
23635        let catalog = active_registry().unwrap();
23636        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23637        let workspace = WorkspaceConfig::new(
23638            WorkspaceId::new("primary").unwrap(),
23639            &source,
23640        ).unwrap();
23641        let mut shared = NodeShared::new(
23642            handle,
23643            "fixture-token".to_owned(),
23644            NodeId::new("node-worktree-compensation").unwrap(),
23645            vec![workspace],
23646            vec![agent("claude")],
23647        );
23648        shared.state_path = Some(invalid_state_path);
23649
23650        let error = shared.create_worktree(
23651            WorkspaceId::new("primary").unwrap(),
23652            WorkspaceId::new("compensated").unwrap(),
23653            target.to_string_lossy().into_owned(),
23654            "gate4agent/compensated".to_owned(),
23655            Some("HEAD".to_owned()),
23656        ).await.unwrap_err();
23657
23658        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
23659        assert!(!target.exists());
23660        assert!(list_git_worktrees(source.to_str().unwrap()).await.unwrap()
23661            .iter()
23662            .all(|worktree| !worktree_paths_equal(&worktree.path, &target.to_string_lossy())));
23663        std::fs::remove_dir_all(root).unwrap();
23664    }
23665
23666    #[test]
23667    fn selected_worktree_requires_authoritative_non_main_non_bare_entry() {
23668        fn worktree(path: &str, is_main: bool, is_bare: bool) -> NativeGitWorktreeSnapshot {
23669            NativeGitWorktreeSnapshot {
23670                path: path.to_owned(),
23671                head: "0123456789abcdef".to_owned(),
23672                branch: Some("refs/heads/test".to_owned()),
23673                is_bare,
23674                is_main,
23675                locked: false,
23676                lock_reason: None,
23677                prunable: false,
23678                prunable_reason: None,
23679                workspace_id: None,
23680            }
23681        }
23682
23683        let source = r"C:\repo";
23684        let selected = r"C:\repo-worktree";
23685        let listed = vec![
23686            worktree(source, true, false),
23687            worktree(selected, false, false),
23688        ];
23689        assert!(validate_selected_worktree(source, selected, &listed).is_ok());
23690
23691        let main_failure = validate_selected_worktree(source, source, &listed).unwrap_err();
23692        assert_eq!(main_failure.code, NodeFailureCode::WorktreeProtected);
23693
23694        let bare = vec![
23695            worktree(source, true, false),
23696            worktree(selected, false, true),
23697        ];
23698        let bare_failure = validate_selected_worktree(source, selected, &bare).unwrap_err();
23699        assert_eq!(bare_failure.code, NodeFailureCode::WorktreeProtected);
23700
23701        let foreign_failure =
23702            validate_selected_worktree(source, r"C:\foreign", &listed).unwrap_err();
23703        assert_eq!(foreign_failure.code, NodeFailureCode::WorktreeProtected);
23704    }
23705
23706    fn terminal_frame(sequence: u64) -> TerminalFrame {
23707        TerminalFrame {
23708            sequence,
23709            size: gate4agent_types::TerminalSize {
23710                rows: 24,
23711                columns: 80,
23712            },
23713            cursor_row: 2,
23714            cursor_column: 3,
23715            contents: format!("frame-{sequence}"),
23716            formatted: vec![1, 2, 3],
23717            scrollback_formatted: Vec::new(),
23718            alternate_screen: false,
23719            mouse_protocol_enabled: false,
23720            mouse_protocol_encoding: Default::default(),
23721            produced_at_unix_ms: 0,
23722            screen_state: gate4agent_types::PtyScreenState::default(),
23723            bracketed_paste: None,
23724        }
23725    }
23726
23727    #[test]
23728    fn terminal_frame_watermark_coalesces_and_resets_for_exact_session_address() {
23729        let shared = terminal_test_shared();
23730        let mut receiver = shared.terminal_event_tx.subscribe();
23731        let original = terminal_address(1);
23732
23733        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
23734        let first = receiver.try_recv().unwrap();
23735        assert_eq!(first.len(), 1);
23736        assert!(matches!(
23737            &first[0].event,
23738            NodeEvent::TerminalFrame { address, frame }
23739                if address == &original && frame.sequence == 7
23740        ));
23741
23742        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
23743        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(6))]);
23744        assert!(matches!(
23745            receiver.try_recv(),
23746            Err(broadcast::error::TryRecvError::Empty)
23747        ));
23748
23749        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(8))]);
23750        let advanced = receiver.try_recv().unwrap();
23751        assert_eq!(advanced[0].sequence, 2);
23752
23753        let resumed = terminal_address(2);
23754        shared.publish_terminal_frame_candidates(vec![(resumed.clone(), terminal_frame(1))]);
23755        let reset = receiver.try_recv().unwrap();
23756        assert_eq!(reset[0].sequence, 3);
23757        assert!(matches!(
23758            &reset[0].event,
23759            NodeEvent::TerminalFrame { address, frame }
23760                if address == &resumed && frame.sequence == 1
23761        ));
23762        shared.clear_terminal_frame_watermark(&resumed);
23763        assert!(!shared
23764            .terminal_frame_watermarks
23765            .lock()
23766            .unwrap_or_else(|poisoned| poisoned.into_inner())
23767            .contains_key(&resumed.session.instance_id));
23768    }
23769
23770    #[test]
23771    fn terminal_frame_batches_replace_without_entering_node_event_history() {
23772        let shared = terminal_test_shared();
23773        let mut receiver = shared.terminal_event_tx.subscribe();
23774        let address = terminal_address(1);
23775        let count = NODE_EVENT_HISTORY_MAX as u64 + 17;
23776
23777        for sequence in 1..=count {
23778            shared.publish_terminal_frame_candidates(vec![(
23779                address.clone(),
23780                terminal_frame(sequence),
23781            )]);
23782        }
23783
23784        let history = shared
23785            .history
23786            .lock()
23787            .unwrap_or_else(|poisoned| poisoned.into_inner());
23788        assert!(history.events.is_empty());
23789        assert_eq!(history.last_sequence, count);
23790        assert_eq!(history.replay_floor_sequence, 1);
23791        drop(history);
23792        let NodeResponse::Resync {
23793            event_sequence,
23794            oldest_available_sequence,
23795            events,
23796            ..
23797        } = shared.resync(0) else {
23798            unreachable!("resync helper returned another response")
23799        };
23800        assert_eq!(event_sequence, count);
23801        assert_eq!(oldest_available_sequence, 1);
23802        assert!(events.is_empty());
23803        assert!(matches!(
23804            resync_required_event(&shared).event,
23805            NodeEvent::ResyncRequired {
23806                oldest_available_sequence: 1,
23807            }
23808        ));
23809        assert!(matches!(
23810            receiver.try_recv(),
23811            Err(broadcast::error::TryRecvError::Lagged(skipped)) if skipped == count - 1
23812        ));
23813        let latest = receiver.try_recv().unwrap();
23814        assert_eq!(latest.len(), 1);
23815        assert_eq!(latest[0].sequence, count);
23816    }
23817
23818    #[test]
23819    fn real_durable_eviction_advances_authoritative_replay_floor() {
23820        let shared = terminal_test_shared();
23821        for _ in 0..=NODE_EVENT_HISTORY_MAX {
23822            shared.publish(NodeEvent::ControllerChanged { controller: None });
23823        }
23824
23825        let NodeResponse::Resync {
23826            event_sequence,
23827            oldest_available_sequence,
23828            events,
23829            ..
23830        } = shared.resync(0) else {
23831            unreachable!("resync helper returned another response")
23832        };
23833        assert_eq!(event_sequence, NODE_EVENT_HISTORY_MAX as u64 + 1);
23834        assert_eq!(oldest_available_sequence, 2);
23835        assert_eq!(events.first().map(|event| event.sequence), Some(2));
23836        assert_eq!(events.len(), NODE_EVENT_HISTORY_MAX);
23837        assert!(matches!(
23838            resync_required_event(&shared).event,
23839            NodeEvent::ResyncRequired {
23840                oldest_available_sequence: 2,
23841            }
23842        ));
23843    }
23844
23845    #[test]
23846    fn legacy_terminal_frame_connection_has_no_live_subscription() {
23847        let shared = terminal_test_shared();
23848        let without_capability = terminal_event_subscription(&shared, false);
23849        let with_capability = terminal_event_subscription(&shared, true);
23850        assert!(without_capability.is_none());
23851        assert!(with_capability.is_some());
23852        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23853            capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
23854        }));
23855    }
23856
23857    #[test]
23858    fn continuous_terminal_output_leaves_request_service_after_bounded_event_burst() {
23859        let mut pending = Vec::new();
23860        let mut event_sequence = 0_u64;
23861        for frame_sequence in 1..=4 {
23862            for instance in 1..=(NODE_CONNECTION_EVENT_BURST_MAX as u64 * 2) {
23863                event_sequence += 1;
23864                let mut address = terminal_address(1);
23865                address.session.instance_id = AgentInstanceId(instance);
23866                queue_connection_event(
23867                    &mut pending,
23868                    NodeEventEnvelope {
23869                        sequence: event_sequence,
23870                        event: NodeEvent::TerminalFrame {
23871                            address,
23872                            frame: terminal_frame(frame_sequence),
23873                        },
23874                    },
23875                    0,
23876                );
23877            }
23878        }
23879        assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX * 2);
23880        pending.sort_unstable_by_key(|event| event.sequence);
23881
23882        let mut uninterrupted = pending.clone();
23883        let first_burst = connection_event_burst_len(uninterrupted.len());
23884        uninterrupted.drain(..first_burst);
23885        let second_burst = connection_event_burst_len(uninterrupted.len());
23886        uninterrupted.drain(..second_burst);
23887        assert!(uninterrupted.is_empty());
23888
23889        let (frame_tx, mut frame_rx) =
23890            mpsc::channel::<Result<ClientFrame, FrameError>>(1);
23891        frame_tx
23892            .try_send(Ok(ClientFrame::Request(RequestEnvelope {
23893                request_id: 77,
23894                request: NodeRequest::Snapshot,
23895            })))
23896            .unwrap();
23897        let burst_len = connection_event_burst_len(pending.len());
23898        assert_eq!(burst_len, NODE_CONNECTION_EVENT_BURST_MAX);
23899        pending.drain(..burst_len);
23900        assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX);
23901        assert!(matches!(
23902            frame_rx.try_recv(),
23903            Ok(Ok(ClientFrame::Request(RequestEnvelope {
23904                request_id: 77,
23905                request: NodeRequest::Snapshot,
23906            })))
23907        ));
23908    }
23909
23910    #[test]
23911    fn terminal_lag_watermark_discards_retained_and_replayed_batches() {
23912        let mut pending = Vec::new();
23913        for sequence in [7_u64, 8] {
23914            let mut address = terminal_address(1);
23915            address.session.instance_id = AgentInstanceId(sequence);
23916            queue_connection_event(
23917                &mut pending,
23918                NodeEventEnvelope {
23919                    sequence,
23920                    event: NodeEvent::TerminalFrame {
23921                        address,
23922                        frame: terminal_frame(sequence),
23923                    },
23924                },
23925                0,
23926            );
23927        }
23928        let mut discard_through = 0;
23929        apply_connection_resync_watermark(&mut pending, &mut discard_through, 8);
23930        assert!(pending.is_empty());
23931
23932        let replayed = NodeEventEnvelope {
23933            sequence: 8,
23934            event: NodeEvent::TerminalFrame {
23935                address: terminal_address(1),
23936                frame: terminal_frame(8),
23937            },
23938        };
23939        let advanced = NodeEventEnvelope {
23940            sequence: 9,
23941            event: NodeEvent::TerminalFrame {
23942                address: terminal_address(2),
23943                frame: terminal_frame(1),
23944            },
23945        };
23946        queue_connection_event_batch(
23947            &mut pending,
23948            &[replayed, advanced.clone()],
23949            discard_through,
23950        );
23951        assert_eq!(pending, vec![advanced]);
23952    }
23953
23954    fn record(provider: &str, record_id: &str) -> ManagedSessionRecord {
23955        ManagedSessionRecord {
23956            record_id: SessionRecordId::new(record_id).unwrap(),
23957            display_name: format!("{provider} record"),
23958            provider: agent(provider),
23959            mode: SessionMode::Pty,
23960            state: ManagedSessionState::Unavailable,
23961            workspace_id: WorkspaceId::new("primary").unwrap(),
23962            canonical_root: opaque_windows_path(
23963                std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23964            ),
23965            provider_session: None,
23966            active_session: None,
23967            environment_profile: None,
23968            bundle: None,
23969            context_id: None,
23970            context: None,
23971            exported_context: None,
23972            task_binding: None,
23973            created_at_unix_ms: 1,
23974            updated_at_unix_ms: 1,
23975            last_error: None,
23976        }
23977    }
23978
23979    fn retention_test_record(
23980        workspace_id: &str,
23981        record_id: &str,
23982        state: ManagedSessionState,
23983        updated_at_unix_ms: u64,
23984    ) -> ManagedSessionRecord {
23985        ManagedSessionRecord {
23986            record_id: SessionRecordId::new(record_id).unwrap(),
23987            display_name: format!("{record_id} display"),
23988            provider: agent("claude"),
23989            mode: SessionMode::Pty,
23990            state,
23991            workspace_id: WorkspaceId::new(workspace_id).unwrap(),
23992            canonical_root: opaque_windows_path(
23993                std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23994            ),
23995            provider_session: None,
23996            active_session: None,
23997            environment_profile: None,
23998            bundle: None,
23999            context_id: None,
24000            context: None,
24001            exported_context: None,
24002            task_binding: None,
24003            created_at_unix_ms: updated_at_unix_ms,
24004            updated_at_unix_ms,
24005            last_error: None,
24006        }
24007    }
24008
24009    /// Two workspaces of three `Unavailable` records each, staggered by
24010    /// age, plus one never-touchable decoy per non-`Unavailable` state in
24011    /// `workspace-a` (all as old as that workspace's oldest real record --
24012    /// if the sweep's state gate ever leaked, these would be the first
24013    /// thing it wrongly retired). Ages, oldest to newest:
24014    /// `workspace-a`: a1 (500s), a2 (300s), a3 (100s).
24015    /// `workspace-b`: b1 (400s), b2 (200s), b3 (50s).
24016    fn retention_fixture_records(now: u64) -> Vec<ManagedSessionRecord> {
24017        vec![
24018            retention_test_record("workspace-a", "a1", ManagedSessionState::Unavailable, now - 500_000),
24019            retention_test_record("workspace-a", "a2", ManagedSessionState::Unavailable, now - 300_000),
24020            retention_test_record("workspace-a", "a3", ManagedSessionState::Unavailable, now - 100_000),
24021            retention_test_record("workspace-b", "b1", ManagedSessionState::Unavailable, now - 400_000),
24022            retention_test_record("workspace-b", "b2", ManagedSessionState::Unavailable, now - 200_000),
24023            retention_test_record("workspace-b", "b3", ManagedSessionState::Unavailable, now - 50_000),
24024            retention_test_record("workspace-a", "decoy-live", ManagedSessionState::Live, now - 500_000),
24025            retention_test_record(
24026                "workspace-a",
24027                "decoy-pending",
24028                ManagedSessionState::IdentityPending,
24029                now - 500_000,
24030            ),
24031            retention_test_record(
24032                "workspace-a",
24033                "decoy-dormant",
24034                ManagedSessionState::Dormant,
24035                now - 500_000,
24036            ),
24037        ]
24038    }
24039
24040    fn retention_fixture_shared() -> (NodeShared, NativeRuntime, PathBuf, u64) {
24041        let root = temporary_workspace_root("session-record-retention");
24042        std::fs::create_dir_all(&root).unwrap();
24043        let catalog = active_registry().unwrap();
24044        let (handle, runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24045        let workspace_a =
24046            WorkspaceConfig::new(WorkspaceId::new("workspace-a").unwrap(), &root).unwrap();
24047        let workspace_b =
24048            WorkspaceConfig::new(WorkspaceId::new("workspace-b").unwrap(), &root).unwrap();
24049        let mut shared = NodeShared::new(
24050            handle,
24051            "fixture-token".to_owned(),
24052            NodeId::new("node-retention-test").unwrap(),
24053            vec![workspace_a, workspace_b],
24054            vec![agent("claude")],
24055        );
24056        shared.state_path = Some(root.join("node-state-v10.json"));
24057        let now = unix_time_ms();
24058        for record in retention_fixture_records(now) {
24059            shared.insert_record(record).unwrap();
24060        }
24061        shared
24062            .persist_state_locked()
24063            .expect("fixture-only, empty-history persist against a fresh temp state path");
24064        (shared, runtime, root, now)
24065    }
24066
24067    fn retention_removed_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
24068        shared
24069            .history
24070            .lock()
24071            .unwrap_or_else(|poisoned| poisoned.into_inner())
24072            .events
24073            .iter()
24074            .filter_map(|envelope| match &envelope.event {
24075                NodeEvent::SessionRecordRemoved { record_id } => {
24076                    Some(record_id.as_str().to_owned())
24077                }
24078                _ => None,
24079            })
24080            .collect()
24081    }
24082
24083    fn retention_remaining_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
24084        shared
24085            .session_records
24086            .lock()
24087            .unwrap_or_else(|poisoned| poisoned.into_inner())
24088            .records
24089            .keys()
24090            .map(|record_id| record_id.as_str().to_owned())
24091            .collect()
24092    }
24093
24094    fn owned(values: &[&str]) -> std::collections::BTreeSet<String> {
24095        values.iter().map(|value| (*value).to_owned()).collect()
24096    }
24097
24098    #[tokio::test]
24099    async fn session_record_retention_keep_n_only_keeps_the_newest_per_workspace() {
24100        let (mut shared, runtime, root, _now) = retention_fixture_shared();
24101        shared.session_record_retention = SessionRecordRetentionConfig {
24102            age_ms: 0,
24103            keep_per_workspace: 1,
24104        };
24105        shared.retire_unavailable_session_records().await;
24106
24107        assert_eq!(
24108            retention_remaining_record_ids(&shared),
24109            owned(&["a3", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
24110        );
24111        assert_eq!(
24112            retention_removed_record_ids(&shared),
24113            owned(&["a1", "a2", "b1", "b2"]),
24114        );
24115        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 4);
24116
24117        drop(shared);
24118        drop(runtime);
24119        std::fs::remove_dir_all(root).unwrap();
24120    }
24121
24122    /// Age-only would retire {a1, a2, b1}; keep-N-only would retire
24123    /// {a1, a2, b1, b2}. With both flags set the sweep must retire exactly
24124    /// the intersection {a1, a2, b1} -- `b2` fails the age test (it is only
24125    /// 200s old against a 250s threshold) even though it fails the keep-N
24126    /// test too, proving retirement needs BOTH, not either.
24127    #[tokio::test]
24128    async fn session_record_retention_both_set_retires_only_the_intersection() {
24129        let (mut shared, runtime, root, _now) = retention_fixture_shared();
24130        shared.session_record_retention = SessionRecordRetentionConfig {
24131            age_ms: 250_000,
24132            keep_per_workspace: 1,
24133        };
24134        shared.retire_unavailable_session_records().await;
24135
24136        assert_eq!(
24137            retention_remaining_record_ids(&shared),
24138            owned(&["a3", "b2", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
24139        );
24140        assert_eq!(retention_removed_record_ids(&shared), owned(&["a1", "a2", "b1"]));
24141        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 3);
24142
24143        drop(shared);
24144        drop(runtime);
24145        std::fs::remove_dir_all(root).unwrap();
24146    }
24147
24148    #[tokio::test]
24149    async fn session_record_retention_both_disabled_is_a_no_op() {
24150        let (mut shared, runtime, root, _now) = retention_fixture_shared();
24151        shared.session_record_retention = SessionRecordRetentionConfig::default();
24152        shared.retire_unavailable_session_records().await;
24153
24154        assert_eq!(
24155            retention_remaining_record_ids(&shared),
24156            owned(&[
24157                "a1", "a2", "a3", "b1", "b2", "b3",
24158                "decoy-live", "decoy-pending", "decoy-dormant",
24159            ]),
24160        );
24161        assert!(retention_removed_record_ids(&shared).is_empty());
24162        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 0);
24163
24164        drop(shared);
24165        drop(runtime);
24166        std::fs::remove_dir_all(root).unwrap();
24167    }
24168
24169    #[test]
24170    fn session_record_context_export_selects_one_exact_history_identity() {
24171        let candidates = vec![
24172            HistoryCandidateSummary {
24173                id: "candidate-other".to_owned(),
24174                session_id_hint: "provider-other".to_owned(),
24175                modified_at_unix_ms: Some(1),
24176            },
24177            HistoryCandidateSummary {
24178                id: "candidate-exact".to_owned(),
24179                session_id_hint: "provider-exact".to_owned(),
24180                modified_at_unix_ms: Some(2),
24181            },
24182        ];
24183        assert_eq!(
24184            select_history_candidate(&candidates, Some("provider-exact")).unwrap(),
24185            "candidate-exact",
24186        );
24187    }
24188
24189    #[test]
24190    fn session_record_context_export_rejects_missing_and_ambiguous_history_identity() {
24191        let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
24192            id: id.to_owned(),
24193            session_id_hint: hint.to_owned(),
24194            modified_at_unix_ms: None,
24195        };
24196        let missing = select_history_candidate(
24197            &[candidate("candidate-other", "provider-other")],
24198            Some("provider-exact"),
24199        )
24200        .unwrap_err();
24201        assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
24202        let ambiguous = select_history_candidate(
24203            &[
24204                candidate("candidate-a", "provider-exact"),
24205                candidate("candidate-b", "provider-exact"),
24206            ],
24207            Some("provider-exact"),
24208        )
24209        .unwrap_err();
24210        assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
24211    }
24212
24213    /// The PTY-sidecar-observation shape (no fleet provider today, but
24214    /// capability-keyed rather than vendor-locked) never carries a verified
24215    /// `provider_session`; its record-bound export instead falls back to
24216    /// "exactly one discovered candidate" (see
24217    /// `export_context_pack_for_session_record_inner`). This is the same
24218    /// ambiguity refusal as the identity-filtered path, just without an
24219    /// identity to filter by.
24220    #[test]
24221    fn session_record_context_export_selects_sole_history_candidate_without_identity() {
24222        let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
24223            id: id.to_owned(),
24224            session_id_hint: hint.to_owned(),
24225            modified_at_unix_ms: None,
24226        };
24227        assert_eq!(
24228            select_history_candidate(&[candidate("candidate-sole", "sidecar-session")], None)
24229                .unwrap(),
24230            "candidate-sole",
24231        );
24232        let missing = select_history_candidate(&[], None).unwrap_err();
24233        assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
24234        let ambiguous = select_history_candidate(
24235            &[
24236                candidate("candidate-a", "sidecar-session-a"),
24237                candidate("candidate-b", "sidecar-session-b"),
24238            ],
24239            None,
24240        )
24241        .unwrap_err();
24242        assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
24243    }
24244
24245    #[test]
24246    fn session_record_context_export_revalidation_rejects_record_change() {
24247        let session = terminal_address(1);
24248        let identity = ProviderSessionIdentity {
24249            key: ProviderSessionKey::SessionId,
24250            id: "provider-exact".to_owned(),
24251            transcript_path: Some(r"C:\private\provider.jsonl".to_owned()),
24252        };
24253        let mut expected = record("claude", "record-context-export");
24254        expected.state = ManagedSessionState::Live;
24255        expected.provider_session = Some(identity.clone());
24256        expected.active_session = Some(session.clone());
24257        let binding = SessionBinding {
24258            workspace_id: session.workspace_id.clone(),
24259            generation: session.session.generation,
24260            runtime_policy: ProviderRuntimePolicy::raw_pty(),
24261            pending_resume: None,
24262            record_id: Some(expected.record_id.clone()),
24263            managed_worktree_lease_id: None,
24264            environment_profile: None,
24265            bundle: None,
24266            context: None,
24267            materialization_id: None,
24268        };
24269        let current_root = windows_path_text(&expected.canonical_root).to_owned();
24270        assert!(session_record_context_export_binding_is_exact(
24271            &expected,
24272            &expected,
24273            Some(&identity),
24274            &session,
24275            &binding,
24276            &expected.provider,
24277            &current_root,
24278            false,
24279        ));
24280        let mut changed = expected.clone();
24281        changed.updated_at_unix_ms += 1;
24282        assert!(!session_record_context_export_binding_is_exact(
24283            &expected,
24284            &changed,
24285            Some(&identity),
24286            &session,
24287            &binding,
24288            &expected.provider,
24289            &current_root,
24290            false,
24291        ));
24292        // A same-instant timestamp bump alone (state and active_session
24293        // otherwise unchanged) is not the clean-detachment transition
24294        // either, even with the reactive flag on: only the exact Live ->
24295        // Dormant + active_session cleared shape qualifies.
24296        assert!(!session_record_context_export_binding_is_exact(
24297            &expected,
24298            &changed,
24299            Some(&identity),
24300            &session,
24301            &binding,
24302            &expected.provider,
24303            &current_root,
24304            true,
24305        ));
24306        let mut detached = expected.clone();
24307        detached.state = ManagedSessionState::Dormant;
24308        detached.active_session = None;
24309        detached.updated_at_unix_ms += 1;
24310        assert!(!session_record_context_export_binding_is_exact(
24311            &expected,
24312            &detached,
24313            Some(&identity),
24314            &session,
24315            &binding,
24316            &expected.provider,
24317            &current_root,
24318            false,
24319        ));
24320        assert!(session_record_context_export_binding_is_exact(
24321            &expected,
24322            &detached,
24323            Some(&identity),
24324            &session,
24325            &binding,
24326            &expected.provider,
24327            &current_root,
24328            true,
24329        ));
24330    }
24331
24332    /// Mirrors `session_record_context_export_revalidation_rejects_record_change`
24333    /// for a record whose provider never carries a verified identity (the
24334    /// PTY-sidecar-observation shape -- capability-keyed, no fleet provider
24335    /// declares one today): `identity` is `None` throughout, and the
24336    /// exactness check falls back to comparing `provider_session` itself,
24337    /// which must still be `None` on both sides.
24338    #[test]
24339    fn session_record_context_export_binding_is_exact_without_identity() {
24340        let session = terminal_address(1);
24341        let mut expected = record("pty-sidecar-fixture", "record-context-export-no-identity");
24342        expected.state = ManagedSessionState::Live;
24343        expected.active_session = Some(session.clone());
24344        let binding = SessionBinding {
24345            workspace_id: session.workspace_id.clone(),
24346            generation: session.session.generation,
24347            runtime_policy: ProviderRuntimePolicy::raw_pty(),
24348            pending_resume: None,
24349            record_id: Some(expected.record_id.clone()),
24350            managed_worktree_lease_id: None,
24351            environment_profile: None,
24352            bundle: None,
24353            context: None,
24354            materialization_id: None,
24355        };
24356        let current_root = windows_path_text(&expected.canonical_root).to_owned();
24357        assert!(session_record_context_export_binding_is_exact(
24358            &expected,
24359            &expected,
24360            None,
24361            &session,
24362            &binding,
24363            &expected.provider,
24364            &current_root,
24365            false,
24366        ));
24367        // A record that gained an identity between the initial lookup and
24368        // this recheck no longer matches the `None` this shape expects to
24369        // keep seeing -- treated the same as any other mid-export drift.
24370        let mut drifted = expected.clone();
24371        drifted.provider_session = Some(ProviderSessionIdentity {
24372            key: ProviderSessionKey::SessionId,
24373            id: "unexpected-identity".to_owned(),
24374            transcript_path: None,
24375        });
24376        assert!(!session_record_context_export_binding_is_exact(
24377            &expected,
24378            &drifted,
24379            None,
24380            &session,
24381            &binding,
24382            &expected.provider,
24383            &current_root,
24384            false,
24385        ));
24386    }
24387
24388    #[test]
24389    fn session_record_context_export_preflight_requires_live_usable_source() {
24390        let mut source = record("claude", "record-context-export-preflight");
24391        source.state = ManagedSessionState::Live;
24392        assert!(session_record_context_export_source_is_usable(
24393            &source,
24394            &SessionStatus::Running,
24395            false,
24396        ));
24397        assert!(session_record_context_export_source_is_usable(
24398            &source,
24399            &SessionStatus::Exited { exit_code: Some(0) },
24400            false,
24401        ));
24402
24403        source.state = ManagedSessionState::Dormant;
24404        assert!(!session_record_context_export_source_is_usable(
24405            &source,
24406            &SessionStatus::Running,
24407            false,
24408        ));
24409        // The reactive auto-export-at-exit path (allow_clean_detachment) is
24410        // the only caller that may treat a Dormant, cleanly-exited record as
24411        // still usable — the on-demand path above never does.
24412        assert!(session_record_context_export_source_is_usable(
24413            &source,
24414            &SessionStatus::Exited { exit_code: Some(0) },
24415            true,
24416        ));
24417        assert!(!session_record_context_export_source_is_usable(
24418            &source,
24419            &SessionStatus::Exited { exit_code: Some(0) },
24420            false,
24421        ));
24422        source.state = ManagedSessionState::Live;
24423        assert!(!session_record_context_export_source_is_usable(
24424            &source,
24425            &SessionStatus::Starting,
24426            false,
24427        ));
24428    }
24429
24430    #[test]
24431    fn session_record_context_export_final_conflict_leaves_catalog_empty() {
24432        let catalog = active_registry().unwrap();
24433        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24434        let workspace = WorkspaceConfig::new(
24435            WorkspaceId::new("primary").unwrap(),
24436            std::env::current_dir().unwrap(),
24437        )
24438        .unwrap();
24439        let shared = NodeShared::new(
24440            handle,
24441            "fixture-token".to_owned(),
24442            NodeId::new("node-context-export-conflict").unwrap(),
24443            vec![workspace],
24444            vec![agent("claude")],
24445        );
24446        let history = HistorySessionRecord {
24447            session_id: "provider-exact".to_owned(),
24448            title: None,
24449            cwd: None,
24450            model: None,
24451            message_count: 1,
24452            completed_turn_count: None,
24453            total_tokens: 0,
24454            messages: vec![gate4agent_types::HistoryMessageRecord {
24455                role: gate4agent_types::HistoryMessageRole::User,
24456                text: "bounded context".to_owned(),
24457            }],
24458        };
24459        let pack = NodeContextPack::export(
24460            ContextPackLineageReceipt {
24461                source_node_id: shared.node_id.clone(),
24462                source_session: terminal_address(1),
24463                source_provider: agent("claude"),
24464            },
24465            &history,
24466        )
24467        .unwrap();
24468        let context_id = pack.receipt().id.clone();
24469        let session = terminal_address(1);
24470        let identity = ProviderSessionIdentity {
24471            key: ProviderSessionKey::SessionId,
24472            id: history.session_id.clone(),
24473            transcript_path: None,
24474        };
24475        let mut expected = record("claude", "record-context-export-final-conflict");
24476        expected.state = ManagedSessionState::Live;
24477        expected.provider_session = Some(identity.clone());
24478        expected.active_session = Some(session.clone());
24479        let mut changed = expected.clone();
24480        changed.updated_at_unix_ms += 1;
24481        shared.insert_record(changed).unwrap();
24482
24483        let error = shared
24484            .commit_context_pack_for_session_record(
24485                &expected,
24486                Some(&identity),
24487                &session,
24488                "candidate-exact",
24489                &history,
24490                pack,
24491                false,
24492            )
24493            .unwrap_err();
24494
24495        assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
24496        assert!(shared
24497            .context_catalog
24498            .read()
24499            .unwrap_or_else(|poisoned| poisoned.into_inner())
24500            .get(&context_id)
24501            .is_none());
24502    }
24503
24504    #[tokio::test]
24505    async fn session_task_binding_mints_assigns_clears_with_cas_and_persists_v9() {
24506        let root = temporary_workspace_root("session-task-binding");
24507        std::fs::create_dir_all(&root).unwrap();
24508        let state_path = root.join("node-state.json");
24509        let catalog = active_registry().unwrap();
24510        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24511        let workspace = WorkspaceConfig::new(WorkspaceId::new("primary").unwrap(), &root).unwrap();
24512        let node_id = NodeId::new("node-1").unwrap();
24513        let mut shared = NodeShared::new(
24514            handle,
24515            "fixture-token".to_owned(),
24516            node_id.clone(),
24517            vec![workspace],
24518            vec![agent("claude")],
24519        );
24520        shared.state_path = Some(state_path.clone());
24521        let record = record("claude", "sr-task");
24522        let record_id = record.record_id.clone();
24523        shared.insert_record(record).unwrap();
24524        shared.persist_state().unwrap();
24525        shared
24526            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
24527            .unwrap();
24528
24529        let minted = process_request_inner(
24530            &shared,
24531            77,
24532            ClientRole::Operator,
24533            NodeRequest::SetSessionTask {
24534                record_id: record_id.clone(),
24535                expected_revision: 0,
24536                target: SessionTaskTargetV1::New,
24537            },
24538        )
24539        .await
24540        .unwrap();
24541        let NodeResponse::SessionRecordUpdated { record: minted } = minted else {
24542            panic!("session task mutation returned another response");
24543        };
24544        let minted_binding = minted.task_binding.clone().unwrap();
24545        let minted_task_id = minted_binding.task_id.clone().unwrap();
24546        assert_eq!(minted_binding.revision, 1);
24547        assert!(minted_task_id.as_str().starts_with("task-"));
24548        assert_eq!(minted_task_id.as_str().len(), 29);
24549
24550        let event_count = shared.current_sequence();
24551        let repeated = process_request_inner(
24552            &shared,
24553            77,
24554            ClientRole::Operator,
24555            NodeRequest::SetSessionTask {
24556                record_id: record_id.clone(),
24557                expected_revision: 1,
24558                target: SessionTaskTargetV1::Existing {
24559                    task_id: minted_task_id.clone(),
24560                },
24561            },
24562        )
24563        .await
24564        .unwrap();
24565        assert_eq!(repeated, NodeResponse::SessionRecordUpdated { record: minted.clone() });
24566        assert_eq!(shared.current_sequence(), event_count);
24567
24568        let stale = process_request_inner(
24569            &shared,
24570            77,
24571            ClientRole::Operator,
24572            NodeRequest::SetSessionTask {
24573                record_id: record_id.clone(),
24574                expected_revision: 0,
24575                target: SessionTaskTargetV1::Existing {
24576                    task_id: minted_task_id,
24577                },
24578            },
24579        )
24580        .await
24581        .unwrap_err();
24582        assert_eq!(stale.code, NodeFailureCode::SessionRecordConflict);
24583
24584        let assigned_id: TaskId = "task-ffeeddccbbaa998877665544".parse().unwrap();
24585        let assigned = process_request_inner(
24586            &shared,
24587            77,
24588            ClientRole::Operator,
24589            NodeRequest::SetSessionTask {
24590                record_id: record_id.clone(),
24591                expected_revision: 1,
24592                target: SessionTaskTargetV1::Existing {
24593                    task_id: assigned_id.clone(),
24594                },
24595            },
24596        )
24597        .await
24598        .unwrap();
24599        let NodeResponse::SessionRecordUpdated { record: assigned } = assigned else {
24600            panic!("session task assignment returned another response");
24601        };
24602        assert_eq!(assigned.task_binding.as_ref().unwrap().revision, 2);
24603        assert_eq!(assigned.task_binding.as_ref().unwrap().task_id.as_ref(), Some(&assigned_id));
24604
24605        let cleared = process_request_inner(
24606            &shared,
24607            77,
24608            ClientRole::Operator,
24609            NodeRequest::SetSessionTask {
24610                record_id: record_id.clone(),
24611                expected_revision: 2,
24612                target: SessionTaskTargetV1::Clear,
24613            },
24614        )
24615        .await
24616        .unwrap();
24617        let NodeResponse::SessionRecordUpdated { record: cleared } = cleared else {
24618            panic!("session task clear returned another response");
24619        };
24620        assert_eq!(cleared.task_binding.as_ref().unwrap().revision, 3);
24621        assert!(cleared.task_binding.as_ref().unwrap().task_id.is_none());
24622        assert!(cleared.task_binding.as_ref().unwrap().changed_at_unix_ms > 0);
24623        assert_eq!(
24624            cleared.task_binding.as_ref().unwrap().changed_at_unix_ms,
24625            cleared.updated_at_unix_ms,
24626        );
24627
24628        let persisted = std::fs::read(&state_path).unwrap();
24629        assert_eq!(serde_json::from_slice::<serde_json::Value>(&persisted).unwrap()["version"], NODE_STATE_SCHEMA_V10);
24630        let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
24631        assert_eq!(loaded.records[0].task_binding, cleared.task_binding);
24632        std::fs::remove_dir_all(root).unwrap();
24633    }
24634
24635    #[test]
24636    fn session_task_capability_strips_snapshots_events_and_replies() {
24637        let mut bound = record("claude", "sr-task-projection");
24638        bound.updated_at_unix_ms = 2;
24639        bound.task_binding = Some(SessionTaskBindingV1 {
24640            revision: 1,
24641            task_id: Some("task-00112233445566778899aaff".parse().unwrap()),
24642            changed_at_unix_ms: 2,
24643        });
24644        let request = NodeRequest::SetSessionTask {
24645            record_id: bound.record_id.clone(),
24646            expected_revision: 1,
24647            target: SessionTaskTargetV1::Clear,
24648        };
24649        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
24650            capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
24651        }));
24652        assert!(!request_uses_unnegotiated_capability(
24653            &request,
24654            &baseline_capabilities().unwrap(),
24655        ));
24656        assert!(request_uses_unnegotiated_capability(&request, &[]));
24657
24658        let mut snapshot = terminal_test_shared().snapshot();
24659        snapshot.session_records.push(bound.clone());
24660        let mut reply = ResponseEnvelope {
24661            request_id: 1,
24662            result: Ok(NodeResponse::Resync {
24663                event_sequence: 1,
24664                oldest_available_sequence: 1,
24665                snapshot,
24666                events: vec![NodeEventEnvelope {
24667                    sequence: 1,
24668                    event: NodeEvent::SessionRecordUpserted {
24669                        record: bound.clone(),
24670                    },
24671                }],
24672            }),
24673        };
24674        project_response_without_session_task_binding(&mut reply);
24675        let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
24676            panic!("task projection changed the response kind");
24677        };
24678        assert!(snapshot.session_records[0].task_binding.is_none());
24679        let NodeEvent::SessionRecordUpserted { record } = &events[0].event else {
24680            panic!("task projection changed the event kind");
24681        };
24682        assert!(record.task_binding.is_none());
24683
24684        let projected = project_event_without_session_task_binding(NodeEventEnvelope {
24685            sequence: 2,
24686            event: NodeEvent::SessionRecordUpserted { record: bound.clone() },
24687        });
24688        let NodeEvent::SessionRecordUpserted { record } = projected.event else {
24689            panic!("task event projection changed the event kind");
24690        };
24691        assert!(record.task_binding.is_none());
24692
24693        let mut direct = ResponseEnvelope {
24694            request_id: 2,
24695            result: Ok(NodeResponse::SessionRecordUpdated { record: bound }),
24696        };
24697        project_response_without_session_task_binding(&mut direct);
24698        let Ok(NodeResponse::SessionRecordUpdated { record }) = direct.result else {
24699            panic!("task reply projection changed the response kind");
24700        };
24701        assert!(record.task_binding.is_none());
24702    }
24703
24704    #[test]
24705    fn windows_runtime_default_node_endpoint_is_exact_and_valid() {
24706        assert_eq!(DEFAULT_NODE_ENDPOINT, r"\\.\pipe\gate4agent-node");
24707        let workspace = WorkspaceConfig::new(
24708            WorkspaceId::new("primary").unwrap(),
24709            std::env::current_dir().unwrap(),
24710        )
24711        .unwrap();
24712        let config = NodeServerConfig::new(
24713            DEFAULT_NODE_ENDPOINT,
24714            "fixture-token",
24715            NodeId::new("node-1").unwrap(),
24716            [workspace],
24717        )
24718        .unwrap();
24719        assert_eq!(config.endpoint, DEFAULT_NODE_ENDPOINT);
24720    }
24721
24722    #[cfg(all(feature = "fixture", windows))]
24723    #[test]
24724    fn provider_bundle_argv_hold_fixture_is_parent_bounded_and_waits_after_proof() {
24725        let root = temporary_workspace_root("provider-bundle-argv-hold");
24726        let outside = temporary_workspace_root("provider-bundle-argv-hold-outside");
24727        std::fs::create_dir_all(&root).unwrap();
24728        std::fs::create_dir_all(&outside).unwrap();
24729        let proof_path = root.join("provider.proof");
24730        let release_signal = root.join("release.signal");
24731        let config = NodeServerConfig::new(
24732            format!(
24733                r"\\.\pipe\gate4agent-provider-bundle-hold-{}",
24734                std::process::id(),
24735            ),
24736            "fixture-token",
24737            NodeId::new("provider-bundle-hold-node").unwrap(),
24738            [WorkspaceConfig::new(
24739                WorkspaceId::new("primary").unwrap(),
24740                std::env::current_dir().unwrap(),
24741            )
24742            .unwrap()],
24743        )
24744        .unwrap()
24745        .with_spawn_profiles(SpawnProfileRegistry::new([
24746            crate::protocol::SpawnProfileDefaults {
24747                profile_id: crate::protocol::SpawnProfileId::new("kimi-bundle-hold").unwrap(),
24748                revision: crate::protocol::SpawnProfileRevision::new("kimi-bundle-hold-r1")
24749                    .unwrap(),
24750                provider: agent("kimi"),
24751                mode: SessionMode::Pty,
24752                terminal_size: gate4agent_types::TerminalSize {
24753                    rows: 24,
24754                    columns: 80,
24755                },
24756                prompt: None,
24757                bundle_id: None,
24758                context_id: None,
24759                environment_profile_id: None,
24760            },
24761        ])
24762        .unwrap());
24763        let server = NodeServer::new_provider_bundle_argv_hold_fixture(
24764            config,
24765            agent("kimi"),
24766            proof_path.clone(),
24767            release_signal.clone(),
24768        )
24769        .unwrap();
24770        assert_eq!(server.shared.enabled_providers, [agent("kimi")]);
24771
24772        let spec = NodeServer::provider_bundle_argv_fixture_spec(
24773            agent("kimi"),
24774            proof_path.clone(),
24775            Some(release_signal.clone()),
24776        )
24777        .unwrap();
24778        let wrapper = &spec.launch.fixed_args[spec.launch.fixed_args.len() - 3];
24779        let proof_write = wrapper.find("WriteAllLines($proofPath").unwrap();
24780        let release_wait = wrapper
24781            .find("while (-not (Test-Path -LiteralPath $releaseSignal -PathType Leaf))")
24782            .unwrap();
24783        let fixture_ready = wrapper.find("fixture-ready>").unwrap();
24784        assert!(proof_write < release_wait);
24785        assert!(release_wait < fixture_ready);
24786        assert!(wrapper.contains("[DateTime]::UtcNow.AddSeconds(45)"));
24787        assert!(wrapper.contains("Start-Sleep -Milliseconds 20"));
24788        assert_eq!(
24789            &spec.launch.fixed_args[spec.launch.fixed_args.len() - 2..],
24790            &[
24791                proof_path.to_string_lossy().into_owned(),
24792                release_signal.to_string_lossy().into_owned(),
24793            ],
24794        );
24795
24796        let error = NodeServer::provider_bundle_argv_fixture_spec(
24797            agent("kimi"),
24798            proof_path,
24799            Some(outside.join("release.signal")),
24800        )
24801        .unwrap_err();
24802        assert!(matches!(
24803            error,
24804            NodeServerError::Registry(message)
24805                if message == "provider bundle argv release path must share the proof parent"
24806        ));
24807        drop(server);
24808        std::fs::remove_dir_all(root).unwrap();
24809        std::fs::remove_dir_all(outside).unwrap();
24810    }
24811
24812    #[cfg(feature = "fixture")]
24813    #[test]
24814    fn four_provider_context_fixture_registry_is_unambiguous_and_history_bound() {
24815        let root = temporary_workspace_root("context-fixture-registry");
24816        std::fs::create_dir_all(&root).unwrap();
24817        let proof_path = root.join("context.proof");
24818        let config = NodeServerConfig::new(
24819            format!(
24820                r"\\.\pipe\gate4agent-context-fixture-registry-{}",
24821                std::process::id(),
24822            ),
24823            "fixture-token",
24824            NodeId::new("context-fixture-node").unwrap(),
24825            [WorkspaceConfig::new(
24826                WorkspaceId::new("primary").unwrap(),
24827                std::env::current_dir().unwrap(),
24828            )
24829            .unwrap()],
24830        )
24831        .unwrap();
24832        let server = NodeServer::new_context_pack_fixture(config, proof_path).unwrap();
24833        let mut enabled = server
24834            .shared
24835            .enabled_providers
24836            .iter()
24837            .map(AgentId::as_str)
24838            .collect::<Vec<_>>();
24839        enabled.sort_unstable();
24840        assert_eq!(enabled, ["claude", "codex", "grok", "kimi"]);
24841        for provider in &server.shared.enabled_providers {
24842            assert!(server
24843                .shared
24844                .provider_adapter_contracts
24845                .iter()
24846                .any(|contract| {
24847                    &contract.provider == provider
24848                        && contract.family == AdapterFamily::History
24849                }));
24850        }
24851        drop(server);
24852        std::fs::remove_dir_all(root).unwrap();
24853    }
24854
24855    #[cfg(feature = "fixture")]
24856    #[test]
24857    fn monitoring_context_fixture_combines_claude_and_codex_hooks_with_history() {
24858        let root = temporary_workspace_root("monitoring-context-fixture");
24859        std::fs::create_dir_all(&root).unwrap();
24860        let proof_path = root.join("context.proof");
24861        let standard = NodeServer::context_pack_fixture_catalog(
24862            proof_path.clone(),
24863            None,
24864            false,
24865        ).unwrap();
24866        let monitoring = NodeServer::context_pack_fixture_catalog(
24867            proof_path.clone(),
24868            None,
24869            true,
24870        ).unwrap();
24871        let claude = AgentId::new("claude").unwrap();
24872        let standard_claude = standard.get(&claude).unwrap();
24873        let monitoring_claude = monitoring.get(&claude).unwrap();
24874        // Lifecycle hooks are retired (owner ruling 2026-09-25): neither the
24875        // standard nor the monitoring fixture declares a hook adapter
24876        // anymore.
24877        assert!(monitoring_claude.capabilities.adapters.hook.is_none());
24878        assert_eq!(
24879            monitoring_claude.capabilities.adapters.history,
24880            standard_claude.capabilities.adapters.history,
24881        );
24882        assert_ne!(monitoring_claude.launch, standard_claude.launch);
24883        let codex = AgentId::new("codex").unwrap();
24884        let standard_codex = standard.get(&codex).unwrap();
24885        let monitoring_codex = monitoring.get(&codex).unwrap();
24886        assert!(monitoring_codex.capabilities.adapters.hook.is_none());
24887        assert_eq!(
24888            monitoring_codex.capabilities.adapters.history,
24889            standard_codex.capabilities.adapters.history,
24890        );
24891        assert_ne!(monitoring_codex.launch, standard_codex.launch);
24892        let codex_validation = monitoring_codex.launch.fixed_args.iter()
24893            .find(|argument| argument.contains("F7_CODEX_BUNDLE_CONTEXT_VALIDATED"))
24894            .unwrap();
24895        for required in [
24896            "$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')",
24897            "$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')",
24898            "@('role', 'text')",
24899            "g4a-private-provider-session-canary",
24900            "private-provider-login-identity",
24901            "$rawProof",
24902            "$env:GATE4AGENT_HOOK_URL",
24903            "g4a-private-provider-session-canary",
24904        ] {
24905            assert!(codex_validation.contains(required), "missing privacy validation: {required}");
24906        }
24907        for provider in ["grok", "kimi"] {
24908            let provider = AgentId::new(provider).unwrap();
24909            assert_eq!(monitoring.get(&provider), standard.get(&provider));
24910        }
24911
24912        let config = NodeServerConfig::new(
24913            format!(
24914                r"\\.\pipe\gate4agent-monitoring-context-fixture-{}",
24915                std::process::id(),
24916            ),
24917            "fixture-token",
24918            NodeId::new("monitoring-context-fixture-node").unwrap(),
24919            [WorkspaceConfig::new(
24920                WorkspaceId::new("primary").unwrap(),
24921                std::env::current_dir().unwrap(),
24922            )
24923            .unwrap()],
24924        )
24925        .unwrap();
24926        let server = NodeServer::new_monitoring_context_pack_fixture(config, proof_path).unwrap();
24927        assert!(server.shared.fixture_semantic_hook_policy);
24928        drop(server);
24929        std::fs::remove_dir_all(root).unwrap();
24930    }
24931
24932    #[cfg(feature = "fixture")]
24933    #[test]
24934    fn kimi_context_only_fixture_is_bounded_and_preserves_codex_validation() {
24935        let root = temporary_workspace_root("kimi-context-only-fixture");
24936        std::fs::create_dir_all(&root).unwrap();
24937        let proof_path = root.join("context.proof");
24938        let standard = NodeServer::context_pack_fixture_catalog(
24939            proof_path.clone(),
24940            None,
24941            false,
24942        ).unwrap();
24943        let kimi = AgentId::new("kimi").unwrap();
24944        let context_only = NodeServer::context_pack_fixture_catalog(
24945            proof_path,
24946            Some(&kimi),
24947            false,
24948        )
24949        .unwrap();
24950
24951        let codex = AgentId::new("codex").unwrap();
24952        assert_eq!(
24953            standard.get(&codex).unwrap().launch.fixed_args,
24954            context_only.get(&codex).unwrap().launch.fixed_args,
24955        );
24956        let script = context_only
24957            .get(&kimi)
24958            .unwrap()
24959            .launch
24960            .fixed_args
24961            .iter()
24962            .find(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED"))
24963            .expect("Kimi context-only validation script is missing");
24964        for required in [
24965            "$bundleArgs.Count -ne 0",
24966            "GATE4AGENT_CONTEXT_ROOT",
24967            "context-pack.json",
24968            "$entries.Count -ne 1",
24969            "g4a-context-pack-v1",
24970            "$document.PSObject.Properties['cwd']",
24971            "'claude', 'codex', 'grok', 'kimi'",
24972            "$hasUser",
24973            "$hasAssistant",
24974            "SHA256",
24975            "context-only",
24976        ] {
24977            assert!(script.contains(required), "missing validation: {required}");
24978        }
24979        for forbidden in ["CODEX_HOME", "USERPROFILE", "auth", "token", "global config"] {
24980            assert!(!script.contains(forbidden), "forbidden home/auth access: {forbidden}");
24981        }
24982        assert!(!standard
24983            .get(&kimi)
24984            .unwrap()
24985            .launch
24986            .fixed_args
24987            .iter()
24988            .any(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED")));
24989
24990        std::fs::remove_dir_all(root).unwrap();
24991    }
24992
24993    #[cfg(all(feature = "fixture", windows))]
24994    #[tokio::test]
24995    async fn kimi_context_only_fixture_spawns_catalogued_context_pack_through_spawn_spec() {
24996        let root = temporary_workspace_root("kimi-context-only-spawn");
24997        let workspace_root = root.join("workspace");
24998        std::fs::create_dir_all(&workspace_root).unwrap();
24999        let materialization_root = root.join("private-materializations");
25000        let proof_path = root.join("context.proof");
25001        let node_id = NodeId::new("node-kimi-context-only-spawn").unwrap();
25002        let workspace_id = WorkspaceId::new("primary").unwrap();
25003        let profile_id = crate::protocol::SpawnProfileId::new("target-kimi").unwrap();
25004        let endpoint = format!(
25005            r"\\.\pipe\gate4agent-kimi-context-only-spawn-{}-{}",
25006            std::process::id(),
25007            unix_time_ms(),
25008        );
25009        let spawn_profiles = SpawnProfileRegistry::new([
25010            crate::protocol::SpawnProfileDefaults {
25011                profile_id: profile_id.clone(),
25012                revision: crate::protocol::SpawnProfileRevision::new("target-kimi-r1").unwrap(),
25013                provider: agent("kimi"),
25014                mode: SessionMode::Pty,
25015                terminal_size: gate4agent_types::TerminalSize {
25016                    rows: 30,
25017                    columns: 120,
25018                },
25019                prompt: None,
25020                bundle_id: None,
25021                context_id: None,
25022                environment_profile_id: None,
25023            },
25024        ])
25025        .unwrap();
25026        let mut config = NodeServerConfig::new(
25027            &endpoint,
25028            "fixture-token",
25029            node_id.clone(),
25030            [WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap()],
25031        )
25032        .unwrap()
25033        .with_spawn_profiles(spawn_profiles)
25034        .with_session_environment_materialization(
25035            materialization_root,
25036            Arc::new(FixtureSecretResolver {
25037                deny: Arc::new(AtomicBool::new(false)),
25038            }),
25039        )
25040        .unwrap();
25041        config.fixture_raw_pty_runtime = true;
25042        let server = NodeServer::new_context_only_proof_fixture(
25043            config,
25044            agent("kimi"),
25045            proof_path.clone(),
25046        )
25047        .unwrap();
25048        let shared = Arc::clone(&server.shared);
25049        let shutdown = server.shutdown_handle();
25050
25051        let history = HistorySessionRecord {
25052            session_id: "context-only-source-history".to_owned(),
25053            title: None,
25054            cwd: None,
25055            model: None,
25056            message_count: 2,
25057            completed_turn_count: None,
25058            total_tokens: 0,
25059            messages: vec![
25060                gate4agent_types::HistoryMessageRecord {
25061                    role: gate4agent_types::HistoryMessageRole::User,
25062                    text: "continue the bounded parent run through native C2".to_owned(),
25063                },
25064                gate4agent_types::HistoryMessageRecord {
25065                    role: gate4agent_types::HistoryMessageRole::Assistant,
25066                    text: "the exact parent context is ready for the child run".to_owned(),
25067                },
25068            ],
25069        };
25070        let pack = NodeContextPack::export(
25071            ContextPackLineageReceipt {
25072                source_node_id: node_id.clone(),
25073                source_session: SessionAddress {
25074                    workspace_id: workspace_id.clone(),
25075                    session: SessionKey {
25076                        instance_id: AgentInstanceId(71),
25077                        generation: SessionGeneration(1),
25078                    },
25079                },
25080                source_provider: agent("codex"),
25081            },
25082            &history,
25083        )
25084        .unwrap();
25085        let expected_context_hash = digest(&SHA256, pack.bytes())
25086            .as_ref()
25087            .iter()
25088            .map(|byte| format!("{byte:02x}"))
25089            .collect::<String>();
25090        let context = shared
25091            .context_catalog
25092            .write()
25093            .unwrap_or_else(|poisoned| poisoned.into_inner())
25094            .insert(pack)
25095            .unwrap();
25096        let spec = SpawnSpec {
25097            target: crate::protocol::SpawnTarget {
25098                node_id,
25099                workspace_id: workspace_id.clone(),
25100                worktree_id: None,
25101            },
25102            profile_id,
25103            expected_profile_revision: crate::protocol::SpawnProfileRevision::new(
25104                "target-kimi-r1",
25105            ).unwrap(),
25106            overrides: crate::protocol::SpawnOverrides {
25107                context_id: crate::protocol::SpawnOverride::Set {
25108                    value: context.id.clone(),
25109                },
25110                ..crate::protocol::SpawnOverrides::default()
25111            },
25112            deadline_ms: crate::protocol::SpawnDeadlineMs::new(10_000).unwrap(),
25113            idempotency_key: SpawnIdempotencyKey::new("kimi-context-only-spawn").unwrap(),
25114            required_capabilities: SpawnRequiredCapabilities::default(),
25115        };
25116        let resolved = match shared.resolve_spawn_spec(&spec) {
25117            Ok(resolved) => resolved,
25118            Err(error) => panic!(
25119                "Kimi context-only resolve rejected: exact_code={:?} message={}",
25120                error.code,
25121                error.message,
25122            ),
25123        };
25124        let resolved_context = match shared.resolve_context(&resolved) {
25125            Ok(Some(resolved_context)) => resolved_context,
25126            Ok(None) => panic!("Kimi context-only resolve omitted the exact ContextPack"),
25127            Err(error) => panic!(
25128                "Kimi context catalog resolve rejected: exact_code={:?} message={}",
25129                error.code,
25130                error.message,
25131            ),
25132        };
25133        assert_eq!(resolved_context, context);
25134        let runtime_policy = match shared
25135            .admit_provider_runtime(&agent("kimi"), ProviderRuntimeRequirement::RawPty)
25136            .await
25137        {
25138            Ok(runtime_policy) => runtime_policy,
25139            Err(error) => panic!(
25140                "Kimi RawPty admission rejected: exact_code={:?} message={}",
25141                error.code,
25142                error.message,
25143            ),
25144        };
25145        assert!(runtime_policy.raw_pty_lifecycle);
25146
25147        let launch_control = shared.native_launch_profile_control.as_ref().unwrap();
25148        let generic_overlay = NativeLaunchEnvironmentOverlay::new(
25149            agent("kimi"),
25150            TransportKind::Pty,
25151            vec![EnvMutation {
25152                key: OsString::from("GATE4AGENT_UNPROFILED_GENERIC"),
25153                value: Some(OsString::from("must-remain-rejected")),
25154            }],
25155        )
25156        .unwrap();
25157        assert_eq!(
25158            launch_control
25159                .install_native_launch_environment_overlay(
25160                    AgentInstanceId(72),
25161                    generic_overlay,
25162                )
25163                .unwrap_err(),
25164            gate4agent_runtime_native::NativeLaunchProfileError::EnvironmentOverlaySelectionMissing,
25165        );
25166
25167        let diagnostic_address = SessionAddress {
25168            workspace_id: workspace_id.clone(),
25169            session: SessionKey {
25170                instance_id: AgentInstanceId(73),
25171                generation: SessionGeneration(1),
25172            },
25173        };
25174        let (diagnostic_overlay, diagnostic_materialization) = match shared
25175            .prepare_session_materialization(
25176                &diagnostic_address,
25177                &agent("kimi"),
25178                SessionMode::Pty,
25179                None,
25180                None,
25181                Some(&resolved_context),
25182                None,
25183            )
25184        {
25185            Ok(Some(prepared)) => prepared,
25186            Ok(None) => panic!("Kimi context-only materialization produced no launch overlay"),
25187            Err(error) => panic!(
25188                "Kimi context-only materialization rejected: exact_code={:?} message={}",
25189                error.code,
25190                error.message,
25191            ),
25192        };
25193        let diagnostic_overlay = diagnostic_overlay
25194            .expect("Kimi context-only materialization omitted its environment overlay");
25195        assert!(matches!(
25196            &diagnostic_overlay,
25197            PreparedNativeLaunchOverlay::Environment(_),
25198        ));
25199        match shared.install_prepared_launch_overlay(
25200            diagnostic_address.session.instance_id,
25201            diagnostic_overlay,
25202        ) {
25203            Ok(None) => {}
25204            Ok(Some(_)) => panic!("Kimi context-only overlay unexpectedly retained argv authority"),
25205            Err(error) => panic!(
25206                "Kimi context-only overlay installation rejected: exact_code={:?} message={}",
25207                error.code,
25208                error.message,
25209            ),
25210        }
25211        assert!(launch_control.clear_native_instance_launch_overlay(
25212            diagnostic_address.session.instance_id,
25213        ));
25214        drop(diagnostic_materialization);
25215        assert!(shared
25216            .materializations
25217            .lock()
25218            .unwrap_or_else(|poisoned| poisoned.into_inner())
25219            .is_empty());
25220
25221        let node_task = tokio::spawn(server.run());
25222        let ready_client = timeout(Duration::from_secs(5), async {
25223            loop {
25224                match gate4agent_node_wire::LocalNodeClient::connect(
25225                    &endpoint,
25226                    &shared.node_id,
25227                    ClientRole::Observer,
25228                    "fixture-token",
25229                )
25230                .await
25231                {
25232                    Ok(client) => break client,
25233                    Err(_) if !node_task.is_finished() => sleep(Duration::from_millis(10)).await,
25234                    Err(error) => panic!("Kimi context-only Node stopped before readiness: {error}"),
25235                }
25236            }
25237        })
25238        .await
25239        .expect("Kimi context-only Node did not become ready");
25240        assert_eq!(
25241            ready_client
25242                .hello()
25243                .snapshot
25244                .provider_runtime_statuses
25245                .iter()
25246                .find(|status| status.provider().as_str() == "kimi")
25247                .expect("Kimi runtime status is missing")
25248                .mode(),
25249            crate::protocol::ProviderRuntimeMode::RawPassthrough,
25250        );
25251        drop(ready_client);
25252
25253        let receipt = match shared.spawn_from_spec(spec).await {
25254            Ok(receipt) => receipt,
25255            Err(error) => panic!(
25256                "Kimi context-only SpawnSpec rejected: exact_code={:?} message={}",
25257                error.code,
25258                error.message,
25259            ),
25260        };
25261        assert_eq!(receipt.provider, agent("kimi"));
25262        assert_eq!(receipt.context.as_ref(), Some(&context));
25263        assert!(receipt.context_binding_is_valid());
25264        timeout(Duration::from_secs(5), async {
25265            while !proof_path.is_file() {
25266                sleep(Duration::from_millis(10)).await;
25267            }
25268        })
25269        .await
25270        .expect("Kimi context-only fixture did not write its proof");
25271
25272        let proof = std::fs::read_to_string(&proof_path).unwrap();
25273        let lines = proof.lines().collect::<Vec<_>>();
25274        assert_eq!(lines.len(), 7);
25275        assert_eq!(lines[0], "context-only");
25276        assert!(Path::new(lines[1]).is_absolute());
25277        assert_ne!(
25278            std::fs::canonicalize(Path::new(lines[1])).unwrap(),
25279            std::fs::canonicalize(&workspace_root).unwrap(),
25280        );
25281        assert_eq!(
25282            std::fs::canonicalize(Path::new(lines[2])).unwrap(),
25283            std::fs::canonicalize(&workspace_root).unwrap(),
25284        );
25285        assert_eq!(lines[3], expected_context_hash);
25286        assert_eq!(lines[4], "g4a-context-pack-v1");
25287        assert_eq!(lines[5], "codex");
25288        assert_eq!(lines[6], "2");
25289        assert!(shared.handle.snapshot().sessions.iter().any(|session| {
25290            session.instance_id == receipt.session.session.instance_id
25291                && session.agent_id.as_str() == "kimi"
25292        }));
25293
25294        shutdown.request_shutdown().await.unwrap();
25295        timeout(Duration::from_secs(5), node_task)
25296            .await
25297            .unwrap()
25298            .unwrap()
25299            .unwrap();
25300        drop(shutdown);
25301        drop(shared);
25302        std::fs::remove_dir_all(root).unwrap();
25303    }
25304
25305    #[cfg(feature = "fixture")]
25306    #[test]
25307    fn clean_exit_fixture_constructor_is_fixture_scoped_and_path_bounded() {
25308        let root = temporary_workspace_root("clean-exit-constructor");
25309        std::fs::create_dir_all(&root).unwrap();
25310        let config = construction_test_config("clean-exit-constructor")
25311            .with_state_path(root.join("node-state.json"))
25312            .unwrap();
25313        let server = NodeServer::new_clean_exit_fixture(
25314            config,
25315            root.clone(),
25316            root.join("started.marker"),
25317            root.join("release.signal"),
25318        )
25319        .unwrap();
25320        assert_eq!(
25321            server
25322                .shared
25323                .enabled_providers
25324                .iter()
25325                .map(AgentId::as_str)
25326                .collect::<Vec<_>>(),
25327            ["claude"],
25328        );
25329        drop(server);
25330        std::fs::remove_dir_all(root).unwrap();
25331    }
25332
25333    #[test]
25334    fn production_registry_provider_contract_manifest_is_exact() {
25335        let registry = active_registry().unwrap();
25336        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
25337        assert_eq!(
25338            providers
25339                .iter()
25340                .map(|contract| (contract.provider.as_str(), contract.revision.as_str()))
25341                .collect::<Vec<_>>(),
25342            vec![
25343                ("claude", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25344                ("codex", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25345                ("grok", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25346                ("kimi", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25347            ]
25348        );
25349        assert_eq!(
25350            adapters
25351                .iter()
25352                .map(|contract| (
25353                    contract.provider.as_str(),
25354                    contract.family,
25355                    contract.adapter_id.as_str(),
25356                    contract.revision.as_str(),
25357                ))
25358                .collect::<Vec<_>>(),
25359            vec![
25360                ("claude", AdapterFamily::PtySemantic, "claude-code", "gate4agent-adapter/v1"),
25361                ("claude", AdapterFamily::Pipe, "claude-code", "gate4agent-adapter/v1"),
25362                ("claude", AdapterFamily::Acp, "claude-code", "gate4agent-adapter/v1"),
25363                ("claude", AdapterFamily::OneShot, "claude", "gate4agent-inline/claude-code-2.1/v1"),
25364                ("claude", AdapterFamily::History, "claude-code", "gate4agent-adapter/v1"),
25365                ("claude", AdapterFamily::Resume, "claude-code", "gate4agent-adapter/v1"),
25366                ("claude", AdapterFamily::SessionOptions, "claude-code", "gate4agent-session-options/orca-d8629c4/v1"),
25367                ("codex", AdapterFamily::PtySemantic, "codex", "gate4agent-adapter/v1"),
25368                ("codex", AdapterFamily::Pipe, "codex", "gate4agent-adapter/v1"),
25369                ("codex", AdapterFamily::Acp, "codex", "gate4agent-adapter/v1"),
25370                ("codex", AdapterFamily::OneShot, "codex", "gate4agent-inline/codex-cli-0.144/v1"),
25371                ("codex", AdapterFamily::History, "codex", "gate4agent-adapter/v1"),
25372                ("codex", AdapterFamily::Resume, "codex", "gate4agent-adapter/v1"),
25373                ("codex", AdapterFamily::SessionOptions, "codex", "gate4agent-session-options/orca-d8629c4/v1"),
25374                ("grok", AdapterFamily::Acp, "grok", "gate4agent-adapter/v1"),
25375                ("grok", AdapterFamily::History, "grok", "gate4agent-adapter/v1"),
25376                ("grok", AdapterFamily::Resume, "grok", "gate4agent-adapter/v1"),
25377                ("kimi", AdapterFamily::PtySemantic, "kimi", "gate4agent-adapter/v1"),
25378                ("kimi", AdapterFamily::Pipe, "kimi", "gate4agent-adapter/v1"),
25379                ("kimi", AdapterFamily::Acp, "kimi", "gate4agent-adapter/v1"),
25380                ("kimi", AdapterFamily::OneShot, "kimi", "gate4agent-inline/kimi-code-0.31/v1"),
25381                ("kimi", AdapterFamily::History, "kimi", "gate4agent-adapter/v1"),
25382                ("kimi", AdapterFamily::Resume, "kimi", "gate4agent-adapter/v1"),
25383            ]
25384        );
25385    }
25386
25387    fn construction_test_config(name: &str) -> NodeServerConfig {
25388        let workspace = WorkspaceConfig::new(
25389            WorkspaceId::new("primary").unwrap(),
25390            std::env::current_dir().unwrap(),
25391        )
25392        .unwrap();
25393        NodeServerConfig::new(
25394            format!(r"\\.\pipe\gate4agent-{name}"),
25395            "fixture-token",
25396            NodeId::new(name).unwrap(),
25397            [workspace],
25398        )
25399        .unwrap()
25400    }
25401
25402    fn maximum_size_provider_manifest_registry() -> AgentRegistry {
25403        let adapter = AdapterBinding::new(
25404            gate4agent_types::AdapterId::new("a".repeat(96)).unwrap(),
25405            "r".repeat(crate::protocol::MAX_ADAPTER_CONTRACT_REVISION_BYTES),
25406            gate4agent_types::AdapterVerification::SyntheticFixture,
25407        )
25408        .unwrap();
25409        let agent_ids = ["claude", "codex", "kimi"]
25410            .into_iter()
25411            .map(|agent_id| AgentId::new(agent_id).unwrap())
25412            .collect::<Vec<_>>();
25413        let adapters = gate4agent_catalog::AdapterRegistry::new(
25414            [
25415                AdapterFamily::PtySemantic,
25416                AdapterFamily::Pipe,
25417                AdapterFamily::OneShot,
25418                AdapterFamily::Acp,
25419                AdapterFamily::Hook,
25420                AdapterFamily::ManagedHook,
25421                AdapterFamily::History,
25422                AdapterFamily::Resume,
25423                AdapterFamily::SessionOptions,
25424                AdapterFamily::CapabilityProbe,
25425            ]
25426            .into_iter()
25427            .map(|family| gate4agent_catalog::AdapterDescriptor {
25428                family,
25429                binding: adapter.clone(),
25430                agents: agent_ids.clone(),
25431            }),
25432        )
25433        .unwrap();
25434        let specs = agent_ids
25435            .iter()
25436            .map(|agent_id| {
25437                let agent_id = agent_id.as_str();
25438                let mut spec = builtin_registry().get_by_id(agent_id).unwrap().clone();
25439                spec.revision =
25440                    "r".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES);
25441                spec.capabilities.transports.pty_adapter = Some(adapter.clone());
25442                spec.capabilities.transports.pipe = Some(gate4agent_types::PipeTransportSpec {
25443                    adapter: adapter.clone(),
25444                    protocol: gate4agent_types::PipeProtocol::SemanticNdjson,
25445                    launch_override: None,
25446                    prompt_delivery: gate4agent_types::PipePromptDelivery::None,
25447                });
25448                spec.capabilities.transports.acp = Some(gate4agent_types::AcpTransportSpec {
25449                    adapter: adapter.clone(),
25450                    launch_override: None,
25451                });
25452                spec.capabilities.adapters.hook = Some(adapter.clone());
25453                spec.capabilities.adapters.managed_hook = Some(adapter.clone());
25454                spec.capabilities.adapters.one_shot = Some(adapter.clone());
25455                spec.capabilities.adapters.history = Some(adapter.clone());
25456                spec.capabilities.adapters.resume = Some(adapter.clone());
25457                spec.capabilities.adapters.session_options = Some(adapter.clone());
25458                spec.capabilities.adapters.capability_probe = Some(adapter.clone());
25459                spec
25460            })
25461            .collect::<Vec<_>>();
25462        AgentRegistry::new_with_adapters(specs, &adapters).unwrap()
25463    }
25464
25465    /// The real fleet's manifest is accepted by a real server construction.
25466    ///
25467    /// The count is asserted against `active_registry` itself rather than a
25468    /// literal, so this stays a test that construction succeeds for whatever
25469    /// the fleet is -- which is the property worth guarding -- instead of a
25470    /// second place the fleet size is written down and has to be kept in
25471    /// step with the first.
25472    #[test]
25473    fn node_server_construction_accepts_the_production_manifest() {
25474        let registry = active_registry().unwrap();
25475        let (providers, _) = provider_contract_manifest(&registry).unwrap();
25476        assert_eq!(providers.len(), registry.iter().count());
25477        assert!(!providers.is_empty());
25478        let server = NodeServer::new_with_registry(
25479            construction_test_config("manifest-production-bound"),
25480            registry,
25481        )
25482        .unwrap();
25483        drop(server);
25484    }
25485
25486    #[test]
25487    fn node_server_construction_rejects_count_valid_manifest_over_handshake_capacity() {
25488        let registry = maximum_size_provider_manifest_registry();
25489        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
25490        assert_eq!(providers.len(), 3);
25491        assert_eq!(adapters.len(), 30);
25492        assert!(adapters.len() <= crate::protocol::MAX_PROVIDER_ADAPTER_CONTRACTS);
25493        let error = match NodeServer::new_with_registry(
25494            construction_test_config("manifest-handshake-overflow"),
25495            registry,
25496        ) {
25497            Ok(_) => panic!("oversized negotiated manifest was accepted"),
25498            Err(error) => error,
25499        };
25500        assert!(matches!(
25501            error,
25502            NodeServerError::ProviderContractManifest(message)
25503                if message.contains("authentication binding length")
25504                    && message.contains("8192-byte limit")
25505        ));
25506    }
25507
25508    #[test]
25509    fn provider_contract_manifest_rejects_wire_revision_overflow_before_startup() {
25510        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
25511        spec.revision = "x".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES + 1);
25512        let registry = AgentRegistry::new([spec]).unwrap();
25513        assert!(matches!(
25514            provider_contract_manifest(&registry),
25515            Err(NodeServerError::ProviderContractManifest(message))
25516                if message.contains("exceeds the 128-byte limit")
25517        ));
25518    }
25519
25520    #[test]
25521    fn provider_contract_manifest_rejects_invalid_wire_revision_before_startup() {
25522        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
25523        spec.revision = "invalid revision".to_owned();
25524        let registry = AgentRegistry::new([spec]).unwrap();
25525        assert!(matches!(
25526            provider_contract_manifest(&registry),
25527            Err(NodeServerError::ProviderContractManifest(message))
25528                if message.contains("bounded lowercase ASCII")
25529        ));
25530    }
25531
25532    #[test]
25533    fn provider_contract_manifest_rejects_conflicting_duplicate_family_before_startup() {
25534        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
25535        let transport_binding = spec.capabilities.adapters.one_shot.clone().unwrap();
25536        let conflicting_binding = builtin_registry()
25537            .get_by_id("codex")
25538            .unwrap()
25539            .capabilities
25540            .adapters
25541            .one_shot
25542            .clone()
25543            .unwrap();
25544        let pipe = spec.capabilities.transports.pipe.as_mut().unwrap();
25545        pipe.protocol = gate4agent_types::PipeProtocol::OneShotText;
25546        pipe.adapter = transport_binding;
25547        spec.capabilities.adapters.one_shot = Some(conflicting_binding);
25548        let registry = AgentRegistry::new([spec]).unwrap();
25549        assert!(matches!(
25550            provider_contract_manifest(&registry),
25551            Err(NodeServerError::ProviderContractManifest(message))
25552                if message.contains("conflicting OneShot adapter bindings")
25553        ));
25554    }
25555
25556    #[test]
25557    fn public_node_failures_expose_only_fixed_categories() {
25558        let provider = failure(
25559            NodeFailureCode::BackendOperationFailed,
25560            r"provider bearer-secret failed at C:\private\session.jsonl",
25561        );
25562        assert_eq!(provider.message, "backend-operation-failed");
25563        assert!(!provider.message.contains("bearer-secret"));
25564        assert!(!provider.message.contains(r"C:\private"));
25565        let persistence = persistence_failure(io::Error::new(
25566            io::ErrorKind::Other,
25567            r"state write failed at C:\private\state-v1.json",
25568        ));
25569        assert_eq!(persistence.message, DURABLE_STATE_COMMIT_FAILED_ERROR);
25570        assert!(!persistence.message.contains(r"C:\private"));
25571
25572        for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::Unsupported] {
25573            let generic = durable_state_load_error(io::Error::new(kind, "host I/O failure"));
25574            assert_eq!(
25575                generic.to_string(),
25576                "node durable state failed: durable-state-load-failed",
25577            );
25578        }
25579    }
25580
25581    #[test]
25582    fn terminal_byte_sequences_are_bounded_before_dispatch() {
25583        let sequence = b"\x1b[1;5D".to_vec();
25584        assert_eq!(
25585            terminal_bytes_action(sequence.clone()).unwrap(),
25586            InputAction::TerminalBytes(sequence),
25587        );
25588        assert_eq!(
25589            terminal_bytes_action(Vec::new()).unwrap_err().code,
25590            NodeFailureCode::InvalidRequest,
25591        );
25592        assert_eq!(
25593            terminal_bytes_action(vec![0; MAX_NODE_TERMINAL_BYTES + 1])
25594                .unwrap_err()
25595                .code,
25596            NodeFailureCode::InvalidRequest,
25597        );
25598    }
25599
25600    #[test]
25601    fn node_server_rejects_a_second_owner_of_the_same_durable_state_path() {
25602        let root = temporary_workspace_root("exclusive-state-owner");
25603        std::fs::create_dir_all(&root).unwrap();
25604        let workspace = WorkspaceConfig::new(
25605            WorkspaceId::new("primary").unwrap(),
25606            &root,
25607        )
25608        .unwrap();
25609        let state_path = root.join("state-v1.json");
25610        let endpoint = format!(
25611            r"\\.\pipe\gate4agent-node-state-lock-{}-{}",
25612            std::process::id(),
25613            std::time::SystemTime::now()
25614                .duration_since(std::time::UNIX_EPOCH)
25615                .unwrap()
25616                .as_nanos(),
25617        );
25618        let config = NodeServerConfig::new(
25619            endpoint,
25620            "fixture-token",
25621            NodeId::new("node-1").unwrap(),
25622            [workspace],
25623        )
25624        .unwrap()
25625        .with_state_path(&state_path)
25626        .unwrap();
25627
25628        let first = NodeServer::new(config.clone()).unwrap();
25629        let error = match NodeServer::new(config.clone()) {
25630            Ok(_) => panic!("a second node server acquired the same durable state path"),
25631            Err(error) => error,
25632        };
25633        assert!(matches!(
25634            &error,
25635            NodeServerError::DurableState(error)
25636                if error.kind() == io::ErrorKind::WouldBlock
25637        ));
25638        assert_eq!(
25639            error.to_string(),
25640            "node durable state failed: durable-state-lock-failed",
25641        );
25642        assert!(!error.to_string().contains(&state_path.to_string_lossy().into_owned()));
25643        drop(first);
25644        let second = NodeServer::new(config).unwrap();
25645        drop(second);
25646        std::fs::remove_dir_all(root).unwrap();
25647    }
25648
25649    #[test]
25650    fn session_binding_is_workspace_scoped_and_removed_fail_closed() {
25651        let catalog = active_registry().unwrap();
25652        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25653        let workspace_id = WorkspaceId::new("primary").unwrap();
25654        let workspace = WorkspaceConfig::new(
25655            workspace_id.clone(),
25656            std::env::current_dir().unwrap(),
25657        )
25658        .unwrap();
25659        let shared = NodeShared::new(
25660            handle,
25661            "fixture-token".to_owned(),
25662            NodeId::new("node-1").unwrap(),
25663            vec![workspace.clone()],
25664            vec![agent("claude"), agent("codex"), agent("kimi")],
25665        );
25666        let instance_id = AgentInstanceId(17);
25667        let generation = SessionGeneration(3);
25668        assert!(shared.address_for(instance_id, generation).is_none());
25669        shared.bind_session(instance_id, workspace_id.clone(), generation);
25670        let address = SessionAddress {
25671            workspace_id,
25672            session: SessionKey { instance_id, generation },
25673        };
25674        assert_eq!(
25675            shared.workspace_root(&address.workspace_id).unwrap(),
25676            workspace.canonical_root,
25677        );
25678        assert_eq!(shared.address_for(instance_id, generation), Some(address.clone()));
25679        shared.remove_binding(&address);
25680        assert!(shared.address_for(instance_id, generation).is_none());
25681    }
25682
25683    #[test]
25684    fn open_provider_request_gate_is_state_aware_and_fail_closed() {
25685        let address = SessionAddress {
25686            workspace_id: WorkspaceId::new("primary").unwrap(),
25687            session: SessionKey {
25688                instance_id: AgentInstanceId(7),
25689                generation: SessionGeneration(2),
25690            },
25691        };
25692        let record_id = SessionRecordId::new("record-a").unwrap();
25693        let size = gate4agent_types::TerminalSize {
25694            rows: 24,
25695            columns: 80,
25696        };
25697        let address_requests = [
25698            NodeRequest::Resume {
25699                session: address.clone(),
25700                terminal_size: size,
25701                initial_prompt: None,
25702            },
25703            NodeRequest::Prompt { session: address.clone(), text: "p".to_owned() },
25704            NodeRequest::Paste { session: address.clone(), text: "p".to_owned() },
25705            NodeRequest::Input { session: address.clone(), text: "p".to_owned() },
25706            NodeRequest::TerminalBytes { session: address.clone(), bytes: vec![1] },
25707            NodeRequest::TerminalControl {
25708                session: address.clone(),
25709                control: TerminalControl::Enter,
25710            },
25711            NodeRequest::Resize { session: address.clone(), size },
25712            NodeRequest::Interrupt { session: address.clone() },
25713            NodeRequest::Stop { session: address.clone(), force: false },
25714            NodeRequest::Remove { session: address },
25715        ];
25716        for request in &address_requests {
25717            assert!(request_requires_open_provider_ids_with(
25718                request,
25719                |_| Some(agent("third-party-agent")),
25720                |_| Some(agent("claude")),
25721            ));
25722            assert!(!request_requires_open_provider_ids_with(
25723                request,
25724                |_| Some(agent("claude")),
25725                |_| Some(agent("claude")),
25726            ));
25727            assert!(request_requires_open_provider_ids_with(
25728                request,
25729                |_| None,
25730                |_| Some(agent("claude")),
25731            ));
25732        }
25733
25734        let record_requests = [
25735            NodeRequest::RenameSessionRecord {
25736                record_id: record_id.clone(),
25737                display_name: "renamed".to_owned(),
25738            },
25739            NodeRequest::ResumeSessionRecord {
25740                record_id: record_id.clone(),
25741                terminal_size: size,
25742                initial_prompt: None,
25743            },
25744            NodeRequest::ForgetSessionRecord { record_id },
25745        ];
25746        for request in &record_requests {
25747            assert!(request_requires_open_provider_ids_with(
25748                request,
25749                |_| Some(agent("claude")),
25750                |_| Some(agent("grok")),
25751            ));
25752            assert!(!request_requires_open_provider_ids_with(
25753                request,
25754                |_| Some(agent("claude")),
25755                |_| Some(agent("kimi")),
25756            ));
25757            assert!(request_requires_open_provider_ids_with(
25758                request,
25759                |_| Some(agent("claude")),
25760                |_| None,
25761            ));
25762        }
25763
25764        assert!(request_requires_open_provider_ids_with(
25765            &NodeRequest::Spawn {
25766                workspace_id: WorkspaceId::new("primary").unwrap(),
25767                provider: agent("third-party-agent"),
25768                mode: SessionMode::Pty,
25769                terminal_size: size,
25770                initial_prompt: None,
25771            },
25772            |_| None,
25773            |_| None,
25774        ));
25775        assert!(!request_requires_open_provider_ids_with(
25776            &NodeRequest::Snapshot,
25777            |_| None,
25778            |_| None,
25779        ));
25780        assert!(request_requires_open_provider_ids_with(
25781            &NodeRequest::ForgetContextPack {
25782                context_id: SpawnContextId::new("ctx-forget").unwrap(),
25783            },
25784            |_| None,
25785            |_| None,
25786        ));
25787    }
25788
25789    #[test]
25790    fn legacy_projection_filters_open_record_replies_and_event_history() {
25791        let catalog = active_registry().unwrap();
25792        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25793        let workspace = WorkspaceConfig::new(
25794            WorkspaceId::new("primary").unwrap(),
25795            std::env::current_dir().unwrap(),
25796        )
25797        .unwrap();
25798        let shared = NodeShared::new(
25799            handle,
25800            "fixture-token".to_owned(),
25801            NodeId::new("node-1").unwrap(),
25802            vec![workspace],
25803            vec![agent("claude"), agent("grok")],
25804        );
25805        let legacy = record("claude", "legacy-record");
25806        let open = record("grok", "open-record");
25807        let legacy_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
25808            record: legacy.clone(),
25809        });
25810        let open_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
25811            record: open.clone(),
25812        });
25813        let legacy_removed = shared.publish(NodeEvent::SessionRecordRemoved {
25814            record_id: legacy.record_id.clone(),
25815        });
25816        let open_removed = shared.publish(NodeEvent::SessionRecordRemoved {
25817            record_id: open.record_id.clone(),
25818        });
25819        assert!(project_event_legacy_provider_ids(&shared, legacy_upsert.clone()).is_some());
25820        assert!(project_event_legacy_provider_ids(&shared, open_upsert.clone()).is_none());
25821        assert!(project_event_legacy_provider_ids(&shared, legacy_removed.clone()).is_some());
25822        assert!(project_event_legacy_provider_ids(&shared, open_removed.clone()).is_none());
25823
25824        let mut record_reply = ResponseEnvelope {
25825            request_id: 1,
25826            result: Ok(NodeResponse::SessionRecordUpdated { record: open }),
25827        };
25828        project_response_legacy_provider_ids(&shared, &mut record_reply);
25829        assert!(matches!(
25830            record_reply.result,
25831            Err(NodeFailure { code: NodeFailureCode::UnsupportedCapability, .. })
25832        ));
25833
25834        let mut resync = ResponseEnvelope {
25835            request_id: 2,
25836            result: Ok(NodeResponse::Resync {
25837                event_sequence: open_removed.sequence,
25838                oldest_available_sequence: 1,
25839                snapshot: shared.snapshot(),
25840                events: vec![legacy_upsert, open_upsert, legacy_removed, open_removed],
25841            }),
25842        };
25843        project_response_legacy_provider_ids(&shared, &mut resync);
25844        let Ok(NodeResponse::Resync { snapshot, events, .. }) = resync.result else {
25845            panic!("legacy projection changed the resync response kind");
25846        };
25847        assert!(snapshot.session_records.iter().all(|item| {
25848            provider_id_is_legacy(&item.provider)
25849        }));
25850        assert_eq!(events.len(), 2);
25851        assert!(events.iter().all(|event| matches!(
25852            &event.event,
25853            NodeEvent::SessionRecordUpserted { record }
25854                if provider_id_is_legacy(&record.provider)
25855        ) || matches!(&event.event, NodeEvent::SessionRecordRemoved { .. })));
25856    }
25857
25858    #[test]
25859    fn session_binding_capacity_is_bounded_by_the_control_session_limit() {
25860        let catalog = active_registry().unwrap();
25861        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25862        let workspace_id = WorkspaceId::new("primary").unwrap();
25863        let workspace = WorkspaceConfig::new(
25864            workspace_id.clone(),
25865            std::env::current_dir().unwrap(),
25866        )
25867        .unwrap();
25868        let shared = NodeShared::new(
25869            handle,
25870            "fixture-token".to_owned(),
25871            NodeId::new("node-1").unwrap(),
25872            vec![workspace],
25873            vec![agent("claude")],
25874        );
25875        for index in 0..CONTROL_SESSIONS_MAX {
25876            shared.bind_session(
25877                AgentInstanceId((index + 1) as u64),
25878                workspace_id.clone(),
25879                SessionGeneration(1),
25880            );
25881        }
25882        let error = shared.ensure_binding_capacity().unwrap_err();
25883        assert_eq!(error.code, NodeFailureCode::BackendBusy);
25884    }
25885
25886    #[tokio::test]
25887    async fn provider_spawn_admission_rejects_before_session_mutation() {
25888        let catalog = active_registry().unwrap();
25889        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25890        let workspace = WorkspaceConfig::new(
25891            WorkspaceId::new("primary").unwrap(),
25892            std::env::current_dir().unwrap(),
25893        )
25894        .unwrap();
25895        let mut shared = NodeShared::new(
25896            handle,
25897            "fixture-token".to_owned(),
25898            NodeId::new("node-provider-admission").unwrap(),
25899            vec![workspace],
25900            vec![agent("claude")],
25901        );
25902        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25903            crate::protocol::ProviderRuntimeStatus::unavailable(agent("claude")),
25904        ])
25905        .unwrap();
25906        let terminal_size = gate4agent_types::TerminalSize {
25907            rows: 24,
25908            columns: 80,
25909        };
25910
25911        let unavailable = shared
25912            .spawn_session(
25913                WorkspaceId::new("primary").unwrap(),
25914                agent("claude"),
25915                SessionMode::Pty,
25916                terminal_size,
25917                None,
25918            )
25919            .await
25920            .unwrap_err();
25921        assert_eq!(unavailable.code, NodeFailureCode::BackendOperationFailed);
25922        assert_eq!(unavailable.message, "backend-operation-failed");
25923
25924        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25925            crate::protocol::ProviderRuntimeStatus::raw_passthrough(
25926                agent("claude"),
25927                Some(crate::protocol::ProviderRuntimeVersion::new("999.0.0").unwrap()),
25928            ),
25929        ])
25930        .unwrap();
25931        let semantic = shared
25932            .spawn_session(
25933                WorkspaceId::new("primary").unwrap(),
25934                agent("claude"),
25935                SessionMode::Inline,
25936                terminal_size,
25937                None,
25938            )
25939            .await
25940            .unwrap_err();
25941        assert_eq!(semantic.code, NodeFailureCode::UnsupportedCapability);
25942        assert_eq!(semantic.message, "unsupported-capability");
25943
25944        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
25945        assert!(shared
25946            .session_bindings
25947            .lock()
25948            .unwrap_or_else(|poisoned| poisoned.into_inner())
25949            .is_empty());
25950        assert!(shared
25951            .session_records
25952            .lock()
25953            .unwrap_or_else(|poisoned| poisoned.into_inner())
25954            .records
25955            .is_empty());
25956        assert!(shared.handle.snapshot().sessions.is_empty());
25957    }
25958
25959    #[tokio::test]
25960    async fn raw_spawn_binding_is_visible_with_a_durable_live_record() {
25961        let catalog = active_registry().unwrap();
25962        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25963        let workspace_id = WorkspaceId::new("primary").unwrap();
25964        let workspace = WorkspaceConfig::new(
25965            workspace_id.clone(),
25966            std::env::current_dir().unwrap(),
25967        )
25968        .unwrap();
25969        let shared = NodeShared::new(
25970            handle,
25971            "fixture-token".to_owned(),
25972            NodeId::new("node-raw-spawn-record").unwrap(),
25973            vec![workspace],
25974            vec![agent("claude")],
25975        );
25976        let address = SessionAddress {
25977            workspace_id,
25978            session: SessionKey {
25979                instance_id: AgentInstanceId(71),
25980                generation: SessionGeneration::default(),
25981            },
25982        };
25983
25984        let record_id = shared
25985            .bind_spawn_session(
25986                &address,
25987                agent("claude"),
25988                SessionMode::Pty,
25989                ProviderRuntimePolicy::raw_pty(),
25990                SpawnRecordPolicy::Always,
25991                None,
25992            )
25993            .unwrap()
25994            .unwrap();
25995        shared
25996            .handle
25997            .dispatch(shared.prepare_command(ControlCommand::Register {
25998                instance_id: address.session.instance_id,
25999                agent_id: agent("claude"),
26000                transport: TransportKind::Pty,
26001            }))
26002            .unwrap();
26003        runtime.tick().await;
26004
26005        assert!(shared.handle.snapshot().sessions.iter().any(|session| {
26006            session.instance_id == address.session.instance_id
26007                && session.generation == address.session.generation
26008        }));
26009        let record = shared.record(&record_id).unwrap();
26010        assert_eq!(record.display_name, "claude #1");
26011        assert_eq!(record.state, ManagedSessionState::Live);
26012        assert_eq!(record.active_session.as_ref(), Some(&address));
26013        assert!(record.provider_session.is_none());
26014        let binding_record_id = shared
26015            .session_bindings
26016            .lock()
26017            .unwrap_or_else(|poisoned| poisoned.into_inner())
26018            .get(&address.session.instance_id)
26019            .and_then(|binding| binding.record_id.clone());
26020        assert_eq!(binding_record_id.as_ref(), Some(&record_id));
26021
26022        let renamed = shared
26023            .rename_session_record(&record_id, "raw review".to_owned())
26024            .unwrap();
26025        assert_eq!(renamed.record_id, record_id);
26026        assert_eq!(renamed.display_name, "raw review");
26027        shared.reconcile_managed_record(
26028            &record_id,
26029            &address,
26030            &ControlEvent {
26031                sequence: 1,
26032                command_id: None,
26033                instance_id: address.session.instance_id,
26034                generation: address.session.generation,
26035                event: ControlEventKind::Running { process_id: None },
26036            },
26037            false,
26038            false,
26039        );
26040        let running = shared.record(&record_id).unwrap();
26041        assert_eq!(running.state, ManagedSessionState::Live);
26042        assert_eq!(running.active_session.as_ref(), Some(&address));
26043        assert_eq!(running.display_name, "raw review");
26044
26045        shared.reconcile_managed_record(
26046            &record_id,
26047            &address,
26048            &ControlEvent {
26049                sequence: 2,
26050                command_id: None,
26051                instance_id: address.session.instance_id,
26052                generation: address.session.generation,
26053                event: ControlEventKind::Exited {
26054                    exit_code: Some(0),
26055                    forced: false,
26056                },
26057            },
26058            false,
26059            false,
26060        );
26061        let detached = shared.record(&record_id).unwrap();
26062        assert_eq!(detached.state, ManagedSessionState::Unavailable);
26063        assert!(detached.active_session.is_none());
26064        assert_eq!(detached.display_name, "raw review");
26065        let renamed_detached = shared
26066            .rename_session_record(&record_id, "raw review detached".to_owned())
26067            .unwrap();
26068        assert_eq!(renamed_detached.record_id, record_id);
26069        assert_eq!(renamed_detached.state, ManagedSessionState::Unavailable);
26070        assert!(renamed_detached.active_session.is_none());
26071        assert_eq!(renamed_detached.display_name, "raw review detached");
26072        assert_eq!(shared.snapshot().session_records, vec![renamed_detached]);
26073    }
26074
26075    #[test]
26076    fn legacy_raw_spawn_binding_remains_untracked() {
26077        let catalog = active_registry().unwrap();
26078        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26079        let workspace_id = WorkspaceId::new("primary").unwrap();
26080        let workspace = WorkspaceConfig::new(
26081            workspace_id.clone(),
26082            std::env::current_dir().unwrap(),
26083        )
26084        .unwrap();
26085        let shared = NodeShared::new(
26086            handle,
26087            "fixture-token".to_owned(),
26088            NodeId::new("node-legacy-raw-spawn").unwrap(),
26089            vec![workspace],
26090            vec![agent("claude")],
26091        );
26092        let address = SessionAddress {
26093            workspace_id,
26094            session: SessionKey {
26095                instance_id: AgentInstanceId(72),
26096                generation: SessionGeneration::default(),
26097            },
26098        };
26099
26100        let record_id = shared
26101            .bind_spawn_session(
26102                &address,
26103                agent("claude"),
26104                SessionMode::Pty,
26105                ProviderRuntimePolicy::raw_pty(),
26106                SpawnRecordPolicy::ProviderIdentityOnly,
26107                None,
26108            )
26109            .unwrap();
26110
26111        assert!(record_id.is_none());
26112        let binding_record_id = shared
26113            .session_bindings
26114            .lock()
26115            .unwrap_or_else(|poisoned| poisoned.into_inner())
26116            .get(&address.session.instance_id)
26117            .and_then(|binding| binding.record_id.clone());
26118        assert!(binding_record_id.is_none());
26119        assert!(shared.snapshot().session_records.is_empty());
26120    }
26121
26122    #[tokio::test]
26123    async fn raw_runtime_policy_rejects_prompt_paste_and_resume_before_mutation() {
26124        let catalog = active_registry().unwrap();
26125        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26126        let workspace_id = WorkspaceId::new("primary").unwrap();
26127        let workspace = WorkspaceConfig::new(
26128            workspace_id.clone(),
26129            std::env::current_dir().unwrap(),
26130        )
26131        .unwrap();
26132        let mut shared = NodeShared::new(
26133            handle,
26134            "fixture-token".to_owned(),
26135            NodeId::new("node-raw-semantic-gate").unwrap(),
26136            vec![workspace],
26137            vec![agent("claude")],
26138        );
26139        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
26140            crate::protocol::ProviderRuntimeStatus::raw_passthrough(agent("claude"), None),
26141        ])
26142        .unwrap();
26143        shared
26144            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
26145            .unwrap();
26146        let address = SessionAddress {
26147            workspace_id,
26148            session: SessionKey {
26149                instance_id: AgentInstanceId(72),
26150                generation: SessionGeneration::default(),
26151            },
26152        };
26153        shared.bind_session_with_policy(
26154            &address,
26155            ProviderRuntimePolicy::raw_pty(),
26156            None,
26157        );
26158        shared
26159            .handle
26160            .dispatch(shared.prepare_command(ControlCommand::Register {
26161                instance_id: address.session.instance_id,
26162                agent_id: agent("claude"),
26163                transport: TransportKind::Pty,
26164            }))
26165            .unwrap();
26166        runtime.tick().await;
26167        let command_id_before = shared.next_command_id.load(Ordering::Acquire);
26168        let revision_before = shared.handle.snapshot().revision;
26169        let history_sequence_before = shared.current_sequence();
26170        let terminal_size = gate4agent_types::TerminalSize {
26171            rows: 24,
26172            columns: 80,
26173        };
26174
26175        for request in [
26176            NodeRequest::Prompt {
26177                session: address.clone(),
26178                text: "prompt".to_owned(),
26179            },
26180            NodeRequest::Paste {
26181                session: address.clone(),
26182                text: "paste".to_owned(),
26183            },
26184            // `initial_prompt: Some(_)` selects `ResumeWithPrompt`, which needs
26185            // the same semantic capabilities as `Prompt`/`Paste`. A bare
26186            // (promptless) resume is a raw PTY relaunch and IS admitted under
26187            // `raw_pty()` — see `raw_pty_admits_provider_native_resume_without_prompt_only`
26188            // in `provider_runtime.rs`; it is deliberately not exercised here.
26189            NodeRequest::Resume {
26190                session: address.clone(),
26191                terminal_size,
26192                initial_prompt: Some("resume".to_owned()),
26193            },
26194        ] {
26195            let error = process_request_inner(
26196                &shared,
26197                77,
26198                ClientRole::Operator,
26199                request,
26200            )
26201            .await
26202            .unwrap_err();
26203            assert_eq!(error.code, NodeFailureCode::UnsupportedCapability);
26204        }
26205
26206        assert_eq!(shared.next_command_id.load(Ordering::Acquire), command_id_before);
26207        assert_eq!(shared.handle.snapshot().revision, revision_before);
26208        assert_eq!(shared.current_sequence(), history_sequence_before);
26209        let bindings = shared
26210            .session_bindings
26211            .lock()
26212            .unwrap_or_else(|poisoned| poisoned.into_inner());
26213        let binding = bindings.get(&address.session.instance_id).unwrap();
26214        assert_eq!(binding.generation, address.session.generation);
26215        assert!(binding.pending_resume.is_none());
26216        assert!(binding.record_id.is_none());
26217    }
26218
26219    #[tokio::test]
26220    async fn windows_rejects_unix_bytes_workspace_path_before_filesystem_access() {
26221        let catalog = active_registry().unwrap();
26222        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26223        let primary = WorkspaceConfig::new(
26224            WorkspaceId::new("primary").unwrap(),
26225            std::env::current_dir().unwrap(),
26226        )
26227        .unwrap();
26228        let shared = NodeShared::new(
26229            handle,
26230            "fixture-token".to_owned(),
26231            NodeId::new("node-1").unwrap(),
26232            vec![primary],
26233            vec![agent("claude")],
26234        );
26235        shared
26236            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
26237            .unwrap();
26238        let before = shared.snapshot();
26239        let error = process_request_inner(
26240            &shared,
26241            77,
26242            ClientRole::Operator,
26243            NodeRequest::RegisterWorkspace {
26244                workspace_id: WorkspaceId::new("foreign").unwrap(),
26245                root: OpaqueHostPath::unix_bytes(b"/srv/repo".to_vec()).unwrap(),
26246            },
26247        )
26248        .await
26249        .unwrap_err();
26250        assert_eq!(error.code, NodeFailureCode::InvalidRequest);
26251        assert_eq!(error.message, "invalid-request");
26252        assert_eq!(shared.snapshot().workspaces, before.workspaces);
26253    }
26254
26255    #[tokio::test]
26256    async fn dynamic_workspaces_reject_duplicates_busy_and_last_then_publish_ordered_events() {
26257        let catalog = active_registry().unwrap();
26258        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26259        let primary_id = WorkspaceId::new("primary").unwrap();
26260        let primary = WorkspaceConfig::new(
26261            primary_id.clone(),
26262            std::env::current_dir().unwrap(),
26263        )
26264        .unwrap();
26265        let shared = NodeShared::new(
26266            handle,
26267            "fixture-token".to_owned(),
26268            NodeId::new("node-1").unwrap(),
26269            vec![primary.clone()],
26270            vec![agent("claude")],
26271        );
26272        let secondary_id = WorkspaceId::new("secondary").unwrap();
26273        let secondary = shared
26274            .register_workspace(
26275                secondary_id.clone(),
26276                std::env::temp_dir().to_string_lossy().into_owned(),
26277            )
26278            .await
26279            .unwrap();
26280        assert_eq!(secondary.workspace_id, secondary_id);
26281        assert!(secondary.sessions.is_empty());
26282        assert_eq!(shared.snapshot().workspaces.len(), 2);
26283
26284        let duplicate_id = shared
26285            .register_workspace(
26286                secondary_id.clone(),
26287                primary.canonical_root().to_owned(),
26288            )
26289            .await
26290            .unwrap_err();
26291        assert_eq!(duplicate_id.code, NodeFailureCode::DuplicateWorkspaceId);
26292        let duplicate_root = shared
26293            .register_workspace(
26294                WorkspaceId::new("duplicate-root").unwrap(),
26295                windows_path_text(&secondary.canonical_root).to_owned(),
26296            )
26297            .await
26298            .unwrap_err();
26299        assert_eq!(duplicate_root.code, NodeFailureCode::DuplicateWorkspaceRoot);
26300
26301        let address = SessionAddress {
26302            workspace_id: secondary_id.clone(),
26303            session: SessionKey {
26304                instance_id: AgentInstanceId(41),
26305                generation: SessionGeneration(1),
26306            },
26307        };
26308        shared.bind_session(
26309            address.session.instance_id,
26310            secondary_id.clone(),
26311            address.session.generation,
26312        );
26313        let busy = shared.unregister_workspace(&secondary_id).unwrap_err();
26314        assert_eq!(busy.code, NodeFailureCode::WorkspaceBusy);
26315        shared.remove_binding(&address);
26316        shared.unregister_workspace(&secondary_id).unwrap();
26317        assert_eq!(shared.snapshot().workspaces.len(), 1);
26318
26319        let last = shared.unregister_workspace(&primary_id).unwrap_err();
26320        assert_eq!(last.code, NodeFailureCode::LastWorkspace);
26321        let history = shared
26322            .history
26323            .lock()
26324            .unwrap_or_else(|poisoned| poisoned.into_inner());
26325        assert!(matches!(
26326            &history.events[0],
26327            NodeEventEnvelope {
26328                sequence: 1,
26329                event: NodeEvent::WorkspaceAdded { workspace },
26330            } if workspace.workspace_id == secondary_id
26331        ));
26332        assert!(matches!(
26333            &history.events[1],
26334            NodeEventEnvelope {
26335                sequence: 2,
26336                event: NodeEvent::WorkspaceRemoved { workspace_id },
26337            } if workspace_id == &secondary_id
26338        ));
26339    }
26340
26341    #[tokio::test]
26342    async fn provider_session_reference_index_is_controller_gated_and_idempotent() {
26343        let secondary_root = temporary_workspace_root("provider-session-reference-index");
26344        std::fs::create_dir_all(&secondary_root).unwrap();
26345        let catalog = active_registry().unwrap();
26346        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26347        let primary_id = WorkspaceId::new("primary").unwrap();
26348        let secondary_id = WorkspaceId::new("secondary").unwrap();
26349        let shared = NodeShared::new(
26350            handle,
26351            "fixture-token".to_owned(),
26352            NodeId::new("node-session-reference-index").unwrap(),
26353            vec![
26354                WorkspaceConfig::new(primary_id.clone(), std::env::current_dir().unwrap())
26355                    .unwrap(),
26356                WorkspaceConfig::new(secondary_id.clone(), &secondary_root).unwrap(),
26357            ],
26358            vec![agent("claude"), agent("codex")],
26359        );
26360        let identity = ProviderSessionIdentity {
26361            key: gate4agent_types::ProviderSessionKey::SessionId,
26362            id: "provider-session-42".to_owned(),
26363            transcript_path: None,
26364        };
26365        let index_request = |workspace_id: WorkspaceId,
26366                             provider: AgentId,
26367                             identity: ProviderSessionIdentity,
26368                             display_name: &str| {
26369            NodeRequest::IndexProviderSession {
26370                workspace_id,
26371                provider,
26372                identity,
26373                display_name: display_name.to_owned(),
26374            }
26375        };
26376        let request = index_request(
26377            primary_id.clone(),
26378            agent("claude"),
26379            identity.clone(),
26380            "release shepherd",
26381        );
26382        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
26383            capability.as_str() == NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY
26384        }));
26385        let observer = process_request_inner(
26386            &shared,
26387            77,
26388            ClientRole::Observer,
26389            request.clone(),
26390        )
26391        .await
26392        .unwrap_err();
26393        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26394        let uncontrolled = process_request_inner(
26395            &shared,
26396            77,
26397            ClientRole::Operator,
26398            request.clone(),
26399        )
26400        .await
26401        .unwrap_err();
26402        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
26403        shared
26404            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
26405            .unwrap();
26406
26407        let indexed = process_request_inner(
26408            &shared,
26409            77,
26410            ClientRole::Operator,
26411            request.clone(),
26412        )
26413        .await
26414        .unwrap();
26415        let NodeResponse::ProviderSessionIndexed { record } = indexed else {
26416            panic!("provider session reference index returned another response");
26417        };
26418        assert_eq!(record.state, ManagedSessionState::Dormant);
26419        assert_eq!(record.mode, SessionMode::Pty);
26420        assert_eq!(record.workspace_id, primary_id);
26421        assert_eq!(record.provider_session.as_ref(), Some(&identity));
26422        assert!(record.active_session.is_none());
26423        assert!(record.environment_profile.is_none());
26424        assert!(record.bundle.is_none());
26425        assert!(record.context.is_none());
26426
26427        let repeated = process_request_inner(
26428            &shared,
26429            77,
26430            ClientRole::Operator,
26431            index_request(
26432                primary_id.clone(),
26433                agent("claude"),
26434                identity.clone(),
26435                "ignored replacement",
26436            ),
26437        )
26438        .await
26439        .unwrap();
26440        assert!(matches!(
26441            repeated,
26442            NodeResponse::ProviderSessionIndexed { record: ref existing }
26443                if existing.record_id == record.record_id
26444                    && existing.display_name == "release shepherd"
26445        ));
26446        let conflicting_workspace = process_request_inner(
26447            &shared,
26448            77,
26449            ClientRole::Operator,
26450            index_request(
26451                secondary_id,
26452                agent("claude"),
26453                identity.clone(),
26454                "release shepherd",
26455            ),
26456        )
26457        .await
26458        .unwrap_err();
26459        assert_eq!(conflicting_workspace.code, NodeFailureCode::SessionRecordConflict);
26460        let conflicting_provider = process_request_inner(
26461            &shared,
26462            77,
26463            ClientRole::Operator,
26464            index_request(
26465                primary_id.clone(),
26466                agent("codex"),
26467                identity.clone(),
26468                "release shepherd",
26469            ),
26470        )
26471        .await
26472        .unwrap_err();
26473        assert_eq!(conflicting_provider.code, NodeFailureCode::SessionRecordConflict);
26474        let transcript_rejected = process_request_inner(
26475            &shared,
26476            77,
26477            ClientRole::Operator,
26478            index_request(
26479                primary_id,
26480                agent("claude"),
26481                ProviderSessionIdentity {
26482                    transcript_path: Some("C:/provider/transcript.jsonl".to_owned()),
26483                    ..identity
26484                },
26485                "release shepherd",
26486            ),
26487        )
26488        .await
26489        .unwrap_err();
26490        assert_eq!(transcript_rejected.code, NodeFailureCode::InvalidRequest);
26491        let upserts = shared
26492            .history
26493            .lock()
26494            .unwrap_or_else(|poisoned| poisoned.into_inner())
26495            .events
26496            .iter()
26497            .filter(|event| matches!(
26498                &event.event,
26499                NodeEvent::SessionRecordUpserted { record: published }
26500                    if published.record_id == record.record_id
26501            ))
26502            .count();
26503        assert_eq!(upserts, 1);
26504        std::fs::remove_dir_all(secondary_root).unwrap();
26505    }
26506
26507    #[tokio::test]
26508    async fn standalone_workspace_is_independent_registered_and_controller_gated() {
26509        let fixture_root = temporary_workspace_root("standalone-independent");
26510        let target = fixture_root.join("independent");
26511        std::fs::create_dir_all(&fixture_root).unwrap();
26512        let catalog = active_registry().unwrap();
26513        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26514        let selected = WorkspaceConfig::new(
26515            WorkspaceId::new("primary").unwrap(),
26516            std::env::current_dir().unwrap(),
26517        )
26518        .unwrap();
26519        let selected_root = selected.canonical_root().to_owned();
26520        let shared = NodeShared::new(
26521            handle,
26522            "fixture-token".to_owned(),
26523            NodeId::new("node-standalone").unwrap(),
26524            vec![selected],
26525            vec![agent("claude")],
26526        );
26527        let workspace_id = WorkspaceId::new("independent").unwrap();
26528        let request = NodeRequest::CreateStandaloneWorkspace {
26529            workspace_id: workspace_id.clone(),
26530            root: opaque_windows_path(target.to_string_lossy().into_owned()),
26531            initial_branch: Some("main".to_owned()),
26532        };
26533        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
26534            capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
26535        }));
26536        assert!(!request_uses_unnegotiated_capability(
26537            &request,
26538            &baseline_capabilities().unwrap(),
26539        ));
26540        assert!(request_uses_unnegotiated_capability(&request, &[]));
26541
26542        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
26543            .await
26544            .unwrap_err();
26545        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26546        assert!(!target.exists());
26547        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
26548            .await
26549            .unwrap_err();
26550        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
26551        assert!(!target.exists());
26552        shared
26553            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
26554            .unwrap();
26555
26556        let response = process_request_inner(&shared, 77, ClientRole::Operator, request)
26557            .await
26558            .unwrap();
26559        let NodeResponse::StandaloneWorkspaceCreated { workspace } = response else {
26560            panic!("standalone workspace creation returned another response");
26561        };
26562        assert_eq!(workspace.workspace_id, workspace_id);
26563        assert!(target.join(".git").is_dir());
26564        let branch = run_git_bounded(
26565            target.to_str().unwrap(),
26566            &["branch", "--show-current"],
26567            GIT_OUTPUT_MAX_BYTES,
26568        )
26569        .await
26570        .unwrap();
26571        assert!(branch.success);
26572        assert_eq!(String::from_utf8(branch.stdout).unwrap().trim(), "main");
26573        let worktrees = list_git_worktrees(target.to_str().unwrap()).await.unwrap();
26574        assert_eq!(worktrees.len(), 1);
26575        let listed_root = WorkspaceConfig::new(
26576            WorkspaceId::new("listed-independent").unwrap(),
26577            &worktrees[0].path,
26578        )
26579        .unwrap();
26580        assert!(platform::roots_equal(
26581            listed_root.canonical_root(),
26582            windows_path_text(&workspace.canonical_root),
26583        ));
26584        assert!(!platform::roots_equal(listed_root.canonical_root(), &selected_root));
26585        assert!(shared.snapshot().workspaces.iter().any(|snapshot| {
26586            snapshot.workspace_id == workspace_id
26587                && snapshot.canonical_root == workspace.canonical_root
26588        }));
26589        assert!(shared
26590            .history
26591            .lock()
26592            .unwrap_or_else(|poisoned| poisoned.into_inner())
26593            .events
26594            .iter()
26595            .any(|event| matches!(
26596                &event.event,
26597                NodeEvent::WorkspaceAdded { workspace }
26598                    if workspace.workspace_id == workspace_id
26599            )));
26600        std::fs::remove_dir_all(fixture_root).unwrap();
26601    }
26602
26603    #[tokio::test]
26604    async fn standalone_workspace_registration_failure_preserves_guarded_repository_for_recovery() {
26605        let fixture_root = temporary_workspace_root("standalone-compensation");
26606        let created_target = fixture_root.join("created-target");
26607        let existing_target = fixture_root.join("existing-target");
26608        let invalid_state_parent = fixture_root.join("state-parent-is-a-file");
26609        let invalid_state_path = invalid_state_parent.join("state.json");
26610        std::fs::create_dir_all(&existing_target).unwrap();
26611        std::fs::write(&invalid_state_parent, "not a directory").unwrap();
26612        let catalog = active_registry().unwrap();
26613        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26614        let primary = WorkspaceConfig::new(
26615            WorkspaceId::new("primary").unwrap(),
26616            std::env::current_dir().unwrap(),
26617        )
26618        .unwrap();
26619        let mut shared = NodeShared::new(
26620            handle,
26621            "fixture-token".to_owned(),
26622            NodeId::new("node-standalone-compensation").unwrap(),
26623            vec![primary],
26624            vec![agent("claude")],
26625        );
26626        shared.state_path = Some(invalid_state_path);
26627
26628        let created_recovery = shared
26629            .create_standalone_workspace(
26630                WorkspaceId::new("compensated").unwrap(),
26631                created_target.to_string_lossy().into_owned(),
26632                Some("main".to_owned()),
26633            )
26634            .await
26635            .unwrap_err();
26636        assert_eq!(
26637            created_recovery.code,
26638            NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
26639        );
26640        assert!(created_target.join(".git").is_dir());
26641        assert!(shared.snapshot().workspaces.iter().all(|workspace| {
26642            workspace.workspace_id.as_str() != "compensated"
26643        }));
26644
26645        let existing_recovery = shared
26646            .create_standalone_workspace(
26647                WorkspaceId::new("restored").unwrap(),
26648                existing_target.to_string_lossy().into_owned(),
26649                None,
26650            )
26651            .await
26652            .unwrap_err();
26653        assert_eq!(
26654            existing_recovery.code,
26655            NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
26656        );
26657        assert!(existing_target.is_dir());
26658        assert!(existing_target.join(".git").is_dir());
26659        assert!(shared.snapshot().workspaces.iter().all(|workspace| {
26660            workspace.workspace_id.as_str() != "restored"
26661        }));
26662
26663        let registered_empty = fixture_root.join("registered-empty");
26664        std::fs::create_dir(&registered_empty).unwrap();
26665        let catalog = active_registry().unwrap();
26666        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26667        let registered = WorkspaceConfig::new(
26668            WorkspaceId::new("registered-empty").unwrap(),
26669            &registered_empty,
26670        )
26671        .unwrap();
26672        let duplicate_shared = NodeShared::new(
26673            handle,
26674            "fixture-token".to_owned(),
26675            NodeId::new("node-standalone-duplicate-root").unwrap(),
26676            vec![registered],
26677            vec![agent("claude")],
26678        );
26679        let duplicate = duplicate_shared
26680            .create_standalone_workspace(
26681                WorkspaceId::new("duplicate-root").unwrap(),
26682                registered_empty.to_string_lossy().into_owned(),
26683                None,
26684            )
26685            .await
26686            .unwrap_err();
26687        assert_eq!(duplicate.code, NodeFailureCode::DuplicateWorkspaceRoot);
26688        assert!(std::fs::read_dir(&registered_empty).unwrap().next().is_none());
26689        std::fs::remove_dir_all(fixture_root).unwrap();
26690    }
26691
26692    #[test]
26693    fn managed_record_id_collision_preserves_the_existing_record() {
26694        let catalog = active_registry().unwrap();
26695        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26696        let workspace = WorkspaceConfig::new(
26697            WorkspaceId::new("primary").unwrap(),
26698            std::env::current_dir().unwrap(),
26699        )
26700        .unwrap();
26701        let shared = NodeShared::new(
26702            handle,
26703            "fixture-token".to_owned(),
26704            NodeId::new("node-1").unwrap(),
26705            vec![workspace.clone()],
26706            vec![agent("claude")],
26707        );
26708        let record_id = SessionRecordId::new("sr-collision").unwrap();
26709        let original = ManagedSessionRecord {
26710            record_id: record_id.clone(),
26711            display_name: "original".to_owned(),
26712            provider: agent("claude"),
26713            mode: SessionMode::Pty,
26714            state: ManagedSessionState::Dormant,
26715            workspace_id: workspace.workspace_id,
26716            canonical_root: opaque_windows_path(workspace.canonical_root),
26717            provider_session: None,
26718            active_session: None,
26719            environment_profile: None,
26720            bundle: None,
26721            context_id: None,
26722            context: None,
26723            exported_context: None,
26724            task_binding: None,
26725            created_at_unix_ms: 1,
26726            updated_at_unix_ms: 1,
26727            last_error: None,
26728        };
26729        shared.insert_record(original.clone()).unwrap();
26730        let mut collision = original.clone();
26731        collision.display_name = "replacement".to_owned();
26732        let error = shared.insert_record(collision).unwrap_err();
26733        assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
26734        assert_eq!(shared.record(&record_id).unwrap(), original);
26735    }
26736
26737    #[test]
26738    fn managed_record_runtime_errors_are_exposed_only_as_allowlisted_categories() {
26739        let catalog = active_registry().unwrap();
26740        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26741        let workspace = WorkspaceConfig::new(
26742            WorkspaceId::new("primary").unwrap(),
26743            std::env::current_dir().unwrap(),
26744        )
26745        .unwrap();
26746        let shared = NodeShared::new(
26747            handle,
26748            "fixture-token".to_owned(),
26749            NodeId::new("node-1").unwrap(),
26750            vec![workspace.clone()],
26751            vec![agent("claude")],
26752        );
26753        let record_id = SessionRecordId::new("sr-runtime-error").unwrap();
26754        shared
26755            .insert_record(ManagedSessionRecord {
26756                record_id: record_id.clone(),
26757                display_name: "runtime error".to_owned(),
26758                provider: agent("claude"),
26759                mode: SessionMode::Pty,
26760                state: ManagedSessionState::IdentityPending,
26761                workspace_id: workspace.workspace_id,
26762                canonical_root: opaque_windows_path(workspace.canonical_root),
26763                provider_session: None,
26764                active_session: None,
26765                environment_profile: None,
26766                bundle: None,
26767                context_id: None,
26768                context: None,
26769                exported_context: None,
26770                task_binding: None,
26771                created_at_unix_ms: 1,
26772                updated_at_unix_ms: 1,
26773                last_error: None,
26774            })
26775            .unwrap();
26776
26777        shared
26778            .mark_record_error(
26779                &record_id,
26780                r"provider failed with bearer-secret-123 at C:\private\session.jsonl",
26781            )
26782            .unwrap();
26783        let record = shared.record(&record_id).unwrap();
26784        assert_eq!(record.state, ManagedSessionState::Unavailable);
26785        assert_eq!(
26786            record.last_error.as_deref(),
26787            Some("provider-runtime-failed"),
26788        );
26789        let snapshot = shared.snapshot();
26790        assert_eq!(snapshot.session_records, vec![record]);
26791    }
26792
26793    /// Regression coverage for the "operator sees `deadline` instead of the
26794    /// kernel's own named rejection" defect: a spawn-dispatch waiter must be
26795    /// able to learn a command's asynchronous kernel rejection (here,
26796    /// specifically classified as `UnsupportedTransport`) exactly once, and
26797    /// must not still find it after that -- proving the fast path this
26798    /// mechanism exists for (`spawn_session_with_deadline`'s commit-wait
26799    /// loop) drains the reason instead of leaving it to be rediscovered (or
26800    /// missed) on a later poll.
26801    #[test]
26802    fn rejected_command_reason_is_classified_and_consumed_exactly_once() {
26803        let catalog = active_registry().unwrap();
26804        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26805        let workspace = WorkspaceConfig::new(
26806            WorkspaceId::new("primary").unwrap(),
26807            std::env::current_dir().unwrap(),
26808        )
26809        .unwrap();
26810        let shared = NodeShared::new(
26811            handle,
26812            "fixture-token".to_owned(),
26813            NodeId::new("node-1").unwrap(),
26814            vec![workspace],
26815            vec![agent("claude")],
26816        );
26817
26818        assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
26819
26820        shared.record_rejected_command(
26821            CommandId(1),
26822            "agent 'claude' does not support transport Acp".to_owned(),
26823            true,
26824        );
26825        // An unrelated command_id must not see another command's reason.
26826        assert!(shared.take_rejected_command_reason(CommandId(2)).is_none());
26827
26828        let reason = shared.take_rejected_command_reason(CommandId(1)).unwrap();
26829        assert_eq!(reason.message, "agent 'claude' does not support transport Acp");
26830        assert!(reason.unsupported_transport);
26831
26832        // Consumed: a second read for the same command_id finds nothing.
26833        assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
26834
26835        // A rejection unrelated to transport support keeps its own
26836        // classification distinct, so the reader can choose the generic
26837        // `BackendOperationFailed` code instead of misreporting
26838        // `UnsupportedTransport`.
26839        shared.record_rejected_command(
26840            CommandId(3),
26841            "backend busy".to_owned(),
26842            false,
26843        );
26844        let unclassified = shared.take_rejected_command_reason(CommandId(3)).unwrap();
26845        assert!(!unclassified.unsupported_transport);
26846    }
26847
26848    #[test]
26849    fn provider_identity_update_is_ignored_without_exact_session_policy() {
26850        let catalog = active_registry().unwrap();
26851        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26852        let workspace = WorkspaceConfig::new(
26853            WorkspaceId::new("primary").unwrap(),
26854            std::env::current_dir().unwrap(),
26855        )
26856        .unwrap();
26857        let shared = NodeShared::new(
26858            handle,
26859            "fixture-token".to_owned(),
26860            NodeId::new("node-identity-policy").unwrap(),
26861            vec![workspace.clone()],
26862            vec![agent("claude")],
26863        );
26864        let address = SessionAddress {
26865            workspace_id: workspace.workspace_id.clone(),
26866            session: SessionKey {
26867                instance_id: AgentInstanceId(90),
26868                generation: SessionGeneration(1),
26869            },
26870        };
26871        let record_id = SessionRecordId::new("sr-policy-denied").unwrap();
26872        shared
26873            .insert_record(ManagedSessionRecord {
26874                record_id: record_id.clone(),
26875                display_name: "policy denied".to_owned(),
26876                provider: agent("claude"),
26877                mode: SessionMode::Pty,
26878                state: ManagedSessionState::IdentityPending,
26879                workspace_id: workspace.workspace_id,
26880                canonical_root: opaque_windows_path(workspace.canonical_root),
26881                provider_session: None,
26882                active_session: Some(address.clone()),
26883                environment_profile: None,
26884                bundle: None,
26885                context_id: None,
26886                context: None,
26887                exported_context: None,
26888                task_binding: None,
26889                created_at_unix_ms: 1,
26890                updated_at_unix_ms: 1,
26891                last_error: None,
26892            })
26893            .unwrap();
26894        let event = ControlEvent {
26895            sequence: 1,
26896            command_id: None,
26897            instance_id: address.session.instance_id,
26898            generation: address.session.generation,
26899            event: ControlEventKind::ProviderEvent {
26900                sequence: 1,
26901                source: gate4agent_types::ProviderSource {
26902                    family: AdapterFamily::PtySemantic,
26903                    binding: AdapterBinding::new(
26904                        gate4agent_types::AdapterId::new("claude").unwrap(),
26905                        "fixture/v1",
26906                        gate4agent_types::AdapterVerification::SyntheticFixture,
26907                    )
26908                    .unwrap(),
26909                },
26910                source_sequence: 1,
26911                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26912                    identity: gate4agent_types::ProviderSessionIdentity {
26913                        key: gate4agent_types::ProviderSessionKey::SessionId,
26914                        id: "provider-session".to_owned(),
26915                        transcript_path: None,
26916                    },
26917                },
26918            },
26919        };
26920
26921        shared.reconcile_managed_record(&record_id, &address, &event, false, false);
26922
26923        let record = shared.record(&record_id).unwrap();
26924        assert!(record.provider_session.is_none());
26925        assert_eq!(record.state, ManagedSessionState::IdentityPending);
26926        assert_eq!(record.active_session.as_ref(), Some(&address));
26927    }
26928
26929    #[test]
26930    fn provider_identity_cannot_rebind_a_record_across_workspaces() {
26931        let secondary_root = temporary_workspace_root("identity-scope");
26932        std::fs::create_dir_all(&secondary_root).unwrap();
26933        let catalog = active_registry().unwrap();
26934        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26935        let primary = WorkspaceConfig::new(
26936            WorkspaceId::new("primary").unwrap(),
26937            std::env::current_dir().unwrap(),
26938        )
26939        .unwrap();
26940        let secondary = WorkspaceConfig::new(
26941            WorkspaceId::new("secondary").unwrap(),
26942            &secondary_root,
26943        )
26944        .unwrap();
26945        let shared = NodeShared::new(
26946            handle,
26947            "fixture-token".to_owned(),
26948            NodeId::new("node-1").unwrap(),
26949            vec![primary.clone(), secondary.clone()],
26950            vec![agent("claude")],
26951        );
26952        let identity = gate4agent_types::ProviderSessionIdentity {
26953            key: gate4agent_types::ProviderSessionKey::SessionId,
26954            id: "provider-session".to_owned(),
26955            transcript_path: None,
26956        };
26957        let dormant_id = SessionRecordId::new("sr-dormant").unwrap();
26958        shared
26959            .insert_record(ManagedSessionRecord {
26960                record_id: dormant_id.clone(),
26961                display_name: "dormant".to_owned(),
26962                provider: agent("claude"),
26963                mode: SessionMode::Pty,
26964                state: ManagedSessionState::Dormant,
26965                workspace_id: primary.workspace_id,
26966                canonical_root: opaque_windows_path(primary.canonical_root),
26967                provider_session: Some(identity.clone()),
26968                active_session: None,
26969                environment_profile: None,
26970                bundle: None,
26971                context_id: None,
26972                context: None,
26973                exported_context: None,
26974                task_binding: None,
26975                created_at_unix_ms: 1,
26976                updated_at_unix_ms: 1,
26977                last_error: None,
26978            })
26979            .unwrap();
26980        let address = SessionAddress {
26981            workspace_id: secondary.workspace_id.clone(),
26982            session: SessionKey {
26983                instance_id: AgentInstanceId(91),
26984                generation: SessionGeneration(1),
26985            },
26986        };
26987        let current_id = SessionRecordId::new("sr-current").unwrap();
26988        shared
26989            .insert_record(ManagedSessionRecord {
26990                record_id: current_id.clone(),
26991                display_name: "current".to_owned(),
26992                provider: agent("claude"),
26993                mode: SessionMode::Pty,
26994                state: ManagedSessionState::IdentityPending,
26995                workspace_id: secondary.workspace_id,
26996                canonical_root: opaque_windows_path(secondary.canonical_root),
26997                provider_session: None,
26998                active_session: Some(address.clone()),
26999                environment_profile: None,
27000                bundle: None,
27001                context_id: None,
27002                context: None,
27003                exported_context: None,
27004                task_binding: None,
27005                created_at_unix_ms: 2,
27006                updated_at_unix_ms: 2,
27007                last_error: None,
27008            })
27009            .unwrap();
27010        shared.bind_managed_session(
27011            &address,
27012            current_id.clone(),
27013            ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
27014            None,
27015        );
27016        let event = ControlEvent {
27017            sequence: 1,
27018            command_id: None,
27019            instance_id: address.session.instance_id,
27020            generation: address.session.generation,
27021            event: ControlEventKind::ProviderEvent {
27022                sequence: 1,
27023                source: gate4agent_types::ProviderSource {
27024                    family: gate4agent_types::AdapterFamily::PtySemantic,
27025                    binding: gate4agent_types::AdapterBinding::new(
27026                        gate4agent_types::AdapterId::new("claude").unwrap(),
27027                        "fixture/v1",
27028                        gate4agent_types::AdapterVerification::SyntheticFixture,
27029                    )
27030                    .unwrap(),
27031                },
27032                source_sequence: 1,
27033                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
27034                    identity,
27035                },
27036            },
27037        };
27038        shared.reconcile_managed_record(&current_id, &address, &event, true, false);
27039
27040        let binding = shared
27041            .session_bindings
27042            .lock()
27043            .unwrap_or_else(|poisoned| poisoned.into_inner())
27044            .get(&address.session.instance_id)
27045            .cloned()
27046            .unwrap();
27047        assert_eq!(binding.record_id.as_ref(), Some(&current_id));
27048        assert_eq!(shared.record(&dormant_id).unwrap().state, ManagedSessionState::Dormant);
27049        let current = shared.record(&current_id).unwrap();
27050        assert_eq!(current.state, ManagedSessionState::Unavailable);
27051        assert_eq!(current.active_session.as_ref(), Some(&address));
27052        assert_eq!(
27053            current.last_error.as_deref(),
27054            Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR),
27055        );
27056        std::fs::remove_dir_all(secondary_root).unwrap();
27057    }
27058
27059    #[tokio::test]
27060    async fn semantic_identity_with_transcript_path_reuses_record_and_survives_reload() {
27061        let root = temporary_workspace_root("semantic-provider-session");
27062        std::fs::create_dir_all(&root).unwrap();
27063        let state_path = root.join("state-v1.json");
27064        let catalog = active_registry().unwrap();
27065        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27066        let workspace = WorkspaceConfig::new(
27067            WorkspaceId::new("primary").unwrap(),
27068            &root,
27069        )
27070        .unwrap();
27071        let node_id = NodeId::new("node-semantic-identity").unwrap();
27072        let shared = NodeShared::new_with_incarnation(
27073            handle,
27074            "fixture-token".to_owned(),
27075            node_id.clone(),
27076            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
27077            vec![workspace.clone()],
27078            vec![agent("claude")],
27079            ProviderRuntimeStatuses::default(),
27080            None,
27081            Vec::new(),
27082            Vec::new(),
27083            SpawnProfileRegistry::default(),
27084            None,
27085            None,
27086            None,
27087            Some(state_path.clone()),
27088            Vec::new(),
27089            Vec::new(),
27090            Vec::new(),
27091            Vec::new(),
27092            None,
27093            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
27094        );
27095        let persisted_identity = gate4agent_types::ProviderSessionIdentity {
27096            key: gate4agent_types::ProviderSessionKey::SessionId,
27097            id: "provider-session".to_owned(),
27098            transcript_path: None,
27099        };
27100        let durable_id = SessionRecordId::new("sr-durable").unwrap();
27101        shared
27102            .insert_record(ManagedSessionRecord {
27103                record_id: durable_id.clone(),
27104                display_name: "durable".to_owned(),
27105                provider: agent("claude"),
27106                mode: SessionMode::Pty,
27107                state: ManagedSessionState::Dormant,
27108                workspace_id: workspace.workspace_id.clone(),
27109                canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
27110                provider_session: Some(persisted_identity.clone()),
27111                active_session: None,
27112                environment_profile: None,
27113                bundle: None,
27114                context_id: None,
27115                context: None,
27116                exported_context: None,
27117                task_binding: None,
27118                created_at_unix_ms: 1,
27119                updated_at_unix_ms: 1,
27120                last_error: None,
27121            })
27122            .unwrap();
27123        let address = SessionAddress {
27124            workspace_id: workspace.workspace_id.clone(),
27125            session: SessionKey {
27126                instance_id: AgentInstanceId(92),
27127                generation: SessionGeneration(2),
27128            },
27129        };
27130        let pending_id = SessionRecordId::new("sr-pending").unwrap();
27131        shared
27132            .insert_record(ManagedSessionRecord {
27133                record_id: pending_id.clone(),
27134                display_name: "pending".to_owned(),
27135                provider: agent("claude"),
27136                mode: SessionMode::Pty,
27137                state: ManagedSessionState::IdentityPending,
27138                workspace_id: workspace.workspace_id.clone(),
27139                canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
27140                provider_session: None,
27141                active_session: Some(address.clone()),
27142                environment_profile: None,
27143                bundle: None,
27144                context_id: None,
27145                context: None,
27146                exported_context: None,
27147                task_binding: None,
27148                created_at_unix_ms: 2,
27149                updated_at_unix_ms: 2,
27150                last_error: None,
27151            })
27152            .unwrap();
27153        shared.bind_managed_session(
27154            &address,
27155            pending_id.clone(),
27156            ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
27157            None,
27158        );
27159        let observed_identity = gate4agent_types::ProviderSessionIdentity {
27160            transcript_path: Some(r"C:\private\provider-transcript.jsonl".to_owned()),
27161            ..persisted_identity
27162        };
27163        let event = ControlEvent {
27164            sequence: 1,
27165            command_id: None,
27166            instance_id: address.session.instance_id,
27167            generation: address.session.generation,
27168            event: ControlEventKind::ProviderEvent {
27169                sequence: 1,
27170                source: gate4agent_types::ProviderSource {
27171                    family: gate4agent_types::AdapterFamily::PtySemantic,
27172                    binding: gate4agent_types::AdapterBinding::new(
27173                        gate4agent_types::AdapterId::new("claude").unwrap(),
27174                        "fixture/v1",
27175                        gate4agent_types::AdapterVerification::SyntheticFixture,
27176                    )
27177                    .unwrap(),
27178                },
27179                source_sequence: 1,
27180                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
27181                    identity: observed_identity.clone(),
27182                },
27183            },
27184        };
27185        shared.reconcile_managed_record(&pending_id, &address, &event, true, false);
27186
27187        let snapshot = shared.snapshot();
27188        assert_eq!(snapshot.session_records.len(), 1);
27189        let live = &snapshot.session_records[0];
27190        assert_eq!(live.record_id, durable_id);
27191        assert_eq!(live.state, ManagedSessionState::Live);
27192        assert_eq!(live.active_session.as_ref(), Some(&address));
27193        assert_eq!(live.provider_session.as_ref(), Some(&observed_identity));
27194        let binding = shared
27195            .session_bindings
27196            .lock()
27197            .unwrap_or_else(|poisoned| poisoned.into_inner())
27198            .get(&address.session.instance_id)
27199            .cloned()
27200            .unwrap();
27201        assert_eq!(binding.record_id.as_ref(), Some(&durable_id));
27202
27203        let mut duplicate = live.clone();
27204        duplicate.record_id = SessionRecordId::new("sr-duplicate").unwrap();
27205        duplicate.display_name = "duplicate".to_owned();
27206        duplicate.state = ManagedSessionState::Dormant;
27207        duplicate.active_session = None;
27208        duplicate.provider_session.as_mut().unwrap().transcript_path = None;
27209        let duplicate_error = shared.insert_record(duplicate).unwrap_err();
27210        assert_eq!(duplicate_error.code, NodeFailureCode::SessionRecordConflict);
27211        assert_eq!(shared.snapshot().session_records.len(), 1);
27212
27213        let terminal_size = gate4agent_types::TerminalSize {
27214            rows: 24,
27215            columns: 80,
27216        };
27217        let (first_busy, second_busy) = tokio::join!(
27218            shared.resume_session_record(&durable_id, terminal_size, None),
27219            shared.resume_session_record(&durable_id, terminal_size, None),
27220        );
27221        for busy in [first_busy.unwrap_err(), second_busy.unwrap_err()] {
27222            assert_eq!(busy.code, NodeFailureCode::SessionRecordBusy);
27223            assert_eq!(busy.message, "session-record-busy");
27224        }
27225
27226        let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
27227        assert_eq!(loaded.records.len(), 1);
27228        assert_eq!(loaded.records[0].record_id, durable_id);
27229        assert_eq!(loaded.records[0].state, ManagedSessionState::Dormant);
27230        assert_eq!(
27231            loaded.records[0]
27232                .provider_session
27233                .as_ref()
27234                .unwrap()
27235                .transcript_path,
27236            None,
27237        );
27238        let catalog = active_registry().unwrap();
27239        let (reloaded_handle, _reloaded_runtime) =
27240            NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27241        let reloaded = NodeShared::new_with_incarnation(
27242            reloaded_handle,
27243            "fixture-token".to_owned(),
27244            node_id,
27245            NodeIncarnationId::from_bytes([1; crate::protocol::NODE_INCARNATION_ID_BYTES]),
27246            vec![workspace],
27247            vec![agent("claude")],
27248            ProviderRuntimeStatuses::default(),
27249            None,
27250            Vec::new(),
27251            Vec::new(),
27252            SpawnProfileRegistry::default(),
27253            None,
27254            None,
27255            None,
27256            None,
27257            loaded.records,
27258            loaded.managed_worktrees,
27259            loaded.managed_worktree_tombstones,
27260            loaded.materializations,
27261            loaded.warning,
27262            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
27263        );
27264        assert_eq!(reloaded.snapshot().session_records.len(), 1);
27265        assert_eq!(reloaded.snapshot().session_records[0].record_id, durable_id);
27266        std::fs::remove_dir_all(root).unwrap();
27267    }
27268
27269    #[tokio::test]
27270    async fn workspace_reregistration_requires_the_recorded_root_and_restores_records() {
27271        let secondary_root = temporary_workspace_root("workspace-restore");
27272        let other_root = temporary_workspace_root("workspace-conflict");
27273        std::fs::create_dir_all(&secondary_root).unwrap();
27274        std::fs::create_dir_all(&other_root).unwrap();
27275        let catalog = active_registry().unwrap();
27276        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27277        let primary = WorkspaceConfig::new(
27278            WorkspaceId::new("primary").unwrap(),
27279            std::env::current_dir().unwrap(),
27280        )
27281        .unwrap();
27282        let secondary = WorkspaceConfig::new(
27283            WorkspaceId::new("secondary").unwrap(),
27284            &secondary_root,
27285        )
27286        .unwrap();
27287        let shared = NodeShared::new(
27288            handle,
27289            "fixture-token".to_owned(),
27290            NodeId::new("node-1").unwrap(),
27291            vec![primary, secondary.clone()],
27292            vec![agent("claude")],
27293        );
27294        let record_id = SessionRecordId::new("sr-workspace").unwrap();
27295        shared
27296            .insert_record(ManagedSessionRecord {
27297                record_id: record_id.clone(),
27298                display_name: "workspace record".to_owned(),
27299                provider: agent("claude"),
27300                mode: SessionMode::Pty,
27301                state: ManagedSessionState::Dormant,
27302                workspace_id: secondary.workspace_id.clone(),
27303                canonical_root: opaque_windows_path(secondary.canonical_root.clone()),
27304                provider_session: Some(gate4agent_types::ProviderSessionIdentity {
27305                    key: gate4agent_types::ProviderSessionKey::SessionId,
27306                    id: "provider-session".to_owned(),
27307                    transcript_path: None,
27308                }),
27309                active_session: None,
27310                environment_profile: None,
27311                bundle: None,
27312                context_id: None,
27313                context: None,
27314                exported_context: None,
27315                task_binding: None,
27316                created_at_unix_ms: 1,
27317                updated_at_unix_ms: 1,
27318                last_error: None,
27319            })
27320            .unwrap();
27321        shared.unregister_workspace(&secondary.workspace_id).unwrap();
27322        let unavailable = shared.record(&record_id).unwrap();
27323        assert_eq!(unavailable.state, ManagedSessionState::Unavailable);
27324        assert_eq!(windows_path_text(&unavailable.canonical_root), secondary.canonical_root);
27325
27326        let conflict = shared
27327            .register_workspace(
27328                secondary.workspace_id.clone(),
27329                other_root.to_string_lossy().into_owned(),
27330            )
27331            .await
27332            .unwrap_err();
27333        assert_eq!(conflict.code, NodeFailureCode::SessionRecordConflict);
27334        shared
27335            .register_workspace(
27336                secondary.workspace_id,
27337                secondary_root.to_string_lossy().into_owned(),
27338            )
27339            .await
27340            .unwrap();
27341        let restored = shared.record(&record_id).unwrap();
27342        assert_eq!(restored.state, ManagedSessionState::Dormant);
27343        assert!(restored.last_error.is_none());
27344        std::fs::remove_dir_all(secondary_root).unwrap();
27345        std::fs::remove_dir_all(other_root).unwrap();
27346    }
27347
27348    #[test]
27349    fn dynamic_workspace_root_is_rejected_before_filesystem_canonicalization() {
27350        let error = validate_workspace_request_root(
27351            &"x".repeat(MAX_WORKSPACE_ROOT_BYTES + 1),
27352        )
27353        .unwrap_err();
27354        assert_eq!(error.code, NodeFailureCode::InvalidWorkspaceRoot);
27355        let control = validate_workspace_request_root("C:\\repo\nchild").unwrap_err();
27356        assert_eq!(control.code, NodeFailureCode::InvalidWorkspaceRoot);
27357    }
27358
27359    #[test]
27360    fn concurrent_publish_and_resync_never_acknowledge_an_unappended_event() {
27361        let catalog = active_registry().unwrap();
27362        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27363        let workspace = WorkspaceConfig::new(
27364            WorkspaceId::new("primary").unwrap(),
27365            std::env::current_dir().unwrap(),
27366        )
27367        .unwrap();
27368        let shared = NodeShared::new(
27369            handle,
27370            "fixture-token".to_owned(),
27371            NodeId::new("node-1").unwrap(),
27372            vec![workspace],
27373            vec![agent("claude")],
27374        );
27375        let publishers = 4_u64;
27376        let events_per_publisher = 256_u64;
27377        let start = std::sync::Barrier::new(publishers as usize + 1);
27378        let finished = AtomicU64::new(0);
27379
27380        std::thread::scope(|scope| {
27381            for _ in 0..publishers {
27382                scope.spawn(|| {
27383                    start.wait();
27384                    for _ in 0..events_per_publisher {
27385                        shared.publish(NodeEvent::ControllerChanged { controller: None });
27386                    }
27387                    finished.fetch_add(1, Ordering::Release);
27388                });
27389            }
27390            start.wait();
27391            while finished.load(Ordering::Acquire) != publishers {
27392                assert_resync_history_is_linearized(shared.resync(0));
27393                std::thread::yield_now();
27394            }
27395        });
27396        let response = shared.resync(0);
27397        assert_resync_history_is_linearized(response.clone());
27398        let NodeResponse::Resync { event_sequence, events, .. } = response else {
27399            unreachable!("resync helper returned another response");
27400        };
27401        assert_eq!(event_sequence, publishers * events_per_publisher);
27402        assert_eq!(events.len() as u64, event_sequence);
27403    }
27404
27405    fn assert_resync_history_is_linearized(response: NodeResponse) {
27406        let NodeResponse::Resync { event_sequence, events, .. } = response else {
27407            unreachable!("resync helper returned another response");
27408        };
27409        assert!(events.windows(2).all(|pair| {
27410            pair[0].sequence.checked_add(1) == Some(pair[1].sequence)
27411        }));
27412        assert_eq!(events.last().map_or(0, |event| event.sequence), event_sequence);
27413    }
27414
27415    #[test]
27416    fn canonical_workspace_roots_are_safe_for_cmd_exe() {
27417        assert_eq!(
27418            platform::normalize_canonical_root(r"\\?\C:\repo\worktree".to_owned()),
27419            r"C:\repo\worktree",
27420        );
27421        assert_eq!(
27422            platform::normalize_canonical_root(r"\\?\UNC\server\share\repo".to_owned()),
27423            r"\\server\share\repo",
27424        );
27425        assert!(validate_workspace_root(
27426            &WorkspaceId::new("network").unwrap(),
27427            r"\\server\share\repo",
27428        )
27429        .is_err());
27430        let workspace = WorkspaceConfig::new(
27431            WorkspaceId::new("primary").unwrap(),
27432            std::env::current_dir().unwrap(),
27433        )
27434        .unwrap();
27435        assert!(!workspace.canonical_root.starts_with(r"\\?\"));
27436    }
27437
27438    #[test]
27439    fn resume_rebinds_only_on_the_expected_authorized_generation() {
27440        let catalog = active_registry().unwrap();
27441        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27442        let workspace_id = WorkspaceId::new("primary").unwrap();
27443        let workspace = WorkspaceConfig::new(
27444            workspace_id.clone(),
27445            std::env::current_dir().unwrap(),
27446        )
27447        .unwrap();
27448        let shared = NodeShared::new(
27449            handle,
27450            "fixture-token".to_owned(),
27451            NodeId::new("node-1").unwrap(),
27452            vec![workspace],
27453            vec![agent("claude")],
27454        );
27455        let instance_id = AgentInstanceId(17);
27456        let original = SessionAddress {
27457            workspace_id,
27458            session: SessionKey {
27459                instance_id,
27460                generation: SessionGeneration(3),
27461            },
27462        };
27463        shared.bind_session(
27464            instance_id,
27465            original.workspace_id.clone(),
27466            original.session.generation,
27467        );
27468        shared
27469            .arm_resume(
27470                &original,
27471                CommandId(41),
27472                ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
27473            )
27474            .unwrap();
27475
27476        shared.publish_control(ControlEvent {
27477            sequence: 1,
27478            command_id: None,
27479            instance_id,
27480            generation: SessionGeneration(4),
27481            event: ControlEventKind::Running { process_id: None },
27482        });
27483        assert_eq!(
27484            shared.address_for(instance_id, SessionGeneration(3)),
27485            Some(original.clone()),
27486        );
27487        assert!(shared.address_for(instance_id, SessionGeneration(4)).is_none());
27488
27489        shared.publish_control(ControlEvent {
27490            sequence: 2,
27491            command_id: Some(CommandId(42)),
27492            instance_id,
27493            generation: SessionGeneration(3),
27494            event: ControlEventKind::CommandRejected {
27495                message: "unrelated command".to_owned(),
27496            },
27497        });
27498
27499        shared.publish_control(ControlEvent {
27500            sequence: 3,
27501            command_id: None,
27502            instance_id,
27503            generation: SessionGeneration(4),
27504            event: ControlEventKind::ResumeAuthorized {
27505                session: gate4agent_types::ResumeSessionSummary {
27506                    key: gate4agent_types::ProviderSessionKey::SessionId,
27507                    id: "fixture-session".to_owned(),
27508                },
27509            },
27510        });
27511        assert!(shared.address_for(instance_id, SessionGeneration(3)).is_none());
27512        let rebound = shared.address_for(instance_id, SessionGeneration(4)).unwrap();
27513        assert_eq!(rebound.workspace_id, original.workspace_id);
27514        let history = shared
27515            .history
27516            .lock()
27517            .unwrap_or_else(|poisoned| poisoned.into_inner());
27518        assert!(history.events.iter().any(|envelope| matches!(
27519            &envelope.event,
27520            NodeEvent::Control { address, event }
27521                if address == &rebound
27522                    && matches!(&event.event, ControlEventKind::ResumeAuthorized { .. })
27523        )));
27524    }
27525
27526    #[test]
27527    fn all_node_pty_prompts_use_bracketed_paste_framing() {
27528        for agent in ["claude", "codex", "kimi"] {
27529            assert_eq!(
27530                prompt_framing(&AgentId::new(agent).unwrap()),
27531                PromptFraming::BracketedPaste
27532            );
27533        }
27534    }
27535
27536    fn generous_walk_deadline() -> Instant {
27537        Instant::now() + Duration::from_secs(30)
27538    }
27539
27540    #[test]
27541    fn workspace_tree_is_relative_and_skips_heavy_directories() {
27542        let root = temporary_workspace_root("tree");
27543        std::fs::create_dir_all(root.join("src")).unwrap();
27544        std::fs::create_dir_all(root.join(".git/objects")).unwrap();
27545        std::fs::create_dir_all(root.join("target/debug")).unwrap();
27546        std::fs::create_dir_all(root.join("node_modules/package")).unwrap();
27547        std::fs::write(root.join("src/lib.rs"), b"pub fn fixture() {}\n").unwrap();
27548        std::fs::write(root.join("README.md"), b"fixture\n").unwrap();
27549        // A renamed/agent-scoped build directory (`target-a2`-style, per the
27550        // real repository's own build layout) that carries the Cache
27551        // Directory Tagging Specification marker cargo writes into every
27552        // `--target-dir` it creates: must be skipped even though its name
27553        // does not exactly match `target`.
27554        std::fs::create_dir_all(root.join("target-a2/debug")).unwrap();
27555        std::fs::write(root.join("target-a2/CACHEDIR.TAG"), b"Signature: 8a477f597d28d172789f06886806bc55\n").unwrap();
27556        // A directory that merely *starts with* `target` but carries no
27557        // CACHEDIR.TAG: the new prefix-adjacent check must not false-match
27558        // on the name alone, so this stays in the response.
27559        std::fs::create_dir_all(root.join("target-notes")).unwrap();
27560        std::fs::write(root.join("target-notes/plan.md"), b"not a build directory\n").unwrap();
27561
27562        let (entries, truncated, _budget) =
27563            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27564        assert!(!truncated);
27565        assert!(entries.iter().any(|entry| {
27566            entry.relative_path.as_utf8() == Some("src")
27567                && entry.kind == WorkspaceEntryKind::Directory
27568        }));
27569        assert!(entries.iter().any(|entry| {
27570            entry.relative_path.as_utf8() == Some("src/lib.rs")
27571                && entry.kind == WorkspaceEntryKind::File
27572        }));
27573        assert!(entries.iter().any(|entry| {
27574            entry.relative_path.as_utf8() == Some("target-notes")
27575                && entry.kind == WorkspaceEntryKind::Directory
27576        }));
27577        assert!(entries.iter().any(|entry| {
27578            entry.relative_path.as_utf8() == Some("target-notes/plan.md")
27579                && entry.kind == WorkspaceEntryKind::File
27580        }));
27581        assert!(entries.iter().all(|entry| {
27582            let path = entry.relative_path.as_utf8().unwrap();
27583            !path.starts_with(".git")
27584                && !(path == "target" || path.starts_with("target/"))
27585                && !(path == "target-a2" || path.starts_with("target-a2/"))
27586                && !path.starts_with("node_modules")
27587        }));
27588
27589        std::fs::remove_dir_all(&root).unwrap();
27590    }
27591
27592    #[test]
27593    fn workspace_walk_stops_at_its_entry_cap_and_marks_a_partial_result() {
27594        let root = temporary_workspace_root("tree-entry-cap");
27595        std::fs::create_dir_all(&root).unwrap();
27596        for index in 0..20 {
27597            std::fs::write(root.join(format!("file-{index:02}.txt")), b"fixture\n").unwrap();
27598        }
27599
27600        let (entries, truncated, budget) =
27601            collect_workspace_entries(&root, generous_walk_deadline(), 5);
27602        assert!(truncated);
27603        assert!(budget.entry_cap_exceeded);
27604        assert!(!budget.time_budget_exceeded);
27605        assert!(entries.len() <= 5);
27606
27607        let inspection_truncation = workspace_inspection_truncation(&budget, false, Duration::from_millis(1))
27608            .expect("an entry-cap-truncated walk must report a truncation marker");
27609        assert!(inspection_truncation.walk_entry_cap_exceeded);
27610        assert!(!inspection_truncation.walk_time_budget_exceeded);
27611        assert!(!inspection_truncation.git_time_budget_exceeded);
27612        assert!(inspection_truncation.entries_visited >= 5);
27613
27614        std::fs::remove_dir_all(&root).unwrap();
27615    }
27616
27617    #[test]
27618    fn workspace_walk_within_budget_reports_no_truncation() {
27619        let root = temporary_workspace_root("tree-no-truncation");
27620        std::fs::create_dir_all(&root).unwrap();
27621        std::fs::write(root.join("only-file.txt"), b"fixture\n").unwrap();
27622
27623        let (_entries, truncated, budget) =
27624            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27625        assert!(!truncated);
27626        assert!(workspace_inspection_truncation(&budget, false, Duration::from_millis(1)).is_none());
27627
27628        std::fs::remove_dir_all(&root).unwrap();
27629    }
27630
27631    #[test]
27632    fn workspace_walk_emits_strict_global_byte_order_for_prefix_named_siblings() {
27633        let root = temporary_workspace_root("tree-prefix-siblings");
27634        std::fs::create_dir_all(root.join("gate4agent-c2/src")).unwrap();
27635        std::fs::write(root.join("gate4agent-c2/src/lib.rs"), b"fixture\n").unwrap();
27636        std::fs::create_dir_all(root.join("gate4agent-c2-client")).unwrap();
27637        std::fs::write(root.join("gate4agent-c2-client/lib.rs"), b"fixture\n").unwrap();
27638
27639        let (entries, truncated, _budget) =
27640            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27641        assert!(!truncated);
27642        let paths = entries
27643            .iter()
27644            .map(|entry| entry.relative_path.as_utf8().unwrap().to_owned())
27645            .collect::<Vec<_>>();
27646        assert!(paths.windows(2).all(|pair| pair[0] < pair[1]));
27647        assert_eq!(
27648            paths,
27649            vec![
27650                "gate4agent-c2".to_owned(),
27651                "gate4agent-c2-client".to_owned(),
27652                "gate4agent-c2-client/lib.rs".to_owned(),
27653                "gate4agent-c2/src".to_owned(),
27654                "gate4agent-c2/src/lib.rs".to_owned(),
27655            ],
27656        );
27657
27658        std::fs::remove_dir_all(&root).unwrap();
27659    }
27660
27661    #[test]
27662    fn git_parsers_cap_status_and_commit_results() {
27663        let mut status_output = Vec::new();
27664        for index in 0..=GIT_STATUS_MAX_ENTRIES {
27665            status_output.extend_from_slice(format!(" M src/file-{index}.rs\0").as_bytes());
27666        }
27667        let mut snapshot = git_snapshot_for_parser();
27668        parse_git_status(&status_output, &mut snapshot);
27669        assert_eq!(snapshot.status.len(), GIT_STATUS_MAX_ENTRIES);
27670        assert!(snapshot.truncated);
27671        assert_eq!(snapshot.status[0].index_status, " ");
27672        assert_eq!(snapshot.status[0].worktree_status, "M");
27673
27674        snapshot.truncated = false;
27675        let commit_output = (0..=GIT_COMMIT_MAX_ENTRIES)
27676            .map(|index| format!("abc{index}\u{1f}commit {index}\n"))
27677            .collect::<String>();
27678        parse_git_commits(commit_output.as_bytes(), &mut snapshot);
27679        assert_eq!(snapshot.recent_commits.len(), GIT_COMMIT_MAX_ENTRIES);
27680        assert!(snapshot.truncated);
27681    }
27682
27683    #[test]
27684    fn porcelain_v1_z_parser_preserves_current_then_previous_rename_and_copy_order() {
27685        let mut snapshot = git_snapshot_for_parser();
27686        parse_git_status(
27687            b"R  src/current name.rs\0src/previous name.rs\0 C src/copy.rs\0src/original.rs\0?? src/line\nname.rs\0",
27688            &mut snapshot,
27689        );
27690
27691        assert!(!snapshot.truncated);
27692        assert_eq!(snapshot.status.len(), 3);
27693        assert_eq!(snapshot.status[0].index_status, "R");
27694        assert_eq!(snapshot.status[0].worktree_status, " ");
27695        assert_eq!(snapshot.status[0].path.as_utf8(), Some("src/current name.rs"));
27696        assert_eq!(
27697            snapshot.status[0]
27698                .previous_path
27699                .as_ref()
27700                .and_then(RepositoryPath::as_utf8),
27701            Some("src/previous name.rs"),
27702        );
27703        assert_eq!(snapshot.status[1].index_status, " ");
27704        assert_eq!(snapshot.status[1].worktree_status, "C");
27705        assert_eq!(snapshot.status[1].path.as_utf8(), Some("src/copy.rs"));
27706        assert_eq!(
27707            snapshot.status[1]
27708                .previous_path
27709                .as_ref()
27710                .and_then(RepositoryPath::as_utf8),
27711            Some("src/original.rs"),
27712        );
27713        assert_eq!(snapshot.status[2].path.as_utf8(), Some("src/line\nname.rs"));
27714        assert!(snapshot.status[2].previous_path.is_none());
27715    }
27716
27717    #[test]
27718    fn porcelain_v1_z_parser_never_invents_invalid_or_incomplete_path_identity() {
27719        let mut snapshot = git_snapshot_for_parser();
27720        parse_git_status(
27721            b"?? valid.rs\0?? invalid-\xff.rs\0?? invalid\\windows.rs\0R  current.rs\0",
27722            &mut snapshot,
27723        );
27724
27725        assert!(snapshot.truncated);
27726        assert_eq!(snapshot.status.len(), 1);
27727        assert_eq!(snapshot.status[0].path.as_utf8(), Some("valid.rs"));
27728        assert!(snapshot.status[0].previous_path.is_none());
27729    }
27730
27731    #[tokio::test]
27732    async fn observer_can_browse_host_directories_without_controller() {
27733        let root = temporary_workspace_root("host-directory-observer");
27734        std::fs::create_dir_all(root.join("directory-b")).unwrap();
27735        std::fs::create_dir_all(root.join("directory-a")).unwrap();
27736        std::fs::write(root.join("ordinary-file"), b"not a directory").unwrap();
27737        let workspace_id = WorkspaceId::new("primary").unwrap();
27738        let workspace = WorkspaceConfig::new(workspace_id, &root).unwrap();
27739        let catalog = active_registry().unwrap();
27740        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27741        let shared = NodeShared::new(
27742            handle,
27743            "fixture-token".to_owned(),
27744            NodeId::new("node-1").unwrap(),
27745            vec![workspace],
27746            vec![agent("claude")],
27747        );
27748        let request = NodeRequest::BrowseHostDirectories {
27749            directory: Some(OpaqueHostPath::utf8(root.to_string_lossy().into_owned()).unwrap()),
27750            after: None,
27751        };
27752        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
27753            capability.as_str() == CAPABILITY_HOST_DIRECTORY_BROWSE_V1
27754        }));
27755        assert!(!request_uses_unnegotiated_capability(
27756            &request,
27757            &baseline_capabilities().unwrap(),
27758        ));
27759        assert!(request_uses_unnegotiated_capability(&request, &[]));
27760
27761        let response = process_request_inner(&shared, 77, ClientRole::Observer, request)
27762            .await
27763            .unwrap();
27764        let NodeResponse::HostDirectoriesBrowsed { listing } = response else {
27765            panic!("host directory browse returned another response");
27766        };
27767        assert_eq!(
27768            listing
27769                .entries
27770                .iter()
27771                .map(|entry| entry.display_name.as_str())
27772                .collect::<Vec<_>>(),
27773            vec!["directory-a", "directory-b"],
27774        );
27775        assert!(listing.entries.iter().all(|entry| !entry.is_link));
27776        assert!(!listing.incomplete);
27777        std::fs::remove_dir_all(root).unwrap();
27778    }
27779
27780    #[tokio::test]
27781    async fn observer_can_inspect_only_a_registered_workspace() {
27782        let root = temporary_workspace_root("observer");
27783        std::fs::create_dir_all(root.join("src")).unwrap();
27784        std::fs::write(root.join("src/main.rs"), b"fn main() {}\n").unwrap();
27785        let workspace_id = WorkspaceId::new("primary").unwrap();
27786        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27787        let catalog = active_registry().unwrap();
27788        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27789        let shared = NodeShared::new(
27790            handle,
27791            "fixture-token".to_owned(),
27792            NodeId::new("node-1").unwrap(),
27793            vec![workspace],
27794            vec![agent("claude")],
27795        );
27796
27797        let response = process_request_inner(
27798            &shared,
27799            77,
27800            ClientRole::Observer,
27801            NodeRequest::InspectWorkspace {
27802                workspace_id: workspace_id.clone(),
27803            },
27804        )
27805        .await
27806        .unwrap();
27807        let NodeResponse::WorkspaceInspected { inspection } = response else {
27808            panic!("workspace inspection returned another response");
27809        };
27810        assert_eq!(inspection.workspace_id, workspace_id);
27811        assert!(inspection.entries.iter().any(|entry| {
27812            entry.relative_path.as_utf8() == Some("src/main.rs")
27813        }));
27814
27815        let unknown = process_request_inner(
27816            &shared,
27817            77,
27818            ClientRole::Observer,
27819            NodeRequest::InspectWorkspace {
27820                workspace_id: WorkspaceId::new("unknown").unwrap(),
27821            },
27822        )
27823        .await
27824        .unwrap_err();
27825        assert_eq!(unknown.code, NodeFailureCode::UnknownWorkspace);
27826        std::fs::remove_dir_all(&root).unwrap();
27827    }
27828
27829    #[test]
27830    fn context_pack_source_accepts_only_running_or_successful_exit() {
27831        assert!(context_pack_source_status_is_usable(&SessionStatus::Running));
27832        assert!(context_pack_source_status_is_usable(&SessionStatus::Exited {
27833            exit_code: Some(0),
27834        }));
27835        assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27836            exit_code: Some(1),
27837        }));
27838        assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27839            exit_code: None,
27840        }));
27841        assert!(!context_pack_source_status_is_usable(&SessionStatus::Failed {
27842            message: "provider failed".to_owned(),
27843        }));
27844        assert!(!context_pack_source_status_is_usable(&SessionStatus::Registered));
27845        assert!(!context_pack_source_status_is_usable(&SessionStatus::Starting));
27846        assert!(!context_pack_source_status_is_usable(&SessionStatus::Stopping));
27847    }
27848
27849    #[test]
27850    fn context_pack_repository_rejects_changed_head_with_same_git_snapshot() {
27851        let git = git_snapshot_for_parser();
27852        let first = ContextPackRepositoryObservation {
27853            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27854            git: git.clone(),
27855            files: Vec::new(),
27856        };
27857        let second = ContextPackRepositoryObservation {
27858            head: ContextPackRepositoryHead::Commit("b".repeat(40)),
27859            git,
27860            files: Vec::new(),
27861        };
27862
27863        let error = stable_context_pack_repository(first, second).unwrap_err();
27864
27865        assert_eq!(
27866            error.code,
27867            NodeFailureCode::ContextPackMaterializationFailed,
27868        );
27869    }
27870
27871    #[test]
27872    fn context_pack_repository_rejects_changed_file_with_same_git_snapshot() {
27873        let git = git_snapshot_for_parser();
27874        let first = ContextPackRepositoryObservation {
27875            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27876            git: git.clone(),
27877            files: vec![ContextPackRepositoryFileSource::utf8(
27878                "README.md",
27879                "first\n".to_owned(),
27880                6,
27881            )],
27882        };
27883        let second = ContextPackRepositoryObservation {
27884            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27885            git,
27886            files: vec![ContextPackRepositoryFileSource::utf8(
27887                "README.md",
27888                "other\n".to_owned(),
27889                6,
27890            )],
27891        };
27892
27893        let error = stable_context_pack_repository(first, second).unwrap_err();
27894
27895        assert_eq!(
27896            error.code,
27897            NodeFailureCode::ContextPackMaterializationFailed,
27898        );
27899    }
27900
27901    #[tokio::test]
27902    async fn native_session_catalog_zero_active_session_fixture() {
27903        let workspace_root = temporary_workspace_root("native-catalog-workspace");
27904        let history_root = temporary_workspace_root("native-catalog-history");
27905        let outside_root = temporary_workspace_root("native-catalog-outside");
27906        std::fs::create_dir_all(&workspace_root).unwrap();
27907        std::fs::create_dir_all(&history_root).unwrap();
27908        std::fs::create_dir_all(&outside_root).unwrap();
27909        let transcript = |session_id: &str, cwd: &Path, title: &str| {
27910            [
27911                serde_json::json!({
27912                    "type": "user",
27913                    "sessionId": session_id,
27914                    "cwd": cwd.to_str().unwrap(),
27915                    "message": { "content": "raw user text must not cross the catalog wire" }
27916                }),
27917                serde_json::json!({
27918                    "type": "assistant",
27919                    "sessionId": session_id,
27920                    "cwd": cwd.to_str().unwrap(),
27921                    "message": { "model": "claude-sonnet", "content": "raw answer" }
27922                }),
27923                serde_json::json!({
27924                    "type": "ai-title",
27925                    "sessionId": session_id,
27926                    "aiTitle": title
27927                }),
27928            ]
27929            .into_iter()
27930            .map(|record| record.to_string())
27931            .collect::<Vec<_>>()
27932            .join("\n")
27933        };
27934        std::fs::write(
27935            history_root.join("stable-session.jsonl"),
27936            transcript("stable-session", &workspace_root, "Workspace review"),
27937        )
27938        .unwrap();
27939        std::fs::write(
27940            history_root.join("outside-session.jsonl"),
27941            transcript("outside-session", &outside_root, "Outside review"),
27942        )
27943        .unwrap();
27944        let history = NativeHistoryConfig::new(vec![
27945            NativeHistoryRoot::new(
27946                gate4agent_types::AdapterId::new("claude-code").unwrap(),
27947                HistorySourceLayout::SingleNdjson,
27948                history_root.clone(),
27949            )
27950            .unwrap(),
27951        ])
27952        .unwrap();
27953        let catalog = active_registry().unwrap();
27954        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27955        assert!(handle.snapshot().sessions.is_empty());
27956        let workspace_id = WorkspaceId::new("primary").unwrap();
27957        let workspace = WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap();
27958        let mut shared = NodeShared::new(
27959            handle,
27960            "fixture-token".to_owned(),
27961            NodeId::new("node-native-catalog").unwrap(),
27962            vec![workspace],
27963            vec![agent("claude")],
27964        );
27965        shared.native_session_catalog = Some(Arc::new(Mutex::new(
27966            NativeSessionCatalogAuthority::new(history),
27967        )));
27968        let route = NativeSessionCatalogRoute::workspace(
27969            workspace_id.clone(),
27970            agent("claude"),
27971        );
27972        let request = NodeRequest::CatalogNativeSessions {
27973            route: route.clone(),
27974            limit: 2,
27975        };
27976        let observer = process_request_inner(&shared, 7, ClientRole::Observer, request.clone())
27977            .await
27978            .unwrap_err();
27979        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27980        let response = process_request_inner(&shared, 7, ClientRole::Operator, request)
27981            .await
27982            .unwrap();
27983        let NodeResponse::NativeSessionsCataloged { entries, summary, .. } = response else {
27984            panic!("native catalog returned a different response");
27985        };
27986        let summary = summary.expect("node must emit native session catalog summary metadata");
27987        assert_eq!(entries.len(), 1);
27988        assert!(gate4agent_types::validate_candidate_id(&entries[0].selection_id).is_ok());
27989        assert_eq!(entries[0].external_group, None);
27990        assert_eq!(entries[0].record_id, None);
27991        assert_eq!(entries[0].title, None);
27992        assert_eq!(entries[0].model, None);
27993        assert_eq!(entries[0].message_count, 0);
27994        let encoded_entries = serde_json::to_string(&entries).unwrap();
27995        assert!(!encoded_entries.contains("stable-session"));
27996        assert!(!encoded_entries.contains(history_root.to_string_lossy().as_ref()));
27997        let page_request = NodeRequest::PageNativeSessions {
27998            route: route.clone(),
27999            window: crate::protocol::NativeSessionCatalogWindow::Recent,
28000            catalog_revision: summary.catalog_revision,
28001            recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
28002            after_selection_id: None,
28003            limit: 2,
28004        };
28005        let observer = process_request_inner(
28006            &shared,
28007            7,
28008            ClientRole::Observer,
28009            page_request.clone(),
28010        )
28011        .await
28012        .unwrap_err();
28013        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
28014        let page_response = process_request_inner(
28015            &shared,
28016            7,
28017            ClientRole::Operator,
28018            page_request.clone(),
28019        )
28020        .await
28021        .unwrap();
28022        assert!(matches!(
28023            page_response,
28024            NodeResponse::NativeSessionsPaged { page, .. }
28025                if page.entries.len() == 1 && !page.has_more && page.remaining_count == 0
28026        ));
28027        let stale = process_request_inner(
28028            &shared,
28029            7,
28030            ClientRole::Operator,
28031            NodeRequest::PageNativeSessions {
28032                route: route.clone(),
28033                window: crate::protocol::NativeSessionCatalogWindow::Recent,
28034                catalog_revision: summary.catalog_revision.wrapping_add(1),
28035                recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
28036                after_selection_id: None,
28037                limit: 2,
28038            },
28039        )
28040        .await
28041        .unwrap_err();
28042        assert_eq!(stale.code, NodeFailureCode::StaleNativeSessionCatalog);
28043        let selection = NativeSessionSelection {
28044            route: route.clone(),
28045            catalog_revision: summary.catalog_revision,
28046            recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
28047            selection_id: entries[0].selection_id.clone(),
28048        };
28049        let preview_response = process_request_inner(
28050            &shared,
28051            7,
28052            ClientRole::Operator,
28053            NodeRequest::PreviewNativeSession {
28054                selection: selection.clone(),
28055                message_limit: 1,
28056            },
28057        )
28058        .await
28059        .unwrap();
28060        let NodeResponse::NativeSessionPreviewed { preview, .. } = preview_response else {
28061            panic!("native preview returned a different response");
28062        };
28063        assert_eq!(preview.messages.len(), 1);
28064        assert!(preview.message_count_exact);
28065        assert_eq!(preview.messages[0].text, "raw answer");
28066        assert!(preview.truncated);
28067
28068        let index_request = NodeRequest::IndexNativeSession {
28069            selection: selection.clone(),
28070            display_name: "Saved Claude".to_owned(),
28071        };
28072        let observer = process_request_inner(
28073            &shared,
28074            7,
28075            ClientRole::Observer,
28076            index_request.clone(),
28077        )
28078        .await
28079        .unwrap_err();
28080        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
28081        let without_controller = process_request_inner(
28082            &shared,
28083            7,
28084            ClientRole::Operator,
28085            index_request.clone(),
28086        )
28087        .await
28088        .unwrap_err();
28089        assert_eq!(without_controller.code, NodeFailureCode::ControllerRequired);
28090        let acquired = process_request_inner(
28091            &shared,
28092            7,
28093            ClientRole::Operator,
28094            NodeRequest::AcquireController { lease_ms: 5_000 },
28095        )
28096        .await
28097        .unwrap();
28098        assert!(matches!(acquired, NodeResponse::Controller { controller: Some(_) }));
28099        let indexed = process_request_inner(
28100            &shared,
28101            7,
28102            ClientRole::Operator,
28103            index_request,
28104        )
28105        .await
28106        .unwrap();
28107        assert!(indexed.native_session_index_contract_is_valid());
28108        let NodeResponse::NativeSessionIndexed {
28109            selection: echoed_selection,
28110            record,
28111        } = indexed else {
28112            panic!("native session index returned a different response");
28113        };
28114        assert_eq!(echoed_selection, selection);
28115        let recataloged = process_request_inner(
28116            &shared,
28117            7,
28118            ClientRole::Operator,
28119            NodeRequest::CatalogNativeSessions {
28120                route,
28121                limit: 2,
28122            },
28123        )
28124        .await
28125        .unwrap();
28126        let NodeResponse::NativeSessionsCataloged { entries, .. } = recataloged else {
28127            panic!("native catalog returned a different response after indexing");
28128        };
28129        assert_eq!(entries[0].record_id.as_ref(), Some(&record.record_id));
28130
28131        let unregistered_route = NativeSessionCatalogRoute::unregistered(agent("claude"));
28132        let external_catalog = process_request_inner(
28133            &shared,
28134            7,
28135            ClientRole::Operator,
28136            NodeRequest::CatalogNativeSessions {
28137                route: unregistered_route.clone(),
28138                limit: 2,
28139            },
28140        )
28141        .await
28142        .unwrap();
28143        let external_json = serde_json::to_string(&external_catalog).unwrap();
28144        assert!(!external_json.contains("outside-session"));
28145        assert!(!external_json.contains(outside_root.to_string_lossy().as_ref()));
28146        assert!(!external_json.contains(history_root.to_string_lossy().as_ref()));
28147        assert!(!external_json.contains("\"session_id\""));
28148        assert!(!external_json.contains("\"cwd\""));
28149        assert!(!external_json.contains("\"path\""));
28150        let NodeResponse::NativeSessionsCataloged {
28151            route: echoed_route,
28152            entries: external_entries,
28153            summary: Some(external_summary),
28154        } = external_catalog
28155        else {
28156            panic!("unregistered native catalog returned a different response");
28157        };
28158        assert_eq!(echoed_route, unregistered_route);
28159        assert_eq!(external_entries.len(), 1);
28160        assert_eq!(external_entries[0].record_id, None);
28161        let external_group = external_entries[0]
28162            .external_group
28163            .as_ref()
28164            .expect("unregistered session must have an external group");
28165        assert_eq!(
28166            external_group.kind,
28167            gate4agent_types::NativeSessionExternalGroupKind::Project,
28168        );
28169        assert!(external_group.group_id.starts_with("external-"));
28170        assert_eq!(
28171            external_group.display_name,
28172            outside_root.file_name().unwrap().to_string_lossy(),
28173        );
28174        let external_selection = NativeSessionSelection {
28175            route: unregistered_route,
28176            catalog_revision: external_summary.catalog_revision,
28177            recent_cutoff_unix_ms: external_summary.recent_cutoff_unix_ms,
28178            selection_id: external_entries[0].selection_id.clone(),
28179        };
28180        let external_preview = process_request_inner(
28181            &shared,
28182            7,
28183            ClientRole::Operator,
28184            NodeRequest::PreviewNativeSession {
28185                selection: external_selection.clone(),
28186                message_limit: 1,
28187            },
28188        )
28189        .await
28190        .unwrap();
28191        let external_preview_json = serde_json::to_string(&external_preview).unwrap();
28192        assert!(!external_preview_json.contains("outside-session"));
28193        assert!(!external_preview_json.contains(outside_root.to_string_lossy().as_ref()));
28194        assert!(!external_preview_json.contains("\"session_id\""));
28195        let NodeResponse::NativeSessionPreviewed { preview, .. } = external_preview else {
28196            panic!("unregistered native preview returned a different response");
28197        };
28198        assert_eq!(preview.messages.len(), 1);
28199        let external_index = process_request_inner(
28200            &shared,
28201            7,
28202            ClientRole::Operator,
28203            NodeRequest::IndexNativeSession {
28204                selection: external_selection,
28205                display_name: "External Claude".to_owned(),
28206            },
28207        )
28208        .await
28209        .unwrap_err();
28210        assert_eq!(
28211            external_index.code,
28212            NodeFailureCode::WorkspaceRegistrationRequired,
28213        );
28214        let record_response = process_request_inner(
28215            &shared,
28216            7,
28217            ClientRole::Operator,
28218            NodeRequest::PreviewSessionRecord {
28219                record_id: record.record_id.clone(),
28220                message_limit: 2,
28221            },
28222        )
28223        .await
28224        .unwrap();
28225        let NodeResponse::SessionRecordPreviewed { record_id, preview } = record_response else {
28226            panic!("record preview returned a different response");
28227        };
28228        assert_eq!(record_id, record.record_id);
28229        assert_eq!(preview.messages.len(), 2);
28230        let encoded = serde_json::to_string(&preview).unwrap();
28231        assert!(!encoded.contains("stable-session"));
28232        assert!(!encoded.contains(history_root.to_string_lossy().as_ref()));
28233        let NodeResponse::Resync { events, .. } = shared.resync(0) else {
28234            panic!("record preview history summary returned a different response");
28235        };
28236        let summaries = events
28237            .iter()
28238            .filter_map(|envelope| match &envelope.event {
28239                NodeEvent::SessionRecordHistorySummarized { record_id, summary }
28240                    if record_id == &record.record_id =>
28241                {
28242                    Some(*summary)
28243                }
28244                _ => None,
28245            })
28246            .collect::<Vec<_>>();
28247        assert_eq!(summaries, vec![SessionHistorySummaryV1::from(&preview)]);
28248        let summary_json = serde_json::to_string(&summaries).unwrap();
28249        for private in [
28250            "stable-session",
28251            "raw user text must not cross the catalog wire",
28252            "raw answer",
28253            "Workspace review",
28254            "claude-sonnet",
28255        ] {
28256            assert!(!summary_json.contains(private), "{private}");
28257        }
28258        let identity = record.provider_session.clone().unwrap();
28259        assert_eq!(
28260            shared.revalidate_session_record_preview(&record, &identity),
28261            Ok(())
28262        );
28263        shared.remove_record_memory(&record.record_id);
28264        assert_eq!(
28265            shared
28266                .revalidate_session_record_preview(&record, &identity)
28267                .unwrap_err()
28268                .code,
28269            NodeFailureCode::UnknownSessionRecord
28270        );
28271        let released = process_request_inner(
28272            &shared,
28273            7,
28274            ClientRole::Operator,
28275            NodeRequest::ReleaseController,
28276        )
28277        .await
28278        .unwrap();
28279        assert!(matches!(released, NodeResponse::Controller { controller: None }));
28280        assert!(shared.controller_state().is_none());
28281        std::fs::remove_dir_all(workspace_root).unwrap();
28282        std::fs::remove_dir_all(history_root).unwrap();
28283        std::fs::remove_dir_all(outside_root).unwrap();
28284    }
28285
28286    #[tokio::test]
28287    async fn native_session_catalog_concurrent_operation_fails_fast_without_waiting_on_mutex() {
28288        let history_root = temporary_workspace_root("native-catalog-concurrent-operation");
28289        std::fs::create_dir_all(&history_root).unwrap();
28290        let history = NativeHistoryConfig::new(vec![
28291            NativeHistoryRoot::new(
28292                gate4agent_types::AdapterId::new("claude-code").unwrap(),
28293                HistorySourceLayout::SingleNdjson,
28294                history_root.clone(),
28295            )
28296            .unwrap(),
28297        ])
28298        .unwrap();
28299        let catalog = Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)));
28300        let held = catalog.lock().unwrap();
28301        let result = timeout(
28302            Duration::from_secs(1),
28303            run_native_session_catalog_operation(
28304                Arc::clone(&catalog),
28305                "native session catalog",
28306                |_| Ok::<(), std::convert::Infallible>(()),
28307            ),
28308        )
28309        .await
28310        .expect("concurrent native catalog request must not wait for the operation deadline")
28311        .unwrap_err();
28312        assert_eq!(result.code, NodeFailureCode::BackendBusy);
28313        drop(held);
28314        std::fs::remove_dir_all(history_root).unwrap();
28315    }
28316
28317    fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
28318        let unique = std::time::SystemTime::now()
28319            .duration_since(std::time::UNIX_EPOCH)
28320            .unwrap()
28321            .as_nanos();
28322        std::env::temp_dir().join(format!(
28323            "gate4agent-node-{label}-{}-{unique}",
28324            std::process::id(),
28325        ))
28326    }
28327
28328    fn git_snapshot_for_parser() -> GitSnapshot {
28329        GitSnapshot {
28330            is_repository: true,
28331            branch: Some("main".to_owned()),
28332            status: Vec::new(),
28333            recent_commits: Vec::new(),
28334            worktrees: Vec::new(),
28335            managed_worktree: None,
28336            truncated: false,
28337            diagnostic: None,
28338        }
28339    }
28340
28341    #[tokio::test]
28342    async fn workspace_inspection_scopes_only_exact_active_managed_branch() {
28343        let root = temporary_workspace_root("managed-git-scope");
28344        std::fs::create_dir_all(&root).unwrap();
28345        run_test_git(&root, &["init"]);
28346        run_test_git(&root, &["config", "user.email", "gate4agent@example.invalid"]);
28347        run_test_git(&root, &["config", "user.name", "Gate4Agent Test"]);
28348        std::fs::write(root.join("seed.txt"), "seed\n").unwrap();
28349        run_test_git(&root, &["add", "seed.txt"]);
28350        run_test_git(&root, &["commit", "-m", "seed"]);
28351        run_test_git(&root, &["checkout", "-b", "gate4agent/a"]);
28352
28353        let workspace_id = WorkspaceId::new("managed-a").unwrap();
28354        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
28355        let catalog = active_registry().unwrap();
28356        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28357        let shared = NodeShared::new(
28358            handle,
28359            "fixture-token".to_owned(),
28360            NodeId::new("node-1").unwrap(),
28361            vec![workspace],
28362            vec![agent("claude")],
28363        );
28364        let mut lease = ManagedWorktreeLeaseRecord {
28365            lease_id: ManagedWorktreeLeaseId::new("mw-a").unwrap(),
28366            source_workspace_id: WorkspaceId::new("primary").unwrap(),
28367            workspace_id: workspace_id.clone(),
28368            profile_id: WorktreeProfileId::new("default").unwrap(),
28369            profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
28370            target_root: root.to_string_lossy().into_owned(),
28371            branch: "gate4agent/a".to_owned(),
28372            base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
28373            expected_head: None,
28374            retention: ManagedWorktreeRetention::Retain,
28375            state: ManagedWorktreeLeaseState::InUse,
28376            session_holders: vec![ManagedWorktreeSessionHolder {
28377                incarnation_id: shared.incarnation_id,
28378                instance_id: AgentInstanceId(7),
28379                generation: SessionGeneration(1),
28380            }],
28381            record_holders: Vec::new(),
28382            cleanup_failure: None,
28383            created_at_unix_ms: 1,
28384            updated_at_unix_ms: 1,
28385        };
28386        *shared
28387            .managed_worktrees
28388            .lock()
28389            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
28390            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
28391
28392        let inspection = shared.inspect_workspace(workspace_id.clone()).await.unwrap();
28393        let scope = inspection.git.managed_worktree.unwrap();
28394        assert_eq!(scope.lease_id, lease.lease_id);
28395        assert_eq!(scope.branch, "gate4agent/a");
28396        assert_eq!(scope.active_session_count, 1);
28397
28398        lease.branch = "gate4agent/b".to_owned();
28399        *shared
28400            .managed_worktrees
28401            .lock()
28402            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
28403            ManagedWorktreeRegistry::from_records(vec![lease], Vec::new()).unwrap();
28404        let mismatch = shared.inspect_workspace(workspace_id).await.unwrap();
28405        assert!(mismatch.git.managed_worktree.is_none());
28406        std::fs::remove_dir_all(root).unwrap();
28407    }
28408
28409    #[tokio::test]
28410    async fn workspace_file_write_requires_controller_and_rejects_stale_revision() {
28411        let root = temporary_workspace_root("workspace-file-write-controller");
28412        std::fs::create_dir_all(root.join("src")).unwrap();
28413        std::fs::write(root.join("src/lib.rs"), b"old\n").unwrap();
28414        let workspace_id = WorkspaceId::new("primary").unwrap();
28415        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
28416        let catalog = active_registry().unwrap();
28417        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28418        let shared = NodeShared::new(
28419            handle,
28420            "fixture-token".to_owned(),
28421            NodeId::new("node-1").unwrap(),
28422            vec![workspace],
28423            vec![agent("claude")],
28424        );
28425        let path = RepositoryPath::utf8("src/lib.rs".to_owned()).unwrap();
28426        let expected_revision = workspace_file_revision(b"old\n");
28427        let request = NodeRequest::WriteWorkspaceFile {
28428            workspace_id: workspace_id.clone(),
28429            path: path.clone(),
28430            expected_revision: expected_revision.clone(),
28431            text: "new\n".to_owned(),
28432        };
28433        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
28434            .await
28435            .unwrap_err();
28436        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
28437        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
28438            .await
28439            .unwrap_err();
28440        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
28441        shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
28442        let response = process_request_inner(&shared, 77, ClientRole::Operator, request).await.unwrap();
28443        let NodeResponse::WorkspaceFileWritten { file } = response else {
28444            panic!("workspace file write returned another response");
28445        };
28446        assert_eq!(file.revision, Some(workspace_file_revision(b"new\n")));
28447        assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
28448        let stale = process_request_inner(
28449            &shared,
28450            77,
28451            ClientRole::Operator,
28452            NodeRequest::WriteWorkspaceFile {
28453                workspace_id,
28454                path,
28455                expected_revision,
28456                text: "lost update\n".to_owned(),
28457            },
28458        )
28459        .await
28460        .unwrap_err();
28461        assert_eq!(stale.code, NodeFailureCode::RepositoryFileRevisionConflict);
28462        assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
28463        std::fs::remove_dir_all(root).unwrap();
28464    }
28465
28466    #[tokio::test]
28467    async fn workspace_entry_create_requires_controller_and_never_overwrites() {
28468        let root = temporary_workspace_root("workspace-entry-create-controller");
28469        std::fs::create_dir_all(root.join("src")).unwrap();
28470        let workspace_id = WorkspaceId::new("primary").unwrap();
28471        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
28472        let catalog = active_registry().unwrap();
28473        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28474        let shared = NodeShared::new(
28475            handle,
28476            "fixture-token".to_owned(),
28477            NodeId::new("node-1").unwrap(),
28478            vec![workspace],
28479            vec![agent("claude")],
28480        );
28481        let path = RepositoryPath::utf8("src/new.rs".to_owned()).unwrap();
28482        let request = NodeRequest::CreateWorkspaceFile {
28483            workspace_id: workspace_id.clone(),
28484            path: path.clone(),
28485        };
28486        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
28487            capability.as_str() == NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY
28488        }));
28489        assert!(!request_uses_unnegotiated_capability(
28490            &request,
28491            &baseline_capabilities().unwrap(),
28492        ));
28493        assert!(request_uses_unnegotiated_capability(&request, &[]));
28494        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
28495            .await
28496            .unwrap_err();
28497        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
28498        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
28499            .await
28500            .unwrap_err();
28501        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
28502        shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
28503
28504        let response = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
28505            .await
28506            .unwrap();
28507        let NodeResponse::WorkspaceFileCreated { file } = response else {
28508            panic!("workspace file create returned another response");
28509        };
28510        assert_eq!(file.workspace_id, workspace_id);
28511        assert_eq!(file.path, path);
28512        assert_eq!(file.revision, Some(workspace_file_revision(&[])));
28513        assert_eq!(std::fs::read(root.join("src/new.rs")).unwrap(), Vec::<u8>::new());
28514
28515        let duplicate = process_request_inner(&shared, 77, ClientRole::Operator, request)
28516            .await
28517            .unwrap_err();
28518        assert_eq!(duplicate.code, NodeFailureCode::RepositoryEntryAlreadyExists);
28519
28520        let directory_path = RepositoryPath::utf8("src/nested".to_owned()).unwrap();
28521        let response = process_request_inner(
28522            &shared,
28523            77,
28524            ClientRole::Operator,
28525            NodeRequest::CreateWorkspaceDirectory {
28526                workspace_id: workspace_id.clone(),
28527                path: directory_path.clone(),
28528            },
28529        )
28530        .await
28531        .unwrap();
28532        assert_eq!(
28533            response,
28534            NodeResponse::WorkspaceDirectoryCreated {
28535                workspace_id: workspace_id.clone(),
28536                entry: WorkspaceEntry {
28537                    relative_path: directory_path,
28538                    kind: WorkspaceEntryKind::Directory,
28539                },
28540            },
28541        );
28542        assert!(root.join("src/nested").is_dir());
28543
28544        let missing_parent = process_request_inner(
28545            &shared,
28546            77,
28547            ClientRole::Operator,
28548            NodeRequest::CreateWorkspaceFile {
28549                workspace_id: workspace_id.clone(),
28550                path: RepositoryPath::utf8("missing/new.rs".to_owned()).unwrap(),
28551            },
28552        )
28553        .await
28554        .unwrap_err();
28555        assert_eq!(missing_parent.code, NodeFailureCode::RepositoryParentNotFound);
28556        let non_directory_parent = process_request_inner(
28557            &shared,
28558            77,
28559            ClientRole::Operator,
28560            NodeRequest::CreateWorkspaceFile {
28561                workspace_id,
28562                path: RepositoryPath::utf8("src/new.rs/child".to_owned()).unwrap(),
28563            },
28564        )
28565        .await
28566        .unwrap_err();
28567        assert_eq!(
28568            non_directory_parent.code,
28569            NodeFailureCode::RepositoryParentNotDirectory,
28570        );
28571        std::fs::remove_dir_all(root).unwrap();
28572    }
28573
28574    #[tokio::test]
28575    async fn workspace_entry_create_timeout_cancels_before_commit_without_late_mutation() {
28576        let root = temporary_workspace_root("workspace-entry-create-timeout-cancel");
28577        std::fs::create_dir_all(root.join("src")).unwrap();
28578        #[cfg(windows)]
28579        let root = std::fs::canonicalize(root).unwrap();
28580        let path = RepositoryPath::utf8("src/late.rs".to_owned()).unwrap();
28581        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
28582        let barrier = Arc::new(std::sync::Barrier::new(2));
28583        let done = Arc::new(AtomicBool::new(false));
28584        let task_commit_state = Arc::clone(&commit_state);
28585        let task_barrier = Arc::clone(&barrier);
28586        let task_done = Arc::clone(&done);
28587        let task_root = root.clone();
28588        let task = tokio::task::spawn_blocking(move || {
28589            task_barrier.wait();
28590            let result = create_workspace_file_on_disk(
28591                &task_root,
28592                &path,
28593                &task_commit_state,
28594            );
28595            task_done.store(true, Ordering::Release);
28596            result
28597        });
28598
28599        let error = settle_workspace_entry_create(
28600            task,
28601            Arc::clone(&commit_state),
28602            Duration::from_millis(10),
28603            "fixture deadline",
28604            "fixture task failed",
28605        )
28606        .await
28607        .unwrap_err();
28608        assert_eq!(error.code, NodeFailureCode::RepositoryEntryCreateTimedOut);
28609        assert_eq!(
28610            commit_state.load(Ordering::Acquire),
28611            WORKSPACE_ENTRY_CREATE_CANCELED,
28612        );
28613
28614        barrier.wait();
28615        timeout(Duration::from_secs(1), async {
28616            while !done.load(Ordering::Acquire) {
28617                tokio::task::yield_now().await;
28618            }
28619        })
28620        .await
28621        .expect("canceled blocking create did not settle");
28622        assert!(!root.join("src/late.rs").exists());
28623        std::fs::remove_dir_all(root).unwrap();
28624    }
28625}
28626
28627#[cfg(test)]
28628mod provisional_spawn_tests {
28629    use super::*;
28630    use std::sync::Arc;
28631
28632    fn shared() -> NodeShared {
28633        let catalog = active_registry().unwrap();
28634        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28635        let workspace = WorkspaceConfig::new(
28636            WorkspaceId::new("primary").unwrap(),
28637            std::env::current_dir().unwrap(),
28638        )
28639        .unwrap()
28640        .with_worktree_service_mode(WorktreeServiceMode::Manual);
28641        NodeShared::new(
28642            handle,
28643            "fixture-token".to_owned(),
28644            NodeId::new("node-provisional-test").unwrap(),
28645            vec![workspace],
28646            vec![AgentId::new("claude").unwrap()],
28647        )
28648    }
28649
28650    /// Provisional draft map: sessionize by instance id only; 50 sequential
28651    /// mark/claim cycles never collide, and a claimed draft is never reaped.
28652    #[test]
28653    fn provisional_spawn_keys_are_unique_and_sessionize_prevents_reap() {
28654        let shared = shared();
28655        let ws = WorkspaceId::new("primary").unwrap();
28656        let mut ids = Vec::with_capacity(50);
28657        for i in 1u64..=50 {
28658            let id = AgentInstanceId(i);
28659            ids.push(id);
28660            let address = SessionAddress {
28661                workspace_id: ws.clone(),
28662                session: SessionKey {
28663                    instance_id: id,
28664                    generation: SessionGeneration(1),
28665                },
28666            };
28667            shared.mark_spawn_provisional(address);
28668        }
28669        assert_eq!(
28670            shared
28671                .provisional_spawns
28672                .lock()
28673                .unwrap_or_else(|p| p.into_inner())
28674                .len(),
28675            50
28676        );
28677        for id in ids.iter().step_by(2) {
28678            shared.sessionize_spawn(*id);
28679        }
28680        assert_eq!(
28681            shared
28682                .provisional_spawns
28683                .lock()
28684                .unwrap_or_else(|p| p.into_inner())
28685                .len(),
28686            25
28687        );
28688        assert!(shared.take_due_provisional_spawns().is_empty());
28689        {
28690            let mut map = shared
28691                .provisional_spawns
28692                .lock()
28693                .unwrap_or_else(|p| p.into_inner());
28694            let past = Instant::now() - Duration::from_secs(91);
28695            for (_, (_, at)) in map.iter_mut() {
28696                *at = past;
28697            }
28698        }
28699        let due = shared.take_due_provisional_spawns();
28700        assert_eq!(due.len(), 25);
28701        let due_ids: std::collections::BTreeSet<_> =
28702            due.iter().map(|a| a.session.instance_id.0).collect();
28703        let expected: std::collections::BTreeSet<_> =
28704            ids.iter().skip(1).step_by(2).map(|id| id.0).collect();
28705        assert_eq!(due_ids, expected);
28706        assert!(shared
28707            .provisional_spawns
28708            .lock()
28709            .unwrap_or_else(|p| p.into_inner())
28710            .is_empty());
28711    }
28712
28713    /// Twenty parallel provisional marks must each get their own instance key;
28714    /// killing/sessionizing one id must not touch the others.
28715    #[test]
28716    fn provisional_spawn_parallel_marks_isolate_kills_by_instance_id() {
28717        let shared = Arc::new(shared());
28718        let ws = WorkspaceId::new("primary").unwrap();
28719        let mut handles = Vec::new();
28720        for i in 1u64..=20 {
28721            let shared = Arc::clone(&shared);
28722            let ws = ws.clone();
28723            handles.push(std::thread::spawn(move || {
28724                let id = AgentInstanceId(i);
28725                let address = SessionAddress {
28726                    workspace_id: ws,
28727                    session: SessionKey {
28728                        instance_id: id,
28729                        generation: SessionGeneration(1),
28730                    },
28731                };
28732                shared.mark_spawn_provisional(address);
28733                id
28734            }));
28735        }
28736        let mut ids = Vec::new();
28737        for h in handles {
28738            ids.push(h.join().unwrap());
28739        }
28740        ids.sort_by_key(|id| id.0);
28741        assert_eq!(ids.len(), 20);
28742        ids.dedup();
28743        assert_eq!(ids.len(), 20, "parallel marks must not share instance keys");
28744        assert_eq!(
28745            shared
28746                .provisional_spawns
28747                .lock()
28748                .unwrap_or_else(|p| p.into_inner())
28749                .len(),
28750            20
28751        );
28752        shared.sessionize_spawn(AgentInstanceId(7));
28753        let left: Vec<_> = shared
28754            .provisional_spawns
28755            .lock()
28756            .unwrap_or_else(|p| p.into_inner())
28757            .keys()
28758            .map(|id| id.0)
28759            .collect();
28760        assert!(!left.contains(&7));
28761        assert_eq!(left.len(), 19);
28762    }
28763}
28764
28765
28766#[cfg(unix)]
28767extern "C" {
28768    #[link_name = "geteuid"]
28769    fn libc_geteuid() -> u32;
28770}
28771
28772#[cfg(test)]
28773mod call_home_link_tests {
28774    use super::*;
28775
28776    #[test]
28777    fn call_home_backoff_is_capped_and_monotonic() {
28778        let mut last = Duration::ZERO;
28779        for attempt in 1..20 {
28780            let next = call_home_backoff(attempt);
28781            assert!(next >= last);
28782            assert!(next <= Duration::from_secs(8));
28783            last = next;
28784        }
28785        assert_eq!(call_home_backoff(0), Duration::from_millis(500));
28786    }
28787
28788    #[tokio::test]
28789    async fn call_home_socket_gets_keepalive() {
28790        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
28791        let addr = listener.local_addr().unwrap();
28792        let (client, _server) = tokio::join!(tokio::net::TcpStream::connect(addr), listener.accept());
28793        let client = client.unwrap();
28794        enable_call_home_keepalive(&client).unwrap();
28795        let sock = socket2::SockRef::from(&client);
28796        assert!(sock.keepalive().unwrap());
28797        #[cfg(any(target_os = "linux", target_os = "macos"))]
28798        assert_eq!(sock.tcp_keepalive_time().unwrap(), CALL_HOME_KEEPALIVE_IDLE);
28799    }
28800}
28801
28802/// A provider transcript for this session id exists in a known provider home.
28803fn provider_session_exists(provider: &str, id: &str) -> bool {
28804    if provider != "claude" { return false; }
28805    let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
28806    if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") { homes.extend(std::env::split_paths(&extra)); }
28807    let name = format!("{id}.jsonl");
28808    homes.iter().any(|h| std::fs::read_dir(h.join(".claude/projects")).map_or(false, |rd| rd.flatten().any(|d| d.path().join(&name).is_file())))
28809}
28810
28811/// Append launch args to a prepared overlay (or create an instance overlay for them).
28812fn overlay_with_extra_args(
28813    provider: &AgentId,
28814    mode: SessionMode,
28815    overlay: Option<PreparedNativeLaunchOverlay>,
28816    args: Vec<OsString>,
28817) -> Result<Option<PreparedNativeLaunchOverlay>, NodeFailure> {
28818    let bad = || failure(NodeFailureCode::BackendOperationFailed, "resume launch args overlay is invalid");
28819    Ok(match (overlay, args) {
28820        (prepared, args) if args.is_empty() => prepared,
28821        (Some(PreparedNativeLaunchOverlay::Instance(mut o)), args) => { o.append_extra_args(args).map_err(|_| bad())?; Some(PreparedNativeLaunchOverlay::Instance(o)) }
28822        (Some(PreparedNativeLaunchOverlay::Environment(o)), args) => Some(PreparedNativeLaunchOverlay::Instance(o.into_instance_with_extra_args(args).map_err(|_| bad())?)),
28823        (None, args) => Some(PreparedNativeLaunchOverlay::Instance(NativeInstanceLaunchOverlay::new(
28824            provider.clone(),
28825            match mode { SessionMode::Pty => TransportKind::Pty, SessionMode::Acp => TransportKind::Acp, SessionMode::Inline => TransportKind::Pipe },
28826            Vec::new(),
28827            args,
28828        ).map_err(|_| bad())?)),
28829    })
28830}
28831
28832/// Log a provider-error decision; escalations and limits go to core as session signals.
28833fn provider_error_act(workspace: &str, instance: u64, provider: &str, via: &str, act: crate::provider_error::Action) {
28834    use crate::provider_error::Action;
28835    match act {
28836        Action::Retry { attempt, delay } => tracing::info!(target: "provider_retry", instance_id = instance, provider, via, attempt, max = crate::provider_error::MAX_ATTEMPTS, delay_ms = delay.as_millis() as u64, "provider error: retry scheduled"),
28837        Action::Limit { found } => {
28838            tracing::info!(target: "provider_retry", instance_id = instance, provider, via, "provider error: limit, no retry");
28839            if via == "acp" {
28840                crate::session_signals::emit(workspace, instance, provider, crate::session_signals::Signal::Limit { hint: found.hint });
28841            }
28842        }
28843        Action::Escalate { found, attempts } => {
28844            tracing::warn!(target: "provider_retry", instance_id = instance, provider, via, class = found.class.as_str(), code = ?found.code, attempts, "provider error: escalated");
28845            crate::session_signals::emit(workspace, instance, provider, crate::session_signals::Signal::ProviderError { class: found.class.as_str(), code: found.code, attempts, hint: found.hint });
28846        }
28847    }
28848}
28849
28850/// Tool calls of ACP sessions in task worktrees, kept as extra evidence for the node's checks.
28851fn acp_tool_evidence(address: &SessionAddress, event: &ControlEvent) {
28852    let id = address.session.instance_id.0;
28853    match &event.event {
28854        ControlEventKind::Exited { .. } | ControlEventKind::Removed => crate::tool_evidence::forget_open(address.workspace_id.as_str(), id),
28855        ControlEventKind::ProviderEvent { event: e, .. } if crate::auto_permit::acp_provider(id).is_some() => crate::tool_evidence::observe(address.workspace_id.as_str(), id, e),
28856        _ => {}
28857    }
28858}
28859
28860/// Turn trace in the node log for ACP sessions (start, tools, permission asks, end, errors):
28861/// names and sizes only, never prompt, tool input or output text.
28862fn trace_acp_turn(address: &SessionAddress, event: &ControlEvent) {
28863    let id = address.session.instance_id.0;
28864    let ControlEventKind::ProviderEvent { event: e, .. } = &event.event else { return };
28865    match e {
28866        ProviderEvent::TurnStarted { prompt } => tracing::info!(instance_id = id, prompt_chars = prompt.as_deref().map(str::len).unwrap_or(0), "acp turn started"),
28867        ProviderEvent::ToolStarted { name, .. } => tracing::info!(instance_id = id, tool = %name, "acp tool call started"),
28868        ProviderEvent::ToolCompleted { is_error, duration_ms, non_execution_kind, .. } => tracing::info!(instance_id = id, is_error, duration_ms = ?duration_ms, not_run = ?non_execution_kind, "acp tool call finished"),
28869        ProviderEvent::InteractionRequested { interaction_kind, tool_name, options, .. } => tracing::info!(instance_id = id, kind = ?interaction_kind, tool = %tool_name, options = options.len(), "acp permission/interaction requested"),
28870        ProviderEvent::TurnInterrupted => tracing::info!(instance_id = id, "acp turn interrupted"),
28871        ProviderEvent::SessionEnded { is_error, stop_reason, .. } => tracing::info!(instance_id = id, is_error, stop_reason = ?stop_reason, "acp turn ended"),
28872        ProviderEvent::Error { message } => tracing::warn!(instance_id = id, error = %message.chars().take(200).collect::<String>(), "acp provider error"),
28873        _ => {}
28874    }
28875}
28876
28877
28878#[cfg(all(test, windows))]
28879mod codex_restart_tests {
28880    use super::*;
28881
28882    fn shared(root: &Path, fail_launch: bool) -> (NodeShared, NativeRuntime, EventSubscription) {
28883        shared_with_acp(root, fail_launch, "unsupported")
28884    }
28885
28886    fn shared_with_acp(root: &Path, fail_launch: bool, acp_outcome: &str) -> (NodeShared, NativeRuntime, EventSubscription) {
28887        let provider = AgentId::new("codex").unwrap();
28888        let mut spec = builtin_registry().get(&provider).unwrap().clone();
28889        spec.launch.program = if fail_launch { "missing-codex-resume-fixture.exe" } else { "powershell.exe" }.into();
28890        spec.launch.fixed_args = vec![
28891            "-NoProfile".into(), "-NonInteractive".into(), "-Command".into(),
28892            r#"& { param([Parameter(ValueFromRemainingArguments=$true)][string[]]$launchArgs) [IO.File]::WriteAllLines((Join-Path (Get-Location) 'resume-argv.txt'), $launchArgs); while (-not (Test-Path -LiteralPath (Join-Path (Get-Location) 'resume-stop'))) { Start-Sleep -Milliseconds 100 } }"#.into(),
28893        ];
28894        // ACP is always a local protocol fixture; never launch an installed vendor.
28895        spec.capabilities.transports.acp.as_mut().unwrap().launch_override = Some(gate4agent_types::LaunchSpec {
28896            program: "python".into(),
28897            fixed_args: vec!["-u".into(), "-c".into(), r#"
28898import json, sys
28899mode = sys.argv[1]
28900load = mode != 'unsupported'
28901for line in sys.stdin:
28902    q = json.loads(line)
28903    method = q.get('method')
28904    with open('resume-acp.jsonl', 'a', encoding='utf-8') as log:
28905        log.write(json.dumps(q) + '\n')
28906    result = {}
28907    if method == 'initialize': result = {'protocolVersion': 1, 'agentCapabilities': {'loadSession': load}}
28908    elif method == 'session/new': result = {'sessionId': 'temporary-thread'}
28909    elif method == 'session/load':
28910        assert q['params']['sessionId'] == '12345678-1234-4234-8234-123456789abc'
28911        if mode == 'refused':
28912            print(json.dumps({'jsonrpc':'2.0','id':q['id'],'error':{'code':-32000,'message':'fixture load refused'}}), flush=True)
28913            continue
28914        result = {'sessionId': q['params']['sessionId']}
28915    if method in ('session/new', 'session/load'): result['modes'] = {'currentModeId':'agent-full-access','availableModes':[{'id':'agent-full-access','name':'Full access'}]}
28916    if 'id' in q: print(json.dumps({'jsonrpc':'2.0','id':q['id'],'result':result}), flush=True)
28917"#.into(), acp_outcome.into()],
28918        });
28919        let catalog = AgentRegistry::new([spec]).unwrap();
28920        let (handle, runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28921        let events = handle.subscribe(CONTROL_EVENT_SUBSCRIPTION_CAPACITY);
28922        let workspace = WorkspaceConfig::new(WorkspaceId::new("primary").unwrap(), root).unwrap();
28923        let mut shared = NodeShared::new(handle, "fixture-token".into(),
28924            NodeId::new("codex-restart-test").unwrap(), vec![workspace], vec![provider.clone()]);
28925        shared.state_path = Some(root.join("state.json"));
28926        shared.native_launch_profile_control = Some(runtime.native_launch_profile_control());
28927        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
28928            crate::protocol::ProviderRuntimeStatus::raw_passthrough(provider, None),
28929        ]).unwrap();
28930        let loaded = session_registry::load(shared.state_path.as_deref(), &shared.node_id).unwrap();
28931        for record in loaded.records { shared.insert_record(record).unwrap(); }
28932        (shared, runtime, events)
28933    }
28934
28935    fn isolated_test(name: &str) -> bool {
28936        if std::env::var_os("HATCHERY_CODEX_RESTART_FIXTURE").is_some() { return false; }
28937        let mut command = std::process::Command::new(std::env::current_exe().unwrap());
28938        command.env_clear();
28939        for name in ["SystemRoot", "PATH", "TEMP", "TMP", "COMSPEC"] {
28940            if let Some(value) = std::env::var_os(name) { command.env(name, value); }
28941        }
28942        let status = command.args(["--exact", name, "--nocapture"])
28943            .env("HATCHERY_AUTO_PERMIT", "0")
28944            .env("HATCHERY_CODEX_RESTART_FIXTURE", "1")
28945            .status().unwrap();
28946        assert!(status.success());
28947        true
28948    }
28949
28950    async fn restart_case(acp: bool, fail_launch: bool, acp_outcome: &str) {
28951        let load = acp_outcome == "load";
28952        // The test process must disable provider setup; this fixture never uses vendor homes.
28953        assert!(!crate::auto_permit::enabled("codex"));
28954        let root = std::env::temp_dir().join(format!("codex-restart-{}-{}", std::process::id(), crate::pty_resume::new_session_id()));
28955        std::fs::create_dir_all(&root).unwrap();
28956        let (seed, _, _) = shared(&root, fail_launch);
28957        let identity = ProviderSessionIdentity {
28958            key: gate4agent_types::ProviderSessionKey::SessionId,
28959            id: "12345678-1234-4234-8234-123456789abc".into(), transcript_path: None,
28960        };
28961        let record = seed.index_provider_session(WorkspaceId::new("primary").unwrap(),
28962            AgentId::new("codex").unwrap(), identity.clone(), "codex #7".into()).unwrap();
28963        if acp {
28964            seed.session_records.lock().unwrap().records.get_mut(&record.record_id).unwrap().mode = SessionMode::Acp;
28965        }
28966        seed.persist_state().unwrap();
28967        let journal = crate::pty_resume::path(&root);
28968        crate::pty_resume::upsert(&journal, crate::pty_resume::Entry {
28969            instance_id: 7, provider: "codex".into(), workspace_id: "primary".into(),
28970            provider_session_id: identity.id.clone(), acp, cols: 96, rows: 32,
28971            options: Some(gate4agent_types::SessionOptionSelection::new("gpt-5.5").with_value("effort", "high")),
28972            session_key: "fixture-session-key".into(), origin: Some("agent".into()),
28973            parent_session_id: Some("codex#2".into()), ..Default::default()
28974        });
28975        drop(seed);
28976        for restart in 0..2 {
28977            let _ = std::fs::remove_file(root.join("resume-stop"));
28978            let _ = std::fs::remove_file(root.join("resume-acp.jsonl"));
28979            let (node, mut runtime, events) = shared_with_acp(&root, fail_launch, acp_outcome);
28980            node.reserve_journalled_instance_ids();
28981            assert_eq!(node.next_instance_id.load(Ordering::Acquire), 8);
28982            let node = Arc::new(node);
28983            let scenario = async {
28984                node.restore_journalled_sessions().await;
28985                let entries = crate::pty_resume::load(&journal);
28986                assert_eq!(entries.len(), 1);
28987                assert_eq!(entries[0].instance_id, 7);
28988                assert_eq!(entries[0].provider_session_id, identity.id);
28989                assert_eq!(entries[0].session_key, "fixture-session-key");
28990                assert_eq!(entries[0].origin.as_deref(), Some("agent"));
28991                assert_eq!(entries[0].parent_session_id.as_deref(), Some("codex#2"));
28992                let current = node.record(&record.record_id).unwrap();
28993                assert_eq!(current.provider_session.as_ref(), Some(&identity));
28994                if acp && acp_outcome == "refused" && restart == 0 {
28995                    let requests = std::fs::read_to_string(root.join("resume-acp.jsonl")).unwrap();
28996                    assert!(requests.lines().any(|line| {
28997                        let request: serde_json::Value = serde_json::from_str(line).unwrap();
28998                        request["method"] == "session/load"
28999                            && request["params"]["sessionId"] == identity.id
29000                    }), "the fallback must follow an actual load refusal");
29001                }
29002                if fail_launch {
29003                    assert!(current.active_session.is_none());
29004                    // Delayed cleanup of the failed generation must not erase the retry.
29005                    sleep(Duration::from_secs(6)).await;
29006                    assert_eq!(crate::pty_resume::load(&journal).len(), 1);
29007                    assert!(!root.join("resume-argv.txt").exists());
29008                } else {
29009                    assert_eq!(current.mode, if load { SessionMode::Acp } else { SessionMode::Pty });
29010                    let active = current.active_session.unwrap();
29011                    assert_eq!(active.session.instance_id.0, 7);
29012                    if acp && !load && restart == 0 {
29013                        assert!(active.session.generation.0 > 1,
29014                            "fallback must bind the re-registered engine generation");
29015                    }
29016                    assert_eq!((entries[0].cols, entries[0].rows), (96, 32));
29017                    assert_eq!(entries[0].acp, load);
29018                    if load {
29019                        let requests = std::fs::read_to_string(root.join("resume-acp.jsonl")).unwrap();
29020                        let requests: Vec<serde_json::Value> = requests.lines()
29021                            .map(|line| serde_json::from_str(line).unwrap()).collect();
29022                        let loads: Vec<_> = requests.iter()
29023                            .filter(|request| request["method"] == "session/load").collect();
29024                        assert_eq!(loads.len(), 1, "resume must load the saved thread exactly once");
29025                        assert_eq!(loads[0]["params"]["sessionId"], identity.id);
29026                        assert!(!root.join("resume-argv.txt").exists());
29027                    }
29028                    if !load {
29029                        let deadline = Instant::now() + Duration::from_secs(10);
29030                        while !root.join("resume-argv.txt").exists() {
29031                            assert!(Instant::now() < deadline, "native resume child did not write argv");
29032                            sleep(Duration::from_millis(20)).await;
29033                        }
29034                        let argv = std::fs::read_to_string(root.join("resume-argv.txt")).unwrap();
29035                        let args: Vec<_> = argv.lines().collect();
29036                        assert!(args.windows(2).any(|pair| pair == ["resume", identity.id.as_str()]), "{args:?}");
29037                        assert!(args.windows(2).any(|pair| pair == ["--model", "gpt-5.5"]));
29038                        // PowerShell parses the command text and removes the TOML string quotes.
29039                        assert!(args.windows(2).any(|pair| pair == ["-c", "model_reasoning_effort=high"]));
29040                        std::fs::remove_file(root.join("resume-argv.txt")).unwrap();
29041                    }
29042                }
29043                std::fs::write(root.join("resume-stop"), b"stop").unwrap();
29044                node.begin_shutdown().await.unwrap();
29045            };
29046            tokio::time::timeout(Duration::from_secs(30), async {
29047                let (_, result) = tokio::join!(scenario,
29048                    drive_runtime_until_shutdown(&mut runtime, events, Arc::clone(&node),
29049                        Duration::from_millis(NativeRuntimeConfig::default().provider_shutdown_timeout_ms)));
29050                result.unwrap();
29051            }).await.unwrap_or_else(|error| {
29052                let history = node.history.lock().unwrap();
29053                panic!("restart fixture timed out: {error}; events: {:?}", history.events);
29054            });
29055        }
29056        std::fs::remove_dir_all(root).unwrap();
29057    }
29058
29059    #[tokio::test]
29060    async fn codex_restart_without_identity_keeps_the_unresolved_entry() {
29061        let root = std::env::temp_dir().join(format!("codex-unresolved-{}", crate::pty_resume::new_session_id()));
29062        std::fs::create_dir_all(&root).unwrap();
29063        let (node, _runtime, _) = shared(&root, false);
29064        let journal = crate::pty_resume::path(&root);
29065        crate::pty_resume::upsert(&journal, crate::pty_resume::Entry {
29066            instance_id: 7, provider: "codex".into(), workspace_id: "primary".into(),
29067            acp: true, ..Default::default()
29068        });
29069        node.restore_journalled_sessions().await;
29070        assert_eq!(crate::pty_resume::load(&journal).len(), 1);
29071        assert!(node.session_bindings.lock().unwrap().is_empty());
29072        assert!(node.session_records.lock().unwrap().records.is_empty());
29073        std::fs::remove_dir_all(root).unwrap();
29074    }
29075
29076    #[test]
29077    fn codex_identity_event_is_journalled_before_the_refresh_tick() {
29078        let root = std::env::temp_dir().join(format!("codex-identity-{}", crate::pty_resume::new_session_id()));
29079        std::fs::create_dir_all(&root).unwrap();
29080        let (node, _runtime, _) = shared(&root, false);
29081        let address = SessionAddress {
29082            workspace_id: WorkspaceId::new("primary").unwrap(),
29083            session: SessionKey { instance_id: AgentInstanceId(7), generation: SessionGeneration(1) },
29084        };
29085        node.bind_spawn_session(&address, AgentId::new("codex").unwrap(), SessionMode::Pty,
29086            ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
29087            SpawnRecordPolicy::ProviderIdentityOnly, None).unwrap();
29088        let journal = crate::pty_resume::path(&root);
29089        crate::pty_resume::upsert(&journal, crate::pty_resume::Entry {
29090            instance_id: 7, provider: "codex".into(), ..Default::default()
29091        });
29092        for id in ["first-thread", "current-thread"] {
29093            node.publish_control(ControlEvent {
29094                sequence: 1, command_id: None, instance_id: AgentInstanceId(7), generation: SessionGeneration(1),
29095                event: ControlEventKind::ProviderEvent {
29096                    sequence: 1,
29097                    source: gate4agent_types::ProviderSource {
29098                        family: AdapterFamily::PtySemantic,
29099                        binding: AdapterBinding::new(
29100                            gate4agent_types::AdapterId::new("codex").unwrap(),
29101                            "fixture/v1", gate4agent_types::AdapterVerification::SyntheticFixture,
29102                        ).unwrap(),
29103                    },
29104                    source_sequence: 1,
29105                    event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
29106                        identity: ProviderSessionIdentity {
29107                            key: gate4agent_types::ProviderSessionKey::SessionId,
29108                            id: id.into(), transcript_path: None,
29109                        },
29110                    },
29111                },
29112            });
29113            assert_eq!(crate::pty_resume::load(&journal)[0].provider_session_id, id);
29114        }
29115        std::fs::remove_dir_all(root).unwrap();
29116    }
29117
29118    #[tokio::test]
29119    async fn codex_pty_restart_preserves_conversation_and_slot_twice() {
29120        if isolated_test("server::codex_restart_tests::codex_pty_restart_preserves_conversation_and_slot_twice") { return; }
29121        restart_case(false, false, "unsupported").await;
29122    }
29123
29124    #[tokio::test]
29125    async fn codex_acp_restart_resumes_same_thread_via_native_pty_twice() {
29126        if isolated_test("server::codex_restart_tests::codex_acp_restart_resumes_same_thread_via_native_pty_twice") { return; }
29127        restart_case(true, false, "unsupported").await;
29128    }
29129
29130    #[tokio::test]
29131    async fn codex_restart_failure_keeps_exact_journal_for_retry() {
29132        if isolated_test("server::codex_restart_tests::codex_restart_failure_keeps_exact_journal_for_retry") { return; }
29133        restart_case(true, true, "refused").await;
29134    }
29135    #[tokio::test]
29136    async fn codex_acp_restart_loads_same_thread_and_slot_twice() {
29137        if isolated_test("server::codex_restart_tests::codex_acp_restart_loads_same_thread_and_slot_twice") { return; }
29138        restart_case(true, false, "load").await;
29139    }
29140
29141    #[tokio::test]
29142    async fn codex_acp_load_refusal_falls_back_to_same_pty_thread() {
29143        if isolated_test("server::codex_restart_tests::codex_acp_load_refusal_falls_back_to_same_pty_thread") { return; }
29144        restart_case(true, false, "refused").await;
29145    }
29146
29147}