Skip to main content

gate4agent_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, AgentId, AgentInstanceId, AgentSpec, ApprovalLevel,
181    CommandEnvelope, CommandId,
182    validate_candidate_id, ControlCommand, ControlEvent, ControlEventKind,
183    HistoryCandidateSummary, HistoryOperation, HistoryQuery, HistorySessionRecord,
184    HostDecisionAuthority as ProviderHostDecisionAuthority,
185    HostRequestDecision as ProviderHostRequestDecision,
186    InputAction,
187    PromptFraming, PromptPayload, ResumeLaunchRequest,
188    ProviderActivity, ProviderEvent, ProviderInteractionKind, ProviderInteractionStatus,
189    ProviderRuntimeCapability, ProviderRuntimePolicy, ProviderSessionIdentity, ProviderSessionKey,
190    ProviderSnapshot, ProviderStopReason,
191    ResumeTarget,
192    SessionGeneration, StartRequest,
193    TerminalControl, TerminalText,
194    SessionStatus, TerminalFrame, TransportKind, CONTROL_SESSIONS_MAX,
195    HISTORY_DISCOVERY_LIMIT_MAX, WORKING_DIRECTORY_MAX_BYTES,
196};
197use std::collections::{BTreeMap, VecDeque};
198use std::ffi::OsString;
199use std::io;
200use std::path::{Path, PathBuf};
201use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, AtomicUsize, Ordering};
202use std::sync::{Arc, Mutex, RwLock};
203use std::time::{Duration, Instant};
204use thiserror::Error;
205use tokio::sync::{broadcast, mpsc, Mutex as AsyncMutex, Notify, OwnedSemaphorePermit, Semaphore};
206use tokio::task::{AbortHandle, JoinSet};
207use tokio::time::{sleep, timeout};
208
209mod http_api;
210
211mod browser_bridge;
212
213mod drive_loop_profile;
214
215#[path = "bundle_delivery.rs"]
216mod bundle_delivery;
217
218#[path = "context_pack_store.rs"]
219mod context_pack_store;
220
221#[cfg(windows)]
222pub use crate::platform::DEFAULT_NODE_ENDPOINT;
223
224const NODE_EVENT_HISTORY_MAX: usize = 4_096;
225const NODE_BROADCAST_CAPACITY: usize = 1_024;
226const NODE_TERMINAL_BROADCAST_CAPACITY: usize = 1;
227/// Unlike `NODE_TERMINAL_BROADCAST_CAPACITY`'s single replaceable slot (a
228/// terminal frame is a full-screen snapshot; only the latest ever matters),
229/// an `AgentStreamChunkV1` is one delta in an ordered sequence -- collapsing
230/// two pending sends into one would silently drop content a subscriber can
231/// never recover. This needs headroom for a burst instead.
232const NODE_AGENT_STREAM_BROADCAST_CAPACITY: usize = 256;
233/// The node never has more than `MAX_HARNESS_MCP_PENDING_CALLS_PER_NODE`
234/// `NodeEvent::HarnessMcpReadCall`s in flight at once (`begin_call`'s own
235/// admission check), and `publish_transient` sends each exactly once as a
236/// single-envelope batch -- so this many slots covers the entire pending
237/// set without ever lagging the one subscriber that matters, the harness's
238/// own connection, the way sharing the durable `event_tx`'s bus could.
239const NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY: usize = MAX_HARNESS_MCP_PENDING_CALLS_PER_NODE;
240const NODE_CONNECTION_EVENT_BURST_MAX: usize = 16;
241const CONTROL_EVENT_SUBSCRIPTION_CAPACITY: usize = 1_024;
242const MAX_PREAUTH_CONNECTIONS: usize = 32;
243const MAX_AUTHENTICATED_CONNECTIONS: usize = 16;
244const AUTH_FRAME_TIMEOUT_MS: u64 = 5_000;
245const FRAME_BODY_TIMEOUT_MS: u64 = 5_000;
246const CONNECTION_SHUTDOWN_GRACE_MS: u64 = 250;
247const SPAWN_DISPATCH_TIMEOUT_MS: u64 = 2_000;
248/// How often `drive_runtime_until_shutdown`'s loop re-evaluates the session
249/// record retention policy (`NodeServerConfig::session_record_retention`).
250/// A sweep cadence, not a policy value -- the policy itself lives entirely
251/// in the operator-configured age/keep-N flags; this just bounds how stale
252/// the in-memory view of dead `Unavailable` records can get between sweeps
253/// on an otherwise-idle node, mirroring the once-per-startup sweep that
254/// also runs alongside `reconcile_managed_worktrees`.
255const SESSION_RECORD_RETENTION_SWEEP_PERIOD: Duration = Duration::from_secs(5 * 60);
256/// TTL for `NodeShared::rejected_command_reasons` entries -- see that
257/// field's own doc for why a spawn waiter needs this at all. Must outlive
258/// the longest a waiter can still be polling for this exact command's
259/// outcome: `gate4agent_node_protocol::MAX_SPAWN_DEADLINE_MS` (120s) is the
260/// largest `deadline_ms` any caller may request, and the margin on top
261/// absorbs the waiter's own poll cadence plus clock skew between the drive
262/// loop (writer) and the waiter (reader).
263const REJECTED_COMMAND_REASON_TTL_MS: u64 = 150_000;
264/// Bounds `NodeShared::rejected_command_reasons` against an unbounded
265/// backlog of asynchronously-rejected commands nobody ever collects (a
266/// waiter that stops polling, or a burst of always-rejected commands from a
267/// misbehaving caller): once at capacity, the oldest entry is evicted to
268/// make room for the newest, mirroring the eviction rule already used by
269/// `spawn_idempotency`.
270const REJECTED_COMMAND_REASON_MAX_ENTRIES: usize = 256;
271/// How long `wait_until_removed` waits between two `Remove` re-dispatches
272/// for the same session. The presence poll runs far faster (2ms); this
273/// only paces the sending, so an accepted `Remove` still in flight is not
274/// piled on by a dozen more that land on an already-dropped instance.
275const REMOVE_REDISPATCH_INTERVAL_MS: u64 = 100;
276const SPAWN_IDEMPOTENCY_MAX_ENTRIES: usize = 256;
277const SPAWN_IDEMPOTENCY_TTL_MS: u64 = 15 * 60 * 1_000;
278const PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS: u64 = 2_500;
279const MUTATION_SETTLE_TIMEOUT_MS: u64 = 5_000;
280const READINESS_SETTLE_HEADROOM_MS: u64 = 2_000;
281const MANAGED_RESUME_SETTLE_TIMEOUT_MS: u64 = 30_000;
282const WORKSPACE_INSPECTION_MAX_CONCURRENCY: usize = 4;
283const WORKSPACE_FILE_READ_TIMEOUT_MS: u64 = 2_000;
284const HOST_DIRECTORY_BROWSE_TIMEOUT_MS: u64 = 2_000;
285const NATIVE_SESSION_CATALOG_TIMEOUT_MS: u64 = 30_000;
286const WORKSPACE_TREE_MAX_DEPTH: usize = 6;
287const WORKSPACE_TREE_MAX_ENTRIES: usize = 512;
288/// Default total wall-clock budget for `inspect_workspace`'s walk+git
289/// chain, overridable via `GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS`.
290const WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT: u64 = 8_000;
291/// The harness relay enforces its own flat response deadline
292/// (`HOST_RUN_READ_RESPONSE_DEADLINE`, `hatchery-harness-service::runtime`,
293/// 12s) above this node-side budget. The two constants live in different
294/// crates with no shared definition, so clamp any override here comfortably
295/// below that external ceiling — a misconfigured value above it would
296/// reopen the exact deadline race this budget exists to close.
297const WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX: u64 = 11_000;
298/// Default cap on directory entries visited (not just returned) by one
299/// walk, overridable via `GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP`.
300const WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT: usize = 50_000;
301const WORKSPACE_INSPECTION_ENTRY_CAP_MAX: usize = 1_000_000;
302const GIT_STATUS_MAX_ENTRIES: usize = 128;
303const GIT_COMMIT_MAX_ENTRIES: usize = 12;
304const GIT_OUTPUT_MAX_BYTES: usize = 64 * 1_024;
305const GIT_DIAGNOSTIC_MAX_BYTES: usize = 1_024;
306const WORKSPACE_FILE_WRITE_TIMEOUT_MS: u64 = 5_000;
307const WORKSPACE_ENTRY_CREATE_TIMEOUT_MS: u64 = 5_000;
308const GIT_COMMAND_TIMEOUT_MS: u64 = 1_500;
309const WORKSPACE_UNAVAILABLE_ERROR: &str = "workspace-unavailable";
310const PROVIDER_SESSION_SCOPE_CONFLICT_ERROR: &str = "provider-session-scope-conflict";
311const PROVIDER_SESSION_LIVE_CONFLICT_ERROR: &str = "provider-session-live-conflict";
312const MANAGED_SESSION_CAPACITY_ERROR: &str = "managed-session-capacity";
313const PROVIDER_IDENTITY_ALLOCATION_ERROR: &str =
314    "provider-session-identity-allocation-failed";
315const PROVIDER_RESUME_REJECTED_ERROR: &str = "provider-resume-rejected";
316const DURABLE_STATE_COMMIT_FAILED_ERROR: &str = "durable-state-commit-failed";
317const DURABLE_STATE_LOCK_FAILED_ERROR: &str = "durable-state-lock-failed";
318const DURABLE_STATE_LOAD_FAILED_ERROR: &str = "durable-state-load-failed";
319const DURABLE_STATE_CONFLICT_ERROR: &str = "durable-state-conflict";
320const DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR: &str = "durable-state-schema-unsupported";
321const DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR: &str =
322    "durable-state-path-semantics-unsupported";
323
324fn opaque_windows_path(value: String) -> OpaqueHostPath {
325    OpaqueHostPath::utf8(value).expect("validated Windows node path must remain wire-safe")
326}
327
328fn agent_progress_from_provider_snapshot(
329    address: SessionAddress,
330    provider: &ProviderSnapshot,
331) -> Option<SessionAgentProgress> {
332    let mut truncated = false;
333    let active_tool_count = bounded_progress_count(provider.active_tools.len(), &mut truncated);
334    let mut active_tool_labels = Vec::with_capacity(
335        provider
336            .active_tools
337            .len()
338            .min(MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS),
339    );
340    for tool in &provider.active_tools {
341        if active_tool_labels.len() == MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS {
342            truncated = true;
343            break;
344        }
345        if let Some(label) = sanitize_progress_tool_label(&tool.name, &mut truncated) {
346            active_tool_labels.push(label);
347        }
348    }
349    let attention = provider
350        .interactions
351        .iter()
352        .find(|interaction| {
353            matches!(
354                interaction.status,
355                ProviderInteractionStatus::Pending | ProviderInteractionStatus::Resolving { .. }
356            )
357        })
358        .map(|interaction| AgentProgressAttentionV1 {
359            kind: match interaction.interaction_kind {
360                ProviderInteractionKind::Approval => AgentProgressAttentionKindV1::Approval,
361                ProviderInteractionKind::Question => AgentProgressAttentionKindV1::Question,
362            },
363            tool_label: sanitize_progress_tool_label(&interaction.tool_name, &mut truncated),
364        });
365    let subagent_count = bounded_progress_count(provider.subagents.len(), &mut truncated);
366    let usage = (provider.completed_turns > 0
367        || provider.usage.input_tokens > 0
368        || provider.usage.output_tokens > 0
369        || provider.usage.cache_read_tokens > 0
370        || provider.usage.cache_write_tokens > 0
371        || provider.usage.reasoning_tokens > 0)
372        .then_some(AgentProgressUsageV1 {
373            input_tokens: provider.usage.input_tokens,
374            output_tokens: provider.usage.output_tokens,
375            cache_read_tokens: provider.usage.cache_read_tokens,
376            cache_write_tokens: provider.usage.cache_write_tokens,
377            reasoning_tokens: provider.usage.reasoning_tokens,
378        });
379    let progress = AgentProgressV1 {
380        provider_sequence: provider.sequence,
381        activity: provider.activity,
382        completed_turns: provider.completed_turns,
383        usage,
384        current: AgentProgressCurrentV1::from(provider.activity),
385        active_tool_labels,
386        active_tool_count,
387        attention,
388        subagent_count,
389        last_event_kind: provider.last_event.as_ref().and_then(agent_progress_event_kind),
390        gap_count: provider.gap_count,
391        stale: provider.stale,
392        truncated,
393    };
394    let entry = SessionAgentProgress { address, progress };
395    serde_json::to_vec(&entry)
396        .ok()
397        .filter(|encoded| encoded.len() <= MAX_AGENT_PROGRESS_ENTRY_BYTES)
398        .map(|_| entry)
399}
400
401fn bounded_progress_count(count: usize, truncated: &mut bool) -> u32 {
402    match u32::try_from(count) {
403        Ok(count) => count,
404        Err(_) => {
405            *truncated = true;
406            u32::MAX
407        }
408    }
409}
410
411fn agent_progress_event_kind(event: &ProviderEvent) -> Option<AgentProgressEventKindV1> {
412    if matches!(event, ProviderEvent::ContextWindowUsage { .. }) {
413        return None;
414    }
415    Some(match event {
416        ProviderEvent::SessionStarted { .. } => AgentProgressEventKindV1::SessionStarted,
417        ProviderEvent::SessionIdentityObserved { .. } => {
418            AgentProgressEventKindV1::SessionIdentityObserved
419        }
420        ProviderEvent::TurnStarted { .. } => AgentProgressEventKindV1::TurnStarted,
421        ProviderEvent::WorkingObserved => AgentProgressEventKindV1::WorkingObserved,
422        ProviderEvent::Text { .. } => AgentProgressEventKindV1::Text,
423        ProviderEvent::Thinking { .. } => AgentProgressEventKindV1::Thinking,
424        ProviderEvent::ToolStarted { .. } => AgentProgressEventKindV1::ToolStarted,
425        ProviderEvent::ToolCompleted { .. } => AgentProgressEventKindV1::ToolCompleted,
426        ProviderEvent::TurnCompleted { .. } => AgentProgressEventKindV1::TurnCompleted,
427        ProviderEvent::TurnInterrupted => AgentProgressEventKindV1::TurnInterrupted,
428        ProviderEvent::SessionEnded { .. } => AgentProgressEventKindV1::SessionEnded,
429        ProviderEvent::Error { .. } => AgentProgressEventKindV1::Error,
430        ProviderEvent::Ready => AgentProgressEventKindV1::Ready,
431        ProviderEvent::InteractionRequested { .. } => {
432            AgentProgressEventKindV1::InteractionRequested
433        }
434        ProviderEvent::InteractionResolved { .. } => AgentProgressEventKindV1::InteractionResolved,
435        ProviderEvent::SubagentStarted { .. } => AgentProgressEventKindV1::SubagentStarted,
436        ProviderEvent::SubagentStopped { .. } => AgentProgressEventKindV1::SubagentStopped,
437        ProviderEvent::RateLimited { .. } => AgentProgressEventKindV1::RateLimited,
438        ProviderEvent::HostRequestObserved { .. } => AgentProgressEventKindV1::HostRequestObserved,
439        ProviderEvent::UnrecognizedNotification { .. } => {
440            AgentProgressEventKindV1::UnrecognizedNotification
441        }
442        ProviderEvent::ContextWindowUsage { .. } => unreachable!("handled above"),
443        // ACP session/update coverage beyond text/tool/turn streaming
444        // (`plan`, `available_commands_update`, `current_mode_update`,
445        // `session_info_update`, `usage_update`, `config_option_update`,
446        // `user_message_chunk`). `AgentProgressEventKindV1` is a versioned
447        // wire enum without a `#[serde(other)]` fallback; minting new
448        // variants for it is a deliberate wire-contract decision for
449        // whoever owns this protocol, not a side effect of parsing more of
450        // ACP's own wire -- until that decision is made, these fold into
451        // the same "we saw something, no dedicated kind for it yet" bucket
452        // as `UnrecognizedNotification`.
453        ProviderEvent::UserMessage { .. }
454        | ProviderEvent::Plan { .. }
455        | ProviderEvent::AvailableCommandsUpdated { .. }
456        | ProviderEvent::ModeChanged { .. }
457        | ProviderEvent::SessionInfoUpdated { .. }
458        | ProviderEvent::UsageUpdated { .. }
459        | ProviderEvent::ConfigOptionsUpdated { .. } => {
460            AgentProgressEventKindV1::UnrecognizedNotification
461        }
462    })
463}
464
465/// Maps this harness's own `HostDecisionAuthority` onto the wider
466/// Maps a host request decider onto the `BlockAuthorityV1` a `Blocked` stream
467/// chunk carries -- the four authorities a `Denied` decision is ever made
468/// under, one-for-one. The remaining `BlockAuthorityV1` variants
469/// (`ProviderClassifier`, `ProviderPermissionRule`, `ProviderSandbox`,
470/// `ProviderRefusal`, `UserRejected`, `ProviderQuota`, `Unknown`) describe a
471/// block the PROVIDER side of the wire reported -- this function never
472/// produces one, because `ProviderHostDecisionAuthority` has no vocabulary for
473/// them; the provider-side blocks below mint those directly from typed fields.
474/// Whether a provider source is a live session transport -- the only kind
475/// whose events reach the agent stream. History, resume, session-option and
476/// capability-probe sources are not sessions and stream nothing.
477fn provider_family_is_live(family: AdapterFamily) -> bool {
478    match family {
479        AdapterFamily::PtySemantic
480        | AdapterFamily::Hook
481        | AdapterFamily::ManagedHook
482        | AdapterFamily::Pipe
483        | AdapterFamily::OneShot
484        | AdapterFamily::Acp => true,
485        AdapterFamily::History
486        | AdapterFamily::Resume
487        | AdapterFamily::SessionOptions
488        | AdapterFamily::CapabilityProbe => false,
489    }
490}
491
492fn block_authority(by: ProviderHostDecisionAuthority) -> BlockAuthorityV1 {
493    match by {
494        ProviderHostDecisionAuthority::Gate => BlockAuthorityV1::HostGate,
495        ProviderHostDecisionAuthority::Policy => BlockAuthorityV1::HostPolicy,
496        ProviderHostDecisionAuthority::Operator => BlockAuthorityV1::Operator,
497        ProviderHostDecisionAuthority::DeadlinePolicy => BlockAuthorityV1::HostDeadline,
498    }
499}
500
501/// The `Blocked::reason` for a `Denied` decision this build's own
502/// `AgentEvent::RpcIncomingRequest::reason` did not attach text to --
503/// reachable today for `Operator`/`HostDeadline`: a deferred `session/
504/// request_permission` request resolved by `AcpSession::
505/// resolve_pending_request`/`expire_deadlines` (`src/acp/session.rs`),
506/// neither of which computes free text (an operator answers approve/reject,
507/// never a comment; an unattended deadline asks nobody anything). `reason`
508/// is a REQUIRED field on the wire (see `AgentStreamChunkKindV1::Blocked`)
509/// -- a categorical fallback naming the authority is the
510/// honest minimum, never a placeholder standing in for detail nobody
511/// reported.
512fn action_blocked_fallback_reason(authority: BlockAuthorityV1) -> &'static str {
513    match authority {
514        BlockAuthorityV1::HostGate => "denied by the dangerous-command gate",
515        BlockAuthorityV1::HostPolicy => "denied by host policy",
516        BlockAuthorityV1::HostDeadline => {
517            "denied by host policy after the operator deadline expired"
518        }
519        BlockAuthorityV1::Operator => "declined by the operator",
520        BlockAuthorityV1::ProviderClassifier => "denied by the provider's classifier",
521        BlockAuthorityV1::ProviderPermissionRule => "denied by a provider permission rule",
522        BlockAuthorityV1::ProviderSandbox => "denied by the provider's sandbox",
523        BlockAuthorityV1::ProviderRefusal => "refused by the provider",
524        BlockAuthorityV1::UserRejected => "rejected by the user",
525        BlockAuthorityV1::ProviderQuota => "the provider's quota or rate limit was exhausted",
526        BlockAuthorityV1::Unknown => "blocked; no reason was reported",
527    }
528}
529
530/// Computes `AgentStreamChunkKindV1::Blocked`'s `tool_class`/`authority`/`reason`
531/// for a `HostRequestObserved` `Denied` decision.
532fn host_request_denied_block(
533    method: &str,
534    by: ProviderHostDecisionAuthority,
535    reason: Option<&str>,
536) -> (String, BlockAuthorityV1, String) {
537    let authority = block_authority(by);
538    let reason_source = reason.unwrap_or_else(|| action_blocked_fallback_reason(authority));
539    // `AgentStreamChunkKindV1::validate` REJECTS an over-long reason outright
540    // rather than truncating -- this cut happens here, at the producer, before
541    // the validator ever sees the text.
542    let (reason_text, _) =
543        truncate_text(reason_source, MAX_ACP_BLOCKED_REASON_BYTES);
544    (tool_class_label(method), authority, reason_text)
545}
546
547// ---------------------------------------------------------------------------
548// K2 -- provider-side blocks read off ACP `tool_call_update._meta.
549// nonExecutionKind` (`ProviderEvent::ToolCompleted::non_execution_kind`) and
550// `SessionEnded::stop_reason` (`stopReason: "refusal"`, or a `session/
551// prompt` RPC error whose vendor code names a quota/limit/rate
552// exhaustion). See
553// `docs/gate4agent/plans/gate4agent-blocked-action-event-2026-09-02.md`
554// §3 slice K2.
555// ---------------------------------------------------------------------------
556
557/// Classifies ACP's `nonExecutionKind` into the typed authority a block
558/// this build mints under, or `None` when the tool truly did not run for a
559/// reason that is NOT a block -- `"interrupted"`/`"cancelled"` mean the
560/// TURN stopped (session closed, the operator cancelled), not that a
561/// policy/classifier/rule refused the call, so those (and any unrecognized
562/// future value) stay a plain `ToolCompleted { success: false }` only, per
563/// this plan's own rule that a tool failure is never inflated into a block
564/// this build cannot actually attribute.
565fn provider_tool_block_authority(kind: Option<&str>) -> Option<(BlockAuthorityV1, &'static str)> {
566    match kind? {
567        "user-rejected" => Some((BlockAuthorityV1::UserRejected, "user-rejected")),
568        "permission-rule" => Some((BlockAuthorityV1::ProviderPermissionRule, "permission-rule")),
569        _ => None,
570    }
571}
572
573/// Splits a provider's own denial sentence into `reason`/`help`, when it
574/// matches the fixed shape Claude Code's classifier/permission-rule denial
575/// uses -- verified live, the owner's sample fixture
576/// (`docs/gate4agent/research/gate4agent-blocked-action-signals-2026-09-02.md`
577/// appendix): "...denied by <authority>. Reason: <reason>. <help...>".
578/// `authority` is NEVER derived from this text (see this module's own rule
579/// that authority comes only from a typed field) -- only `reason`/`help`
580/// are. Falls back to the full text as `reason` with no `help` when the
581/// shape does not match (a provider whose text does not carry this exact
582/// wording) -- the honest minimum: the tool truly did not run, so SOME
583/// reason is always reported, never fabricated detail beyond what arrived.
584fn split_provider_denial_text(text: &str) -> (String, Option<String>) {
585    let trimmed = text.trim();
586    let Some((_, after_reason)) = trimmed.split_once("Reason:") else {
587        return (trimmed.to_owned(), None);
588    };
589    let tail = after_reason.trim_start();
590    let Some((reason, help)) = tail.split_once(". ") else {
591        // "Reason:" present but no ". " to split help off -- the whole
592        // tail is the reason, e.g. "Reason: Blocked by classifier." with
593        // no help text following on some providers/versions.
594        let reason = tail.strip_suffix('.').unwrap_or(tail).trim();
595        return (reason.to_owned(), None);
596    };
597    let help = help.trim();
598    if help.is_empty() {
599        (reason.trim().to_owned(), None)
600    } else {
601        (reason.trim().to_owned(), Some(help.to_owned()))
602    }
603}
604
605/// `tool_class` for a provider-side `ActionBlocked` this build has no typed
606/// tool name for -- `ProviderEvent::ToolCompleted` carries only `id`, never
607/// `name` (see `ProviderEvent::ToolStarted`, which does, but a tool's own
608/// name is never threaded through to its completion). Reads the class off
609/// the guidance tail's own "add a <Class> permission rule" sentence when
610/// present (the shape the owner's sample fixture uses) -- text matching
611/// fills `tool_class` here, same as `reason`/`help`, NEVER `authority` (see
612/// this module's own rule). Falls back to the same generic `"Tool"` class
613/// `ToolCompleted`'s own observation already uses when nothing names one.
614fn tool_class_from_denial_help(help: Option<&str>) -> String {
615    help.and_then(|text| text.split("add a ").nth(1))
616        .and_then(|tail| tail.split(' ').next())
617        .filter(|word| !word.is_empty())
618        .map(str::to_owned)
619        .unwrap_or_else(|| "Tool".to_owned())
620}
621
622/// Whether a `session/prompt` RPC error's vendor code (or, absent one, its
623/// message) names a quota/rate-limit exhaustion -- Codex's
624/// `data.codexErrorInfo: "usageLimitExceeded"` (measured live 2026-09-05)
625/// contains "limit", the word this build actually keys on; "quota"/"rate"
626/// are kept for whichever OTHER vendor's own code names one of those
627/// instead, never confirmed live, but the honest generalization of the
628/// one confirmed sample.
629fn provider_error_is_quota(vendor_code: Option<&str>, message: &str) -> bool {
630    fn names_quota(s: &str) -> bool {
631        let lower = s.to_ascii_lowercase();
632        lower.contains("limit") || lower.contains("quota") || lower.contains("rate")
633    }
634    vendor_code.is_some_and(names_quota) || names_quota(message)
635}
636
637/// The `Blocked` chunk this build mints when a turn stopped via a
638/// typed provider fact rather than a tool call -- `stopReason: "refusal"`
639/// (`authority: ProviderRefusal`) or a `session/prompt` RPC error whose
640/// vendor code (or message) names a quota/limit/rate exhaustion
641/// (`authority: ProviderQuota`, `reason` = the error message verbatim,
642/// `reason_kind` = the vendor code). `None` for every other stop --
643/// `end_turn`, `max_tokens`, `max_turn_requests`, `cancelled`, an untagged
644/// `Other`, or an RPC error whose vendor code/message names none of the
645/// three words: `SessionEnded` stays a plain `Exited`/turn-end fact for
646/// those, never inflated into a block this build cannot actually
647/// attribute. `ProviderRefusal`'s `reason` has no source text to read on
648/// `SessionEnded` itself (ACP's `refusal` stopReason carries none) -- the
649/// honest minimum names the fact that happened, never invented detail; a
650/// later slice that threads the turn's last agent text chunk through can
651/// replace it without changing this function's contract.
652fn provider_session_end_block(
653    stop_reason: &ProviderStopReason,
654) -> Option<(BlockAuthorityV1, String, String)> {
655    match stop_reason {
656        ProviderStopReason::Refusal => Some((
657            BlockAuthorityV1::ProviderRefusal,
658            "refusal".to_owned(),
659            "the provider ended the turn with stopReason: refusal".to_owned(),
660        )),
661        ProviderStopReason::ProviderError { message, vendor_code, .. }
662            if provider_error_is_quota(vendor_code.as_deref(), message) =>
663        {
664            let reason_kind = vendor_code.clone().unwrap_or_else(|| "provider-error".to_owned());
665            Some((BlockAuthorityV1::ProviderQuota, reason_kind, message.clone()))
666        }
667        _ => None,
668    }
669}
670
671/// Builds the `AgentStreamChunkV1` `event`'s `ProviderEvent` carries onto the
672/// `agent-stream-events-v1` channel -- see
673/// `docs/gate4agent/plans/gate4agent-acp-control-plane-on-the-wire-2026-09-02.md`
674/// §5. Two invariants keep the chunk correlatable with the `NodeEvent::Control`
675/// event it derives from, so a client joining the two sees one id and one
676/// ordering:
677///
678/// - `source_sequence` on the chunk is the `source_sequence` the provider
679///   event itself carries.
680/// - `InteractionPrompt::correlation_id` is minted by
681///   `correlation::interaction_correlation` with the
682///   `(instance_id, generation, provider_sequence)` triple -- never the
683///   provider's own `request_id`, whose opacity on the wire is pinned by a
684///   dedicated test.
685///
686/// Returns `None` for every `ControlEventKind` other than `ProviderEvent`
687/// (nothing else carries stream content), for a source that is not a live
688/// session transport, and for the `ProviderEvent` variants below with no
689/// declared chunk kind to carry them, or whose declared chunk kind this build
690/// has no source for yet:
691///
692/// - `ModelCatalog { current, available }` has no `ProviderEvent` source at
693///   all -- grok's vendor model switch is not bridged into `ProviderEvent`
694///   yet, the same gap the plan names explicitly.
695/// - `UserMessage { text, is_delta }` is an echo of the OPERATOR's own
696///   prompt, replayed on session resume -- `Text`/`Thinking` are documented
697///   as "what the agent is saying", so mapping the operator's words onto
698///   `Text` would misrepresent whose content it is. No dedicated
699///   user-message kind is declared, so this stays unproduced rather than
700///   mislabeled.
701///
702/// `HostRequestObserved { .. }` is its own arm below: a `Granted`/`Deferred`
703/// decision returns `None` (nothing for the operator to react to), a `Denied`
704/// decision returns `Some(Blocked { .. })`.
705///
706/// `ToolCompleted { .. }`/`SessionEnded { .. }` are likewise their own arms
707/// below (K2): each returns `None` unless
708/// `provider_tool_block_authority`/`provider_session_end_block` classifies the
709/// event as an actual block (ACP's `_meta.nonExecutionKind`, or `stopReason`/an
710/// RPC error naming a quota).
711fn agent_stream_chunk(event: &ControlEvent) -> Option<AgentStreamChunkV1> {
712    let ControlEventKind::ProviderEvent {
713        sequence: provider_sequence,
714        source,
715        source_sequence,
716        event: provider_event,
717    } = &event.event
718    else {
719        return None;
720    };
721    if !provider_family_is_live(source.family) {
722        return None;
723    }
724    let kind = match provider_event {
725        ProviderEvent::Text { text, is_delta } => AgentStreamChunkKindV1::Text {
726            text: text.clone(),
727            is_delta: *is_delta,
728        },
729        ProviderEvent::Thinking { text } => {
730            AgentStreamChunkKindV1::Thinking { text: text.clone() }
731        }
732        ProviderEvent::InteractionRequested {
733            interaction_kind,
734            tool_name,
735            title,
736            prompt,
737            options,
738            ..
739        } => AgentStreamChunkKindV1::InteractionPrompt {
740            correlation_id: interaction_correlation(
741                event.instance_id,
742                event.generation,
743                *provider_sequence,
744            ),
745            interaction_kind: *interaction_kind,
746            tool_name: tool_name.clone(),
747            // `title`/`options` are carried straight through from
748            // `ProviderEvent::InteractionRequested`, as the agent itself
749            // gave them on `session/request_permission`'s `PermissionTool
750            // Call.title`/`options` (see `gate4agent-shell-native`'s
751            // `provider_event`, the only ACP-transport producer). A PTY- or
752            // hook-sourced interaction carries `title: None` and
753            // `options: []` from its own producer because that source has
754            // none to offer, never because this mapping dropped one.
755            title: title.clone(),
756            prompt: prompt.clone(),
757            options: options
758                .iter()
759                .map(|option| AgentStreamInteractionOptionV1 {
760                    option_id: option.option_id.clone(),
761                    name: option.name.clone(),
762                    kind: option.kind.clone(),
763                })
764                .collect(),
765        },
766        ProviderEvent::ConfigOptionsUpdated { options } => {
767            AgentStreamChunkKindV1::ConfigOptions { options: options.clone() }
768        }
769        // `available` is the mode catalogue `AcpSession::available_modes()`
770        // read back at `session/new` and threaded through by
771        // `gate4agent-shell-native`'s `provider_event` (see that crate's
772        // `ProviderEvent::ModeChanged` arm) -- an empty list here is the
773        // agent's own honest "no modes announced", never "not read": ACP
774        // orders `session/new` before any `session/update`, and the
775        // catalogue is fixed at handshake, so a `ModeChanged` event cannot
776        // exist before the same session object's catalogue is already
777        // populated (see `gate4agent_types::ProviderEvent::ModeChanged`'s
778        // own doc comment for the ordering argument in full).
779        ProviderEvent::ModeChanged { mode_id, available } => AgentStreamChunkKindV1::ModeCatalog {
780            current: Some(mode_id.clone()),
781            available: available
782                .iter()
783                .map(|mode| AgentStreamNamedIdV1 {
784                    id: mode.id.clone(),
785                    name: mode.name.clone(),
786                    description: mode.description.clone(),
787                })
788                .collect(),
789        },
790        // A denied host request is a block -- see this function's own doc
791        // comment for why `HostRequestObserved` is not in the exclusion list
792        // below.
793        ProviderEvent::HostRequestObserved { method, decision, reason, .. } => {
794            let ProviderHostRequestDecision::Denied { by } = decision else {
795                return None;
796            };
797            let (tool_class, authority, reason_text) =
798                host_request_denied_block(method, *by, reason.as_deref());
799            AgentStreamChunkKindV1::Blocked {
800                correlation_id: None,
801                tool_class,
802                authority,
803                reason_kind: None,
804                reason: reason_text,
805                help: None,
806            }
807        }
808        // Same relationship as `HostRequestObserved` above, driven by ACP's
809        // own `_meta.nonExecutionKind` instead of the host's
810        // `HostRequestDecision`.
811        ProviderEvent::ToolCompleted { id, output, non_execution_kind, .. } => {
812            let (authority, reason_kind) =
813                provider_tool_block_authority(non_execution_kind.as_deref())?;
814            let (reason, help) = split_provider_denial_text(output);
815            let (reason, _) =
816                truncate_text(&reason, MAX_ACP_BLOCKED_REASON_BYTES);
817            let help = help.map(|help| {
818                truncate_text(&help, MAX_ACP_BLOCKED_HELP_BYTES).0
819            });
820            AgentStreamChunkKindV1::Blocked {
821                correlation_id: Some(tool_correlation(event, source, id)),
822                tool_class: tool_class_from_denial_help(help.as_deref()),
823                authority,
824                reason_kind: Some(reason_kind.to_owned()),
825                reason,
826                help,
827            }
828        }
829        // Same relationship, driven by `stopReason: "refusal"` or a
830        // `session/prompt` RPC error naming a quota/limit/rate exhaustion
831        // instead of a tool-level fact.
832        ProviderEvent::SessionEnded { stop_reason, .. } => {
833            let (authority, reason_kind, reason) =
834                stop_reason.as_ref().and_then(provider_session_end_block)?;
835            let (reason, _) =
836                truncate_text(&reason, MAX_ACP_BLOCKED_REASON_BYTES);
837            AgentStreamChunkKindV1::Blocked {
838                correlation_id: None,
839                tool_class: "Session".to_owned(),
840                authority,
841                reason_kind: Some(reason_kind),
842                reason,
843                help: None,
844            }
845        }
846        ProviderEvent::SessionStarted { .. }
847        | ProviderEvent::SessionIdentityObserved { .. }
848        | ProviderEvent::TurnStarted { .. }
849        | ProviderEvent::WorkingObserved
850        | ProviderEvent::ToolStarted { .. }
851        | ProviderEvent::TurnCompleted { .. }
852        | ProviderEvent::ContextWindowUsage { .. }
853        | ProviderEvent::TurnInterrupted
854        | ProviderEvent::Error { .. }
855        | ProviderEvent::Ready
856        | ProviderEvent::InteractionResolved { .. }
857        | ProviderEvent::SubagentStarted { .. }
858        | ProviderEvent::SubagentStopped { .. }
859        | ProviderEvent::RateLimited { .. }
860        | ProviderEvent::UnrecognizedNotification { .. }
861        | ProviderEvent::UserMessage { .. }
862        | ProviderEvent::Plan { .. }
863        | ProviderEvent::AvailableCommandsUpdated { .. }
864        | ProviderEvent::SessionInfoUpdated { .. }
865        | ProviderEvent::UsageUpdated { .. } => return None,
866    };
867    Some(AgentStreamChunkV1 { source_sequence: *source_sequence, kind })
868}
869
870async fn run_native_session_catalog_operation<T, E, F>(
871    catalog: Arc<Mutex<NativeSessionCatalogAuthority>>,
872    operation: &'static str,
873    execute: F,
874) -> Result<T, NodeFailure>
875where
876    T: Send + 'static,
877    E: std::fmt::Display + Send + 'static,
878    F: FnOnce(&mut NativeSessionCatalogAuthority) -> Result<T, E> + Send + 'static,
879{
880    let task = tokio::task::spawn_blocking(move || {
881        let mut catalog = match catalog.try_lock() {
882            Ok(catalog) => catalog,
883            Err(std::sync::TryLockError::Poisoned(poisoned)) => poisoned.into_inner(),
884            Err(std::sync::TryLockError::WouldBlock) => {
885                return Err(failure(
886                    NodeFailureCode::BackendBusy,
887                    "native session catalog operation is already running",
888                ));
889            }
890        };
891        execute(&mut catalog).map_err(|error| {
892            failure(
893                NodeFailureCode::BackendOperationFailed,
894                &format!("{operation} failed: {error}"),
895            )
896        })
897    });
898    timeout(Duration::from_millis(NATIVE_SESSION_CATALOG_TIMEOUT_MS), task)
899        .await
900        .map_err(|_| {
901            failure(
902                NodeFailureCode::BackendBusy,
903                &format!("{operation} exceeded its bounded deadline"),
904            )
905        })?
906        .map_err(|_| {
907            failure(
908                NodeFailureCode::BackendOperationFailed,
909                &format!("{operation} task failed"),
910            )
911        })?
912}
913
914fn windows_path_text(path: &OpaqueHostPath) -> &str {
915    path.as_utf8()
916        .expect("Windows node only creates UTF-8 opaque host paths")
917}
918
919fn require_windows_path(path: OpaqueHostPath) -> Result<String, NodeFailure> {
920    path.as_utf8()
921        .map(str::to_owned)
922        .ok_or_else(|| failure(
923            NodeFailureCode::InvalidRequest,
924            "this node requires UTF-8 host paths",
925        ))
926}
927
928fn protocol_worktree(mut worktree: NativeGitWorktreeSnapshot) -> GitWorktreeSnapshot {
929    GitWorktreeSnapshot {
930        path: opaque_windows_path(worktree.path),
931        head: worktree.head,
932        branch: worktree.branch,
933        is_bare: worktree.is_bare,
934        is_main: worktree.is_main,
935        locked: worktree.locked,
936        lock_reason: worktree.lock_reason,
937        prunable: worktree.prunable,
938        prunable_reason: worktree.prunable_reason,
939        workspace_id: worktree.workspace_id.take(),
940    }
941}
942
943#[derive(Clone, Debug, Eq, PartialEq)]
944pub struct WorkspaceConfig {
945    workspace_id: WorkspaceId,
946    canonical_root: String,
947    worktree_service_mode: WorktreeServiceMode,
948    managed_worktree_profiles: BTreeMap<WorktreeProfileId, ManagedWorktreeProfile>,
949}
950
951impl WorkspaceConfig {
952    pub fn new(
953        workspace_id: WorkspaceId,
954        root: impl AsRef<Path>,
955    ) -> Result<Self, NodeServerError> {
956        let root = root.as_ref();
957        if !root.is_absolute() {
958            return Err(NodeServerError::InvalidWorkspaceRoot {
959                workspace_id,
960                path: root.to_string_lossy().into_owned(),
961                message: "path is not absolute".to_owned(),
962            });
963        }
964        let canonical = std::fs::canonicalize(root).map_err(|error| {
965            NodeServerError::InvalidWorkspaceRoot {
966                workspace_id: workspace_id.clone(),
967                path: root.to_string_lossy().into_owned(),
968                message: error.to_string(),
969            }
970        })?;
971        if !canonical.is_dir() {
972            return Err(NodeServerError::InvalidWorkspaceRoot {
973                workspace_id,
974                path: root.to_string_lossy().into_owned(),
975                message: "path is not a directory".to_owned(),
976            });
977        }
978        let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
979            NodeServerError::InvalidWorkspaceRoot {
980                workspace_id: workspace_id.clone(),
981                path: path.to_string_lossy().into_owned(),
982                message: "canonical path is not valid Unicode".to_owned(),
983            }
984        })?;
985        let canonical_root = platform::normalize_canonical_root(canonical_root);
986        validate_workspace_root(&workspace_id, &canonical_root)?;
987        Ok(Self {
988            workspace_id,
989            canonical_root,
990            worktree_service_mode: WorktreeServiceMode::Manual,
991            managed_worktree_profiles: BTreeMap::new(),
992        })
993    }
994
995    pub fn workspace_id(&self) -> &WorkspaceId {
996        &self.workspace_id
997    }
998
999    pub fn canonical_root(&self) -> &str {
1000        &self.canonical_root
1001    }
1002
1003    pub fn with_worktree_service_mode(mut self, mode: WorktreeServiceMode) -> Self {
1004        self.worktree_service_mode = mode;
1005        self
1006    }
1007
1008    pub fn with_managed_worktree_profile(
1009        mut self,
1010        profile: ManagedWorktreeProfile,
1011    ) -> Result<Self, NodeServerError> {
1012        profile.validate_for_workspace(&self.canonical_root).map_err(|message| {
1013            NodeServerError::InvalidManagedWorktreeProfile {
1014                workspace_id: self.workspace_id.clone(),
1015                profile_id: profile.profile_id().clone(),
1016                message,
1017            }
1018        })?;
1019        if self.managed_worktree_profiles.contains_key(profile.profile_id()) {
1020            return Err(NodeServerError::DuplicateManagedWorktreeProfile {
1021                workspace_id: self.workspace_id,
1022                profile_id: profile.profile_id().clone(),
1023            });
1024        }
1025        if self.managed_worktree_profiles.len()
1026            == MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE
1027        {
1028            return Err(NodeServerError::ManagedWorktreeProfileCapacity {
1029                workspace_id: self.workspace_id,
1030                max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
1031            });
1032        }
1033        self.managed_worktree_profiles
1034            .insert(profile.profile_id().clone(), profile);
1035        Ok(self)
1036    }
1037}
1038
1039#[derive(Clone)]
1040pub struct NodeServerConfig {
1041    pub endpoint: String,
1042    api_listen: Option<std::net::SocketAddr>,
1043    /// Opt-in node-envelope HTTP+WS browser bridge (separate from `--api-listen`
1044    /// ops HTTP and from framed C2). Loopback only; default `None` = off.
1045    bridge_listen: Option<std::net::SocketAddr>,
1046    /// Optional bridge shared secret (`GATE4AGENT_BRIDGE_TOKEN`). Distinct from
1047    /// NODE_TOKEN; never logged. `None` = loopback bind is the only gate.
1048    bridge_token: Option<String>,
1049    /// A relay to CALL, rather than wait to be called by. `None` keeps the
1050    /// node purely a listener, which is what it has always been.
1051    call_home: Option<std::net::SocketAddr>,
1052    pub node_id: NodeId,
1053    pub workspaces: Vec<WorkspaceConfig>,
1054    access_token: String,
1055    pub runtime: NativeRuntimeConfig,
1056    session_record_retention: SessionRecordRetentionConfig,
1057    state_path: Option<PathBuf>,
1058    spawn_profiles: SpawnProfileRegistry,
1059    session_environment: Option<NodeSessionEnvironmentConfig>,
1060    history: Option<NativeHistoryConfig>,
1061    harness_mcp_helper: Option<ReviewedHarnessMcpProgram>,
1062    /// Station network allowlist catalog (opaque ids). Empty default.
1063    /// Loaded at startup from `--network-allowlist-catalog` / env
1064    /// `GATE4AGENT_NODE_NETWORK_ALLOWLIST_CATALOG` file. Never secrets.
1065    network_allowlist_catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog,
1066    #[cfg(feature = "fixture")]
1067    fixture_raw_pty_runtime: bool,
1068}
1069
1070#[derive(Clone)]
1071struct NodeSessionEnvironmentConfig {
1072    root: PathBuf,
1073    resolver: Arc<dyn NodeSecretResolver>,
1074}
1075
1076impl NodeServerConfig {
1077    pub fn new(
1078        endpoint: impl Into<String>,
1079        access_token: impl Into<String>,
1080        node_id: NodeId,
1081        workspaces: impl IntoIterator<Item = WorkspaceConfig>,
1082    ) -> Result<Self, NodeServerError> {
1083        let endpoint = endpoint.into();
1084        let access_token = access_token.into();
1085        if !platform::validate_endpoint(&endpoint) {
1086            return Err(NodeServerError::InvalidEndpoint);
1087        }
1088        if access_token.is_empty() || access_token.len() > 4_096 {
1089            return Err(NodeServerError::InvalidAccessToken);
1090        }
1091        let workspaces = workspaces.into_iter().collect::<Vec<_>>();
1092        if workspaces.is_empty() {
1093            return Err(NodeServerError::NoWorkspaces);
1094        }
1095        let mut workspace_ids = BTreeMap::new();
1096        let mut workspace_roots = BTreeMap::new();
1097        for workspace in &workspaces {
1098            if workspace.worktree_service_mode == WorktreeServiceMode::Managed
1099                && workspace.managed_worktree_profiles.is_empty()
1100            {
1101                return Err(NodeServerError::ManagedWorktreeProfileRequired(
1102                    workspace.workspace_id.clone(),
1103                ));
1104            }
1105            if workspace.worktree_service_mode != WorktreeServiceMode::Managed
1106                && !workspace.managed_worktree_profiles.is_empty()
1107            {
1108                return Err(NodeServerError::ManagedWorktreeProfileModeMismatch(
1109                    workspace.workspace_id.clone(),
1110                ));
1111            }
1112            if workspace_ids
1113                .insert(workspace.workspace_id.clone(), ())
1114                .is_some()
1115            {
1116                return Err(NodeServerError::DuplicateWorkspaceId(
1117                    workspace.workspace_id.clone(),
1118                ));
1119            }
1120            let root_key = platform::root_identity(&workspace.canonical_root);
1121            if let Some(existing) = workspace_roots.insert(
1122                root_key,
1123                workspace.workspace_id.clone(),
1124            ) {
1125                return Err(NodeServerError::DuplicateWorkspaceRoot {
1126                    first: existing,
1127                    second: workspace.workspace_id.clone(),
1128                    root: workspace.canonical_root.clone(),
1129                });
1130            }
1131        }
1132        Ok(Self {
1133            endpoint,
1134            api_listen: None,
1135            bridge_listen: None,
1136            bridge_token: None,
1137            call_home: None,
1138            node_id,
1139            workspaces,
1140            access_token,
1141            runtime: NativeRuntimeConfig::default(),
1142            session_record_retention: SessionRecordRetentionConfig::default(),
1143            state_path: None,
1144            spawn_profiles: SpawnProfileRegistry::default(),
1145            session_environment: None,
1146            history: None,
1147            harness_mcp_helper: None,
1148            network_allowlist_catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog::new(),
1149            #[cfg(feature = "fixture")]
1150            fixture_raw_pty_runtime: false,
1151        })
1152    }
1153
1154    pub fn with_api_listen(
1155        mut self,
1156        api_listen: std::net::SocketAddr,
1157    ) -> Result<Self, NodeServerError> {
1158        if !api_listen.ip().is_loopback() {
1159            return Err(NodeServerError::InvalidApiListen(api_listen));
1160        }
1161        self.api_listen = Some(api_listen);
1162        Ok(self)
1163    }
1164
1165    /// Opt-in node-envelope HTTP+WS bridge listener. Loopback only (same spirit
1166    /// as `with_api_listen`). Does not enable by default — libraries and the
1167    /// binary leave this `None` until `--bridge-listen` / `with_bridge_listen`.
1168    pub fn with_bridge_listen(
1169        mut self,
1170        bridge_listen: std::net::SocketAddr,
1171    ) -> Result<Self, NodeServerError> {
1172        if !bridge_listen.ip().is_loopback() {
1173            return Err(NodeServerError::InvalidBridgeListen(bridge_listen));
1174        }
1175        self.bridge_listen = Some(bridge_listen);
1176        Ok(self)
1177    }
1178
1179    /// Optional bridge shared secret, distinct from the C2/node access token.
1180    /// Never log the value. Empty / oversize refused.
1181    pub fn with_bridge_token(
1182        mut self,
1183        bridge_token: impl Into<String>,
1184    ) -> Result<Self, NodeServerError> {
1185        let bridge_token = bridge_token.into();
1186        if bridge_token.is_empty() || bridge_token.len() > 4_096 {
1187            return Err(NodeServerError::InvalidBridgeToken);
1188        }
1189        self.bridge_token = Some(bridge_token);
1190        Ok(self)
1191    }
1192
1193    /// Also dial `relay` and serve this wire over the connection, in
1194    /// addition to accepting on the local endpoint.
1195    ///
1196    /// Who opens the socket and who is the protocol's server are separate
1197    /// questions, and this is the node's half of separating them. The node
1198    /// stays the server either way -- it answers `NodeRequest`s and emits
1199    /// `NodeEvent`s exactly as it does for a relay that dialled it. All
1200    /// that changes is that a node with no address anyone can reach makes
1201    /// the connection itself, and names itself once it arrives (see
1202    /// `gate4agent_node_wire::write_call_home_announce`).
1203    ///
1204    /// Loopback only, and that is not an oversight to be flagged and
1205    /// worked around. This wire carries terminal contents, keystrokes and
1206    /// file bytes as plaintext JSON; its mutual challenge-response
1207    /// authenticates both ends and encrypts nothing. Every listener in
1208    /// this stack is loopback-guarded for that reason, and a dialler must
1209    /// hold the same line -- a node calling a public relay would put the
1210    /// whole control plane on the wire in the clear. Reaching a relay on
1211    /// another host is a transport question, and it stays unanswered here
1212    /// on purpose: this change is about direction, not distance.
1213    pub fn with_call_home(
1214        mut self,
1215        relay: std::net::SocketAddr,
1216    ) -> Result<Self, NodeServerError> {
1217        if !relay.ip().is_loopback() || relay.port() == 0 {
1218            return Err(NodeServerError::InvalidCallHome(relay));
1219        }
1220        self.call_home = Some(relay);
1221        Ok(self)
1222    }
1223
1224    pub fn with_state_path(
1225        mut self,
1226        state_path: impl Into<PathBuf>,
1227    ) -> Result<Self, NodeServerError> {
1228        let state_path = state_path.into();
1229        if !state_path.is_absolute() || state_path.file_name().is_none() {
1230            return Err(NodeServerError::InvalidStatePath(
1231                state_path.to_string_lossy().into_owned(),
1232            ));
1233        }
1234        self.state_path = Some(state_path);
1235        Ok(self)
1236    }
1237
1238    pub fn with_spawn_profiles(mut self, spawn_profiles: SpawnProfileRegistry) -> Self {
1239        self.spawn_profiles = spawn_profiles;
1240        self
1241    }
1242
1243    /// Sets the age/keep-N policy the drive loop's retention sweep uses to
1244    /// retire dead `ManagedSessionState::Unavailable` records. Both fields
1245    /// default to `0` (disabled) via `SessionRecordRetentionConfig::default`
1246    /// when this is never called -- a node never deletes a durable record
1247    /// until an operator opts in with a real value.
1248    pub fn with_session_record_retention(
1249        mut self,
1250        retention: SessionRecordRetentionConfig,
1251    ) -> Self {
1252        self.session_record_retention = retention;
1253        self
1254    }
1255
1256    pub fn with_session_environment_materialization(
1257        mut self,
1258        root: impl Into<PathBuf>,
1259        resolver: Arc<dyn NodeSecretResolver>,
1260    ) -> Result<Self, NodeServerError> {
1261        let root = root.into();
1262        if !root.is_absolute() {
1263            return Err(NodeServerError::InvalidSessionEnvironmentRoot);
1264        }
1265        self.session_environment = Some(NodeSessionEnvironmentConfig { root, resolver });
1266        Ok(self)
1267    }
1268
1269    /// Enables bounded provider-history discovery and loading from explicit
1270    /// host-owned roots. Ambient vendor homes are never inferred here.
1271    pub fn with_history(mut self, history: NativeHistoryConfig) -> Self {
1272        self.history = Some(history);
1273        self
1274    }
1275
1276    /// Enables the H3B read proxy with one exact operator-reviewed helper file.
1277    pub fn with_harness_mcp_helper(
1278        mut self,
1279        helper_program: impl Into<PathBuf>,
1280    ) -> Result<Self, NodeServerError> {
1281        #[cfg(not(windows))]
1282        {
1283            let _ = helper_program;
1284            return Err(NodeServerError::InvalidHarnessMcpHelper);
1285        }
1286        #[cfg(windows)]
1287        {
1288        self.harness_mcp_helper = Some(
1289            ReviewedHarnessMcpProgram::review(helper_program.into())
1290                .map_err(|_| NodeServerError::InvalidHarnessMcpHelper)?,
1291        );
1292        Ok(self)
1293        }
1294    }
1295
1296    /// Install an in-memory station network allowlist catalog (opaque ids +
1297    /// optional node-local permits / provider-native mapping). Empty remains
1298    /// the default — unknown spawn ids refuse at resolve. Never cookies /
1299    /// OAuth / proxy credentials. C2 / inventory still list ids only.
1300    pub fn with_network_allowlist_catalog(
1301        mut self,
1302        catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog,
1303    ) -> Result<Self, NodeServerError> {
1304        if catalog.len() > crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES {
1305            return Err(NodeServerError::NetworkAllowlistCatalogCapacity {
1306                max: crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES,
1307            });
1308        }
1309        self.network_allowlist_catalog = catalog;
1310        Ok(self)
1311    }
1312
1313    /// Load station network allowlist catalog from an absolute regular file
1314    /// (v1 id-list or v2 JSON schema). Unset path is not used here — callers
1315    /// that want env fallback use `resolve_network_allowlist_catalog`.
1316    pub fn with_network_allowlist_catalog_file(
1317        self,
1318        path: impl AsRef<std::path::Path>,
1319    ) -> Result<Self, NodeServerError> {
1320        let catalog = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(path)
1321            .map_err(NodeServerError::from)?;
1322        self.with_network_allowlist_catalog(catalog)
1323    }
1324}
1325
1326pub fn default_state_path(node_id: &NodeId) -> Result<PathBuf, NodeServerError> {
1327    platform::default_state_path(node_id.as_str())
1328        .ok_or(NodeServerError::LocalStateDirectoryUnavailable)
1329}
1330
1331pub fn default_node_endpoint() -> Result<PathBuf, NodeServerError> {
1332    platform::default_node_endpoint().ok_or(NodeServerError::LocalRuntimeDirectoryUnavailable)
1333}
1334
1335fn delivery_store_root_for_state_path(state_path: &Path) -> Option<PathBuf> {
1336    let parent = state_path.parent()?;
1337    let mut name = state_path.file_name()?.to_os_string();
1338    name.push(".delivery-store");
1339    Some(parent.join(name))
1340}
1341
1342fn context_pack_store_root_for_state_path(state_path: &Path) -> Option<PathBuf> {
1343    let parent = state_path.parent()?;
1344    let mut name = state_path.file_name()?.to_os_string();
1345    name.push(".context-pack-store");
1346    Some(parent.join(name))
1347}
1348
1349pub struct NodeServer {
1350    config: NodeServerConfig,
1351    runtime: NativeRuntime,
1352    shared: Arc<NodeShared>,
1353    events: EventSubscription,
1354    state_path_lock: Option<session_registry::StatePathLock>,
1355}
1356
1357#[cfg(feature = "fixture")]
1358#[derive(Clone, Default)]
1359pub struct SpawnManagedWorktreeV2FailureProbe {
1360    latest_code: Arc<Mutex<Option<NodeFailureCode>>>,
1361}
1362
1363#[cfg(feature = "fixture")]
1364impl SpawnManagedWorktreeV2FailureProbe {
1365    pub fn latest_code(&self) -> Option<NodeFailureCode> {
1366        *self
1367            .latest_code
1368            .lock()
1369            .unwrap_or_else(|poisoned| poisoned.into_inner())
1370    }
1371
1372    fn record(&self, code: Option<NodeFailureCode>) {
1373        *self
1374            .latest_code
1375            .lock()
1376            .unwrap_or_else(|poisoned| poisoned.into_inner()) = code;
1377    }
1378}
1379
1380#[cfg(all(feature = "fixture", windows))]
1381struct IsolatedCodexHomeFixtureEnvironment;
1382
1383#[cfg(all(feature = "fixture", windows))]
1384impl gate4agent_runtime_native::NativeChildEnvironmentResolver
1385    for IsolatedCodexHomeFixtureEnvironment
1386{
1387    fn resolve_child_environment(
1388        &self,
1389    ) -> Result<
1390        Vec<gate4agent_catalog::EnvMutation>,
1391        gate4agent_runtime_native::NativeChildEnvironmentResolveError,
1392    > {
1393        Ok(vec![gate4agent_catalog::EnvMutation {
1394            key: OsString::from("USERPROFILE"),
1395            value: None,
1396        }])
1397    }
1398}
1399
1400impl NodeServer {
1401    pub fn new(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1402        let catalog = active_registry()?;
1403        Self::new_with_registry(config, catalog)
1404    }
1405
1406    #[cfg(feature = "fixture")]
1407    pub fn new_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1408        let mut spec = gate4agent_testkit::interactive_agent_spec();
1409        spec.id = AgentId::new("claude").map_err(|error| NodeServerError::Registry(error.to_string()))?;
1410        spec.display_name = "Controlled Claude PTY fixture".to_owned();
1411        Self::new_fixture_with_spec(config, spec)
1412    }
1413
1414    #[cfg(feature = "fixture")]
1415    pub fn new_clean_exit_fixture(
1416        config: NodeServerConfig,
1417        fixture_root: PathBuf,
1418        started_marker: PathBuf,
1419        release_signal: PathBuf,
1420    ) -> Result<Self, NodeServerError> {
1421        let mut spec = gate4agent_testkit::controlled_clean_exit_agent_spec(
1422            &fixture_root,
1423            &started_marker,
1424            &release_signal,
1425        )
1426        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1427        spec.id = AgentId::new("claude")
1428            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1429        spec.display_name = "Controlled Claude clean-exit fixture".to_owned();
1430        Self::new_fixture_with_spec(config, spec)
1431    }
1432
1433    /// A `claude` source whose session establishes a verified provider
1434    /// identity via an early `SessionStart` Hook post (so it is eligible for
1435    /// auto-export-at-exit and its native history is independently
1436    /// loadable), waits for `release_signal`, then exits cleanly — paired
1437    /// on the same Node with a plain `codex` target that never validates
1438    /// anything about what it receives. Built for A2 durable ContextPack
1439    /// E2E coverage: the test itself inspects materialized files and
1440    /// managed-session-record fields directly, rather than having the
1441    /// target script self-validate.
1442    #[cfg(all(feature = "fixture", windows))]
1443    pub fn new_durable_context_pack_clean_exit_fixture(
1444        config: NodeServerConfig,
1445        fixture_root: PathBuf,
1446        started_marker: PathBuf,
1447        release_signal: PathBuf,
1448        session_id: &str,
1449    ) -> Result<Self, NodeServerError> {
1450        let mut source_spec = gate4agent_testkit::identified_clean_exit_agent_spec(
1451            &fixture_root,
1452            &started_marker,
1453            &release_signal,
1454            session_id,
1455        )
1456        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1457        let source_id = AgentId::new("claude")
1458            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1459        let source_provider = builtin_registry().get(&source_id).ok_or_else(|| {
1460            NodeServerError::Registry(
1461                "durable context pack clean-exit fixture source adapter is unavailable"
1462                    .to_owned(),
1463            )
1464        })?;
1465        source_spec.capabilities.adapters.history =
1466            source_provider.capabilities.adapters.history.clone();
1467        source_spec.id = source_id;
1468        source_spec.display_name =
1469            "Controlled Claude durable context-pack clean-exit fixture".to_owned();
1470        // Both specs launch via `powershell.exe`; the registry's ambiguity
1471        // check keys on `detection.command` (+ aliases), not `launch.program`
1472        // -- give each a distinct detection identity, same precedent as
1473        // `context_pack_fixture_catalog`'s per-provider
1474        // `gate4agent-{provider}-context-fixture.cmd` commands.
1475        source_spec.detection.command =
1476            "gate4agent-claude-durable-context-pack-fixture.cmd".to_owned();
1477        source_spec.detection.aliases.clear();
1478
1479        let mut target_spec = gate4agent_testkit::interactive_agent_spec();
1480        target_spec.id = AgentId::new("codex")
1481            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1482        target_spec.display_name =
1483            "Controlled Codex durable context-pack target fixture".to_owned();
1484        target_spec.detection.command =
1485            "gate4agent-codex-durable-context-pack-fixture.cmd".to_owned();
1486        target_spec.detection.aliases.clear();
1487
1488        let catalog = AgentRegistry::new([source_spec, target_spec])
1489            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1490        let mut server = Self::new_with_registry(config, catalog)?;
1491        Arc::get_mut(&mut server.shared)
1492            .expect("fixture Node shared state must remain uniquely owned before run")
1493            .fixture_semantic_hook_policy = true;
1494        Ok(server)
1495    }
1496
1497    #[cfg(feature = "fixture")]
1498    pub fn new_monitoring_hook_fixture(
1499        config: NodeServerConfig,
1500    ) -> Result<Self, NodeServerError> {
1501        let mut spec = gate4agent_testkit::monitoring_hook_agent_spec();
1502        spec.id = AgentId::new("claude")
1503            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1504        let mut server = Self::new_fixture_with_spec(config, spec)?;
1505        Arc::get_mut(&mut server.shared)
1506            .expect("fixture Node shared state must remain uniquely owned before run")
1507            .fixture_semantic_hook_policy = true;
1508        Ok(server)
1509    }
1510
1511    #[cfg(all(feature = "fixture", windows))]
1512    pub fn new_provider_bundle_argv_fixture(
1513        config: NodeServerConfig,
1514        agent_id: AgentId,
1515        proof_path: PathBuf,
1516    ) -> Result<Self, NodeServerError> {
1517        let spec = Self::provider_bundle_argv_fixture_spec(agent_id, proof_path, None)?;
1518        Self::new_fixture_with_spec(config, spec)
1519    }
1520
1521    #[cfg(all(feature = "fixture", windows))]
1522    pub fn new_provider_bundle_argv_hold_fixture(
1523        config: NodeServerConfig,
1524        agent_id: AgentId,
1525        proof_path: PathBuf,
1526        release_signal: PathBuf,
1527    ) -> Result<Self, NodeServerError> {
1528        let spec = Self::provider_bundle_argv_fixture_spec(
1529            agent_id,
1530            proof_path,
1531            Some(release_signal),
1532        )?;
1533        Self::new_fixture_with_spec(config, spec)
1534    }
1535
1536    #[cfg(all(feature = "fixture", windows))]
1537    fn provider_bundle_argv_fixture_spec(
1538        agent_id: AgentId,
1539        proof_path: PathBuf,
1540        release_signal: Option<PathBuf>,
1541    ) -> Result<gate4agent_types::AgentSpec, NodeServerError> {
1542        if !matches!(agent_id.as_str(), "claude" | "codex" | "kimi") {
1543            return Err(NodeServerError::Registry(
1544                "provider bundle argv fixture requires Claude, Codex, or Kimi".to_owned(),
1545            ));
1546        }
1547        let mut spec = gate4agent_testkit::interactive_agent_spec();
1548        spec.id = agent_id.clone();
1549        spec.display_name = format!("Controlled {agent_id} bundle argv fixture");
1550        let provider = builtin_registry()
1551            .get(&agent_id)
1552            .ok_or_else(|| {
1553                NodeServerError::Registry(
1554                    "provider bundle argv fixture adapter is unavailable".to_owned(),
1555                )
1556            })?;
1557        spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1558        if !proof_path.is_absolute()
1559            || !proof_path
1560                .parent()
1561                .is_some_and(|parent| parent.is_dir())
1562        {
1563            return Err(NodeServerError::Registry(
1564                "provider bundle argv proof path requires an existing absolute parent"
1565                    .to_owned(),
1566            ));
1567        }
1568        if let Some(release_signal) = release_signal.as_ref() {
1569            if !release_signal.is_absolute()
1570                || !release_signal
1571                    .parent()
1572                    .is_some_and(|parent| parent.is_dir())
1573            {
1574                return Err(NodeServerError::Registry(
1575                    "provider bundle argv release path requires an existing absolute parent"
1576                        .to_owned(),
1577                ));
1578            }
1579            if release_signal.exists() {
1580                return Err(NodeServerError::Registry(
1581                    "provider bundle argv release path must not exist".to_owned(),
1582                ));
1583            }
1584            let proof_parent = std::fs::canonicalize(
1585                proof_path
1586                    .parent()
1587                    .expect("validated provider bundle proof parent"),
1588            )
1589            .map_err(|_| {
1590                NodeServerError::Registry(
1591                    "provider bundle argv proof parent is unavailable".to_owned(),
1592                )
1593            })?;
1594            let release_parent = std::fs::canonicalize(
1595                release_signal
1596                    .parent()
1597                    .expect("validated provider bundle release parent"),
1598            )
1599            .map_err(|_| {
1600                NodeServerError::Registry(
1601                    "provider bundle argv release parent is unavailable".to_owned(),
1602                )
1603            })?;
1604            if proof_parent != release_parent {
1605                return Err(NodeServerError::Registry(
1606                    "provider bundle argv release path must share the proof parent".to_owned(),
1607                ));
1608            }
1609        }
1610        let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1611            NodeServerError::Registry(
1612                "provider bundle argv proof path is not valid Unicode".to_owned(),
1613            )
1614        })?;
1615        let release_signal = release_signal.map(|release_signal| {
1616            release_signal.into_os_string().into_string().map_err(|_| {
1617                NodeServerError::Registry(
1618                    "provider bundle argv release path is not valid Unicode".to_owned(),
1619                )
1620            })
1621        }).transpose()?;
1622        let script = spec
1623            .launch
1624            .fixed_args
1625            .pop()
1626            .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
1627        let bundle_validation = if agent_id.as_str() == "codex" {
1628            "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') }"
1629        } else if agent_id.as_str() == "claude" {
1630            "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 }"
1631        } else {
1632            "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 }"
1633        };
1634        if let Some(release_signal) = release_signal {
1635            spec.launch.fixed_args.push(format!(
1636                "& {{ 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} }}",
1637            ));
1638            spec.launch.fixed_args.push(proof_path);
1639            spec.launch.fixed_args.push(release_signal);
1640        } else {
1641            spec.launch.fixed_args.push(format!(
1642                "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {bundle_validation}; {script} }}",
1643            ));
1644            spec.launch.fixed_args.push(proof_path);
1645        }
1646        Ok(spec)
1647    }
1648
1649    #[cfg(all(feature = "fixture", windows))]
1650    pub fn new_context_pack_fixture(
1651        config: NodeServerConfig,
1652        proof_path: PathBuf,
1653    ) -> Result<Self, NodeServerError> {
1654        let catalog = Self::context_pack_fixture_catalog(proof_path, None, false)?;
1655        Self::new_with_registry(config, catalog)
1656    }
1657
1658    #[cfg(all(feature = "fixture", windows))]
1659    pub fn new_monitoring_context_pack_fixture(
1660        config: NodeServerConfig,
1661        proof_path: PathBuf,
1662    ) -> Result<Self, NodeServerError> {
1663        let catalog = Self::context_pack_fixture_catalog(proof_path, None, true)?;
1664        let mut server = Self::new_with_registry(config, catalog)?;
1665        Arc::get_mut(&mut server.shared)
1666            .expect("fixture Node shared state must remain uniquely owned before run")
1667            .fixture_semantic_hook_policy = true;
1668        Ok(server)
1669    }
1670
1671    #[cfg(all(feature = "fixture", windows))]
1672    pub fn install_isolated_codex_home_environment_fixture(
1673        &mut self,
1674        profile_id: SpawnEnvironmentProfileId,
1675        profile_revision: crate::protocol::SpawnEnvironmentProfileRevision,
1676    ) -> Result<(), NodeServerError> {
1677        let provider = AgentId::new("codex")
1678            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1679        let native_profile = gate4agent_runtime_native::NativeLaunchProfile::new(
1680            gate4agent_runtime_native::NativeLaunchProfileId::new(format!(
1681                "{}-pty",
1682                profile_id.as_str(),
1683            ))
1684            .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1685            provider.clone(),
1686            TransportKind::Pty,
1687            vec![OsString::from("USERPROFILE")],
1688            Arc::new(IsolatedCodexHomeFixtureEnvironment),
1689        )
1690        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1691        let materialization = NodeSessionMaterializationProfile::new(
1692            Vec::new(),
1693            vec![crate::session_environment::NodeSessionPathBinding::new(
1694                "CODEX_HOME",
1695                crate::session_environment::NodeSessionPathClass::ProviderHome,
1696            )
1697            .map_err(|error| NodeServerError::Registry(error.to_string()))?],
1698            Vec::new(),
1699        )
1700        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1701        self.install_environment_profile(
1702            NodeEnvironmentProfile::new_with_materialization(
1703                profile_id,
1704                profile_revision,
1705                provider,
1706                [native_profile],
1707                Some(materialization),
1708            )
1709            .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1710        )
1711    }
1712
1713    #[cfg(all(feature = "fixture", windows))]
1714    pub fn new_context_only_proof_fixture(
1715        config: NodeServerConfig,
1716        target_provider: AgentId,
1717        proof_path: PathBuf,
1718    ) -> Result<Self, NodeServerError> {
1719        if target_provider.as_str() != "kimi" {
1720            return Err(NodeServerError::Registry(
1721                "context-only proof fixture requires Kimi".to_owned(),
1722            ));
1723        }
1724        let catalog = Self::context_pack_fixture_catalog(
1725            proof_path,
1726            Some(&target_provider),
1727            false,
1728        )?;
1729        Self::new_with_registry(config, catalog)
1730    }
1731
1732    #[cfg(all(feature = "fixture", windows))]
1733    fn context_pack_fixture_catalog(
1734        proof_path: PathBuf,
1735        context_only_target: Option<&AgentId>,
1736        monitoring_claude_source: bool,
1737    ) -> Result<AgentRegistry, NodeServerError> {
1738        if !proof_path.is_absolute()
1739            || !proof_path
1740                .parent()
1741                .is_some_and(|parent| parent.is_dir())
1742        {
1743            return Err(NodeServerError::Registry(
1744                "context pack proof path requires an existing absolute parent".to_owned(),
1745            ));
1746        }
1747        let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1748            NodeServerError::Registry(
1749                "context pack proof path is not valid Unicode".to_owned(),
1750            )
1751        })?;
1752        let providers = ["claude", "codex", "grok", "kimi"];
1753        let builtins = builtin_registry();
1754        let mut specs = Vec::with_capacity(providers.len());
1755        for provider_id in providers {
1756            let agent_id = AgentId::new(provider_id)
1757                .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1758            let provider = builtins.get(&agent_id).ok_or_else(|| {
1759                NodeServerError::Registry(
1760                    "context pack fixture provider adapter is unavailable".to_owned(),
1761                )
1762            })?;
1763            let monitored_provider =
1764                monitoring_claude_source && matches!(provider_id, "claude" | "codex");
1765            let mut spec = if monitored_provider {
1766                gate4agent_testkit::monitoring_hook_agent_spec()
1767            } else {
1768                gate4agent_testkit::interactive_agent_spec()
1769            };
1770            spec.id = agent_id.clone();
1771            spec.display_name = format!("Controlled {agent_id} context pack fixture");
1772            spec.detection.command = format!("gate4agent-{provider_id}-context-fixture.cmd");
1773            spec.detection.aliases.clear();
1774            spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1775            if provider_id == "codex" {
1776                let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1777                    NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1778                })?;
1779                let validation = r#"
1780$bundleArgs = @($bundleArgs)
1781if ($bundleArgs.Count -ne 0) { exit 91 }
1782$codexHome = [Environment]::GetEnvironmentVariable('CODEX_HOME', 'Process')
1783$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1784$cwd = (Get-Location).ProviderPath
1785if ([string]::IsNullOrEmpty($codexHome) -or -not [IO.Path]::IsPathRooted($codexHome) -or -not (Test-Path -LiteralPath $codexHome -PathType Container)) { exit 92 }
1786$skillPath = Join-Path $codexHome 'skills/review-code/SKILL.md'
1787if (-not (Test-Path -LiteralPath $skillPath -PathType Leaf)) { exit 93 }
1788$homeFiles = @(Get-ChildItem -LiteralPath $codexHome -Recurse -Force -File)
1789if ($homeFiles.Count -ne 1 -or [IO.Path]::GetFullPath($homeFiles[0].FullName) -ine [IO.Path]::GetFullPath($skillPath)) { exit 94 }
1790$sha = [Security.Cryptography.SHA256]::Create()
1791try { $skillHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($skillPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1792if ($skillHash -cne 'd78fe03be106b673fcf5415c8359e99f0298b5071cd11822c58ebcb27e52a68d') { exit 95 }
1793if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 96 }
1794$contextPath = Join-Path $contextRoot 'context-pack.json'
1795if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 97 }
1796$contextFiles = @(Get-ChildItem -LiteralPath $contextRoot -Recurse -Force -File)
1797if ($contextFiles.Count -ne 1 -or [IO.Path]::GetFullPath($contextFiles[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 98 }
1798$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1799$codexFull = [IO.Path]::GetFullPath($codexHome).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1800$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1801$separator = [IO.Path]::DirectorySeparatorChar
1802if ($contextFull.Equals($codexFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($codexFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 99 }
1803if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 100 }
1804$rawContext = [IO.File]::ReadAllText($contextPath)
1805if ($rawContext.Contains('g4a-private-provider-session-canary') -or $rawContext.Contains('private-provider-login-identity')) { exit 101 }
1806try { $document = $rawContext | ConvertFrom-Json -ErrorAction Stop } catch { exit 102 }
1807$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')
1808$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')
1809foreach ($property in @($document.PSObject.Properties.Name)) {
1810    if ($allowedTopLevel -cnotcontains [string]$property) { exit 103 }
1811}
1812foreach ($property in $requiredTopLevel) {
1813    if ($null -eq $document.PSObject.Properties[$property]) { exit 104 }
1814}
1815if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 105 }
1816if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 106 }
1817$messages = @($document.retained_messages)
1818if ($messages.Count -eq 0) { exit 107 }
1819foreach ($message in $messages) {
1820    $messageProperties = @($message.PSObject.Properties.Name)
1821    foreach ($property in $messageProperties) {
1822        if (@('role', 'text') -cnotcontains [string]$property) { exit 108 }
1823    }
1824    if ($messageProperties.Count -ne 2 -or @('user', 'assistant') -cnotcontains [string]$message.role -or [string]::IsNullOrEmpty([string]$message.text)) { exit 109 }
1825}
1826$sha = [Security.Cryptography.SHA256]::Create()
1827try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1828[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))
1829$rawProof = [IO.File]::ReadAllText($proofPath)
1830if ($rawProof.Contains('g4a-private-provider-session-canary') -or $rawProof.Contains('private-provider-login-identity')) { exit 110 }
1831[Console]::Out.WriteLine('F7_CODEX_BUNDLE_CONTEXT_VALIDATED')
1832"#;
1833                spec.launch.fixed_args.push(format!(
1834                    "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1835                ));
1836                spec.launch.fixed_args.push(proof_path.clone());
1837            } else if context_only_target.map(AgentId::as_str) == Some(provider_id) {
1838                let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1839                    NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1840                })?;
1841                let validation = r#"
1842$bundleArgs = @($bundleArgs)
1843if ($bundleArgs.Count -ne 0) { exit 91 }
1844$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1845$cwd = (Get-Location).ProviderPath
1846if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 92 }
1847$contextPath = Join-Path $contextRoot 'context-pack.json'
1848if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 93 }
1849$entries = @(Get-ChildItem -LiteralPath $contextRoot -Force)
1850if ($entries.Count -ne 1 -or $entries[0] -isnot [IO.FileInfo] -or [IO.Path]::GetFullPath($entries[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 94 }
1851$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1852$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1853$separator = [IO.Path]::DirectorySeparatorChar
1854if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase) -or $cwdFull.StartsWith($contextFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 95 }
1855try { $document = [IO.File]::ReadAllText($contextPath) | ConvertFrom-Json -ErrorAction Stop } catch { exit 96 }
1856if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 97 }
1857if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 98 }
1858$messages = @($document.retained_messages)
1859if ($messages.Count -eq 0) { exit 99 }
1860$hasUser = $false
1861$hasAssistant = $false
1862foreach ($message in $messages) {
1863    if ([string]::IsNullOrWhiteSpace([string]$message.text)) { exit 100 }
1864    if ([string]$message.role -ceq 'user') { $hasUser = $true }
1865    elseif ([string]$message.role -ceq 'assistant') { $hasAssistant = $true }
1866    else { exit 101 }
1867}
1868if (-not $hasUser -or -not $hasAssistant) { exit 102 }
1869$sha = [Security.Cryptography.SHA256]::Create()
1870try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1871[IO.File]::WriteAllLines($proofPath, [string[]]@('context-only', $contextRoot, $cwd, $contextHash, [string]$document.schema, [string]$document.source_provider, [string]$messages.Count))
1872[Console]::Out.WriteLine('F7_KIMI_CONTEXT_ONLY_VALIDATED')
1873"#;
1874                spec.launch.fixed_args.push(format!(
1875                    "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1876                ));
1877                spec.launch.fixed_args.push(proof_path.clone());
1878            }
1879            specs.push(spec);
1880        }
1881        let catalog = AgentRegistry::new(specs)
1882            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1883        Ok(catalog)
1884    }
1885
1886    #[cfg(feature = "fixture")]
1887    pub fn new_exact_launcher_fixture(
1888        config: NodeServerConfig,
1889        agent_id: AgentId,
1890        launcher: String,
1891    ) -> Result<Self, NodeServerError> {
1892        if !matches!(agent_id.as_str(), "claude" | "codex" | "grok" | "kimi") {
1893            return Err(NodeServerError::Registry(
1894                "exact launcher fixture requires a supported provider".to_owned(),
1895            ));
1896        }
1897        if launcher.contains('\0') {
1898            return Err(NodeServerError::Registry(
1899                "exact launcher fixture path contains NUL".to_owned(),
1900            ));
1901        }
1902        let launcher_path = Path::new(&launcher);
1903        if !launcher_path.is_absolute() {
1904            return Err(NodeServerError::Registry(
1905                "exact launcher fixture path must be absolute".to_owned(),
1906            ));
1907        }
1908        let metadata = std::fs::metadata(launcher_path).map_err(|_| {
1909            NodeServerError::Registry("exact launcher fixture path is unavailable".to_owned())
1910        })?;
1911        if !metadata.is_file() {
1912            return Err(NodeServerError::Registry(
1913                "exact launcher fixture path must be a regular file".to_owned(),
1914            ));
1915        }
1916        let mut spec = builtin_registry()
1917            .get(&agent_id)
1918            .ok_or_else(|| {
1919                NodeServerError::Registry(
1920                    "exact launcher fixture provider is unavailable".to_owned(),
1921                )
1922            })?
1923            .clone();
1924        spec.launch.program = launcher;
1925        Self::new_fixture_with_spec(config, spec)
1926    }
1927
1928    /// Shared H3B validation and spec construction for
1929    /// `new_harness_mcp_proxy_fixture` and
1930    /// `new_harness_mcp_acp_launcher_fixture`: an absolute, exact-regular-
1931    /// file provider launcher plus at most 32 Unicode, NUL-free fixed
1932    /// arguments (65,536 bytes total), replacing `spec.launch` on a clone of
1933    /// the named builtin catalog entry -- detection command and expected
1934    /// process matcher rebased on the launcher's own basename, exactly the
1935    /// same shape `new_harness_mcp_proxy_fixture` always built.
1936    #[cfg(feature = "fixture")]
1937    fn h3b_provider_spec_with_program_and_args(
1938        agent_id: AgentId,
1939        provider_program: PathBuf,
1940        fixed_args: Vec<OsString>,
1941    ) -> Result<AgentSpec, NodeServerError> {
1942        if !provider_program.is_absolute() || fixed_args.len() > 32 {
1943            return Err(NodeServerError::Registry(
1944                "H3B fixture requires an absolute provider and at most 32 arguments".to_owned(),
1945            ));
1946        }
1947        let metadata = std::fs::symlink_metadata(&provider_program).map_err(|_| {
1948            NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
1949        })?;
1950        if !metadata.file_type().is_file() || metadata.file_type().is_symlink() {
1951            return Err(NodeServerError::Registry(
1952                "H3B fixture provider must be an exact regular file".to_owned(),
1953            ));
1954        }
1955        let program = provider_program.into_os_string().into_string().map_err(|_| {
1956            NodeServerError::Registry("H3B fixture provider path is not Unicode".to_owned())
1957        })?;
1958        let fixed_args = fixed_args.into_iter().map(|argument| {
1959            argument.into_string().map_err(|_| NodeServerError::Registry(
1960                "H3B fixture argument is not Unicode".to_owned(),
1961            ))
1962        }).collect::<Result<Vec<_>, _>>()?;
1963        let argument_bytes = fixed_args.iter()
1964            .try_fold(0usize, |total, argument| total.checked_add(argument.len()));
1965        if program.contains('\0')
1966            || fixed_args.iter().any(|argument| argument.contains('\0'))
1967            || !matches!(argument_bytes, Some(total) if total <= 65_536)
1968        {
1969            return Err(NodeServerError::Registry(
1970                "H3B fixture provider or arguments are invalid".to_owned(),
1971            ));
1972        }
1973        let mut spec = builtin_registry().get(&agent_id).ok_or_else(|| {
1974            NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
1975        })?.clone();
1976        let process_name = Path::new(&program).file_name().and_then(|name| name.to_str())
1977            .ok_or_else(|| NodeServerError::Registry(
1978                "H3B fixture provider basename is unavailable".to_owned(),
1979            ))?.to_owned();
1980        spec.detection.command = process_name.clone();
1981        spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
1982            name: process_name,
1983        }];
1984        spec.launch.program = program;
1985        spec.launch.fixed_args = fixed_args;
1986        Ok(spec)
1987    }
1988
1989    #[cfg(feature = "fixture")]
1990    pub fn new_harness_mcp_proxy_fixture(
1991        config: NodeServerConfig,
1992        provider_program: PathBuf,
1993        fixed_args: Vec<OsString>,
1994        harness_mcp_program: PathBuf,
1995    ) -> Result<Self, NodeServerError> {
1996        let mut config = config.with_harness_mcp_helper(harness_mcp_program)?;
1997        let agent_id = AgentId::new("codex")
1998            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1999        let mut spec =
2000            Self::h3b_provider_spec_with_program_and_args(agent_id, provider_program, fixed_args)?;
2001        spec.display_name = "Controlled H3B MCP proxy fixture".to_owned();
2002        config.fixture_raw_pty_runtime = true;
2003        Self::new_fixture_with_spec(config, spec)
2004    }
2005
2006    /// The ACP-mode sibling of `new_harness_mcp_proxy_fixture`: the same
2007    /// `--harness-mcp-helper` wiring and the same absolute-program-plus-
2008    /// fixed-args launcher replacement as `new_exact_launcher_fixture`, but
2009    /// admitting `ProviderRuntimeRequirement::Acp` the honest way instead of
2010    /// hardcoding it. `new_harness_mcp_proxy_fixture` forces `config.
2011    /// fixture_raw_pty_runtime = true`, which tears out the live
2012    /// `ProviderRuntimeMonitor` entirely (`NodeServer::new_with_registry`)
2013    /// and makes `NodeShared::admit_provider_runtime`'s no-monitor branch
2014    /// hardcode `acp_transport = false` for every provider, refusing
2015    /// `ProviderRuntimeRequirement::Acp` unconditionally regardless of what
2016    /// the catalog declares. This constructor leaves `fixture_raw_pty_
2017    /// runtime` at its default `false`, so a real `ProviderRuntimeMonitor`
2018    /// is built from the (cloned, launcher-replaced) catalog spec, and
2019    /// `ProviderRuntimeMonitor::supports_acp_transport` reads the honest,
2020    /// unmodified catalog fact: every builtin provider this accepts
2021    /// (`claude`, `codex`, `grok`, `kimi`) already declares `capabilities.
2022    /// transports.acp` in `gate4agent-catalog::builtin`, so no capability is
2023    /// invented here -- this constructor only removes the fixture-only
2024    /// bypass standing in its way. `provider_runtime::policy_for_transport`'s
2025    /// `TransportKind::Acp` arm (applied unconditionally at spawn-prep time
2026    /// for every ACP session, fixture or not) is what actually grants the
2027    /// ACP-shaped `ProviderRuntimePolicy` downstream of that admission --
2028    /// this constructor never calls it directly, and does not need to.
2029    #[cfg(feature = "fixture")]
2030    pub fn new_harness_mcp_acp_launcher_fixture(
2031        config: NodeServerConfig,
2032        helper_program: PathBuf,
2033        provider: AgentId,
2034        program: PathBuf,
2035        args: Vec<OsString>,
2036    ) -> Result<Self, NodeServerError> {
2037        if !matches!(provider.as_str(), "claude" | "codex" | "grok" | "kimi") {
2038            return Err(NodeServerError::Registry(
2039                "ACP harness-MCP launcher fixture requires a supported provider".to_owned(),
2040            ));
2041        }
2042        let config = config.with_harness_mcp_helper(helper_program)?;
2043        let mut spec = Self::h3b_provider_spec_with_program_and_args(provider, program, args)?;
2044        let launch = spec.launch.clone();
2045        let acp = spec.capabilities.transports.acp.as_mut().ok_or_else(|| {
2046            NodeServerError::Registry(
2047                "ACP harness-MCP launcher fixture provider does not declare an ACP transport"
2048                    .to_owned(),
2049            )
2050        })?;
2051        // Routes the ACP spawn itself through the exact replaced launcher
2052        // (`AcpSession::spawn_with_launch` -> a bare `Command::new(&launch.
2053        // program)`, `gate4agent`'s `src/acp/spawn.rs`) instead of the
2054        // catalog's own vendor-npm resolution (`acp_command`, same file) --
2055        // without this the ACP transport ignores `spec.launch` entirely and
2056        // tries to spawn the real vendor CLI (e.g. `npx -y @agentclientprotocol/
2057        // codex-acp@1.10.0`), which this fixture must never reach.
2058        acp.launch_override = Some(launch);
2059        spec.display_name = "Controlled ACP harness-MCP launcher fixture".to_owned();
2060        Self::new_fixture_with_spec(config, spec)
2061    }
2062
2063    #[cfg(feature = "fixture")]
2064    pub fn new_resume_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
2065        let mut spec = gate4agent_testkit::interactive_agent_spec();
2066        let claude_id = AgentId::new("claude")
2067            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2068        let claude = builtin_registry()
2069            .get(&claude_id)
2070            .ok_or_else(|| NodeServerError::Registry("Claude fixture adapter is unavailable".to_owned()))?;
2071        spec.id = claude_id;
2072        spec.display_name = "Resumable Claude PTY fixture".to_owned();
2073        spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
2074            name: "claude".to_owned(),
2075        }];
2076        spec.capabilities.transports.pty_adapter = claude
2077            .capabilities
2078            .transports
2079            .pty_adapter
2080            .clone();
2081        spec.capabilities.adapters.resume = claude
2082            .capabilities
2083            .adapters
2084            .resume
2085            .clone();
2086        let script = spec
2087            .launch
2088            .fixed_args
2089            .pop()
2090            .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
2091        spec.launch.fixed_args.push(format!(
2092            "& {{ param([Parameter(ValueFromRemainingArguments=$true)][string[]]$ignored) {script} }}",
2093        ));
2094        Self::new_fixture_with_spec(config, spec)
2095    }
2096
2097    #[cfg(feature = "fixture")]
2098    pub fn new_cmd_cwd_fixture(
2099        config: NodeServerConfig,
2100        command: String,
2101    ) -> Result<Self, NodeServerError> {
2102        let mut spec = gate4agent_testkit::interactive_agent_spec();
2103        spec.id = AgentId::new("claude")
2104            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2105        spec.display_name = "Controlled cmd cwd fixture".to_owned();
2106        spec.detection.command = "gate4agent-cwd-fixture.cmd".to_owned();
2107        spec.launch.program = command;
2108        spec.launch.fixed_args.clear();
2109        Self::new_fixture_with_spec(config, spec)
2110    }
2111
2112    #[cfg(feature = "fixture")]
2113    fn new_fixture_with_spec(
2114        config: NodeServerConfig,
2115        spec: gate4agent_types::AgentSpec,
2116    ) -> Result<Self, NodeServerError> {
2117        let catalog = AgentRegistry::new([spec])
2118            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2119        Self::new_with_registry(config, catalog)
2120    }
2121
2122    fn new_with_registry(
2123        config: NodeServerConfig,
2124        catalog: AgentRegistry,
2125    ) -> Result<Self, NodeServerError> {
2126        for profile in config.spawn_profiles.iter() {
2127            if catalog.get(&profile.provider).is_none() {
2128                return Err(NodeServerError::Registry(
2129                    "spawn profile references an unavailable provider".to_owned(),
2130                ));
2131            }
2132        }
2133        let input_settle_timeout_ms = catalog
2134            .iter()
2135            .map(|spec| spec.readiness.timeout_ms)
2136            .max()
2137            .unwrap_or(MUTATION_SETTLE_TIMEOUT_MS)
2138            .saturating_add(READINESS_SETTLE_HEADROOM_MS)
2139            .max(MUTATION_SETTLE_TIMEOUT_MS);
2140        let (provider_contracts, provider_adapter_contracts) =
2141            provider_contract_manifest(&catalog)?;
2142        let compatibility_support = node_compatibility_support_for_manifest(
2143            &provider_contracts,
2144            &provider_adapter_contracts,
2145        )?;
2146        validate_node_negotiated_handshake_capacity(&compatibility_support)
2147            .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2148        let enabled_providers = provider_contracts
2149            .iter()
2150            .map(|contract| contract.provider.clone())
2151            .collect();
2152        #[cfg(feature = "fixture")]
2153        let fixture_raw_pty_runtime = config.fixture_raw_pty_runtime;
2154        #[cfg(not(feature = "fixture"))]
2155        let fixture_raw_pty_runtime = false;
2156        let (provider_runtime_monitor, provider_runtime_statuses) = if fixture_raw_pty_runtime {
2157            let statuses = ProviderRuntimeStatuses::new(catalog.iter().map(|spec| {
2158                crate::protocol::ProviderRuntimeStatus::raw_passthrough(spec.id.clone(), None)
2159            })).expect("fixture provider catalog remains bounded");
2160            (None, statuses)
2161        } else {
2162            let monitor = Arc::new(ProviderRuntimeMonitor::new(&catalog));
2163            let statuses = monitor.collect();
2164            (Some(monitor), statuses)
2165        };
2166        let state_path_lock =
2167            session_registry::StatePathLock::acquire(config.state_path.as_deref())
2168                .map_err(|error| {
2169                    durable_state_server_error(error, DURABLE_STATE_LOCK_FAILED_ERROR)
2170                })?;
2171        let (delivery_store, delivered_bundles) = match config.state_path.as_ref() {
2172            Some(state_path) => {
2173                let delivery_root = delivery_store_root_for_state_path(state_path)
2174                    .ok_or(NodeServerError::DeliveryStore)?;
2175                let (store, bundles) = DeliveryStore::open(delivery_root)
2176                    .map_err(|_| NodeServerError::DeliveryStore)?;
2177                (Some(store), bundles)
2178            }
2179            None => (None, Vec::new()),
2180        };
2181        let delivered_catalog = BundleCatalog::new(delivered_bundles)
2182            .map_err(|_| NodeServerError::DeliveryStore)?;
2183        let (context_pack_store, durable_context_packs) = match config.state_path.as_ref() {
2184            Some(state_path) => {
2185                let context_pack_root = context_pack_store_root_for_state_path(state_path)
2186                    .ok_or(NodeServerError::ContextPackStore)?;
2187                let (store, packs) = ContextPackStore::open(context_pack_root)
2188                    .map_err(|_| NodeServerError::ContextPackStore)?;
2189                (Some(store), packs)
2190            }
2191            None => (None, Vec::new()),
2192        };
2193        let mut durable_context_catalog = ContextPackCatalog::default();
2194        for pack in durable_context_packs {
2195            durable_context_catalog
2196                .insert(pack)
2197                .map_err(|_| NodeServerError::ContextPackStore)?;
2198        }
2199        let session_environment_materializer = config
2200            .session_environment
2201            .as_ref()
2202            .map(|environment| {
2203                SessionEnvironmentMaterializer::new(
2204                    environment.root.clone(),
2205                    Arc::clone(&environment.resolver),
2206                )
2207                .map_err(|_| NodeServerError::SessionEnvironmentMaterializer)
2208            })
2209            .transpose()?;
2210        let native_session_catalog = config.history.clone().map(|history| {
2211            Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)))
2212        });
2213        let (handle, runtime) = match config.history.clone() {
2214            Some(history) => NativeRuntime::new_with_history(catalog, config.runtime, history),
2215            None => NativeRuntime::new(catalog, config.runtime),
2216        };
2217        let native_launch_profile_control = runtime.native_launch_profile_control();
2218        let harness_mcp_registry = config
2219            .harness_mcp_helper
2220            .clone()
2221            .map(HarnessMcpProxyRegistry::new);
2222        let events = handle.subscribe(CONTROL_EVENT_SUBSCRIPTION_CAPACITY);
2223        let incarnation_id = random_incarnation_id()
2224            .map_err(NodeServerError::IncarnationIdentity)?;
2225        let mut loaded = session_registry::load(config.state_path.as_deref(), &config.node_id)
2226            .map_err(durable_state_load_error)?;
2227        let managed_worktrees = std::mem::take(&mut loaded.managed_worktrees);
2228        let managed_worktree_tombstones =
2229            std::mem::take(&mut loaded.managed_worktree_tombstones);
2230        let materializations = std::mem::take(&mut loaded.materializations);
2231        let managed_spawn_replays = std::mem::take(&mut loaded.managed_spawn_replays);
2232        let (workspaces, records, persistence_warning) =
2233            merge_durable_state(&config.workspaces, loaded)?;
2234        let mut shared = NodeShared::new_with_incarnation(
2235            handle,
2236            config.access_token.clone(),
2237            config.node_id.clone(),
2238            incarnation_id,
2239            workspaces,
2240            enabled_providers,
2241            provider_runtime_statuses,
2242            provider_runtime_monitor,
2243            provider_contracts,
2244            provider_adapter_contracts,
2245            config.spawn_profiles.clone(),
2246            Some(native_launch_profile_control),
2247            native_session_catalog,
2248            session_environment_materializer,
2249            config.state_path.clone(),
2250            records,
2251            managed_worktrees,
2252            managed_worktree_tombstones,
2253            materializations,
2254            persistence_warning,
2255            input_settle_timeout_ms,
2256        );
2257        shared.harness_mcp_registry = harness_mcp_registry.clone();
2258        shared.session_record_retention = config.session_record_retention;
2259        shared.network_allowlist_catalog =
2260            RwLock::new(config.network_allowlist_catalog.clone());
2261        shared.bundle_catalog = RwLock::new(delivered_catalog);
2262        shared.delivery_store = Mutex::new(delivery_store);
2263        shared.context_catalog = RwLock::new(durable_context_catalog);
2264        shared.context_pack_store = Mutex::new(context_pack_store);
2265        shared.managed_spawn_replays = Mutex::new(
2266            managed_spawn_replays
2267                .into_iter()
2268                .map(|record| (record.idempotency_key.clone(), record))
2269                .collect(),
2270        );
2271        let shared = Arc::new(shared);
2272        if let Some(registry) = harness_mcp_registry {
2273            let weak = Arc::downgrade(&shared);
2274            registry.set_event_sink(Arc::new(move |event| match weak.upgrade() {
2275                Some(shared) => shared.publish_transient(event),
2276                None => false,
2277            }));
2278        }
2279        if shared.persistence_error().is_none() {
2280            shared.persist_state().map_err(|error| {
2281                durable_state_server_error(error, DURABLE_STATE_COMMIT_FAILED_ERROR)
2282            })?;
2283        }
2284        Ok(Self {
2285            config,
2286            runtime,
2287            shared,
2288            events,
2289            state_path_lock,
2290        })
2291    }
2292
2293    pub fn shutdown_handle(&self) -> NodeShutdownHandle {
2294        NodeShutdownHandle {
2295            shared: Arc::clone(&self.shared),
2296        }
2297    }
2298
2299    #[cfg(feature = "fixture")]
2300    pub fn spawn_managed_worktree_v2_failure_probe_fixture(
2301        &self,
2302    ) -> SpawnManagedWorktreeV2FailureProbe {
2303        self.shared
2304            .fixture_spawn_managed_worktree_v2_failure_probe
2305            .clone()
2306    }
2307
2308    /// Installs one immutable host-local environment profile before the server runs.
2309    ///
2310    /// Native resolvers remain inside the runtime; the shared node registry keeps
2311    /// only public IDs, revisions, provider bindings, and opaque native profile IDs.
2312    pub fn install_environment_profile(
2313        &mut self,
2314        profile: NodeEnvironmentProfile,
2315    ) -> Result<(), NodeServerError> {
2316        if !self.shared.enabled_providers.contains(profile.provider()) {
2317            return Err(NodeServerError::EnvironmentProfileProviderUnavailable(
2318                profile.provider().clone(),
2319            ));
2320        }
2321        let (binding, native_profiles, materialization) = profile.into_parts();
2322        if materialization.is_some() && self.shared.session_environment_materializer.is_none() {
2323            return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2324        }
2325        let mut profiles = self
2326            .shared
2327            .environment_profiles
2328            .write()
2329            .unwrap_or_else(|poisoned| poisoned.into_inner());
2330        if profiles.contains_key(&binding.id) {
2331            return Err(NodeServerError::DuplicateEnvironmentProfile(
2332                binding.id.clone(),
2333            ));
2334        }
2335        if profiles.len() >= MAX_NODE_ENVIRONMENT_PROFILES {
2336            return Err(NodeServerError::EnvironmentProfileCapacity {
2337                max: MAX_NODE_ENVIRONMENT_PROFILES,
2338            });
2339        }
2340        for native_id in binding.native_profile_ids() {
2341            if profiles.values().any(|current| {
2342                current.native_profile_ids().any(|current_id| current_id == native_id)
2343            }) {
2344                return Err(NodeServerError::DuplicateNativeEnvironmentProfile(
2345                    native_id.to_string(),
2346                ));
2347            }
2348        }
2349
2350        let mut installed = Vec::with_capacity(native_profiles.len());
2351        for native_profile in native_profiles {
2352            let native_id = native_profile.id().clone();
2353            if let Err(error) = self.runtime.upsert_native_launch_profile(native_profile) {
2354                for installed_id in installed.into_iter().rev() {
2355                    let _ = self.runtime.remove_native_launch_profile(&installed_id);
2356                }
2357                return Err(NodeServerError::NativeEnvironmentProfile(error.to_string()));
2358            }
2359            installed.push(native_id);
2360        }
2361        if let Some(materialization) = materialization {
2362            self.shared
2363                .environment_materialization_profiles
2364                .write()
2365                .unwrap_or_else(|poisoned| poisoned.into_inner())
2366                .insert(binding.id.clone(), materialization);
2367        }
2368        profiles.insert(binding.id.clone(), binding);
2369        Ok(())
2370    }
2371
2372    /// Installs one immutable host-local bundle before the server runs.
2373    pub fn install_bundle(&mut self, bundle: NodeBundle) -> Result<(), NodeServerError> {
2374        if self.shared.session_environment_materializer.is_none() {
2375            return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2376        }
2377        let mut catalog = self
2378            .shared
2379            .bundle_catalog
2380            .write()
2381            .unwrap_or_else(|poisoned| poisoned.into_inner());
2382        catalog
2383            .insert_idempotent(bundle)
2384            .map_err(|error| NodeServerError::BundleCatalog(error.to_string()))?;
2385        Ok(())
2386    }
2387
2388    pub async fn run_until_ctrl_signal(self) -> Result<(), NodeServerError> {
2389        let shutdown = self.shutdown_handle();
2390        let run = self.run();
2391        tokio::pin!(run);
2392        tokio::select! {
2393            result = &mut run => result,
2394            signal = wait_for_ctrl_signal() => {
2395                signal?;
2396                let shutdown_result = shutdown.request_shutdown().await;
2397                let run_result = run.await;
2398                shutdown_result.and(run_result)
2399            }
2400        }
2401    }
2402
2403    pub async fn run(self) -> Result<(), NodeServerError> {
2404        let NodeServer {
2405            config,
2406            mut runtime,
2407            shared,
2408            events,
2409            state_path_lock: _state_path_lock,
2410        } = self;
2411        shared.reconcile_materializations();
2412        shared.reconcile_managed_worktrees().await;
2413        shared.retire_unavailable_session_records().await;
2414        shared.reconcile_context_pack_exports().await;
2415        // Lifecycle hooks are retired (owner ruling 2026-09-25): the node
2416        // observes sessions through ACP where a provider has it; there is no
2417        // hook ingress.
2418        let endpoint = config.endpoint.clone();
2419        let call_home = config.call_home;
2420        let api_listen = config.api_listen;
2421        let bridge_listen = config.bridge_listen;
2422        let bridge_auth = browser_bridge::BridgeAuth::from_optional(config.bridge_token)
2423            .map_err(|_| NodeServerError::InvalidBridgeToken)?;
2424        let accept_shared = Arc::clone(&shared);
2425        let api_shared = Arc::clone(&shared);
2426        let bridge_shared = Arc::clone(&shared);
2427        let shutdown_shared = Arc::clone(&shared);
2428        let shutdown_timeout = Duration::from_millis(
2429            config.runtime.provider_shutdown_timeout_ms.max(1),
2430        );
2431        let result = {
2432            let runtime_loop = drive_runtime_until_shutdown(
2433                &mut runtime,
2434                events,
2435                Arc::clone(&shared),
2436                shutdown_timeout,
2437            );
2438            let accept_loop = accept_connections(&endpoint, call_home, accept_shared);
2439            let api_loop = http_api::run(api_listen, api_shared);
2440            let bridge_loop = browser_bridge::run(bridge_listen, bridge_auth, bridge_shared);
2441            tokio::pin!(runtime_loop);
2442            tokio::pin!(accept_loop);
2443            tokio::pin!(api_loop);
2444            tokio::pin!(bridge_loop);
2445            tokio::select! {
2446                runtime_result = &mut runtime_loop => {
2447                    let shutdown_result = shutdown_shared
2448                        .begin_shutdown()
2449                        .await
2450                        .map_err(NodeServerError::ShutdownDispatch);
2451                    let (accept_result, api_result, bridge_result) =
2452                        tokio::join!(&mut accept_loop, &mut api_loop, &mut bridge_loop);
2453                    match runtime_result {
2454                        Err(error) => Err(error),
2455                        Ok(()) => shutdown_result
2456                            .and(accept_result)
2457                            .and(api_result.map_err(NodeServerError::HttpApi))
2458                            .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2459                    }
2460                }
2461                accept_result = &mut accept_loop => {
2462                    let shutdown_result = shutdown_shared
2463                        .begin_shutdown()
2464                        .await
2465                        .map_err(NodeServerError::ShutdownDispatch);
2466                    let (runtime_result, api_result, bridge_result) =
2467                        tokio::join!(&mut runtime_loop, &mut api_loop, &mut bridge_loop);
2468                    match accept_result {
2469                        Err(error) => Err(error),
2470                        Ok(()) => shutdown_result
2471                            .and(runtime_result)
2472                            .and(api_result.map_err(NodeServerError::HttpApi))
2473                            .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2474                    }
2475                }
2476                api_result = &mut api_loop => {
2477                    let shutdown_result = shutdown_shared
2478                        .begin_shutdown()
2479                        .await
2480                        .map_err(NodeServerError::ShutdownDispatch);
2481                    let (runtime_result, accept_result, bridge_result) =
2482                        tokio::join!(&mut runtime_loop, &mut accept_loop, &mut bridge_loop);
2483                    match api_result {
2484                        Err(error) => Err(NodeServerError::HttpApi(error)),
2485                        Ok(()) => shutdown_result
2486                            .and(runtime_result)
2487                            .and(accept_result)
2488                            .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2489                    }
2490                }
2491                bridge_result = &mut bridge_loop => {
2492                    let shutdown_result = shutdown_shared
2493                        .begin_shutdown()
2494                        .await
2495                        .map_err(NodeServerError::ShutdownDispatch);
2496                    let (runtime_result, accept_result, api_result) =
2497                        tokio::join!(&mut runtime_loop, &mut accept_loop, &mut api_loop);
2498                    match bridge_result {
2499                        Err(error) => Err(NodeServerError::BridgeApi(error)),
2500                        Ok(()) => shutdown_result
2501                            .and(runtime_result)
2502                            .and(accept_result)
2503                            .and(api_result.map_err(NodeServerError::HttpApi)),
2504                    }
2505                }
2506            }
2507        };
2508        result
2509    }
2510}
2511
2512async fn drive_runtime_until_shutdown(
2513    runtime: &mut NativeRuntime,
2514    events: EventSubscription,
2515    shared: Arc<NodeShared>,
2516    shutdown_timeout: Duration,
2517) -> Result<(), NodeServerError> {
2518    // Published once, never per iteration. The worker loops inside
2519    // `NativeRuntime` fold into this profile continuously; turning it into
2520    // percentiles is what costs, and that is `GET /metrics`'s bill to pay.
2521    let _ = shared
2522        .shell_efficiency_profile
2523        .set(runtime.shell_efficiency_profile());
2524    let mut shutdown_started = None;
2525    let mut last_retention_sweep = Instant::now();
2526    loop {
2527        // Every phase below is timed unconditionally -- see
2528        // `drive_loop_profile`'s own doc comment for why an always-on,
2529        // allocation-free ring is cheap enough to never need a feature
2530        // flag. This is the only place these numbers can be taken: the
2531        // phases are calls made directly from this loop, not something a
2532        // callee could time on our behalf.
2533        let runtime_tick_start = Instant::now();
2534        let tick_result = runtime.tick().await;
2535        let runtime_tick_elapsed = runtime_tick_start.elapsed();
2536
2537        let event_drain_start = Instant::now();
2538        // Every command the kernel refused, named. `NativeRuntimeTick`
2539        // has carried these outcomes all along and nothing read them, so a
2540        // rejected command -- a hook event denied by runtime policy, a
2541        // stale generation, an unsupported capability -- vanished at the
2542        // last step of the loop that produced it. A refusal the operator
2543        // cannot see is indistinguishable from an event that never arrived,
2544        // and telling those two apart is most of debugging this stack.
2545        for outcome in &tick_result.command_outcomes {
2546            if let Err(error) = &outcome.result {
2547                shared.rejected_commands_total.fetch_add(1, Ordering::Relaxed);
2548                tracing::warn!(
2549                    command_id = outcome.command_id.0,
2550                    cause = %error,
2551                    "control command rejected"
2552                );
2553                shared.record_rejected_command(
2554                    outcome.command_id,
2555                    error.to_string(),
2556                    error.is_unsupported_transport(),
2557                );
2558            }
2559        }
2560        let mut events_drained: u64 = 0;
2561        while let Ok(event) = events.try_recv() {
2562            events_drained += 1;
2563            let clean_exit = matches!(
2564                event.event,
2565                ControlEventKind::Exited { exit_code: Some(0), forced: false },
2566            );
2567            let instance_id = event.instance_id;
2568            let generation = event.generation;
2569            // Resolve and spawn before publish_control: publish_control's own
2570            // managed-record bookkeeping (reconcile_managed_record) downgrades
2571            // this exact record from Live to Dormant and clears
2572            // active_session for every Exited event, inside the same
2573            // synchronous call. Capturing (record_id, session) here — before
2574            // that runs — is the only way to observe them still Live and
2575            // still populated; the export itself must still run on its own
2576            // detached task (never awaited inline on this loop) because it
2577            // needs NativeRuntime::tick() to keep running to ever settle, and
2578            // tick() is only ever driven by this same loop. See
2579            // reconcile_context_pack_export_for_record's doc comment.
2580            if clean_exit {
2581                if let Some((record_id, session)) =
2582                    shared.managed_record_export_target_for_instance(instance_id, generation)
2583                {
2584                    let export_shared = Arc::clone(&shared);
2585                    tokio::spawn(async move {
2586                        export_shared
2587                            .reconcile_context_pack_export_for_record(&record_id, session)
2588                            .await;
2589                    });
2590                }
2591            }
2592            shared.publish_control(event);
2593        }
2594        let event_drain_elapsed = event_drain_start.elapsed();
2595
2596        let publish_terminal_frames_start = Instant::now();
2597        shared.publish_terminal_frames();
2598        let publish_terminal_frames_elapsed = publish_terminal_frames_start.elapsed();
2599
2600        // Physical PTY count and the runtime's own per-phase tick timings
2601        // both live inside `NativeRuntime`, which `GET /metrics` (reading
2602        // only `NodeShared`) cannot reach directly -- this loop is the one
2603        // place holding `&mut NativeRuntime`, so it is the only place that
2604        // can copy either out.
2605        shared
2606            .native_session_gauge
2607            .store(runtime.active_native_sessions(), Ordering::Relaxed);
2608        *shared
2609            .runtime_tick_profile
2610            .lock()
2611            .unwrap_or_else(|poisoned| poisoned.into_inner()) = runtime.tick_profile_snapshot();
2612
2613        let remainder_start = Instant::now();
2614        if shared.shutdown.load(Ordering::Acquire) {
2615            let started = *shutdown_started.get_or_insert_with(Instant::now);
2616            let snapshot = shared.handle.snapshot();
2617            let sessions_settled = snapshot.sessions.iter().all(|session| {
2618                !matches!(
2619                    session.status,
2620                    gate4agent_types::SessionStatus::Starting
2621                        | gate4agent_types::SessionStatus::Running
2622                        | gate4agent_types::SessionStatus::Stopping
2623                )
2624            });
2625            if runtime.active_native_sessions() == 0 && sessions_settled {
2626                return Ok(());
2627            }
2628            if started.elapsed() >= shutdown_timeout {
2629                return Err(NodeServerError::ShutdownTimedOut {
2630                    active_native_sessions: runtime.active_native_sessions(),
2631                });
2632            }
2633        }
2634        let remainder_elapsed = remainder_start.elapsed();
2635
2636        {
2637            let mut profile = shared
2638                .drive_loop_profile
2639                .lock()
2640                .unwrap_or_else(|poisoned| poisoned.into_inner());
2641            profile.record_runtime_tick(runtime_tick_elapsed);
2642            profile.record_event_drain(event_drain_elapsed);
2643            profile.record_publish_terminal_frames(publish_terminal_frames_elapsed);
2644            profile.record_remainder(remainder_elapsed);
2645            profile.note_iteration(Instant::now());
2646        }
2647
2648        // "Produced nothing" means this iteration's `NativeRuntime::tick`
2649        // applied zero observations (`tick_result.observations_applied`,
2650        // which already folds in any terminal frames collected -- see
2651        // `NativeEffectDispatcher::drain_observations`) AND the
2652        // control-event subscription drained above forwarded zero events.
2653        // Command ingress and dispatched effects are deliberately NOT part
2654        // of this definition: an idle node still runs `step_control_plane`
2655        // and every provider supervisor's `tick` regardless, every 10ms --
2656        // so "nothing" here means nothing OBSERVABLE left this iteration,
2657        // matching what a reader would assume from `drive_loop_iterations`'s
2658        // own field name, not that the tick itself was free of work.
2659        shared.drive_loop_iterations_total.fetch_add(1, Ordering::Relaxed);
2660        if tick_result.observations_applied == 0 && events_drained == 0 {
2661            shared.drive_loop_iterations_idle.fetch_add(1, Ordering::Relaxed);
2662        }
2663
2664        // A sweep cadence, not a policy value -- see
2665        // `SESSION_RECORD_RETENTION_SWEEP_PERIOD`'s own doc comment. The
2666        // policy itself (whether this does anything at all) lives entirely
2667        // in `NodeShared::session_record_retention`.
2668        if last_retention_sweep.elapsed() >= SESSION_RECORD_RETENTION_SWEEP_PERIOD {
2669            shared.retire_unavailable_session_records().await;
2670            last_retention_sweep = Instant::now();
2671        }
2672
2673        sleep(Duration::from_millis(10)).await;
2674    }
2675}
2676
2677#[derive(Clone)]
2678pub struct NodeShutdownHandle {
2679    shared: Arc<NodeShared>,
2680}
2681
2682impl NodeShutdownHandle {
2683    pub async fn request_shutdown(&self) -> Result<(), NodeServerError> {
2684        self.shared
2685            .begin_shutdown()
2686            .await
2687            .map_err(NodeServerError::ShutdownDispatch)
2688    }
2689}
2690
2691async fn wait_for_ctrl_signal() -> Result<(), NodeServerError> {
2692    #[cfg(windows)]
2693    {
2694    let mut ctrl_c = tokio::signal::windows::ctrl_c()?;
2695    let mut ctrl_break = tokio::signal::windows::ctrl_break()?;
2696    return tokio::select! {
2697        signal = ctrl_c.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2698        signal = ctrl_break.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2699    };
2700    }
2701    #[cfg(unix)]
2702    {
2703        let mut interrupt = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())?;
2704        let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
2705        tokio::select! {
2706            signal = interrupt.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2707            signal = terminate.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2708        }
2709    }
2710}
2711
2712/// The providers this node actually runs.
2713///
2714/// `builtin_registry()` is a reference catalog of everything the project has
2715/// ever described; the fleet is this list, and it is deliberately short. A
2716/// provider only belongs here if it can be exercised for real -- there is a
2717/// subscription behind it and a failure in it can be reproduced and
2718/// debugged. Carrying one that cannot is carrying code nobody can verify,
2719/// which is worse than not supporting it: it looks supported.
2720///
2721/// A provider dropped from the fleet by this rule is removed outright, not
2722/// carried dark: no catalog entry, no adapter, no dead code claiming support
2723/// for something nobody can verify runs.
2724fn active_registry() -> Result<AgentRegistry, NodeServerError> {
2725    let specs = builtin_registry()
2726        .iter()
2727        .filter(|spec| matches!(spec.id.as_str(), "claude" | "codex" | "grok" | "kimi"))
2728        .cloned()
2729        .collect::<Vec<_>>();
2730    AgentRegistry::new(specs).map_err(|error| NodeServerError::Registry(error.to_string()))
2731}
2732
2733fn provider_contract_manifest(
2734    catalog: &AgentRegistry,
2735) -> Result<
2736    (
2737        Vec<ProviderContractSupport>,
2738        Vec<ProviderAdapterContractSupport>,
2739    ),
2740    NodeServerError,
2741> {
2742    let mut provider_contracts = Vec::new();
2743    let mut provider_adapter_contracts = Vec::new();
2744    for spec in catalog.iter() {
2745        let provider = spec.id.clone();
2746        let revision = ProviderContractRevision::new(spec.revision.clone())
2747            .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2748        provider_contracts.push(ProviderContractSupport {
2749            provider: provider.clone(),
2750            revision,
2751        });
2752        for (family, binding) in declared_adapter_bindings(spec)? {
2753            let revision = AdapterContractRevision::new(binding.revision.clone())
2754                .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2755            provider_adapter_contracts.push(ProviderAdapterContractSupport {
2756                provider: provider.clone(),
2757                family,
2758                adapter_id: binding.id.clone(),
2759                revision,
2760            });
2761        }
2762    }
2763    validate_provider_contract_manifest(&provider_contracts, &provider_adapter_contracts)
2764        .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2765    Ok((provider_contracts, provider_adapter_contracts))
2766}
2767
2768fn declared_adapter_bindings(
2769    spec: &AgentSpec,
2770) -> Result<Vec<(AdapterFamily, &AdapterBinding)>, NodeServerError> {
2771    let mut bindings = Vec::new();
2772    push_declared_binding(
2773        &mut bindings,
2774        AdapterFamily::PtySemantic,
2775        spec.capabilities.transports.pty_adapter.as_ref(),
2776    )?;
2777    push_declared_binding(
2778        &mut bindings,
2779        AdapterFamily::Pipe,
2780        spec.capabilities.adapters.pty_sidecar.as_ref(),
2781    )?;
2782    let pipe_transport = spec.capabilities.transports.pipe.as_ref();
2783    let pipe_family = pipe_transport.map(|transport| match transport.protocol {
2784        gate4agent_types::PipeProtocol::SemanticNdjson
2785        | gate4agent_types::PipeProtocol::StructuredJsonl => AdapterFamily::Pipe,
2786        gate4agent_types::PipeProtocol::OneShotText => AdapterFamily::OneShot,
2787    });
2788    push_declared_binding(
2789        &mut bindings,
2790        AdapterFamily::Pipe,
2791        pipe_transport
2792            .filter(|_| pipe_family == Some(AdapterFamily::Pipe))
2793            .map(|transport| &transport.adapter),
2794    )?;
2795    push_declared_binding(
2796        &mut bindings,
2797        AdapterFamily::Acp,
2798        spec.capabilities
2799            .transports
2800            .acp
2801            .as_ref()
2802            .map(|transport| &transport.adapter),
2803    )?;
2804    push_declared_binding(
2805        &mut bindings,
2806        AdapterFamily::Hook,
2807        spec.capabilities.adapters.hook.as_ref(),
2808    )?;
2809    push_declared_binding(
2810        &mut bindings,
2811        AdapterFamily::ManagedHook,
2812        spec.capabilities.adapters.managed_hook.as_ref(),
2813    )?;
2814    push_declared_binding(
2815        &mut bindings,
2816        AdapterFamily::OneShot,
2817        pipe_transport
2818            .filter(|_| pipe_family == Some(AdapterFamily::OneShot))
2819            .map(|transport| &transport.adapter),
2820    )?;
2821    push_declared_binding(
2822        &mut bindings,
2823        AdapterFamily::OneShot,
2824        spec.capabilities.adapters.one_shot.as_ref(),
2825    )?;
2826    push_declared_binding(
2827        &mut bindings,
2828        AdapterFamily::History,
2829        spec.capabilities.adapters.history.as_ref(),
2830    )?;
2831    push_declared_binding(
2832        &mut bindings,
2833        AdapterFamily::Resume,
2834        spec.capabilities.adapters.resume.as_ref(),
2835    )?;
2836    push_declared_binding(
2837        &mut bindings,
2838        AdapterFamily::SessionOptions,
2839        spec.capabilities.adapters.session_options.as_ref(),
2840    )?;
2841    push_declared_binding(
2842        &mut bindings,
2843        AdapterFamily::CapabilityProbe,
2844        spec.capabilities.adapters.capability_probe.as_ref(),
2845    )?;
2846    Ok(bindings)
2847}
2848
2849fn push_declared_binding<'a>(
2850    bindings: &mut Vec<(AdapterFamily, &'a AdapterBinding)>,
2851    family: AdapterFamily,
2852    binding: Option<&'a AdapterBinding>,
2853) -> Result<(), NodeServerError> {
2854    let Some(binding) = binding else {
2855        return Ok(());
2856    };
2857    if let Some((_, existing)) = bindings
2858        .iter()
2859        .find(|(existing_family, _)| *existing_family == family)
2860    {
2861        if *existing == binding {
2862            return Ok(());
2863        }
2864        return Err(NodeServerError::ProviderContractManifest(format!(
2865            "provider declares conflicting {family:?} adapter bindings"
2866        )));
2867    }
2868    bindings.push((family, binding));
2869    Ok(())
2870}
2871
2872fn merge_durable_state(
2873    configured: &[WorkspaceConfig],
2874    mut loaded: LoadedNodeState,
2875) -> Result<(Vec<WorkspaceConfig>, Vec<ManagedSessionRecord>, Option<String>), NodeServerError> {
2876    let mut workspaces = configured
2877        .iter()
2878        .cloned()
2879        .map(|workspace| (workspace.workspace_id.clone(), workspace))
2880        .collect::<BTreeMap<_, _>>();
2881    let mut roots = workspaces
2882        .values()
2883        .map(|workspace| {
2884            (
2885                platform::root_identity(&workspace.canonical_root),
2886                workspace.workspace_id.clone(),
2887            )
2888        })
2889        .collect::<BTreeMap<_, _>>();
2890    let mut warning = loaded
2891        .warning
2892        .take()
2893        .map(|message| session_registry::sanitized_persistence_summary(&message));
2894
2895    for (workspace_id, canonical_root) in loaded.workspaces {
2896        if let Some(existing) = workspaces.get(&workspace_id) {
2897            if !platform::roots_equal(&existing.canonical_root, &canonical_root) {
2898                return Err(NodeServerError::DurableState(io::Error::new(
2899                    io::ErrorKind::InvalidData,
2900                    DURABLE_STATE_CONFLICT_ERROR,
2901                )));
2902            }
2903            continue;
2904        }
2905        if roots.get(&platform::root_identity(&canonical_root)).is_some() {
2906            return Err(NodeServerError::DurableState(io::Error::new(
2907                io::ErrorKind::InvalidData,
2908                DURABLE_STATE_CONFLICT_ERROR,
2909            )));
2910        }
2911        if !Path::new(&canonical_root).is_dir() {
2912            warning.get_or_insert_with(|| {
2913                session_registry::DURABLE_STATE_WORKSPACE_WARNING.to_owned()
2914            });
2915            continue;
2916        }
2917        let workspace = WorkspaceConfig::new(workspace_id.clone(), &canonical_root).map_err(|_| {
2918            NodeServerError::DurableState(io::Error::new(
2919                io::ErrorKind::InvalidData,
2920                DURABLE_STATE_CONFLICT_ERROR,
2921            ))
2922        })?;
2923        roots.insert(
2924            platform::root_identity(&workspace.canonical_root),
2925            workspace.workspace_id.clone(),
2926        );
2927        workspaces.insert(workspace_id, workspace);
2928    }
2929
2930    for record in &mut loaded.records {
2931        match workspaces.get(&record.workspace_id) {
2932            Some(workspace)
2933                if platform::roots_equal(
2934                    &workspace.canonical_root,
2935                    windows_path_text(&record.canonical_root),
2936                ) => {}
2937            Some(_) => {
2938                return Err(NodeServerError::DurableState(io::Error::new(
2939                    io::ErrorKind::InvalidData,
2940                    DURABLE_STATE_CONFLICT_ERROR,
2941                )));
2942            }
2943            None => {
2944                record.state = ManagedSessionState::Unavailable;
2945                record.active_session = None;
2946                record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
2947            }
2948        }
2949    }
2950    Ok((workspaces.into_values().collect(), loaded.records, warning))
2951}
2952
2953/// One entry of `NodeShared::rejected_command_reasons` -- see that field's
2954/// own doc for the mechanism this exists to close.
2955struct RejectedCommandReason {
2956    message: String,
2957    /// True when the kernel refused this command specifically because the
2958    /// agent does not declare the requested transport (`KernelCommandError::
2959    /// UnsupportedTransport`) -- lets the reader choose the named
2960    /// `NodeFailureCode::UnsupportedTransport` over the generic
2961    /// `BackendOperationFailed` bucket, so this one well-known rejection
2962    /// keeps its identity all the way to the operator wire.
2963    unsupported_transport: bool,
2964    expires_at: Instant,
2965}
2966
2967struct NodeShared {
2968    handle: Gate4AgentHandle,
2969    access_token: String,
2970    node_id: NodeId,
2971    incarnation_id: NodeIncarnationId,
2972    started_at_unix_ms: u64,
2973    workspaces: RwLock<BTreeMap<WorkspaceId, String>>,
2974    worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode>,
2975    managed_worktree_profiles:
2976        BTreeMap<WorkspaceId, BTreeMap<WorktreeProfileId, ManagedWorktreeProfile>>,
2977    managed_worktrees: Mutex<ManagedWorktreeRegistry>,
2978    enabled_providers: Vec<AgentId>,
2979    provider_runtime_statuses: ProviderRuntimeStatuses,
2980    provider_runtime_status_updates:
2981        RwLock<BTreeMap<AgentId, crate::protocol::ProviderRuntimeStatus>>,
2982    provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
2983    provider_contracts: Vec<ProviderContractSupport>,
2984    provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
2985    spawn_profiles: SpawnProfileRegistry,
2986    environment_profiles:
2987        RwLock<BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>>,
2988    environment_materialization_profiles:
2989        RwLock<BTreeMap<SpawnEnvironmentProfileId, NodeSessionMaterializationProfile>>,
2990    /// Station **network** allowlist catalog (opaque policy ids only).
2991    /// Empty by default — any `network_allowlist` on spawn must be registered
2992    /// here or resolve refuses with `UnknownNetworkAllowlist`. Optional
2993    /// startup load via `NodeServerConfig::with_network_allowlist_catalog(_file)`
2994    /// / env `GATE4AGENT_NODE_NETWORK_ALLOWLIST_CATALOG`. Plan
2995    /// `station-network-and-browser-profile-knobs-2026-10-02.md` §2.1 / §4.
2996    /// Never holds cookies / OAuth / proxy credentials.
2997    network_allowlist_catalog: RwLock<crate::network_allowlist_catalog::NetworkAllowlistCatalog>,
2998    /// Node-local exclusive dig2browser station profile leases (feature
2999    /// `dig2-station-probe`). Opaque `browser_profile_id` ↔ session holder;
3000    /// cleared on session end. Never ImportSession / cookies on C2.
3001    #[cfg(feature = "dig2-station-probe")]
3002    browser_station_leases: crate::dig2_station_lease::BrowserStationLeaseTable,
3003    bundle_catalog: RwLock<BundleCatalog>,
3004    delivery_store: Mutex<Option<DeliveryStore>>,
3005    context_pack_store: Mutex<Option<ContextPackStore>>,
3006    context_catalog: RwLock<ContextPackCatalog>,
3007    native_launch_profile_control: Option<NativeLaunchProfileControl>,
3008    harness_mcp_registry: Option<HarnessMcpProxyRegistry>,
3009    native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
3010    session_environment_materializer: Option<SessionEnvironmentMaterializer>,
3011    materializations: Mutex<BTreeMap<MaterializationId, MaterializationOwnershipRecord>>,
3012    spawn_idempotency: Mutex<SpawnIdempotencyCache>,
3013    managed_spawn_replays:
3014        Mutex<BTreeMap<SpawnIdempotencyKey, ManagedWorktreeSpawnReplayRecordV10>>,
3015    controller: Mutex<Option<ControllerLease>>,
3016    history: Mutex<NodeEventHistory>,
3017    event_tx: broadcast::Sender<NodeEventEnvelope>,
3018    terminal_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3019    /// The outbound `NodeEvent::AgentStream` push channel -- its own
3020    /// dedicated, capability-gated broadcast, mirroring `terminal_event_tx`
3021    /// exactly rather than riding the durable `event_tx` history (see
3022    /// `publish`'s own assertion): a subscriber who never negotiated
3023    /// `NODE_AGENT_STREAM_EVENTS_CAPABILITY` must never receive one of
3024    /// these, the same guarantee `include_terminal_frame_events` already
3025    /// gives terminal frames.
3026    agent_stream_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3027    /// The outbound `NodeEvent::HarnessMcpReadCall` push channel -- mirrors
3028    /// `agent_stream_event_tx` exactly, its own dedicated, capability-gated
3029    /// broadcast rather than a ride on the durable `event_tx` history. A
3030    /// `HarnessMcpReadCall` is a request/response call a live proxy call is
3031    /// waiting on (`harness_mcp_proxy.rs`'s `begin_call`); the shared bus's
3032    /// finite capacity and its willingness to silently evict a lagging
3033    /// subscriber's backlog is exactly how the call vanished with neither
3034    /// side ever logging it (see `publish_transient`'s own doc).
3035    harness_mcp_proxy_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3036    terminal_frame_watermarks:
3037        Mutex<BTreeMap<AgentInstanceId, (SessionAddress, u64)>>,
3038    next_connection_id: AtomicU64,
3039    next_instance_id: AtomicU64,
3040    next_command_id: AtomicU64,
3041    shutdown: AtomicBool,
3042    shutdown_notify: Notify,
3043    preauth_slots: Arc<Semaphore>,
3044    authenticated_slots: Arc<Semaphore>,
3045    inspection_slots: Arc<Semaphore>,
3046    mutation_gate: AsyncMutex<()>,
3047    session_bindings: Mutex<BTreeMap<AgentInstanceId, SessionBinding>>,
3048    session_records: Mutex<SessionRecords>,
3049    state_path: Option<PathBuf>,
3050    persistence_error: RwLock<Option<String>>,
3051    state_transaction: Mutex<()>,
3052    input_settle_timeout_ms: u64,
3053    /// Drive-loop phase/rate timings -- see `drive_loop_profile`'s own doc
3054    /// comment. Written once per iteration by
3055    /// `drive_runtime_until_shutdown`, read by `GET /metrics`.
3056    drive_loop_profile: Mutex<DriveLoopProfiler>,
3057    /// Latest copy of `NativeRuntime::tick_profile_snapshot()` -- `Copy`,
3058    /// so an overwrite each iteration is cheap. The runtime itself is not
3059    /// reachable from here (it is owned locally by
3060    /// `drive_runtime_until_shutdown`), so this is how its own per-phase
3061    /// tick timings reach `GET /metrics`.
3062    runtime_tick_profile: Mutex<TickProfileSnapshot>,
3063    /// The live shell-efficiency profile, shared with the per-instance
3064    /// worker loops that fold into it.
3065    ///
3066    /// Deliberately NOT the per-iteration snapshot copy `runtime_tick_
3067    /// profile` above keeps: producing a snapshot sorts every ring behind
3068    /// it, and that sort belongs on the read. A `OnceLock` rather than a
3069    /// field set at construction because `NodeShared` is built before the
3070    /// `NativeRuntime` that owns the profile exists -- the drive loop
3071    /// publishes it once, before its first iteration. Unset means the
3072    /// runtime has not started driving yet, which `GET /metrics` reports as
3073    /// empty series rather than as zeroes it never measured.
3074    shell_efficiency_profile: OnceLock<Arc<Mutex<ShellEfficiencyProfile>>>,
3075    /// Physical PTY sessions the native runtime currently owns
3076    /// (`NativeRuntime::active_native_sessions`), refreshed once per drive
3077    /// loop iteration -- deliberately a separate count from
3078    /// `handle.snapshot().sessions.len()` (the control-plane kernel's own
3079    /// session bookkeeping), since the two can disagree and the gap is
3080    /// itself a diagnostic signal `GET /metrics` exposes.
3081    native_session_gauge: AtomicUsize,
3082    /// Lifetime totals for the per-connection serve loop, summed across
3083    /// every live connection.
3084    ///
3085    /// The drive loop's own phase timings account for about ten
3086    /// microseconds an iteration on an idle node, yet the process burns a
3087    /// quarter of a core — and burns it ONLY while a c2 is attached (0.5%
3088    /// with none, 23% with two, measured). That gap is somewhere in
3089    /// serving a connection, and there was no way to see it: iterations
3090    /// says whether the loop is spinning rather than sleeping, and events
3091    /// says whether it is spinning over real work or over nothing.
3092    connection_loop_iterations: AtomicU64,
3093    connection_events_sent: AtomicU64,
3094    /// Lifetime totals for `drive_runtime_until_shutdown`'s own iterations
3095    /// -- `drive_loop_iterations_idle` counts iterations that applied zero
3096    /// `NativeRuntime::tick` observations AND forwarded zero control
3097    /// events; see the exact "produced nothing" definition at the site in
3098    /// `drive_runtime_until_shutdown` that increments these. Read twice
3099    /// and divide by the interval, same rule as `connections` above --
3100    /// `idle / total` is backlog item 5's "wake on work" number.
3101    /// Lifetime count of control commands the kernel refused.
3102    rejected_commands_total: AtomicU64,
3103    /// The named reason behind a recent kernel rejection, by the
3104    /// `CommandId` `dispatch_bounded` handed back when it enqueued that
3105    /// command -- populated by `drive_runtime_until_shutdown` for every
3106    /// `Err` in a tick's `command_outcomes`, and drained by whichever
3107    /// spawn-dispatch waiter recognizes its own `Register`/`Start`
3108    /// `CommandId` among them.
3109    ///
3110    /// A `ControlCommand` only ever fails synchronously (a `NodeFailure`
3111    /// out of `dispatch`/`dispatch_bounded` itself) for a port-level ingress
3112    /// problem -- full or disconnected -- never for the kernel refusing the
3113    /// command's content (an unsupported transport, an unknown agent, ...);
3114    /// that verdict exists only once `NativeRuntime::tick` has processed the
3115    /// command, one drive-loop iteration later, in a different task than
3116    /// whichever caller is awaiting the spawn. Without this map, a spawn
3117    /// wait loop that polls `self.handle.snapshot()` for a session that
3118    /// will now never appear has no way to learn why -- it can only spin
3119    /// until its own commit deadline and report a generic "did not commit"
3120    /// timeout, discarding a reason the kernel already named precisely (see
3121    /// `KernelCommandError`'s variants, e.g. `UnsupportedTransport`).
3122    rejected_command_reasons: Mutex<BTreeMap<CommandId, RejectedCommandReason>>,
3123    drive_loop_iterations_total: AtomicU64,
3124    drive_loop_iterations_idle: AtomicU64,
3125    /// The operator-chosen age/keep-N policy the retention sweep applies to
3126    /// `ManagedSessionState::Unavailable` records. Both `0` by default --
3127    /// see `SessionRecordRetentionConfig`'s own doc comment.
3128    session_record_retention: SessionRecordRetentionConfig,
3129    /// Lifetime count of managed session records the retention sweep has
3130    /// retired. Same tier as `rejected_commands_total` above: a live,
3131    /// process-lifetime instrument, never persisted and never reloaded from
3132    /// `state-v1.json` -- a restart legitimately resets it, matching every
3133    /// other `*_total` counter here.
3134    retired_records_total: AtomicU64,
3135    #[cfg(feature = "fixture")]
3136    fixture_semantic_hook_policy: bool,
3137    #[cfg(feature = "fixture")]
3138    fixture_spawn_managed_worktree_v2_failure_probe: SpawnManagedWorktreeV2FailureProbe,
3139}
3140
3141impl Drop for NodeShared {
3142    fn drop(&mut self) {
3143        if let Some(registry) = self.harness_mcp_registry.as_ref() {
3144            let instances = registry.shutdown();
3145            if let Some(control) = self.native_launch_profile_control.as_ref() {
3146                for instance_id in instances {
3147                    control.clear_native_mcp_server_launch_overlay(instance_id);
3148                }
3149            }
3150        }
3151    }
3152}
3153
3154#[derive(Clone, Debug, Eq, PartialEq)]
3155struct SessionBinding {
3156    workspace_id: WorkspaceId,
3157    generation: SessionGeneration,
3158    runtime_policy: ProviderRuntimePolicy,
3159    pending_resume: Option<(SessionGeneration, CommandId, ProviderRuntimePolicy)>,
3160    record_id: Option<SessionRecordId>,
3161    managed_worktree_lease_id: Option<ManagedWorktreeLeaseId>,
3162    environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
3163    bundle: Option<ResolvedBundleReceipt>,
3164    context: Option<ResolvedContextPackReceipt>,
3165    materialization_id: Option<MaterializationId>,
3166}
3167
3168#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3169enum SpawnRecordPolicy {
3170    ProviderIdentityOnly,
3171    Always,
3172}
3173
3174struct NativeEnvironmentSelectionGuard {
3175    control: Option<NativeLaunchProfileControl>,
3176    instance_id: AgentInstanceId,
3177}
3178
3179struct NativeInstanceOverlayGuard {
3180    control: Option<NativeLaunchProfileControl>,
3181    instance_id: AgentInstanceId,
3182}
3183
3184struct NativeHarnessMcpOverlayGuard {
3185    control: NativeLaunchProfileControl,
3186    instance_id: AgentInstanceId,
3187}
3188
3189impl NativeHarnessMcpOverlayGuard {
3190    fn retain(self) { std::mem::forget(self); }
3191}
3192
3193impl Drop for NativeHarnessMcpOverlayGuard {
3194    fn drop(&mut self) {
3195        self.control
3196            .clear_native_mcp_server_launch_overlay(self.instance_id);
3197    }
3198}
3199
3200impl NativeInstanceOverlayGuard {
3201    fn retain(mut self) {
3202        self.control = None;
3203    }
3204}
3205
3206impl Drop for NativeInstanceOverlayGuard {
3207    fn drop(&mut self) {
3208        if let Some(control) = self.control.as_ref() {
3209            control.clear_native_instance_launch_overlay(self.instance_id);
3210        }
3211    }
3212}
3213
3214enum PreparedNativeLaunchOverlay {
3215    Environment(NativeLaunchEnvironmentOverlay),
3216    Instance(NativeInstanceLaunchOverlay),
3217}
3218
3219impl NativeEnvironmentSelectionGuard {
3220    fn retain(mut self) {
3221        self.control = None;
3222    }
3223}
3224
3225impl Drop for NativeEnvironmentSelectionGuard {
3226    fn drop(&mut self) {
3227        if let Some(control) = self.control.as_ref() {
3228            control.clear_native_launch_profile_selection(self.instance_id);
3229        }
3230    }
3231}
3232
3233struct SessionMaterializationGuard<'a> {
3234    shared: &'a NodeShared,
3235    id: Option<MaterializationId>,
3236    /// Absolute ProviderHome (`home/`) under the session materialization root.
3237    /// Used for Claude station network `--settings` sidecar writes.
3238    provider_home: Option<PathBuf>,
3239}
3240
3241impl SessionMaterializationGuard<'_> {
3242    fn id(&self) -> Option<&MaterializationId> {
3243        self.id.as_ref()
3244    }
3245
3246    fn provider_home(&self) -> Option<&Path> {
3247        self.provider_home.as_deref()
3248    }
3249
3250    fn retain(mut self) {
3251        self.id = None;
3252    }
3253}
3254
3255impl Drop for SessionMaterializationGuard<'_> {
3256    fn drop(&mut self) {
3257        if let Some(id) = self.id.take() {
3258            let _ = self.shared.cleanup_materialization(&id);
3259        }
3260    }
3261}
3262
3263struct SessionRecords {
3264    records: BTreeMap<SessionRecordId, ManagedSessionRecord>,
3265}
3266
3267struct SpawnIdempotencyCache {
3268    entries: BTreeMap<SpawnIdempotencyKey, SpawnIdempotencyEntry>,
3269}
3270
3271struct SpawnIdempotencyEntry {
3272    value: SpawnIdempotencyValue,
3273    expires_at: Instant,
3274}
3275
3276enum SpawnIdempotencyValue {
3277    Standard {
3278        spec: SpawnSpec,
3279        result: Result<ResolvedSpawnReceipt, NodeFailure>,
3280    },
3281    Managed {
3282        request: ManagedWorktreeSpawnRequest,
3283        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
3284    },
3285    HarnessMcp,
3286}
3287
3288enum DurableManagedSpawnReplayDecision {
3289    Reserved,
3290    PendingLinked,
3291    Committed(ManagedWorktreeSpawnReceipt),
3292}
3293
3294impl SpawnIdempotencyValue {
3295    fn references_context(&self, context_id: &SpawnContextId) -> bool {
3296        match self {
3297            Self::Standard { result, .. } => result
3298                .as_ref()
3299                .ok()
3300                .and_then(|receipt| receipt.context.as_ref()),
3301            Self::Managed { result, .. } => result
3302                .as_ref()
3303                .ok()
3304                .and_then(|receipt| receipt.spawn.context.as_ref()),
3305            Self::HarnessMcp => None,
3306        }
3307        .is_some_and(|receipt| &receipt.id == context_id)
3308    }
3309}
3310
3311#[derive(Clone, Copy)]
3312struct ControllerLease {
3313    connection_id: u64,
3314    expires_at: Instant,
3315}
3316
3317struct NodeEventHistory {
3318    last_sequence: u64,
3319    replay_floor_sequence: u64,
3320    events: VecDeque<NodeEventEnvelope>,
3321    record_providers: BTreeMap<SessionRecordId, AgentId>,
3322    removed_record_providers: BTreeMap<u64, AgentId>,
3323}
3324
3325impl NodeEventHistory {
3326    fn new(record_providers: BTreeMap<SessionRecordId, AgentId>) -> Self {
3327        Self {
3328            last_sequence: 0,
3329            replay_floor_sequence: 1,
3330            events: VecDeque::with_capacity(NODE_EVENT_HISTORY_MAX),
3331            record_providers,
3332            removed_record_providers: BTreeMap::new(),
3333        }
3334    }
3335}
3336
3337impl NodeShared {
3338    fn new_with_incarnation(
3339        handle: Gate4AgentHandle,
3340        access_token: String,
3341        node_id: NodeId,
3342        incarnation_id: NodeIncarnationId,
3343        workspaces: Vec<WorkspaceConfig>,
3344        enabled_providers: Vec<AgentId>,
3345        provider_runtime_statuses: ProviderRuntimeStatuses,
3346        provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
3347        provider_contracts: Vec<ProviderContractSupport>,
3348        provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
3349        spawn_profiles: SpawnProfileRegistry,
3350        native_launch_profile_control: Option<NativeLaunchProfileControl>,
3351        native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
3352        session_environment_materializer: Option<SessionEnvironmentMaterializer>,
3353        state_path: Option<PathBuf>,
3354        records: Vec<ManagedSessionRecord>,
3355        managed_worktrees: Vec<ManagedWorktreeLeaseRecord>,
3356        managed_worktree_tombstones: Vec<ManagedWorktreeLeaseRecord>,
3357        materializations: Vec<MaterializationOwnershipRecord>,
3358        persistence_warning: Option<String>,
3359        input_settle_timeout_ms: u64,
3360    ) -> Self {
3361        let (event_tx, _) = broadcast::channel(NODE_BROADCAST_CAPACITY);
3362        let (terminal_event_tx, _) = broadcast::channel(NODE_TERMINAL_BROADCAST_CAPACITY);
3363        let (agent_stream_event_tx, _) = broadcast::channel(NODE_AGENT_STREAM_BROADCAST_CAPACITY);
3364        let (harness_mcp_proxy_event_tx, _) =
3365            broadcast::channel(NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY);
3366        let record_providers = records
3367            .iter()
3368            .map(|record| (record.record_id.clone(), record.provider.clone()))
3369            .collect();
3370        let mut worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode> = workspaces
3371            .iter()
3372            .map(|workspace| {
3373                (workspace.workspace_id.clone(), workspace.worktree_service_mode)
3374            })
3375            .collect();
3376        for lease in &managed_worktrees {
3377            worktree_service_modes.insert(lease.workspace_id.clone(), WorktreeServiceMode::Off);
3378        }
3379        let managed_worktree_profiles = workspaces.iter()
3380            .map(|workspace| (
3381                workspace.workspace_id.clone(),
3382                workspace.managed_worktree_profiles.clone(),
3383            ))
3384            .collect();
3385        let mut managed_worktrees = ManagedWorktreeRegistry::from_records(
3386            managed_worktrees,
3387            managed_worktree_tombstones,
3388        ).expect("durable managed worktree registry was validated while loading");
3389        managed_worktrees.clear_stale_session_holders(incarnation_id, unix_time_ms());
3390        managed_worktrees.reattach_record_holders(
3391            &managed_worktree_record_holders(&records),
3392            unix_time_ms(),
3393        );
3394        Self {
3395            handle,
3396            access_token,
3397            node_id,
3398            incarnation_id,
3399            started_at_unix_ms: std::time::SystemTime::now()
3400                .duration_since(std::time::UNIX_EPOCH)
3401                .unwrap_or_default()
3402                .as_millis()
3403                .min(u64::MAX as u128) as u64,
3404            workspaces: RwLock::new(
3405                workspaces
3406                    .into_iter()
3407                    .map(|workspace| (workspace.workspace_id, workspace.canonical_root))
3408                    .collect(),
3409            ),
3410            worktree_service_modes,
3411            managed_worktree_profiles,
3412            managed_worktrees: Mutex::new(managed_worktrees),
3413            enabled_providers,
3414            provider_runtime_statuses,
3415            provider_runtime_status_updates: RwLock::new(BTreeMap::new()),
3416            provider_runtime_monitor,
3417            provider_contracts,
3418            provider_adapter_contracts,
3419            spawn_profiles,
3420            environment_profiles: RwLock::new(BTreeMap::new()),
3421            environment_materialization_profiles: RwLock::new(BTreeMap::new()),
3422            network_allowlist_catalog: RwLock::new(crate::network_allowlist_catalog::NetworkAllowlistCatalog::new()),
3423            #[cfg(feature = "dig2-station-probe")]
3424            browser_station_leases: crate::dig2_station_lease::new_lease_table(),
3425            bundle_catalog: RwLock::new(BundleCatalog::default()),
3426            delivery_store: Mutex::new(None),
3427            context_pack_store: Mutex::new(None),
3428            context_catalog: RwLock::new(ContextPackCatalog::default()),
3429            native_launch_profile_control,
3430            harness_mcp_registry: None,
3431            native_session_catalog,
3432            session_environment_materializer,
3433            materializations: Mutex::new(
3434                materializations
3435                    .into_iter()
3436                    .map(|record| (record.id().clone(), record))
3437                    .collect(),
3438            ),
3439            spawn_idempotency: Mutex::new(SpawnIdempotencyCache {
3440                entries: BTreeMap::new(),
3441            }),
3442            managed_spawn_replays: Mutex::new(BTreeMap::new()),
3443            controller: Mutex::new(None),
3444            history: Mutex::new(NodeEventHistory::new(record_providers)),
3445            event_tx,
3446            terminal_event_tx,
3447            agent_stream_event_tx,
3448            harness_mcp_proxy_event_tx,
3449            terminal_frame_watermarks: Mutex::new(BTreeMap::new()),
3450            next_connection_id: AtomicU64::new(1),
3451            next_instance_id: AtomicU64::new(1),
3452            next_command_id: AtomicU64::new(1),
3453            shutdown: AtomicBool::new(false),
3454            shutdown_notify: Notify::new(),
3455            preauth_slots: Arc::new(Semaphore::new(MAX_PREAUTH_CONNECTIONS)),
3456            authenticated_slots: Arc::new(Semaphore::new(MAX_AUTHENTICATED_CONNECTIONS)),
3457            inspection_slots: Arc::new(Semaphore::new(WORKSPACE_INSPECTION_MAX_CONCURRENCY)),
3458            mutation_gate: AsyncMutex::new(()),
3459            session_bindings: Mutex::new(BTreeMap::new()),
3460            session_records: Mutex::new(SessionRecords {
3461                records: records
3462                    .into_iter()
3463                    .map(|record| (record.record_id.clone(), record))
3464                    .collect(),
3465            }),
3466            state_path,
3467            persistence_error: RwLock::new(
3468                persistence_warning
3469                    .map(|message| session_registry::sanitized_persistence_summary(&message)),
3470            ),
3471            state_transaction: Mutex::new(()),
3472            input_settle_timeout_ms,
3473            drive_loop_profile: Mutex::new(DriveLoopProfiler::default()),
3474            runtime_tick_profile: Mutex::new(TickProfileSnapshot::default()),
3475            shell_efficiency_profile: OnceLock::new(),
3476            native_session_gauge: AtomicUsize::new(0),
3477            connection_loop_iterations: AtomicU64::new(0),
3478            connection_events_sent: AtomicU64::new(0),
3479            rejected_commands_total: AtomicU64::new(0),
3480            rejected_command_reasons: Mutex::new(BTreeMap::new()),
3481            drive_loop_iterations_total: AtomicU64::new(0),
3482            drive_loop_iterations_idle: AtomicU64::new(0),
3483            session_record_retention: SessionRecordRetentionConfig::default(),
3484            retired_records_total: AtomicU64::new(0),
3485            #[cfg(feature = "fixture")]
3486            fixture_semantic_hook_policy: false,
3487            #[cfg(feature = "fixture")]
3488            fixture_spawn_managed_worktree_v2_failure_probe:
3489                SpawnManagedWorktreeV2FailureProbe::default(),
3490        }
3491    }
3492
3493    #[cfg(test)]
3494    fn new(
3495        handle: Gate4AgentHandle,
3496        access_token: String,
3497        node_id: NodeId,
3498        workspaces: Vec<WorkspaceConfig>,
3499        enabled_providers: Vec<AgentId>,
3500    ) -> Self {
3501        Self::new_with_incarnation(
3502            handle,
3503            access_token,
3504            node_id,
3505            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
3506            workspaces,
3507            enabled_providers,
3508            ProviderRuntimeStatuses::default(),
3509            None,
3510            Vec::new(),
3511            Vec::new(),
3512            SpawnProfileRegistry::default(),
3513            None,
3514            None,
3515            None,
3516            None,
3517            Vec::new(),
3518            Vec::new(),
3519            Vec::new(),
3520            Vec::new(),
3521            None,
3522            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
3523        )
3524    }
3525
3526    fn persistence_error(&self) -> Option<String> {
3527        self.persistence_error
3528            .read()
3529            .unwrap_or_else(|poisoned| poisoned.into_inner())
3530            .clone()
3531    }
3532
3533    async fn admit_provider_runtime(
3534        &self,
3535        provider: &AgentId,
3536        requirement: ProviderRuntimeRequirement,
3537    ) -> Result<ProviderRuntimePolicy, NodeFailure> {
3538        let admission = if let Some(monitor) = self.provider_runtime_monitor.clone() {
3539            let pipe_transport = monitor.supports_pipe_transport(provider);
3540            let acp_transport = monitor.supports_acp_transport(provider);
3541            let provider = provider.clone();
3542            let refresh = tokio::task::spawn_blocking(move || monitor.evaluate(&provider));
3543            let (status, admission) = timeout(
3544                Duration::from_millis(PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS),
3545                refresh,
3546            )
3547            .await
3548            .map_err(|_| failure(
3549                NodeFailureCode::BackendBusy,
3550                "provider runtime probe exceeded its bounded deadline",
3551            ))?
3552            .map_err(|_| failure(
3553                NodeFailureCode::BackendOperationFailed,
3554                "provider runtime probe task failed",
3555            ))?;
3556            if let Some(status) = status {
3557                self
3558                    .provider_runtime_status_updates
3559                    .write()
3560                    .unwrap_or_else(|poisoned| poisoned.into_inner())
3561                    .insert(status.provider().clone(), status);
3562            }
3563            admission.and_then(|policy| {
3564                require_policy(policy, pipe_transport, acp_transport, requirement).map(|()| policy)
3565            })
3566        } else {
3567            // No live monitor means no catalog to consult -- there is
3568            // nothing here to confirm this provider declares a Pipe or ACP
3569            // transport, so Inline/Acp are rejected the same conservative
3570            // way an undeclared provider always was, rather than admitted
3571            // on trust.
3572            crate::provider_runtime::admit_status(
3573                &self.provider_runtime_statuses,
3574                provider,
3575                false,
3576                false,
3577                requirement,
3578            )
3579        };
3580        let admission = admission.map(|policy| self.admit_declared_hook_semantics(provider, policy));
3581        admission.map_err(|error| match error {
3582            ProviderRuntimeAdmissionError::LauncherUnavailable => failure(
3583                NodeFailureCode::BackendOperationFailed,
3584                "provider launcher is unavailable",
3585            ),
3586            ProviderRuntimeAdmissionError::SemanticCapabilityUnverified => failure(
3587                NodeFailureCode::UnsupportedCapability,
3588                "provider semantic capability is not verified",
3589            ),
3590            ProviderRuntimeAdmissionError::ProbeBusy => failure(
3591                NodeFailureCode::BackendBusy,
3592                "provider runtime probe is already in progress",
3593            ),
3594        })
3595    }
3596
3597    /// Whether the catalog declares a Pipe transport for `provider` --
3598    /// the fact `require_policy` needs to admit or reject
3599    /// `ProviderRuntimeRequirement::Inline`. With no live monitor there is no
3600    /// catalog to consult, so this stays conservative and rejects, matching
3601    /// `admit_provider_runtime`'s own no-monitor branch.
3602    fn provider_supports_pipe_transport(&self, provider: &AgentId) -> bool {
3603        match self.provider_runtime_monitor.as_deref() {
3604            Some(monitor) => monitor.supports_pipe_transport(provider),
3605            None => false,
3606        }
3607    }
3608
3609    /// Whether the catalog declares an ACP transport for `provider` -- the
3610    /// fact `require_policy` needs to admit or reject
3611    /// `ProviderRuntimeRequirement::Acp`. Mirrors
3612    /// `provider_supports_pipe_transport` exactly, including the
3613    /// conservative no-monitor default.
3614    fn provider_supports_acp_transport(&self, provider: &AgentId) -> bool {
3615        match self.provider_runtime_monitor.as_deref() {
3616            Some(monitor) => monitor.supports_acp_transport(provider),
3617            None => false,
3618        }
3619    }
3620
3621    /// Grants hook ingestion to a provider whose catalog entry declares a
3622    /// Hook adapter, whatever runtime mode its launcher probe landed in.
3623    ///
3624    /// `admit_status`'s `RawPassthrough` arm hands back
3625    /// `ProviderRuntimePolicy::raw_pty()`, which grants nothing semantic --
3626    /// correct for everything it was written to answer, and wrong for hooks
3627    /// specifically. A hook event is not inferred from terminal behaviour,
3628    /// so it has nothing to do with whether a launcher probe verified this
3629    /// CLI version's PTY contract; the authority is the catalog declaring
3630    /// the adapter plus the ingress authenticating the route. Without this
3631    /// the node installs a provider's hooks, the CLI faithfully calls them,
3632    /// and every event is refused -- which is exactly the state grok was in.
3633    ///
3634    /// Applied to both admission paths so the monitor's own derivation and
3635    /// this coarse fallback cannot disagree about the same provider.
3636    fn admit_declared_hook_semantics(
3637        &self,
3638        provider: &AgentId,
3639        policy: ProviderRuntimePolicy,
3640    ) -> ProviderRuntimePolicy {
3641        if policy.hook_semantics || !policy.raw_pty_lifecycle {
3642            return policy;
3643        }
3644        let declares_hook = self.provider_adapter_contracts.iter().any(|contract| {
3645            contract.provider == *provider && contract.family == AdapterFamily::Hook
3646        });
3647        if !declares_hook {
3648            return policy;
3649        }
3650        ProviderRuntimePolicy::new(
3651            policy.raw_pty_lifecycle,
3652            policy.semantic_readiness,
3653            policy.structured_prompt,
3654            policy.provider_session_identity,
3655            policy.semantic_resume,
3656            true,
3657        )
3658        .expect("granting hook semantics over a raw-PTY policy is internally valid")
3659    }
3660
3661    /// Station network allowlist → provider launch overlay argv.
3662    ///
3663    /// - **Codex**: first-slice `-c sandbox_workspace_write.network_access` from
3664    ///   `provider_native` under Moderate (PTY or ACP).
3665    /// - **Claude**: catalog `permits` → ProviderHome sidecar settings JSON +
3666    ///   documented `--settings <path>` on **PTY** only (Linux/macOS/WSL2).
3667    ///   ACP refuses (no measured ACP settings-path surface). Native Windows
3668    ///   refuses (no vendor Bash sandbox). Membership-only (empty permits) =
3669    ///   no overlay.
3670    /// - **Kimi / Grok**: no first-party settings/argv network surface here —
3671    ///   empty overlay when no `provider_native`; invented native keys already
3672    ///   refuse at load / resolve.
3673    ///
3674    /// Empty when no allowlist / nothing to map. Never cookies / secrets.
3675    fn resolve_network_allowlist_overlay_args(
3676        &self,
3677        provider: &AgentId,
3678        mode: SessionMode,
3679        approval_level: ApprovalLevel,
3680        network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
3681        provider_home: Option<&Path>,
3682    ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
3683        let Some(allowlist_id) = network_allowlist else {
3684            return Ok(Vec::new());
3685        };
3686        let catalog = self
3687            .network_allowlist_catalog
3688            .read()
3689            .unwrap_or_else(|poisoned| poisoned.into_inner());
3690        let Some(entry) = catalog.get(allowlist_id) else {
3691            return Err(failure(
3692                NodeFailureCode::UnknownNetworkAllowlist,
3693                "spawn network allowlist is unavailable on this node",
3694            ));
3695        };
3696
3697        // Claude: settings-shaped overlay from permits (not provider_native).
3698        if provider.as_str() == "claude" && !entry.permits.is_empty() {
3699            crate::network_allowlist_catalog::claude_bash_sandbox_network_os_supported().map_err(
3700                |message| {
3701                    failure(
3702                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3703                        &message,
3704                    )
3705                },
3706            )?;
3707            if !matches!(mode, SessionMode::Pty) {
3708                return Err(failure(
3709                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3710                    "Claude sandbox.network --settings overlay requires PTY transport; ACP has no measured settings-path surface (refuse rather than invent RPC)",
3711                ));
3712            }
3713            let Some(home) = provider_home else {
3714                return Err(failure(
3715                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3716                    "Claude sandbox.network --settings overlay requires ProviderHome materialization (CLAUDE_CONFIG_DIR)",
3717                ));
3718            };
3719            let domains =
3720                crate::network_allowlist_catalog::claude_allowed_domains_from_permits(&entry.permits);
3721            if domains.is_empty() {
3722                return Err(failure(
3723                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3724                    "Claude sandbox.network overlay could not map catalog permits to allowedDomains",
3725                ));
3726            }
3727            let settings_path = home.join(
3728                crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
3729            );
3730            let json =
3731                crate::network_allowlist_catalog::claude_station_network_settings_json(&domains);
3732            crate::session_environment::secure_create_file(&settings_path, json.as_bytes()).map_err(
3733                |_| {
3734                    failure(
3735                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3736                        "Claude station network settings sidecar could not be written under ProviderHome",
3737                    )
3738                },
3739            )?;
3740            let overlay =
3741                crate::network_allowlist_catalog::claude_settings_network_overlay_args(&settings_path);
3742            return Ok(overlay.into_iter().map(std::ffi::OsString::from).collect());
3743        }
3744
3745        let Some(native) = entry.provider_native.as_ref() else {
3746            return Ok(Vec::new());
3747        };
3748        let overlay = crate::network_allowlist_catalog::resolve_provider_native_launch_overlay(
3749            provider.as_str(),
3750            approval_level,
3751            native,
3752        )
3753        .map_err(|message| {
3754            failure(
3755                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3756                &message,
3757            )
3758        })?;
3759        if overlay.is_empty() {
3760            return Ok(Vec::new());
3761        }
3762        if !matches!(mode, SessionMode::Pty | SessionMode::Acp) {
3763            return Err(failure(
3764                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3765                "Codex network_access -c overlay requires PTY or ACP transport",
3766            ));
3767        }
3768        Ok(overlay.into_iter().map(std::ffi::OsString::from).collect())
3769    }
3770
3771    /// Backward-compatible alias used by Codex-focused unit tests.
3772    fn resolve_codex_network_access_overlay_args(
3773        &self,
3774        provider: &AgentId,
3775        mode: SessionMode,
3776        approval_level: ApprovalLevel,
3777        network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
3778    ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
3779        self.resolve_network_allowlist_overlay_args(
3780            provider,
3781            mode,
3782            approval_level,
3783            network_allowlist,
3784            None,
3785        )
3786    }
3787
3788    fn resolve_environment_profile(
3789        &self,
3790        resolved: &ResolvedSpawnSpec,
3791    ) -> Result<Option<ResolvedEnvironmentProfileReceipt>, NodeFailure> {
3792        // Station network allowlist catalog enforce (plan
3793        // station-network-and-browser-profile-knobs §2.1 / §4): empty-default
3794        // catalog — unknown ids refuse rather than silent ambient. Runs even
3795        // when no environment_profile_id is present so a bare allowlist still
3796        // cannot bypass the catalog. Never cookies / OAuth / proxy credentials.
3797        if let Some(allowlist_id) = resolved.network_allowlist.as_ref() {
3798            let catalog = self
3799                .network_allowlist_catalog
3800                .read()
3801                .unwrap_or_else(|poisoned| poisoned.into_inner());
3802            let Some(entry) = catalog.get(allowlist_id) else {
3803                return Err(failure(
3804                    NodeFailureCode::UnknownNetworkAllowlist,
3805                    "spawn network allowlist is unavailable on this node",
3806                ));
3807            };
3808            // Track B: refuse provider-native mappings this provider cannot
3809            // honor (e.g. codex_network_access on Claude). Never silent ambient.
3810            if let Some(native) = entry.provider_native.as_ref() {
3811                if let Err(message) = crate::network_allowlist_catalog::provider_native_mapping_supported(
3812                    resolved.provider.as_str(),
3813                    resolved.approval_level,
3814                    native,
3815                ) {
3816                    return Err(failure(
3817                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3818                        &message,
3819                    ));
3820                }
3821            }
3822        }
3823        // Station knobs Track A (dig2browser-station-probe-and-network-permit-set):
3824        // when feature `dig2-station-probe` is on and browser_profile_id is set,
3825        // cheap named-pipe path reachability must succeed (Windows
3826        // `\\.\pipe\{suffix}`); platform-unsupported or unreachable → clear
3827        // NodeFailureCode. Runs even without environment_profile_id (same
3828        // placement spirit as allowlist catalog). Feature off keeps stub echo.
3829        // Never cookies / OAuth / proxy credentials. Exclusive lease is
3830        // acquired later in spawn_session_with_deadline (after instance id).
3831        #[cfg(feature = "dig2-station-probe")]
3832        if resolved.browser_profile_id.is_some() {
3833            use crate::dig2_station_probe::{probe_station_reachable, StationProbeError};
3834            match probe_station_reachable() {
3835                Ok(()) => {}
3836                Err(StationProbeError::UnsupportedPlatform)
3837                | Err(StationProbeError::InvalidPipeSuffix) => {
3838                    return Err(failure(
3839                        NodeFailureCode::BrowserStationProbeUnavailable,
3840                        "dig2browser station probe unavailable on this platform",
3841                    ));
3842                }
3843                Err(StationProbeError::Unreachable) => {
3844                    return Err(failure(
3845                        NodeFailureCode::BrowserStationUnreachable,
3846                        "dig2browser station named-pipe is unreachable",
3847                    ));
3848                }
3849            }
3850        }
3851        let Some(profile_id) = resolved.environment_profile_id.as_ref() else {
3852            return Ok(None);
3853        };
3854        let profiles = self
3855            .environment_profiles
3856            .read()
3857            .unwrap_or_else(|poisoned| poisoned.into_inner());
3858        let binding = profiles.get(profile_id).ok_or_else(|| {
3859            failure(
3860                NodeFailureCode::UnknownEnvironmentProfile,
3861                "spawn environment profile is unavailable on this node",
3862            )
3863        })?;
3864        if binding.provider != resolved.provider
3865            || binding.native_profile_id(resolved.mode).is_none()
3866        {
3867            return Err(failure(
3868                NodeFailureCode::EnvironmentProfileBindingMismatch,
3869                "spawn environment profile does not match the provider and mode",
3870            ));
3871        }
3872        // Echo opaque network_allowlist / browser_profile_id onto the receipt
3873        // after catalog + optional dig2 probe above. Empty/whitespace ids
3874        // already refuse at Spawn*Id construction.
3875        Ok(Some(ResolvedEnvironmentProfileReceipt {
3876            profile_id: binding.id.clone(),
3877            profile_revision: binding.revision.clone(),
3878            network_allowlist: resolved.network_allowlist.clone(),
3879            browser_profile_id: resolved.browser_profile_id.clone(),
3880        }))
3881    }
3882
3883    fn resolve_bundle(
3884        &self,
3885        resolved: &ResolvedSpawnSpec,
3886        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3887    ) -> Result<Option<ResolvedBundleReceipt>, NodeFailure> {
3888        let Some(bundle_id) = resolved.bundle_id.as_ref() else {
3889            return Ok(None);
3890        };
3891        let catalog = self.bundle_catalog
3892            .read()
3893            .unwrap_or_else(|poisoned| poisoned.into_inner());
3894        let bundle = catalog.get(bundle_id).ok_or_else(|| {
3895                failure(
3896                    NodeFailureCode::UnknownBundle,
3897                    "spawn bundle is unavailable on this node",
3898                )
3899            })?;
3900        self.bundle_layout(
3901            &resolved.provider,
3902            resolved.mode,
3903            environment_profile,
3904            bundle,
3905        )?;
3906        Ok(Some(bundle.receipt()))
3907    }
3908
3909    fn begin_delivery_stage(
3910        &self,
3911        manifest: DeliveryBundleManifestV2,
3912    ) -> Result<NodeResponse, NodeFailure> {
3913        let mut store = self
3914            .delivery_store
3915            .lock()
3916            .unwrap_or_else(|poisoned| poisoned.into_inner());
3917        let store = store.as_mut().ok_or_else(|| {
3918            failure(
3919                NodeFailureCode::DeliveryStageStorageFailed,
3920                "node delivery store is unavailable",
3921            )
3922        })?;
3923        let begun = store.begin(manifest).map_err(delivery_failure)?;
3924        Ok(NodeResponse::DeliveryStageBegun {
3925            stage_id: begun.stage_id,
3926            manifest_digest: begun.manifest_digest,
3927            missing_blobs: begun.missing_blobs,
3928        })
3929    }
3930
3931    fn put_delivery_blob_chunk(
3932        &self,
3933        stage_id: DeliveryStageId,
3934        blob_digest: DeliveryBlobDigestV1,
3935        offset: u64,
3936        chunk_hex: DeliveryBlobChunkHexV1,
3937    ) -> Result<NodeResponse, NodeFailure> {
3938        let mut store = self
3939            .delivery_store
3940            .lock()
3941            .unwrap_or_else(|poisoned| poisoned.into_inner());
3942        let store = store.as_mut().ok_or_else(|| {
3943            failure(
3944                NodeFailureCode::DeliveryStageStorageFailed,
3945                "node delivery store is unavailable",
3946            )
3947        })?;
3948        let accepted = store
3949            .put_chunk(&stage_id, &blob_digest, offset, &chunk_hex.decode())
3950            .map_err(delivery_failure)?;
3951        Ok(NodeResponse::DeliveryBlobChunkAccepted {
3952            stage_id: accepted.stage_id,
3953            blob_digest: accepted.blob_digest,
3954            next_offset: accepted.next_offset,
3955        })
3956    }
3957
3958    fn commit_delivery_stage(
3959        &self,
3960        stage_id: DeliveryStageId,
3961    ) -> Result<DeliveryCommitReceiptV1, NodeFailure> {
3962        let mut store = self
3963            .delivery_store
3964            .lock()
3965            .unwrap_or_else(|poisoned| poisoned.into_inner());
3966        let store = store.as_mut().ok_or_else(|| {
3967            failure(
3968                NodeFailureCode::DeliveryStageStorageFailed,
3969                "node delivery store is unavailable",
3970            )
3971        })?;
3972        let prepared = store.prepare_commit(&stage_id).map_err(delivery_failure)?;
3973        let mut catalog = self
3974            .bundle_catalog
3975            .write()
3976            .unwrap_or_else(|poisoned| poisoned.into_inner());
3977        let mut next_catalog = catalog.clone();
3978        next_catalog
3979            .insert_idempotent(prepared.bundle().clone())
3980            .map_err(|_| {
3981                failure(
3982                    NodeFailureCode::DeliveryStageConflict,
3983                    "delivery bundle identity conflicts with installed content",
3984                )
3985            })?;
3986        let receipt = store.publish_commit(prepared).map_err(delivery_failure)?;
3987        *catalog = next_catalog;
3988        Ok(receipt)
3989    }
3990
3991    fn abort_delivery_stage(
3992        &self,
3993        stage_id: &DeliveryStageId,
3994    ) -> Result<(), NodeFailure> {
3995        let mut store = self
3996            .delivery_store
3997            .lock()
3998            .unwrap_or_else(|poisoned| poisoned.into_inner());
3999        let store = store.as_mut().ok_or_else(|| {
4000            failure(
4001                NodeFailureCode::DeliveryStageStorageFailed,
4002                "node delivery store is unavailable",
4003            )
4004        })?;
4005        store.abort(stage_id).map_err(delivery_failure)
4006    }
4007
4008    fn resolve_context(
4009        &self,
4010        resolved: &ResolvedSpawnSpec,
4011    ) -> Result<Option<ResolvedContextPackReceipt>, NodeFailure> {
4012        let Some(context_id) = resolved.context_id.as_ref() else {
4013            return Ok(None);
4014        };
4015        self.context_catalog
4016            .read()
4017            .unwrap_or_else(|poisoned| poisoned.into_inner())
4018            .get(context_id)
4019            .map(|context| context.receipt().clone())
4020            .ok_or_else(|| {
4021                failure(
4022                    NodeFailureCode::UnknownContextPack,
4023                    "spawn context pack is unavailable on this node",
4024                )
4025            })
4026            .map(Some)
4027    }
4028
4029    fn bundle_layout(
4030        &self,
4031        provider: &AgentId,
4032        mode: SessionMode,
4033        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4034        bundle: &NodeBundle,
4035    ) -> Result<BundleProviderLayout, NodeFailure> {
4036        let layout = validate_bundle_binding(provider, mode, bundle).map_err(|_| {
4037            failure(
4038                NodeFailureCode::BundleBindingMismatch,
4039                "spawn bundle does not match the provider and mode",
4040            )
4041        })?;
4042        if layout != BundleProviderLayout::Codex {
4043            return Ok(layout);
4044        }
4045        let profile = environment_profile.and_then(|receipt| {
4046            let profiles = self
4047                .environment_profiles
4048                .read()
4049                .unwrap_or_else(|poisoned| poisoned.into_inner());
4050            let binding = profiles.get(&receipt.profile_id)?;
4051            if binding.revision != receipt.profile_revision
4052                || &binding.provider != provider
4053                || binding.native_profile_id(mode).is_none()
4054            {
4055                return None;
4056            }
4057            self.environment_materialization_profiles
4058                .read()
4059                .unwrap_or_else(|poisoned| poisoned.into_inner())
4060                .get(&receipt.profile_id)
4061                .cloned()
4062        });
4063        if !profile.is_some_and(|profile| profile.supports_bundle_layout(layout)) {
4064            return Err(failure(
4065                NodeFailureCode::BundleBindingMismatch,
4066                "Codex bundle requires an exact isolated CODEX_HOME profile",
4067            ));
4068        }
4069        Ok(layout)
4070    }
4071
4072    fn select_environment_profile(
4073        &self,
4074        instance_id: AgentInstanceId,
4075        provider: &AgentId,
4076        mode: SessionMode,
4077        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4078    ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
4079        let Some(environment_profile) = environment_profile else {
4080            return Ok(None);
4081        };
4082        let native_profile_id = {
4083            let profiles = self
4084                .environment_profiles
4085                .read()
4086                .unwrap_or_else(|poisoned| poisoned.into_inner());
4087            let binding = profiles
4088                .get(&environment_profile.profile_id)
4089                .ok_or_else(|| {
4090                    failure(
4091                        NodeFailureCode::UnknownEnvironmentProfile,
4092                        "session environment profile is unavailable on this node",
4093                    )
4094                })?;
4095            if &binding.revision != &environment_profile.profile_revision
4096                || &binding.provider != provider
4097            {
4098                return Err(failure(
4099                    NodeFailureCode::EnvironmentProfileBindingMismatch,
4100                    "session environment profile revision or provider changed",
4101                ));
4102            }
4103            binding.native_profile_id(mode).cloned().ok_or_else(|| {
4104                failure(
4105                    NodeFailureCode::EnvironmentProfileBindingMismatch,
4106                    "session environment profile does not support this mode",
4107                )
4108            })?
4109        };
4110        let control = self.native_launch_profile_control.clone().ok_or_else(|| {
4111            failure(
4112                NodeFailureCode::BackendOperationFailed,
4113                "native environment profile controller is unavailable",
4114            )
4115        })?;
4116        control
4117            .select_native_launch_profile(instance_id, native_profile_id)
4118            .map_err(|_| {
4119                failure(
4120                    NodeFailureCode::BackendOperationFailed,
4121                    "native environment profile selection failed",
4122                )
4123            })?;
4124        Ok(Some(NativeEnvironmentSelectionGuard {
4125            control: Some(control),
4126            instance_id,
4127        }))
4128    }
4129
4130    fn install_prepared_launch_overlay(
4131        &self,
4132        instance_id: AgentInstanceId,
4133        overlay: PreparedNativeLaunchOverlay,
4134    ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
4135        let control = self.native_launch_profile_control.clone().ok_or_else(|| {
4136            failure(
4137                NodeFailureCode::BackendOperationFailed,
4138                "native launch profile controller is unavailable",
4139            )
4140        })?;
4141        match overlay {
4142            PreparedNativeLaunchOverlay::Environment(overlay) => {
4143                control
4144                    .install_native_launch_environment_overlay(instance_id, overlay)
4145                    .map_err(|_| failure(
4146                        NodeFailureCode::BackendOperationFailed,
4147                        "session environment overlay installation failed",
4148                    ))?;
4149                Ok(None)
4150            }
4151            PreparedNativeLaunchOverlay::Instance(overlay) => {
4152                control
4153                    .install_native_instance_launch_overlay(instance_id, overlay)
4154                    .map_err(|_| failure(
4155                        NodeFailureCode::BundleBindingMismatch,
4156                        "session bundle launch overlay installation failed",
4157                    ))?;
4158                Ok(Some(NativeInstanceOverlayGuard {
4159                    control: Some(control),
4160                    instance_id,
4161                }))
4162            }
4163        }
4164    }
4165
4166    fn environment_materialization_profile(
4167        &self,
4168        environment_profile: &ResolvedEnvironmentProfileReceipt,
4169    ) -> Option<NodeSessionMaterializationProfile> {
4170        self.environment_materialization_profiles
4171            .read()
4172            .unwrap_or_else(|poisoned| poisoned.into_inner())
4173            .get(&environment_profile.profile_id)
4174            .cloned()
4175    }
4176
4177    fn materialization_profile(
4178        &self,
4179        provider: &AgentId,
4180        mode: SessionMode,
4181        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4182        bundle: Option<&ResolvedBundleReceipt>,
4183        context: Option<&ResolvedContextPackReceipt>,
4184    ) -> Result<Option<(
4185        NodeSessionMaterializationProfile,
4186        bool,
4187        Option<BundleProviderLayout>,
4188    )>, NodeFailure> {
4189        let environment = environment_profile
4190            .and_then(|receipt| self.environment_materialization_profile(receipt));
4191        let (bundle, bundle_layout) = match bundle {
4192            Some(receipt) => {
4193                let catalog = self.bundle_catalog.read()
4194                    .unwrap_or_else(|poisoned| poisoned.into_inner());
4195                let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
4196                    NodeFailureCode::UnknownBundle,
4197                    "session bundle is unavailable on this node",
4198                ))?;
4199                if installed.receipt() != *receipt {
4200                    return Err(failure(
4201                        NodeFailureCode::BundleBindingMismatch,
4202                        "session bundle revision or digest changed",
4203                    ));
4204                }
4205                let layout = self.bundle_layout(
4206                    provider,
4207                    mode,
4208                    environment_profile,
4209                    installed,
4210                )?;
4211                (Some(installed.clone()), Some(layout))
4212            }
4213            None => (None, None),
4214        };
4215        let has_environment_overlay = environment.is_some() || context.is_some();
4216        let profile = match (environment, bundle.as_ref(), bundle_layout) {
4217            (None, None, None) => None,
4218            (Some(profile), None, None) => Some(Ok(profile)),
4219            (None, Some(bundle), Some(layout)) => {
4220                Some(NodeSessionMaterializationProfile::from_bundle(bundle, layout))
4221            }
4222            (Some(profile), Some(bundle), Some(layout)) => {
4223                Some(profile.with_bundle(bundle, layout))
4224            }
4225            _ => {
4226                return Err(failure(
4227                    NodeFailureCode::BundleBindingMismatch,
4228                    "session bundle provider layout is unavailable",
4229                ));
4230            }
4231        };
4232        let profile = profile
4233            .transpose()
4234            .map_err(|_| failure(
4235                NodeFailureCode::BundleMaterializationFailed,
4236                "session bundle materialization profile is invalid",
4237            ))?;
4238        let profile = match (profile, context) {
4239            (Some(profile), Some(receipt)) => {
4240                let catalog = self.context_catalog
4241                    .read()
4242                    .unwrap_or_else(|poisoned| poisoned.into_inner());
4243                let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
4244                    NodeFailureCode::UnknownContextPack,
4245                    "session context pack is unavailable on this node",
4246                ))?;
4247                if pack.receipt() != receipt {
4248                    return Err(failure(
4249                        NodeFailureCode::ContextPackMaterializationFailed,
4250                        "session context pack identity changed",
4251                    ));
4252                }
4253                profile.with_context(pack)
4254            }
4255            (None, Some(receipt)) => {
4256                let catalog = self.context_catalog
4257                    .read()
4258                    .unwrap_or_else(|poisoned| poisoned.into_inner());
4259                let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
4260                    NodeFailureCode::UnknownContextPack,
4261                    "session context pack is unavailable on this node",
4262                ))?;
4263                if pack.receipt() != receipt {
4264                    return Err(failure(
4265                        NodeFailureCode::ContextPackMaterializationFailed,
4266                        "session context pack identity changed",
4267                    ));
4268                }
4269                NodeSessionMaterializationProfile::from_context(pack)
4270            }
4271            (Some(profile), None) => Ok(profile),
4272            (None, None) => return Ok(None),
4273        }
4274        .map_err(|_| failure(
4275            NodeFailureCode::ContextPackMaterializationFailed,
4276            "session context pack materialization profile is invalid",
4277        ))?;
4278        Ok(Some((profile, has_environment_overlay, bundle_layout)))
4279    }
4280
4281    fn prepare_session_materialization<'a>(
4282        &'a self,
4283        address: &SessionAddress,
4284        provider: &AgentId,
4285        mode: SessionMode,
4286        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4287        bundle: Option<&ResolvedBundleReceipt>,
4288        context: Option<&ResolvedContextPackReceipt>,
4289        managed_lease_id: Option<ManagedWorktreeLeaseId>,
4290    ) -> Result<Option<(Option<PreparedNativeLaunchOverlay>, SessionMaterializationGuard<'a>)>, NodeFailure> {
4291        let Some((profile, has_environment_overlay, bundle_layout)) =
4292            self.materialization_profile(provider, mode, environment_profile, bundle, context)?
4293        else {
4294            return Ok(None);
4295        };
4296        let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
4297            failure(
4298                NodeFailureCode::BackendOperationFailed,
4299                "session environment materializer is unavailable",
4300            )
4301        })?;
4302        let materialization_failure_code = if context.is_some() {
4303            NodeFailureCode::ContextPackMaterializationFailed
4304        } else if bundle.is_some() {
4305            NodeFailureCode::BundleMaterializationFailed
4306        } else {
4307            NodeFailureCode::BackendOperationFailed
4308        };
4309        let id = MaterializationId::new(format!(
4310            "mat-{}-{}-{}",
4311            self.incarnation_id,
4312            address.session.instance_id.0,
4313            address.session.generation.0,
4314        ))
4315        .map_err(|_| {
4316            failure(
4317                NodeFailureCode::BackendOperationFailed,
4318                "session environment materialization identity failed",
4319            )
4320        })?;
4321        let owner = MaterializationOwner::Session {
4322            incarnation_id: self.incarnation_id,
4323            instance_id: address.session.instance_id,
4324            generation: address.session.generation,
4325        };
4326        let mut ownership = materializer
4327            .begin(
4328                id.clone(),
4329                environment_profile.cloned(),
4330                bundle.cloned(),
4331                context.cloned(),
4332                owner,
4333                managed_lease_id,
4334                &profile,
4335                unix_time_ms(),
4336            )
4337            .map_err(|_| {
4338                failure(
4339                    materialization_failure_code,
4340                    "session materialization preparation failed",
4341                )
4342            })?;
4343        {
4344            let _transaction = self
4345                .state_transaction
4346                .lock()
4347                .unwrap_or_else(|poisoned| poisoned.into_inner());
4348            let duplicate = self
4349                .materializations
4350                .lock()
4351                .unwrap_or_else(|poisoned| poisoned.into_inner())
4352                .insert(id.clone(), ownership.clone());
4353            if duplicate.is_some() {
4354                return Err(failure(
4355                    NodeFailureCode::BackendOperationFailed,
4356                    "session environment materialization identity conflict",
4357                ));
4358            }
4359            if let Err(error) = self.persist_state_locked() {
4360                self.materializations
4361                    .lock()
4362                    .unwrap_or_else(|poisoned| poisoned.into_inner())
4363                    .remove(&id);
4364                return Err(persistence_failure(error));
4365            }
4366        }
4367        let environment = match materializer.materialize(&mut ownership, &profile, unix_time_ms()) {
4368            Ok(environment) => environment,
4369            Err(_) => {
4370                let transaction = self
4371                    .state_transaction
4372                    .lock()
4373                    .unwrap_or_else(|poisoned| poisoned.into_inner());
4374                self.materializations
4375                    .lock()
4376                    .unwrap_or_else(|poisoned| poisoned.into_inner())
4377                    .insert(id.clone(), ownership.clone());
4378                if self.persist_state_locked().is_err() {
4379                    self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
4380                }
4381                drop(transaction);
4382                if ownership.state() == MaterializationState::CleanupRequired {
4383                    let _ = self.cleanup_materialization(&id);
4384                }
4385                return Err(failure(
4386                    materialization_failure_code,
4387                    "session materialization failed",
4388                ));
4389            }
4390        };
4391        {
4392            let _transaction = self
4393                .state_transaction
4394                .lock()
4395                .unwrap_or_else(|poisoned| poisoned.into_inner());
4396            self.materializations
4397                .lock()
4398                .unwrap_or_else(|poisoned| poisoned.into_inner())
4399                .insert(id.clone(), ownership.clone());
4400            if let Err(error) = self.persist_state_locked() {
4401                drop(_transaction);
4402                let _ = self.cleanup_materialization(&id);
4403                return Err(persistence_failure(error));
4404            }
4405        }
4406        let guard = SessionMaterializationGuard {
4407            shared: self,
4408            id: Some(id),
4409            provider_home: Some(ownership.provider_home().to_path_buf()),
4410        };
4411        let bundle_arguments = match bundle {
4412            Some(receipt) => {
4413                let catalog = self.bundle_catalog.read()
4414                    .unwrap_or_else(|poisoned| poisoned.into_inner());
4415                let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
4416                    NodeFailureCode::UnknownBundle,
4417                    "session bundle is unavailable on this node",
4418                ))?;
4419                if installed.receipt() != *receipt {
4420                    return Err(failure(
4421                        NodeFailureCode::BundleBindingMismatch,
4422                        "session bundle revision or digest changed",
4423                    ));
4424                }
4425                let layout = bundle_layout.ok_or_else(|| failure(
4426                    NodeFailureCode::BundleBindingMismatch,
4427                    "materialized bundle provider layout is unavailable",
4428                ))?;
4429                materializer.revalidate_bundle(&ownership, installed, layout).map_err(|_| {
4430                    failure(
4431                        NodeFailureCode::BundleMaterializationFailed,
4432                        "session bundle byte revalidation failed",
4433                    )
4434                })?;
4435                Some(bundle_launch_arguments(
4436                    layout,
4437                    ownership.bundle_root(),
4438                    ownership.provider_home(),
4439                ).map_err(|_| failure(
4440                    NodeFailureCode::BundleBindingMismatch,
4441                    "session bundle launch binding is invalid",
4442                ))?)
4443            }
4444            None => None,
4445        };
4446        if let Some(receipt) = context {
4447            materializer
4448                .revalidate_context(&ownership, receipt)
4449                .map_err(|_| failure(
4450                    NodeFailureCode::ContextPackMaterializationFailed,
4451                    "session context pack byte revalidation failed",
4452                ))?;
4453        }
4454        let transport = match mode {
4455            SessionMode::Pty => TransportKind::Pty,
4456            SessionMode::Inline => TransportKind::Pipe,
4457            SessionMode::Acp => TransportKind::Acp,
4458        };
4459        let overlay = if let Some(extra_args) = bundle_arguments {
4460            Some(PreparedNativeLaunchOverlay::Instance(
4461                NativeInstanceLaunchOverlay::new(
4462                    provider.clone(),
4463                    transport,
4464                    if has_environment_overlay {
4465                        environment
4466                    } else {
4467                        Vec::new()
4468                    },
4469                    extra_args,
4470                )
4471                .map_err(|_| {
4472                    failure(
4473                        NodeFailureCode::BundleBindingMismatch,
4474                        "session bundle launch overlay is invalid",
4475                    )
4476                })?,
4477            ))
4478        } else if has_environment_overlay {
4479            let context_only = environment_profile.is_none()
4480                && bundle.is_none()
4481                && context.is_some();
4482            let overlay = if context_only {
4483                NativeLaunchEnvironmentOverlay::new_context_root(
4484                    provider.clone(),
4485                    transport,
4486                    environment,
4487                )
4488                .map_err(|_| {
4489                    failure(
4490                        NodeFailureCode::ContextPackMaterializationFailed,
4491                        "context-only launch overlay is invalid",
4492                    )
4493                })?
4494            } else {
4495                NativeLaunchEnvironmentOverlay::new(
4496                    provider.clone(),
4497                    transport,
4498                    environment,
4499                )
4500                .map_err(|_| {
4501                    failure(
4502                        NodeFailureCode::BackendOperationFailed,
4503                        "session environment overlay is invalid",
4504                    )
4505                })?
4506            };
4507            Some(PreparedNativeLaunchOverlay::Environment(overlay))
4508        } else {
4509            None
4510        };
4511        Ok(Some((overlay, guard)))
4512    }
4513
4514    fn cleanup_materialization(&self, id: &MaterializationId) -> Result<(), NodeFailure> {
4515        let Some(mut ownership) = self
4516            .materializations
4517            .lock()
4518            .unwrap_or_else(|poisoned| poisoned.into_inner())
4519            .get(id)
4520            .cloned()
4521        else {
4522            return Ok(());
4523        };
4524        if ownership.state() != MaterializationState::CleanupRequired {
4525            ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
4526                failure(
4527                    NodeFailureCode::BackendOperationFailed,
4528                    "session environment cleanup state is invalid",
4529                )
4530            })?;
4531        }
4532        {
4533            let _transaction = self
4534                .state_transaction
4535                .lock()
4536                .unwrap_or_else(|poisoned| poisoned.into_inner());
4537            let previous = self
4538                .materializations
4539                .lock()
4540                .unwrap_or_else(|poisoned| poisoned.into_inner())
4541                .insert(id.clone(), ownership.clone());
4542            if let Err(error) = self.persist_state_locked() {
4543                let mut materializations = self
4544                    .materializations
4545                    .lock()
4546                    .unwrap_or_else(|poisoned| poisoned.into_inner());
4547                if let Some(previous) = previous {
4548                    materializations.insert(id.clone(), previous);
4549                }
4550                return Err(persistence_failure(error));
4551            }
4552        }
4553        let cleanup = self
4554            .session_environment_materializer
4555            .as_ref()
4556            .ok_or(())
4557            .and_then(|materializer| materializer.cleanup(&ownership).map_err(|_| ()));
4558        if cleanup.is_err() {
4559            let _ = ownership.mark_recovery_required(unix_time_ms());
4560            self.materializations
4561                .lock()
4562                .unwrap_or_else(|poisoned| poisoned.into_inner())
4563                .insert(id.clone(), ownership);
4564            if self.persist_state().is_err() {
4565                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
4566            }
4567            return Err(failure(
4568                NodeFailureCode::BackendOperationFailed,
4569                "session environment cleanup requires recovery",
4570            ));
4571        }
4572        let _transaction = self
4573            .state_transaction
4574            .lock()
4575            .unwrap_or_else(|poisoned| poisoned.into_inner());
4576        self.materializations
4577            .lock()
4578            .unwrap_or_else(|poisoned| poisoned.into_inner())
4579            .remove(id);
4580        if let Err(error) = self.persist_state_locked() {
4581            self.materializations
4582                .lock()
4583                .unwrap_or_else(|poisoned| poisoned.into_inner())
4584                .insert(id.clone(), ownership);
4585            return Err(persistence_failure(error));
4586        }
4587        Ok(())
4588    }
4589
4590    fn resolve_record_materialization(
4591        &self,
4592        record_id: &SessionRecordId,
4593        provider: &AgentId,
4594        mode: SessionMode,
4595        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4596        bundle: Option<&ResolvedBundleReceipt>,
4597        context: Option<&ResolvedContextPackReceipt>,
4598    ) -> Result<Option<(MaterializationId, Option<PreparedNativeLaunchOverlay>)>, NodeFailure> {
4599        let ownership = {
4600            let materializations = self
4601                .materializations
4602                .lock()
4603                .unwrap_or_else(|poisoned| poisoned.into_inner());
4604            let mut matches = materializations.values().filter(|record| {
4605                record.owner()
4606                    == &MaterializationOwner::Record {
4607                        record_id: record_id.clone(),
4608                    }
4609            });
4610            let ownership = matches.next().cloned();
4611            if matches.next().is_some() {
4612                return Err(failure(
4613                    NodeFailureCode::BackendOperationFailed,
4614                    "managed session has conflicting environment ownership",
4615                ));
4616            }
4617            ownership
4618        };
4619        if let (Some(ownership), Some(receipt)) = (ownership.as_ref(), context) {
4620            let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
4621                failure(
4622                    NodeFailureCode::ContextPackMaterializationFailed,
4623                    "session context materializer is unavailable",
4624                )
4625            })?;
4626            let pack = materializer
4627                .revalidate_context(ownership, receipt)
4628                .map_err(|_| {
4629                    self.mark_materialization_recovery_required(ownership.id());
4630                    failure(
4631                        NodeFailureCode::ContextPackMaterializationFailed,
4632                        "managed session context pack revalidation failed",
4633                    )
4634                })?;
4635            self.context_catalog
4636                .write()
4637                .unwrap_or_else(|poisoned| poisoned.into_inner())
4638                .insert(pack)
4639                .map_err(|_| failure(
4640                    NodeFailureCode::ContextPackMaterializationFailed,
4641                    "managed session context pack catalog restore failed",
4642                ))?;
4643        }
4644        let materialization = self.materialization_profile(
4645            provider,
4646            mode,
4647            environment_profile,
4648            bundle,
4649            context,
4650        )?;
4651        match (ownership, materialization) {
4652            (None, None) => Ok(None),
4653            (None, Some(_)) | (Some(_), None) => Err(failure(
4654                NodeFailureCode::BackendOperationFailed,
4655                "managed session materialization is unavailable",
4656            )),
4657            (Some(ownership), Some((profile, has_environment_materialization, bundle_layout))) => {
4658                if ownership.environment_profile() != environment_profile
4659                    || ownership.bundle() != bundle
4660                    || ownership.context() != context
4661                    || ownership.state() != MaterializationState::Ready
4662                {
4663                    self.mark_materialization_recovery_required(ownership.id());
4664                    return Err(failure(
4665                        NodeFailureCode::BackendOperationFailed,
4666                        "managed session environment ownership requires recovery",
4667                    ));
4668                }
4669                let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
4670                    failure(
4671                        NodeFailureCode::BackendOperationFailed,
4672                        "session environment materializer is unavailable",
4673                    )
4674                })?;
4675                if materializer.revalidate(&ownership).is_err() {
4676                    self.mark_materialization_recovery_required(ownership.id());
4677                    return Err(failure(
4678                        NodeFailureCode::BackendOperationFailed,
4679                        "managed session environment revalidation failed",
4680                    ));
4681                }
4682                let bundle_arguments = if let Some(bundle_receipt) = bundle {
4683                    let catalog = self.bundle_catalog.read()
4684                        .unwrap_or_else(|poisoned| poisoned.into_inner());
4685                    let Some(installed) = catalog.get(&bundle_receipt.id) else {
4686                        return Err(failure(
4687                            NodeFailureCode::UnknownBundle,
4688                            "managed session bundle is unavailable",
4689                        ));
4690                    };
4691                    let layout = bundle_layout.ok_or_else(|| failure(
4692                        NodeFailureCode::BundleBindingMismatch,
4693                        "record bundle provider layout is unavailable",
4694                    ))?;
4695                    if materializer.revalidate_bundle(&ownership, installed, layout).is_err() {
4696                        drop(catalog);
4697                        self.mark_materialization_recovery_required(ownership.id());
4698                        return Err(failure(
4699                            NodeFailureCode::BundleMaterializationFailed,
4700                            "managed session bundle revalidation failed",
4701                        ));
4702                    }
4703                    Some(bundle_launch_arguments(
4704                        layout,
4705                        ownership.bundle_root(),
4706                        ownership.provider_home(),
4707                    ).map_err(|_| failure(
4708                        NodeFailureCode::BundleBindingMismatch,
4709                        "managed session bundle launch binding is invalid",
4710                    ))?)
4711                } else {
4712                    None
4713                };
4714                if !has_environment_materialization {
4715                    let overlay = bundle_arguments.map(|extra_args| {
4716                        NativeInstanceLaunchOverlay::new(
4717                            provider.clone(),
4718                            TransportKind::Pty,
4719                            Vec::new(),
4720                            extra_args,
4721                        )
4722                        .map(PreparedNativeLaunchOverlay::Instance)
4723                        .map_err(|_| failure(
4724                            NodeFailureCode::BundleBindingMismatch,
4725                            "managed session bundle launch overlay is invalid",
4726                        ))
4727                    }).transpose()?;
4728                    return Ok(Some((ownership.id().clone(), overlay)));
4729                }
4730                let environment = match materializer.resolve_environment(&ownership, &profile) {
4731                    Ok(environment) => environment,
4732                    Err(
4733                        SessionEnvironmentMaterializeError::SecretUnavailable
4734                        | SessionEnvironmentMaterializeError::SecretDenied
4735                        | SessionEnvironmentMaterializeError::InvalidSecretValue,
4736                    ) => {
4737                        return Err(failure(
4738                            NodeFailureCode::BackendOperationFailed,
4739                            "managed session environment resolution failed",
4740                        ));
4741                    }
4742                    Err(
4743                        SessionEnvironmentMaterializeError::InvalidRoot
4744                        | SessionEnvironmentMaterializeError::OwnershipMismatch
4745                        | SessionEnvironmentMaterializeError::Filesystem(_)
4746                        | SessionEnvironmentMaterializeError::Record(_),
4747                    ) => {
4748                        self.mark_materialization_recovery_required(ownership.id());
4749                        return Err(failure(
4750                            NodeFailureCode::BackendOperationFailed,
4751                            "managed session environment resolution failed",
4752                        ));
4753                    }
4754                };
4755                let transport = match mode {
4756                    SessionMode::Pty => TransportKind::Pty,
4757                    SessionMode::Inline => TransportKind::Pipe,
4758                    SessionMode::Acp => TransportKind::Acp,
4759                };
4760                let overlay = match bundle_arguments {
4761                    Some(extra_args) => PreparedNativeLaunchOverlay::Instance(
4762                        NativeInstanceLaunchOverlay::new(
4763                            provider.clone(),
4764                            transport,
4765                            environment,
4766                            extra_args,
4767                        )
4768                        .map_err(|_| failure(
4769                            NodeFailureCode::BundleBindingMismatch,
4770                            "managed session bundle launch overlay is invalid",
4771                        ))?,
4772                    ),
4773                    None => PreparedNativeLaunchOverlay::Environment(
4774                        NativeLaunchEnvironmentOverlay::new(
4775                            provider.clone(),
4776                            transport,
4777                            environment,
4778                        )
4779                        .map_err(|_| {
4780                            failure(
4781                                NodeFailureCode::BackendOperationFailed,
4782                                "managed session environment overlay is invalid",
4783                            )
4784                        })?,
4785                    ),
4786                };
4787                Ok(Some((ownership.id().clone(), Some(overlay))))
4788            }
4789        }
4790    }
4791
4792    fn destroy_record_materialization(
4793        &self,
4794        record_id: &SessionRecordId,
4795    ) -> Result<bool, NodeFailure> {
4796        let ids = self
4797            .materializations
4798            .lock()
4799            .unwrap_or_else(|poisoned| poisoned.into_inner())
4800            .values()
4801            .filter_map(|record| {
4802                (record.owner()
4803                    == &MaterializationOwner::Record {
4804                        record_id: record_id.clone(),
4805                    })
4806                .then(|| record.id().clone())
4807            })
4808            .collect::<Vec<_>>();
4809        if ids.len() > 1 {
4810            return Err(failure(
4811                NodeFailureCode::BackendOperationFailed,
4812                "managed session has conflicting environment ownership",
4813            ));
4814        }
4815        let Some(id) = ids.first().cloned() else {
4816            return Ok(false);
4817        };
4818        let (terminal_record, mut cleanup_ownership) = {
4819            let _transaction = self
4820                .state_transaction
4821                .lock()
4822                .unwrap_or_else(|poisoned| poisoned.into_inner());
4823            let original_record = self.record(record_id)?;
4824            let mut terminal_record = original_record.clone();
4825            terminal_record.provider_session = None;
4826            terminal_record.active_session = None;
4827            terminal_record.state = ManagedSessionState::Unavailable;
4828            terminal_record.last_error = Some("environment-profile-unavailable".to_owned());
4829            terminal_record.updated_at_unix_ms = unix_time_ms();
4830            let original_ownership = self
4831                .materializations
4832                .lock()
4833                .unwrap_or_else(|poisoned| poisoned.into_inner())
4834                .get(&id)
4835                .cloned()
4836                .ok_or_else(|| {
4837                    failure(
4838                        NodeFailureCode::BackendOperationFailed,
4839                        "managed session environment ownership disappeared during cleanup",
4840                    )
4841                })?;
4842            let mut cleanup_ownership = original_ownership.clone();
4843            if cleanup_ownership.state() != MaterializationState::CleanupRequired {
4844                cleanup_ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
4845                    failure(
4846                        NodeFailureCode::BackendOperationFailed,
4847                        "managed session environment cleanup state is invalid",
4848                    )
4849                })?;
4850            }
4851            self.session_records
4852                .lock()
4853                .unwrap_or_else(|poisoned| poisoned.into_inner())
4854                .records
4855                .insert(record_id.clone(), terminal_record.clone());
4856            self.materializations
4857                .lock()
4858                .unwrap_or_else(|poisoned| poisoned.into_inner())
4859                .insert(id.clone(), cleanup_ownership.clone());
4860            if let Err(error) = self.persist_state_locked() {
4861                self.session_records
4862                    .lock()
4863                    .unwrap_or_else(|poisoned| poisoned.into_inner())
4864                    .records
4865                    .insert(record_id.clone(), original_record);
4866                self.materializations
4867                    .lock()
4868                    .unwrap_or_else(|poisoned| poisoned.into_inner())
4869                    .insert(id.clone(), original_ownership);
4870                return Err(persistence_failure(error));
4871            }
4872            (terminal_record, cleanup_ownership)
4873        };
4874        let cleanup = self
4875            .session_environment_materializer
4876            .as_ref()
4877            .ok_or(())
4878            .and_then(|materializer| materializer.cleanup(&cleanup_ownership).map_err(|_| ()));
4879        if cleanup.is_err() {
4880            let _ = cleanup_ownership.mark_recovery_required(unix_time_ms());
4881            self.materializations
4882                .lock()
4883                .unwrap_or_else(|poisoned| poisoned.into_inner())
4884                .insert(id.clone(), cleanup_ownership);
4885            if self.persist_state().is_err() {
4886                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
4887            }
4888            return Err(failure(
4889                NodeFailureCode::BackendOperationFailed,
4890                "managed session environment cleanup requires recovery",
4891            ));
4892        }
4893        let _transaction = self
4894            .state_transaction
4895            .lock()
4896            .unwrap_or_else(|poisoned| poisoned.into_inner());
4897        self.remove_record_memory(record_id);
4898        self.materializations
4899            .lock()
4900            .unwrap_or_else(|poisoned| poisoned.into_inner())
4901            .remove(&id);
4902        if let Err(error) = self.persist_state_locked() {
4903            self.session_records
4904                .lock()
4905                .unwrap_or_else(|poisoned| poisoned.into_inner())
4906                .records
4907                .insert(record_id.clone(), terminal_record);
4908            self.materializations
4909                .lock()
4910                .unwrap_or_else(|poisoned| poisoned.into_inner())
4911                .insert(id, cleanup_ownership);
4912            return Err(persistence_failure(error));
4913        }
4914        Ok(true)
4915    }
4916
4917    fn mark_materialization_recovery_required(&self, id: &MaterializationId) {
4918        let changed = {
4919            let mut materializations = self
4920                .materializations
4921                .lock()
4922                .unwrap_or_else(|poisoned| poisoned.into_inner());
4923            let Some(ownership) = materializations.get_mut(id) else {
4924                return;
4925            };
4926            ownership.mark_recovery_required(unix_time_ms()).is_ok()
4927        };
4928        if changed && self.persist_state().is_err() {
4929            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
4930        }
4931    }
4932
4933    fn reconcile_materializations(&self) {
4934        let records = self
4935            .materializations
4936            .lock()
4937            .unwrap_or_else(|poisoned| poisoned.into_inner())
4938            .values()
4939            .cloned()
4940            .collect::<Vec<_>>();
4941        for mut ownership in records {
4942            let result = match (ownership.state(), ownership.owner()) {
4943                (MaterializationState::Preparing, _)
4944                | (MaterializationState::CleanupRequired, _) => {
4945                    self.cleanup_materialization(ownership.id())
4946                }
4947                (MaterializationState::Ready, MaterializationOwner::Session { .. }) => {
4948                    if ownership.mark_recovery_required(unix_time_ms()).is_ok() {
4949                        self.materializations
4950                            .lock()
4951                            .unwrap_or_else(|poisoned| poisoned.into_inner())
4952                            .insert(ownership.id().clone(), ownership.clone());
4953                        self.persist_state().map_err(persistence_failure)
4954                    } else {
4955                        Err(failure(
4956                            NodeFailureCode::BackendOperationFailed,
4957                            "session environment reconciliation failed",
4958                        ))
4959                    }
4960                }
4961                (MaterializationState::Ready, MaterializationOwner::Record { record_id }) => self
4962                    .session_environment_materializer
4963                    .as_ref()
4964                    .ok_or_else(|| {
4965                        failure(
4966                            NodeFailureCode::BackendOperationFailed,
4967                            "session environment materializer is unavailable",
4968                        )
4969                    })
4970                    .and_then(|materializer| {
4971                        materializer.revalidate(&ownership).map_err(|_| failure(
4972                            NodeFailureCode::BackendOperationFailed,
4973                            "session materialization revalidation failed",
4974                        ))?;
4975                        let record = self.record(record_id)?;
4976                        if record.environment_profile.as_ref() != ownership.environment_profile()
4977                            || record.bundle.as_ref() != ownership.bundle()
4978                            || record.context.as_ref() != ownership.context()
4979                        {
4980                            return Err(failure(
4981                                NodeFailureCode::BackendOperationFailed,
4982                                "session materialization ownership changed after restart",
4983                            ));
4984                        }
4985                        if let Some(receipt) = ownership.bundle() {
4986                            let catalog = self.bundle_catalog.read()
4987                                .unwrap_or_else(|poisoned| poisoned.into_inner());
4988                            let bundle = catalog.get(&receipt.id).ok_or_else(|| failure(
4989                                NodeFailureCode::UnknownBundle,
4990                                "session bundle is unavailable after restart",
4991                            ))?;
4992                            if bundle.receipt() != *receipt {
4993                                return Err(failure(
4994                                    NodeFailureCode::BundleBindingMismatch,
4995                                    "session bundle changed after restart",
4996                                ));
4997                            }
4998                            let layout = self.bundle_layout(
4999                                &record.provider,
5000                                record.mode,
5001                                record.environment_profile.as_ref(),
5002                                bundle,
5003                            )?;
5004                            materializer
5005                                .revalidate_bundle(&ownership, bundle, layout)
5006                                .map_err(|_| failure(
5007                                    NodeFailureCode::BundleMaterializationFailed,
5008                                    "session bundle revalidation failed",
5009                                ))?;
5010                        }
5011                        if let Some(receipt) = ownership.context() {
5012                            let pack = materializer
5013                                .revalidate_context(&ownership, receipt)
5014                                .map_err(|_| {
5015                                    failure(
5016                                        NodeFailureCode::ContextPackMaterializationFailed,
5017                                        "session context revalidation failed",
5018                                    )
5019                                })?;
5020                            self.context_catalog
5021                                .write()
5022                                .unwrap_or_else(|poisoned| poisoned.into_inner())
5023                                .insert(pack)
5024                                .map_err(|_| {
5025                                    failure(
5026                                        NodeFailureCode::ContextPackMaterializationFailed,
5027                                        "session context catalog restore failed",
5028                                    )
5029                                })?;
5030                        }
5031                        Ok(())
5032                    }),
5033                (MaterializationState::RecoveryRequired, _) => Ok(()),
5034            };
5035            if result.is_err() && ownership.state() != MaterializationState::RecoveryRequired {
5036                let _ = ownership.mark_recovery_required(unix_time_ms());
5037                self.materializations
5038                    .lock()
5039                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5040                    .insert(ownership.id().clone(), ownership);
5041                if self.persist_state().is_err() {
5042                    self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5043                }
5044            }
5045        }
5046        let materialized_profiles = self
5047            .environment_materialization_profiles
5048            .read()
5049            .unwrap_or_else(|poisoned| poisoned.into_inner())
5050            .keys()
5051            .cloned()
5052            .collect::<Vec<_>>();
5053        let ownership = self
5054            .materializations
5055            .lock()
5056            .unwrap_or_else(|poisoned| poisoned.into_inner())
5057            .values()
5058            .cloned()
5059            .collect::<Vec<_>>();
5060        let changed = {
5061            let mut session_records = self
5062                .session_records
5063                .lock()
5064                .unwrap_or_else(|poisoned| poisoned.into_inner());
5065            let mut changed = false;
5066            for record in session_records.records.values_mut() {
5067                let environment_profile = record.environment_profile.as_ref();
5068                if environment_profile.is_none()
5069                    && record.bundle.is_none()
5070                    && record.context.is_none()
5071                {
5072                    continue;
5073                }
5074                if record.bundle.is_none()
5075                    && environment_profile.is_some_and(|profile| {
5076                        !materialized_profiles.contains(&profile.profile_id)
5077                    })
5078                {
5079                    continue;
5080                }
5081                let has_exact_ownership = ownership.iter().any(|candidate| {
5082                    candidate.state() == MaterializationState::Ready
5083                        && candidate.environment_profile() == environment_profile
5084                        && candidate.bundle() == record.bundle.as_ref()
5085                        && candidate.context() == record.context.as_ref()
5086                        && candidate.owner()
5087                            == &MaterializationOwner::Record {
5088                                record_id: record.record_id.clone(),
5089                            }
5090                });
5091                if !has_exact_ownership {
5092                    record.provider_session = None;
5093                    record.active_session = None;
5094                    record.state = ManagedSessionState::Unavailable;
5095                    record.last_error = Some(if record.bundle.is_some() {
5096                        "bundle-unavailable".to_owned()
5097                    } else if record.context.is_some() {
5098                        "context-unavailable".to_owned()
5099                    } else {
5100                        "environment-profile-unavailable".to_owned()
5101                    });
5102                    record.updated_at_unix_ms = unix_time_ms();
5103                    changed = true;
5104                }
5105            }
5106            changed
5107        };
5108        if changed && self.persist_state().is_err() {
5109            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5110        }
5111    }
5112
5113    fn replay_spawn_spec(
5114        &self,
5115        spec: &SpawnSpec,
5116        now: Instant,
5117    ) -> Result<Option<Result<ResolvedSpawnReceipt, NodeFailure>>, NodeFailure> {
5118        let mut cache = self
5119            .spawn_idempotency
5120            .lock()
5121            .unwrap_or_else(|poisoned| poisoned.into_inner());
5122        cache.entries.retain(|_, entry| entry.expires_at > now);
5123        if let Some(entry) = cache.entries.get(&spec.idempotency_key) {
5124            let SpawnIdempotencyValue::Standard { spec: existing, result } = &entry.value else {
5125                return Err(failure(
5126                    NodeFailureCode::SpawnIdempotencyConflict,
5127                    "spawn idempotency key was reused across spawn kinds",
5128                ));
5129            };
5130            if existing != spec {
5131                return Err(failure(
5132                    NodeFailureCode::SpawnIdempotencyConflict,
5133                    "spawn idempotency key was reused with a different specification",
5134                ));
5135            }
5136            return Ok(Some(result.clone()));
5137        }
5138        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5139            return Err(failure(
5140                NodeFailureCode::SpawnIdempotencyCapacity,
5141                "spawn idempotency cache reached its bounded capacity",
5142            ));
5143        }
5144        Ok(None)
5145    }
5146
5147    fn remember_spawn_spec(
5148        &self,
5149        spec: SpawnSpec,
5150        result: Result<ResolvedSpawnReceipt, NodeFailure>,
5151        accepted_at: Instant,
5152    ) {
5153        self
5154            .spawn_idempotency
5155            .lock()
5156            .unwrap_or_else(|poisoned| poisoned.into_inner())
5157            .entries
5158            .insert(
5159                spec.idempotency_key.clone(),
5160                SpawnIdempotencyEntry {
5161                    value: SpawnIdempotencyValue::Standard { spec, result },
5162                    expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5163                },
5164            );
5165    }
5166
5167    fn claim_harness_mcp_spawn(
5168        &self,
5169        _reservation_id: &HarnessMcpReservationId,
5170        _activation_digest: &HarnessMcpActivationDigest,
5171        spec: &SpawnSpec,
5172        now: Instant,
5173    ) -> Result<(), NodeFailure> {
5174        let mut cache = self.spawn_idempotency.lock()
5175            .unwrap_or_else(|poisoned| poisoned.into_inner());
5176        cache.entries.retain(|_, entry| entry.expires_at > now);
5177        if cache.entries.contains_key(&spec.idempotency_key) {
5178            return Err(failure(
5179                NodeFailureCode::SpawnIdempotencyConflict,
5180                "spawn idempotency key was reused across spawn kinds",
5181            ));
5182        }
5183        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5184            return Err(failure(
5185                NodeFailureCode::SpawnIdempotencyCapacity,
5186                "spawn idempotency cache reached its bounded capacity",
5187            ));
5188        }
5189        cache.entries.insert(spec.idempotency_key.clone(), SpawnIdempotencyEntry {
5190            value: SpawnIdempotencyValue::HarnessMcp,
5191            expires_at: now + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5192        });
5193        Ok(())
5194    }
5195
5196    fn replay_managed_spawn(
5197        &self,
5198        request: &ManagedWorktreeSpawnRequest,
5199        now: Instant,
5200    ) -> Result<Option<Result<ManagedWorktreeSpawnReceipt, NodeFailure>>, NodeFailure> {
5201        let mut cache = self.spawn_idempotency.lock()
5202            .unwrap_or_else(|poisoned| poisoned.into_inner());
5203        cache.entries.retain(|_, entry| entry.expires_at > now);
5204        if let Some(entry) = cache.entries.get(&request.spawn_spec.idempotency_key) {
5205            let SpawnIdempotencyValue::Managed { request: existing, result } = &entry.value else {
5206                return Err(failure(
5207                    NodeFailureCode::SpawnIdempotencyConflict,
5208                    "spawn idempotency key was reused across spawn kinds",
5209                ));
5210            };
5211            if existing != request {
5212                return Err(failure(
5213                    NodeFailureCode::SpawnIdempotencyConflict,
5214                    "spawn idempotency key was reused with a different managed specification",
5215                ));
5216            }
5217            return Ok(Some(result.clone()));
5218        }
5219        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5220            return Err(failure(
5221                NodeFailureCode::SpawnIdempotencyCapacity,
5222                "spawn idempotency cache reached its bounded capacity",
5223            ));
5224        }
5225        Ok(None)
5226    }
5227
5228    fn remember_managed_spawn(
5229        &self,
5230        request: ManagedWorktreeSpawnRequest,
5231        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
5232        accepted_at: Instant,
5233    ) {
5234        self.spawn_idempotency.lock()
5235            .unwrap_or_else(|poisoned| poisoned.into_inner())
5236            .entries
5237            .insert(
5238                request.spawn_spec.idempotency_key.clone(),
5239                SpawnIdempotencyEntry {
5240                    value: SpawnIdempotencyValue::Managed { request, result },
5241                    expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5242                },
5243            );
5244    }
5245
5246    fn remember_managed_spawn_attempt(
5247        &self,
5248        durable_v2_key: Option<&SpawnIdempotencyKey>,
5249        request: ManagedWorktreeSpawnRequest,
5250        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
5251        accepted_at: Instant,
5252    ) {
5253        if durable_v2_key.is_none() {
5254            self.remember_managed_spawn(request, result, accepted_at);
5255        }
5256    }
5257
5258    fn acquire_durable_managed_spawn_v2(
5259        &self,
5260        request: &ManagedWorktreeSpawnRequestV2,
5261        request_digest: String,
5262    ) -> Result<DurableManagedSpawnReplayDecision, NodeFailure> {
5263        if self.state_path.is_none() {
5264            return Err(failure(
5265                NodeFailureCode::BackendOperationFailed,
5266                "managed worktree V2 requires durable node state",
5267            ));
5268        }
5269        let _transaction = self.state_transaction
5270            .lock()
5271            .unwrap_or_else(|poisoned| poisoned.into_inner());
5272        let key = request.spawn_spec.idempotency_key.clone();
5273        let mut previous = None;
5274        {
5275            let mut replays = self.managed_spawn_replays
5276                .lock()
5277                .unwrap_or_else(|poisoned| poisoned.into_inner());
5278            if let Some(record) = replays.get_mut(&key) {
5279                if record.request_digest != request_digest {
5280                    return Err(failure(
5281                        NodeFailureCode::SpawnIdempotencyConflict,
5282                        "managed worktree V2 idempotency key was reused with a different request",
5283                    ));
5284                }
5285                match &record.state {
5286                    ManagedWorktreeSpawnReplayStateV10::Pending {
5287                        owner_incarnation_id,
5288                        lease_id: None,
5289                    } if owner_incarnation_id != &self.incarnation_id => {
5290                        previous = Some(record.clone());
5291                        record.state = ManagedWorktreeSpawnReplayStateV10::Pending {
5292                            owner_incarnation_id: self.incarnation_id,
5293                            lease_id: None,
5294                        };
5295                        record.updated_at_unix_ms = unix_time_ms();
5296                    }
5297                    ManagedWorktreeSpawnReplayStateV10::Pending {
5298                        lease_id: None,
5299                        ..
5300                    } => {
5301                        return Err(failure(
5302                            NodeFailureCode::BackendBusy,
5303                            "managed worktree V2 reservation is already active",
5304                        ));
5305                    }
5306                    ManagedWorktreeSpawnReplayStateV10::Pending {
5307                        lease_id: Some(_),
5308                        ..
5309                    } => {
5310                        return Ok(DurableManagedSpawnReplayDecision::PendingLinked);
5311                    }
5312                    ManagedWorktreeSpawnReplayStateV10::Committed { receipt } => {
5313                        return Ok(DurableManagedSpawnReplayDecision::Committed(
5314                            receipt.clone(),
5315                        ));
5316                    }
5317                }
5318            } else if replays.len() >= MAX_MANAGED_WORKTREE_SPAWN_REPLAYS {
5319                return Err(failure(
5320                    NodeFailureCode::SpawnIdempotencyCapacity,
5321                    "durable managed worktree replay ledger reached its bounded capacity",
5322                ));
5323            } else {
5324                let now = unix_time_ms();
5325                replays.insert(key.clone(), ManagedWorktreeSpawnReplayRecordV10 {
5326                    idempotency_key: key.clone(),
5327                    request_digest,
5328                    source_workspace_id: request.spawn_spec.target.workspace_id.clone(),
5329                    profile_id: request.worktree_profile_id.clone(),
5330                    expected_profile_revision: request.expected_profile_revision.clone(),
5331                    state: ManagedWorktreeSpawnReplayStateV10::Pending {
5332                        owner_incarnation_id: self.incarnation_id,
5333                        lease_id: None,
5334                    },
5335                    created_at_unix_ms: now,
5336                    updated_at_unix_ms: now,
5337                });
5338            }
5339        }
5340        if let Err(error) = self.persist_state_locked() {
5341            let mut replays = self.managed_spawn_replays
5342                .lock()
5343                .unwrap_or_else(|poisoned| poisoned.into_inner());
5344            match previous {
5345                Some(previous) => {
5346                    replays.insert(key, previous);
5347                }
5348                None => {
5349                    replays.remove(&key);
5350                }
5351            }
5352            return Err(persistence_failure(error));
5353        }
5354        Ok(DurableManagedSpawnReplayDecision::Reserved)
5355    }
5356
5357    fn link_durable_managed_spawn_v2_lease_locked(
5358        &self,
5359        key: &SpawnIdempotencyKey,
5360        lease_id: &ManagedWorktreeLeaseId,
5361    ) -> Result<ManagedWorktreeSpawnReplayRecordV10, NodeFailure> {
5362        let mut replays = self.managed_spawn_replays
5363            .lock()
5364            .unwrap_or_else(|poisoned| poisoned.into_inner());
5365        let record = replays.get_mut(key).ok_or_else(|| {
5366            failure(
5367                NodeFailureCode::ManagedWorktreeRecoveryRequired,
5368                "managed worktree V2 reservation disappeared before allocation",
5369            )
5370        })?;
5371        let previous = record.clone();
5372        match &mut record.state {
5373            ManagedWorktreeSpawnReplayStateV10::Pending {
5374                lease_id: current,
5375                ..
5376            }
5377                if current.is_none() =>
5378            {
5379                *current = Some(lease_id.clone());
5380                record.updated_at_unix_ms = unix_time_ms();
5381                Ok(previous)
5382            }
5383            _ => Err(failure(
5384                NodeFailureCode::ManagedWorktreeRecoveryRequired,
5385                "managed worktree V2 reservation is not allocation-ready",
5386            )),
5387        }
5388    }
5389
5390    fn restore_durable_managed_spawn_v2_locked(
5391        &self,
5392        key: &SpawnIdempotencyKey,
5393        previous: ManagedWorktreeSpawnReplayRecordV10,
5394    ) {
5395        self.managed_spawn_replays
5396            .lock()
5397            .unwrap_or_else(|poisoned| poisoned.into_inner())
5398            .insert(key.clone(), previous);
5399    }
5400
5401    fn commit_durable_managed_spawn_v2(
5402        &self,
5403        request: &ManagedWorktreeSpawnRequestV2,
5404        receipt: ManagedWorktreeSpawnReceipt,
5405    ) -> Result<(), NodeFailure> {
5406        let _transaction = self.state_transaction
5407            .lock()
5408            .unwrap_or_else(|poisoned| poisoned.into_inner());
5409        let key = &request.spawn_spec.idempotency_key;
5410        let previous = {
5411            let mut replays = self.managed_spawn_replays
5412                .lock()
5413                .unwrap_or_else(|poisoned| poisoned.into_inner());
5414            let record = replays.get_mut(key).ok_or_else(|| {
5415                failure(
5416                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
5417                    "managed worktree V2 reservation disappeared before commit",
5418                )
5419            })?;
5420            let previous = record.clone();
5421            match &record.state {
5422                ManagedWorktreeSpawnReplayStateV10::Pending {
5423                    lease_id: Some(lease_id),
5424                    ..
5425                } if lease_id == &receipt.lease.lease_id => {}
5426                _ => {
5427                    return Err(failure(
5428                        NodeFailureCode::ManagedWorktreeRecoveryRequired,
5429                        "managed worktree V2 reservation is not linked to the spawned lease",
5430                    ));
5431                }
5432            }
5433            record.state = ManagedWorktreeSpawnReplayStateV10::Committed { receipt };
5434            record.updated_at_unix_ms = unix_time_ms();
5435            previous
5436        };
5437        if let Err(error) = self.persist_state_locked() {
5438            self.restore_durable_managed_spawn_v2_locked(key, previous);
5439            return Err(persistence_failure(error));
5440        }
5441        Ok(())
5442    }
5443
5444    fn remove_unallocated_durable_managed_spawn_v2(
5445        &self,
5446        request: &ManagedWorktreeSpawnRequestV2,
5447    ) -> Result<(), NodeFailure> {
5448        let _transaction = self.state_transaction
5449            .lock()
5450            .unwrap_or_else(|poisoned| poisoned.into_inner());
5451        let key = &request.spawn_spec.idempotency_key;
5452        let removed = {
5453            let mut replays = self.managed_spawn_replays
5454                .lock()
5455                .unwrap_or_else(|poisoned| poisoned.into_inner());
5456            match replays.get(key).map(|record| &record.state) {
5457                Some(ManagedWorktreeSpawnReplayStateV10::Pending {
5458                    lease_id: None,
5459                    ..
5460                }) => {
5461                    replays.remove(key)
5462                }
5463                _ => None,
5464            }
5465        };
5466        let Some(removed) = removed else {
5467            return Ok(());
5468        };
5469        if let Err(error) = self.persist_state_locked() {
5470            self.managed_spawn_replays
5471                .lock()
5472                .unwrap_or_else(|poisoned| poisoned.into_inner())
5473                .insert(key.clone(), removed);
5474            return Err(persistence_failure(error));
5475        }
5476        Ok(())
5477    }
5478
5479    fn resolve_spawn_spec(&self, spec: &SpawnSpec) -> Result<ResolvedSpawnSpec, NodeFailure> {
5480        if spec.target.node_id != self.node_id {
5481            return Err(failure(
5482                NodeFailureCode::SpawnTargetMismatch,
5483                "spawn target does not match this node",
5484            ));
5485        }
5486        let defaults = self.spawn_profiles.get(&spec.profile_id).ok_or_else(|| {
5487            failure(
5488                NodeFailureCode::UnknownSpawnProfile,
5489                "spawn profile is unavailable on this node",
5490            )
5491        })?;
5492        let resolved = spec.resolve(defaults).map_err(|error| match error {
5493            SpawnSpecResolveError::ProfileRevisionMismatch { .. } => failure(
5494                NodeFailureCode::SpawnProfileRevisionMismatch,
5495                "spawn profile revision does not match the loaded profile",
5496            ),
5497            _ => failure(
5498                NodeFailureCode::InvalidRequest,
5499                "spawn specification could not be resolved",
5500            ),
5501        })?;
5502        if resolved.target.worktree_id.is_some() {
5503            self.require_worktree_service(&resolved.target.workspace_id)?;
5504        }
5505        let environment_profile = self.resolve_environment_profile(&resolved)?;
5506        self.resolve_bundle(&resolved, environment_profile.as_ref())?;
5507        self.resolve_context(&resolved)?;
5508        Ok(resolved)
5509    }
5510
5511    async fn resolve_spawn_workspace(
5512        &self,
5513        resolved: &ResolvedSpawnSpec,
5514        deadline: Instant,
5515    ) -> Result<WorkspaceId, NodeFailure> {
5516        let Some(worktree_id) = resolved.target.worktree_id.as_ref() else {
5517            return Ok(resolved.target.workspace_id.clone());
5518        };
5519        if self.managed_worktrees.lock()
5520            .unwrap_or_else(|poisoned| poisoned.into_inner())
5521            .lease_for_workspace(worktree_id)
5522            .is_some()
5523        {
5524            return Err(failure(
5525                NodeFailureCode::ManagedWorktreeOwnershipConflict,
5526                "managed worktree targets may only be spawned through their lease request",
5527            ));
5528        }
5529        self.require_worktree_service(&resolved.target.workspace_id)?;
5530        let source_root = self.workspace_root(&resolved.target.workspace_id)?;
5531        let selected_root = self.workspace_root(worktree_id)?;
5532        let remaining = spawn_deadline_remaining(deadline)?;
5533        let worktrees = timeout(remaining, list_git_worktrees(&source_root))
5534            .await
5535            .map_err(|_| {
5536                failure(
5537                    NodeFailureCode::SpawnDeadlineExceeded,
5538                    "Git worktree selection exceeded the spawn deadline",
5539                )
5540            })?
5541            .map_err(git_worktree_failure)?;
5542        validate_selected_worktree(&source_root, &selected_root, &worktrees)?;
5543        Ok(worktree_id.clone())
5544    }
5545
5546    async fn spawn_from_spec(
5547        &self,
5548        spec: SpawnSpec,
5549    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
5550        self.spawn_from_spec_with_proxy(spec, None).await
5551    }
5552
5553    async fn spawn_from_spec_with_proxy(
5554        &self,
5555        spec: SpawnSpec,
5556        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
5557    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
5558        let accepted_at = Instant::now();
5559        if harness_mcp.is_none() {
5560            if let Some(replayed) = self.replay_spawn_spec(&spec, accepted_at)? {
5561                return replayed;
5562            }
5563        }
5564        let resolved = self.resolve_spawn_spec(&spec)?;
5565        let environment_profile = self.resolve_environment_profile(&resolved)?;
5566        let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
5567        let context = self.resolve_context(&resolved)?;
5568        let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
5569        let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
5570        let spawn_workspace_id = self.resolve_spawn_workspace(&resolved, deadline).await;
5571        let spawn_workspace_id = match spawn_workspace_id {
5572            Ok(workspace_id) => workspace_id,
5573            Err(error) => {
5574                let result = Err(error);
5575                if harness_mcp.is_none() {
5576                    self.remember_spawn_spec(spec, result.clone(), accepted_at);
5577                }
5578                return result;
5579            }
5580        };
5581        if self.managed_worktrees.lock()
5582            .unwrap_or_else(|poisoned| poisoned.into_inner())
5583            .lease_for_workspace(&spawn_workspace_id)
5584            .is_some()
5585        {
5586            let result = Err(failure(
5587                NodeFailureCode::ManagedWorktreeOwnershipConflict,
5588                "managed worktree targets may only be spawned through their lease request",
5589            ));
5590            if harness_mcp.is_none() {
5591                self.remember_spawn_spec(spec, result.clone(), accepted_at);
5592            }
5593            return result;
5594        }
5595        let result = self
5596            .spawn_session_with_deadline(
5597                spawn_workspace_id,
5598                resolved.provider.clone(),
5599                resolved.mode,
5600                resolved.terminal_size,
5601                resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
5602                environment_profile.clone(),
5603                bundle.clone(),
5604                context.clone(),
5605                None,
5606                None,
5607                SpawnRecordPolicy::Always,
5608                Some(deadline),
5609                &required_capabilities,
5610                harness_mcp,
5611                resolved.approval_level,
5612                resolved.network_allowlist.clone(),
5613                resolved.browser_profile_id.clone(),
5614            )
5615            .await
5616            .map(|(session, _runtime_policy)| {
5617                resolved.receipt_with_materialization(
5618                    self.incarnation_id,
5619                    session,
5620                    environment_profile,
5621                    bundle,
5622                    context,
5623                )
5624            });
5625        if harness_mcp.is_none() {
5626            self.remember_spawn_spec(spec, result.clone(), accepted_at);
5627        }
5628        result
5629    }
5630
5631    async fn spawn_from_spec_with_harness_mcp(
5632        &self,
5633        reservation_id: HarnessMcpReservationId,
5634        activation_digest: HarnessMcpActivationDigest,
5635        spec: SpawnSpec,
5636        deadline_unix_ms: u64,
5637    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
5638        let registry = self.harness_mcp_registry.as_ref().ok_or_else(|| {
5639            failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
5640        })?;
5641        if let Some(receipt) = registry
5642            .replay_spawn(&reservation_id, &activation_digest, &spec)
5643            .map_err(harness_mcp_failure)?
5644        {
5645            return Ok(receipt);
5646        }
5647        let resolved = self.resolve_spawn_spec(&spec)?;
5648        let prepared = registry
5649            .prepare_spawn(
5650                &reservation_id,
5651                &activation_digest,
5652                &spec,
5653                deadline_unix_ms,
5654                resolved.provider.clone(),
5655            )
5656            .map_err(harness_mcp_failure)?;
5657        self.claim_harness_mcp_spawn(
5658            &reservation_id,
5659            &activation_digest,
5660            &spec,
5661            Instant::now(),
5662        )?;
5663        let spawn = self.spawn_from_spec_with_proxy(spec, Some(&prepared)).await;
5664        let mut receipt = match spawn {
5665            Ok(receipt) => receipt,
5666            Err(error) => {
5667                let _ = registry.abort(&reservation_id, &activation_digest);
5668                return Err(error);
5669            }
5670        };
5671        let record_id = self
5672            .session_bindings
5673            .lock()
5674            .unwrap_or_else(|poisoned| poisoned.into_inner())
5675            .get(&receipt.session.session.instance_id)
5676            .and_then(|binding| binding.record_id.clone());
5677        let record_id = match record_id {
5678            Some(record_id) => record_id,
5679            None => {
5680                let rollback = self.remove_session(&receipt.session).await;
5681                let _ = registry.abort(&reservation_id, &activation_digest);
5682                if let Err(rollback_error) = rollback {
5683                    return Err(rollback_error);
5684                }
5685                return Err(failure(
5686                    NodeFailureCode::BindingMismatch,
5687                    "harness MCP spawn did not retain its exact managed record",
5688                ));
5689            }
5690        };
5691        receipt.harness_mcp_proxy = Some(ResolvedHarnessMcpProxyReceiptV1 {
5692            reservation_id: reservation_id.clone(),
5693            activation_digest: activation_digest.clone(),
5694        });
5695        if let Err(error) = registry.mark_spawned(
5696            &prepared,
5697            receipt.session.clone(),
5698            record_id,
5699            receipt.clone(),
5700        ) {
5701            let rollback = self.remove_session(&receipt.session).await;
5702            let _ = registry.abort(&reservation_id, &activation_digest);
5703            if let Err(rollback_error) = rollback {
5704                return Err(rollback_error);
5705            }
5706            return Err(harness_mcp_failure(error));
5707        }
5708        Ok(receipt)
5709    }
5710
5711    async fn spawn_managed_worktree(
5712        &self,
5713        request: ManagedWorktreeSpawnRequest,
5714    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
5715        self.spawn_managed_worktree_inner(request, None).await
5716    }
5717
5718    async fn spawn_managed_worktree_inner(
5719        &self,
5720        request: ManagedWorktreeSpawnRequest,
5721        durable_v2_key: Option<&SpawnIdempotencyKey>,
5722    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
5723        let accepted_at = Instant::now();
5724        if durable_v2_key.is_none() {
5725            if let Some(replayed) = self.replay_managed_spawn(&request, accepted_at)? {
5726                return replayed;
5727            }
5728        }
5729        let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
5730        if request.spawn_spec.target.worktree_id.is_some() {
5731            let result = Err(failure(
5732                NodeFailureCode::InvalidRequest,
5733                "managed worktree spawn must not select a caller-provided worktree",
5734            ));
5735            self.remember_managed_spawn_attempt(
5736                durable_v2_key,
5737                request,
5738                result.clone(),
5739                accepted_at,
5740            );
5741            return result;
5742        }
5743        let environment_profile = self.resolve_environment_profile(&resolved)?;
5744        let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
5745        let context = self.resolve_context(&resolved)?;
5746        let source_workspace_id = resolved.target.workspace_id.clone();
5747        let profile = self.managed_profile(&source_workspace_id, &request.worktree_profile_id)?;
5748        let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
5749        let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
5750        let runtime_requirement = match (resolved.mode, resolved.prompt.is_some()) {
5751            (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
5752            (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
5753            (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
5754            (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
5755        };
5756        let runtime_policy = timeout(
5757            spawn_deadline_remaining(deadline)?,
5758            self.admit_provider_runtime(&resolved.provider, runtime_requirement),
5759        ).await.map_err(|_| failure(
5760            NodeFailureCode::SpawnDeadlineExceeded,
5761            "provider admission exceeded the managed spawn deadline",
5762        ))??;
5763        if required_capabilities.iter().any(|capability| !runtime_policy.admits(*capability)) {
5764            return Err(failure(
5765                NodeFailureCode::UnsupportedSpawnCapability,
5766                "provider runtime does not admit a required spawn capability",
5767            ));
5768        }
5769        self.ensure_binding_capacity()?;
5770        let source_root = self.workspace_root(&source_workspace_id)?;
5771        profile.validate_for_workspace(&source_root)
5772            .map_err(|message| failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message))?;
5773        let base_timeout_ms = u64::try_from(
5774            spawn_deadline_remaining(deadline)?.as_millis(),
5775        ).unwrap_or(u64::MAX).max(1);
5776        let base_commit = resolve_base_commit_with_timeout(
5777            &source_root,
5778            profile.base(),
5779            base_timeout_ms,
5780        ).await.map_err(|error| {
5781            if Instant::now() >= deadline || error.message.contains("timed out") {
5782                failure(
5783                    NodeFailureCode::SpawnDeadlineExceeded,
5784                    "managed worktree base resolution exceeded the spawn deadline",
5785                )
5786            } else {
5787                managed_git_worktree_failure(error)
5788            }
5789        })?;
5790
5791        let lease = {
5792            let _transaction = self.state_transaction.lock()
5793                .unwrap_or_else(|poisoned| poisoned.into_inner());
5794            let previous = self.managed_worktrees.lock()
5795                .unwrap_or_else(|poisoned| poisoned.into_inner()).clone();
5796            let lease = self.managed_worktrees.lock()
5797                .unwrap_or_else(|poisoned| poisoned.into_inner())
5798                .allocate(source_workspace_id.clone(), &profile, base_commit.clone(), unix_time_ms())
5799                .map_err(|message| failure(NodeFailureCode::BackendBusy, &message))?;
5800            let previous_replay = match durable_v2_key
5801                .map(|key| self.link_durable_managed_spawn_v2_lease_locked(key, &lease.lease_id))
5802                .transpose()
5803            {
5804                Ok(previous) => previous,
5805                Err(error) => {
5806                    *self.managed_worktrees.lock()
5807                        .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
5808                    return Err(error);
5809                }
5810            };
5811            if let Err(error) = self.persist_state_locked() {
5812                *self.managed_worktrees.lock()
5813                    .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
5814                if let (Some(key), Some(previous_replay)) =
5815                    (durable_v2_key, previous_replay)
5816                {
5817                    self.restore_durable_managed_spawn_v2_locked(key, previous_replay);
5818                }
5819                return Err(persistence_failure(error));
5820            }
5821            lease
5822        };
5823        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() });
5824        if let Err(message) = profile.validate_target_authority(&lease.lease_id, &lease.target_root) {
5825            self.managed_worktrees.lock()
5826                .unwrap_or_else(|poisoned| poisoned.into_inner())
5827                .remove_unmutated(&lease.lease_id);
5828            if self.persist_state().is_err() {
5829                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5830            }
5831            let result = Err(failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message));
5832            self.remember_managed_spawn_attempt(
5833                durable_v2_key,
5834                request,
5835                result.clone(),
5836                accepted_at,
5837            );
5838            return result;
5839        }
5840        let mutation_timeout_ms = match spawn_deadline_remaining(deadline) {
5841            Ok(remaining) => u64::try_from(remaining.as_millis()).unwrap_or(u64::MAX).max(1),
5842            Err(error) => {
5843                self.reconcile_failed_allocation(&lease, &source_root).await;
5844                let result = Err(error);
5845                self.remember_managed_spawn_attempt(
5846                    durable_v2_key,
5847                    request,
5848                    result.clone(),
5849                    accepted_at,
5850                );
5851                return result;
5852            }
5853        };
5854        let created = match create_git_worktree_with_timeout(
5855                &source_root,
5856                &lease.target_root,
5857                &lease.branch,
5858                Some(&base_commit),
5859                mutation_timeout_ms,
5860            ).await {
5861            Ok(created) => created,
5862            Err(error) => {
5863                self.reconcile_failed_allocation(&lease, &source_root).await;
5864                let result = Err(if Instant::now() >= deadline
5865                    || error.message.contains("timed out")
5866                    || error.message.contains("deadline")
5867                {
5868                    failure(
5869                        NodeFailureCode::SpawnDeadlineExceeded,
5870                        "managed worktree creation exceeded the spawn deadline",
5871                    )
5872                } else {
5873                    managed_git_worktree_failure(error)
5874                });
5875                self.remember_managed_spawn_attempt(
5876                    durable_v2_key,
5877                    request,
5878                    result.clone(),
5879                    accepted_at,
5880                );
5881                return result;
5882            }
5883        };
5884        if !exact_created_worktree(&lease, &created.path, created.branch.as_deref(), &created.head) {
5885            self.mark_managed_recovery_required(&lease.lease_id, ManagedWorktreeCleanupFailure::OwnershipConflict);
5886            let result = Err(failure(
5887                NodeFailureCode::ManagedWorktreeRecoveryRequired,
5888                "created worktree identity did not match its durable lease",
5889            ));
5890            self.remember_managed_spawn_attempt(
5891                durable_v2_key,
5892                request,
5893                result.clone(),
5894                accepted_at,
5895            );
5896            return result;
5897        }
5898        {
5899            let mut registry = self.managed_worktrees.lock()
5900                .unwrap_or_else(|poisoned| poisoned.into_inner());
5901            let current = registry.get_mut(&lease.lease_id)
5902                .expect("allocated managed worktree lease remains present");
5903            current.expected_head = Some(created.head.clone());
5904            current.state = ManagedWorktreeLeaseState::Ready;
5905            current.updated_at_unix_ms = unix_time_ms();
5906        }
5907        if let Err(error) = self.persist_state() {
5908            self.mark_managed_recovery_required(
5909                &lease.lease_id,
5910                ManagedWorktreeCleanupFailure::Backend,
5911            );
5912            let result = Err(persistence_failure(error));
5913            self.remember_managed_spawn_attempt(
5914                durable_v2_key,
5915                request,
5916                result.clone(),
5917                accepted_at,
5918            );
5919            return result;
5920        }
5921        let ready = self.managed_worktrees.lock()
5922            .unwrap_or_else(|poisoned| poisoned.into_inner())
5923            .get(&lease.lease_id)
5924            .expect("ready managed worktree lease remains present")
5925            .snapshot();
5926        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: ready });
5927        if let Err(error) = self.register_workspace(lease.workspace_id.clone(), created.path.clone()).await {
5928            let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
5929            let result = Err(error);
5930            self.remember_managed_spawn_attempt(
5931                durable_v2_key,
5932                request,
5933                result.clone(),
5934                accepted_at,
5935            );
5936            return result;
5937        }
5938        let spawn = self.spawn_session_with_deadline(
5939            lease.workspace_id.clone(),
5940            resolved.provider.clone(),
5941            resolved.mode,
5942            resolved.terminal_size,
5943            resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
5944            environment_profile.clone(),
5945            bundle.clone(),
5946            context.clone(),
5947            Some(lease.lease_id.clone()),
5948            Some(runtime_policy),
5949            SpawnRecordPolicy::Always,
5950            Some(deadline),
5951            &required_capabilities,
5952            None,
5953            resolved.approval_level,
5954            resolved.network_allowlist.clone(),
5955            resolved.browser_profile_id.clone(),
5956        ).await;
5957        let (session, effective_runtime_policy) = match spawn {
5958            Ok(spawned) => spawned,
5959            Err(error) => {
5960                if self
5961                    .cleanup_managed_worktree(&lease.lease_id, true)
5962                    .await
5963                    .is_err()
5964                {
5965                    self.mark_managed_recovery_required(
5966                        &lease.lease_id,
5967                        ManagedWorktreeCleanupFailure::Backend,
5968                    );
5969                }
5970                let result = Err(error);
5971                self.remember_managed_spawn_attempt(
5972                    durable_v2_key,
5973                    request,
5974                    result.clone(),
5975                    accepted_at,
5976                );
5977                return result;
5978            }
5979        };
5980        let (record_id, raw_inventory_record_id) = {
5981            let mut bindings = self.session_bindings.lock()
5982                .unwrap_or_else(|poisoned| poisoned.into_inner());
5983            let binding = bindings.get_mut(&session.session.instance_id)
5984                .expect("successful spawn retains its session binding");
5985            binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
5986            managed_spawn_record_ownership(binding, effective_runtime_policy)
5987        };
5988        let bound = self.managed_worktrees.lock()
5989            .unwrap_or_else(|poisoned| poisoned.into_inner())
5990            .bind_session(
5991                &lease.lease_id,
5992                ManagedWorktreeSessionHolder {
5993                    incarnation_id: self.incarnation_id,
5994                    instance_id: session.session.instance_id,
5995                    generation: session.session.generation,
5996                },
5997                record_id,
5998                unix_time_ms(),
5999            )
6000            .map_err(|message| failure(NodeFailureCode::ManagedWorktreeRecoveryRequired, &message));
6001        let snapshot = match bound {
6002            Ok(snapshot) => snapshot,
6003            Err(error) => {
6004                if raw_inventory_record_id
6005                    .as_ref()
6006                    .is_some_and(|record_id| self.discard_record(record_id).is_err())
6007                {
6008                    self.mark_managed_recovery_required(
6009                        &lease.lease_id,
6010                        ManagedWorktreeCleanupFailure::Backend,
6011                    );
6012                }
6013                let _ = self.remove_session(&session).await;
6014                self.mark_managed_recovery_required(
6015                    &lease.lease_id,
6016                    ManagedWorktreeCleanupFailure::Backend,
6017                );
6018                let result = Err(error);
6019                self.remember_managed_spawn_attempt(
6020                    durable_v2_key,
6021                    request,
6022                    result.clone(),
6023                    accepted_at,
6024                );
6025                return result;
6026            }
6027        };
6028        if let Err(error) = self.persist_state() {
6029            if raw_inventory_record_id
6030                .as_ref()
6031                .is_some_and(|record_id| self.discard_record(record_id).is_err())
6032            {
6033                self.mark_managed_recovery_required(
6034                    &lease.lease_id,
6035                    ManagedWorktreeCleanupFailure::Backend,
6036                );
6037            }
6038            let _ = self.remove_session(&session).await;
6039            if self
6040                .cleanup_managed_worktree(&lease.lease_id, true)
6041                .await
6042                .is_err()
6043            {
6044                self.mark_managed_recovery_required(
6045                    &lease.lease_id,
6046                    ManagedWorktreeCleanupFailure::Backend,
6047                );
6048            }
6049            let result = Err(persistence_failure(error));
6050            self.remember_managed_spawn_attempt(
6051                durable_v2_key,
6052                request,
6053                result.clone(),
6054                accepted_at,
6055            );
6056            return result;
6057        }
6058        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
6059        let receipt = managed_spawn_receipt(
6060            &resolved,
6061            self.incarnation_id,
6062            session,
6063            snapshot,
6064            environment_profile,
6065            bundle,
6066            context,
6067        );
6068        let result = Ok(receipt);
6069        self.remember_managed_spawn_attempt(
6070            durable_v2_key,
6071            request,
6072            result.clone(),
6073            accepted_at,
6074        );
6075        result
6076    }
6077
6078    async fn spawn_managed_worktree_v2(
6079        &self,
6080        request: ManagedWorktreeSpawnRequestV2,
6081    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6082        if request.spawn_spec.target.node_id != self.node_id {
6083            return Err(failure(
6084                NodeFailureCode::SpawnTargetMismatch,
6085                "spawn target does not match this node",
6086            ));
6087        }
6088        if request.spawn_spec.target.worktree_id.is_some() {
6089            return Err(failure(
6090                NodeFailureCode::InvalidRequest,
6091                "managed worktree spawn must not select a caller-provided worktree",
6092            ));
6093        }
6094        let request_digest = managed_spawn_request_digest_v2(&request)?;
6095        let replay = self.acquire_durable_managed_spawn_v2(&request, request_digest)?;
6096        match replay {
6097            DurableManagedSpawnReplayDecision::Committed(receipt) => return Ok(receipt),
6098            DurableManagedSpawnReplayDecision::PendingLinked => {
6099                return Err(failure(
6100                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
6101                    "managed worktree V2 reservation has an incomplete durable allocation",
6102                ));
6103            }
6104            DurableManagedSpawnReplayDecision::Reserved => {}
6105        }
6106        let validated = (|| {
6107            let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6108            let profile = self.managed_profile(
6109                &resolved.target.workspace_id,
6110                &request.worktree_profile_id,
6111            )?;
6112            if profile.revision() != &request.expected_profile_revision {
6113                return Err(failure(
6114                    NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
6115                    "managed worktree profile revision does not match the loaded profile",
6116                ));
6117            }
6118            Ok(())
6119        })();
6120        match validated {
6121            Ok(()) => {}
6122            Err(error) => {
6123                self.remove_unallocated_durable_managed_spawn_v2(&request)?;
6124                return Err(error);
6125            }
6126        }
6127        let legacy = ManagedWorktreeSpawnRequest {
6128            spawn_spec: request.spawn_spec.clone(),
6129            worktree_profile_id: request.worktree_profile_id.clone(),
6130        };
6131        match self
6132            .spawn_managed_worktree_inner(
6133                legacy,
6134                Some(&request.spawn_spec.idempotency_key),
6135            )
6136            .await
6137        {
6138            Ok(receipt) => {
6139                self.commit_durable_managed_spawn_v2(&request, receipt.clone())?;
6140                Ok(receipt)
6141            }
6142            Err(error) => {
6143                self.remove_unallocated_durable_managed_spawn_v2(&request)?;
6144                Err(error)
6145            }
6146        }
6147    }
6148
6149    fn managed_profile(
6150        &self,
6151        workspace_id: &WorkspaceId,
6152        profile_id: &WorktreeProfileId,
6153    ) -> Result<ManagedWorktreeProfile, NodeFailure> {
6154        if self.worktree_service_modes.get(workspace_id).copied()
6155            != Some(WorktreeServiceMode::Managed)
6156        {
6157            return Err(failure(
6158                NodeFailureCode::UnsupportedSpawnCapability,
6159                "workspace does not enable managed worktrees",
6160            ));
6161        }
6162        self.managed_worktree_profiles.get(workspace_id)
6163            .and_then(|profiles| profiles.get(profile_id))
6164            .cloned()
6165            .ok_or_else(|| failure(
6166                NodeFailureCode::InvalidRequest,
6167                "managed worktree profile is unavailable for this workspace",
6168            ))
6169    }
6170
6171    async fn reconcile_failed_allocation(
6172        &self,
6173        lease: &ManagedWorktreeLeaseRecord,
6174        source_root: &str,
6175    ) {
6176        match list_git_worktrees(source_root).await {
6177            Ok(worktrees) => {
6178                match worktrees.iter().find(|item| worktree_paths_equal(&item.path, &lease.target_root)) {
6179                    None => {
6180                        match std::fs::symlink_metadata(&lease.target_root) {
6181                            Err(error) if error.kind() == io::ErrorKind::NotFound => {
6182                                self.managed_worktrees.lock()
6183                                    .unwrap_or_else(|poisoned| poisoned.into_inner())
6184                                    .remove_unmutated(&lease.lease_id);
6185                                let _ = self.persist_state();
6186                            }
6187                            _ => self.mark_managed_recovery_required(
6188                                &lease.lease_id,
6189                                ManagedWorktreeCleanupFailure::OwnershipConflict,
6190                            ),
6191                        }
6192                    }
6193                    Some(item) if exact_owned_worktree(
6194                        lease,
6195                        &item.path,
6196                        item.branch.as_deref(),
6197                        &item.head,
6198                    ) => {
6199                        let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
6200                    }
6201                    Some(_) => self.mark_managed_recovery_required(
6202                        &lease.lease_id,
6203                        ManagedWorktreeCleanupFailure::OwnershipConflict,
6204                    ),
6205                }
6206            }
6207            Err(_) => self.mark_managed_recovery_required(
6208                &lease.lease_id,
6209                ManagedWorktreeCleanupFailure::Backend,
6210            ),
6211        }
6212    }
6213
6214    async fn reconcile_managed_worktrees(&self) {
6215        let leases = self.managed_worktrees.lock()
6216            .unwrap_or_else(|poisoned| poisoned.into_inner())
6217            .active_records()
6218            .cloned()
6219            .collect::<Vec<_>>();
6220        for lease in leases {
6221            let profile = match self.managed_profile(&lease.source_workspace_id, &lease.profile_id) {
6222                Ok(profile) if profile.revision() == &lease.profile_revision => profile,
6223                _ => {
6224                    self.mark_managed_recovery_required(
6225                        &lease.lease_id,
6226                        ManagedWorktreeCleanupFailure::OwnershipConflict,
6227                    );
6228                    continue;
6229                }
6230            };
6231            if profile.validate_target_authority(&lease.lease_id, &lease.target_root).is_err() {
6232                self.mark_managed_recovery_required(
6233                    &lease.lease_id,
6234                    ManagedWorktreeCleanupFailure::OwnershipConflict,
6235                );
6236                continue;
6237            }
6238            let source_root = match self.workspace_root(&lease.source_workspace_id) {
6239                Ok(root) => root,
6240                Err(_) => {
6241                    self.mark_managed_recovery_required(
6242                        &lease.lease_id,
6243                        ManagedWorktreeCleanupFailure::OwnershipConflict,
6244                    );
6245                    continue;
6246                }
6247            };
6248            let worktrees = match list_git_worktrees(&source_root).await {
6249                Ok(worktrees) => worktrees,
6250                Err(_) => {
6251                    self.mark_managed_recovery_required(
6252                        &lease.lease_id,
6253                        ManagedWorktreeCleanupFailure::Backend,
6254                    );
6255                    continue;
6256                }
6257            };
6258            let listed = worktrees.iter()
6259                .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
6260            let Some(listed) = listed else {
6261                match std::fs::symlink_metadata(&lease.target_root) {
6262                    Err(error) if error.kind() == io::ErrorKind::NotFound
6263                        && lease.state == ManagedWorktreeLeaseState::Allocating
6264                        && !lease.has_holders() => {
6265                            let removed = self.managed_worktrees.lock()
6266                                .unwrap_or_else(|poisoned| poisoned.into_inner())
6267                                .tombstone(&lease.lease_id, unix_time_ms());
6268                            if self.persist_state().is_ok() && removed.is_some() {
6269                                self.publish(NodeEvent::ManagedWorktreeRemoved {
6270                                    lease_id: lease.lease_id.clone(),
6271                                });
6272                            }
6273                        }
6274                    _ => self.mark_managed_recovery_required(
6275                        &lease.lease_id,
6276                        ManagedWorktreeCleanupFailure::OwnershipConflict,
6277                    ),
6278                }
6279                continue;
6280            };
6281            if listed.is_main || listed.is_bare || !exact_owned_worktree(
6282                &lease,
6283                &listed.path,
6284                listed.branch.as_deref(),
6285                &listed.head,
6286            ) {
6287                self.mark_managed_recovery_required(
6288                    &lease.lease_id,
6289                    ManagedWorktreeCleanupFailure::OwnershipConflict,
6290                );
6291                continue;
6292            }
6293            if !self.workspaces.read()
6294                .unwrap_or_else(|poisoned| poisoned.into_inner())
6295                .contains_key(&lease.workspace_id)
6296            {
6297                if self.register_workspace(lease.workspace_id.clone(), listed.path.clone()).await.is_err() {
6298                    self.mark_managed_recovery_required(
6299                        &lease.lease_id,
6300                        ManagedWorktreeCleanupFailure::Backend,
6301                    );
6302                    continue;
6303                }
6304            }
6305            let snapshot = {
6306                let mut registry = self.managed_worktrees.lock()
6307                    .unwrap_or_else(|poisoned| poisoned.into_inner());
6308                let current = registry.get_mut(&lease.lease_id)
6309                    .expect("reconciled managed lease remains present");
6310                current.expected_head = Some(listed.head.clone());
6311                current.cleanup_failure = None;
6312                current.state = if current.has_holders() {
6313                    ManagedWorktreeLeaseState::InUse
6314                } else if current.retention == ManagedWorktreeRetention::Retain {
6315                    ManagedWorktreeLeaseState::Retained
6316                } else {
6317                    ManagedWorktreeLeaseState::Ready
6318                };
6319                current.updated_at_unix_ms = unix_time_ms();
6320                current.snapshot()
6321            };
6322            if self.persist_state().is_err() {
6323                self.mark_managed_recovery_required(
6324                    &lease.lease_id,
6325                    ManagedWorktreeCleanupFailure::Backend,
6326                );
6327                continue;
6328            }
6329            self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
6330            if snapshot.state == ManagedWorktreeLeaseState::Ready {
6331                let _ = self.cleanup_managed_worktree(&lease.lease_id, false).await;
6332            }
6333        }
6334    }
6335
6336    /// Crash-recovery startup sweep: durably capture the pack for every
6337    /// `Live` managed record whose most recent session has not yet produced
6338    /// one (recovers a session whose `Exited{0}` event was observed but the
6339    /// Node crashed before the export finished). The reactive fast path for
6340    /// a single just-exited record does not go through this — see
6341    /// `drive_runtime_until_shutdown`, which resolves and spawns directly so
6342    /// it can capture the exact `SessionAddress` before `publish_control`
6343    /// clears it, rather than re-deriving it from (by then likely already
6344    /// `Dormant`) record state the way a rescan naturally would.
6345    ///
6346    /// Returns as soon as every eligible record's export has been *spawned*,
6347    /// not completed — each one runs on its own detached task (see
6348    /// `reconcile_context_pack_export_for_record`'s own doc comment for why:
6349    /// `NativeRuntime::tick()` has not even started its own loop yet at the
6350    /// point this is called from `NodeServer::run()`, so nothing could ever
6351    /// drain the history round trip if this awaited it inline here either).
6352    /// Best-effort throughout: never panics, no unwrap/expect; every failure
6353    /// is a silent skip for THIS record — an unusable source or a racing
6354    /// record is not surfaced to any caller.
6355    async fn reconcile_context_pack_exports(self: &Arc<Self>) {
6356        let candidates: Vec<(SessionRecordId, SessionAddress)> = self
6357            .session_records
6358            .lock()
6359            .unwrap_or_else(|poisoned| poisoned.into_inner())
6360            .records
6361            .values()
6362            .filter(|record| {
6363                record.state == ManagedSessionState::Live
6364                    && record.exported_context.is_none()
6365                    && record.provider_session.is_some()
6366            })
6367            .filter_map(|record| {
6368                record
6369                    .active_session
6370                    .clone()
6371                    .map(|session| (record.record_id.clone(), session))
6372            })
6373            .collect();
6374        for (record_id, session) in candidates {
6375            let shared = Arc::clone(self);
6376            tokio::spawn(async move {
6377                shared
6378                    .reconcile_context_pack_export_for_record(&record_id, session)
6379                    .await;
6380            });
6381        }
6382    }
6383
6384    /// Runs on its own detached task (`tokio::spawn`, see both call sites),
6385    /// never inline on `drive_runtime_until_shutdown`'s own task: the history
6386    /// round trip this performs (`discover_history`/`load_history`, via
6387    /// `export_context_pack_for_session_record`) can only ever settle once
6388    /// `NativeRuntime::tick()` runs again to drain the authority worker's
6389    /// completion — and `tick()` is exclusively owned by, and only ever
6390    /// called from, that same drive-loop task. Awaiting this inline there
6391    /// would starve `tick()` and this call could never observe its own
6392    /// command complete.
6393    ///
6394    /// Because this now genuinely races the drive loop's own
6395    /// `publish_control` (which downgrades this exact record from `Live` to
6396    /// `Dormant`, clearing `active_session`, for the very `Exited` event that
6397    /// triggered this reconcile), the record legitimately may already be
6398    /// `Dormant` by the time this reads it, or may transition mid-flight —
6399    /// `allow_clean_detachment: true` on the downstream export call is what
6400    /// tolerates exactly that transition (and only that transition; see
6401    /// `session_record_export_target_matches`).
6402    async fn reconcile_context_pack_export_for_record(
6403        &self,
6404        record_id: &SessionRecordId,
6405        session: SessionAddress,
6406    ) {
6407        let Ok(record) = self.record(record_id) else {
6408            return;
6409        };
6410        if !matches!(
6411            record.state,
6412            ManagedSessionState::Live | ManagedSessionState::Dormant
6413        ) || record.exported_context.is_some()
6414            || record.provider_session.is_none()
6415        {
6416            return;
6417        }
6418        // Pack bytes are already durably committed inside this call, via
6419        // commit_context_pack_for_session_record's own durable
6420        // write-through (context_pack_store) — no separate step here.
6421        // Unclean exits (non-zero/forced/no loadable history) are already
6422        // rejected by context_pack_source_status_is_usable inside
6423        // materialize_context_pack: this simply returns Err and is skipped.
6424        let Ok(receipt) = self
6425            .export_context_pack_for_session_record(&record.record_id, &session, true)
6426            .await
6427        else {
6428            return;
6429        };
6430        self.set_exported_context(&record.record_id, receipt);
6431    }
6432
6433    /// Records the record's most recent clean-exit pack. No-op (not an
6434    /// error) if the record disappeared, or if it already carries an
6435    /// `exported_context` — first-write-wins, matching the harness-side
6436    /// idempotent reconciliation that consumes this field.
6437    fn set_exported_context(&self, record_id: &SessionRecordId, receipt: ResolvedContextPackReceipt) {
6438        let transaction = self
6439            .state_transaction
6440            .lock()
6441            .unwrap_or_else(|poisoned| poisoned.into_inner());
6442        let (previous, updated) = {
6443            let mut records = self
6444                .session_records
6445                .lock()
6446                .unwrap_or_else(|poisoned| poisoned.into_inner());
6447            let Some(record) = records.records.get_mut(record_id) else {
6448                return;
6449            };
6450            if record.exported_context.is_some() {
6451                return;
6452            }
6453            let previous = record.clone();
6454            record.exported_context = Some(receipt);
6455            record.updated_at_unix_ms = unix_time_ms().max(record.updated_at_unix_ms);
6456            (previous, record.clone())
6457        };
6458        if self.persist_state_locked().is_err() {
6459            self.session_records
6460                .lock()
6461                .unwrap_or_else(|poisoned| poisoned.into_inner())
6462                .records
6463                .insert(record_id.clone(), previous);
6464            return;
6465        }
6466        drop(transaction);
6467        self.publish_record(updated);
6468    }
6469
6470    /// Resolves the managed record and its exact `SessionAddress` currently
6471    /// bound to `instance_id` at exactly `generation`, used by the reactive
6472    /// `Exited{0}` hook in `drive_runtime_until_shutdown` to target a single
6473    /// record without a full rescan. Mirrors the lookup already inlined in
6474    /// `publish_control`. Captured synchronously, before `publish_control`
6475    /// for the same event clears the record's own `active_session` — the
6476    /// caller passes the resolved address through to a detached
6477    /// reconciliation task rather than re-deriving it from the record later,
6478    /// after that race has necessarily already happened.
6479    fn managed_record_export_target_for_instance(
6480        &self,
6481        instance_id: AgentInstanceId,
6482        generation: SessionGeneration,
6483    ) -> Option<(SessionRecordId, SessionAddress)> {
6484        let bindings = self
6485            .session_bindings
6486            .lock()
6487            .unwrap_or_else(|poisoned| poisoned.into_inner());
6488        let binding = bindings.get(&instance_id).filter(|binding| binding.generation == generation)?;
6489        let record_id = binding.record_id.clone()?;
6490        Some((
6491            record_id,
6492            SessionAddress {
6493                workspace_id: binding.workspace_id.clone(),
6494                session: SessionKey { instance_id, generation },
6495            },
6496        ))
6497    }
6498
6499    fn mark_managed_recovery_required(
6500        &self,
6501        lease_id: &ManagedWorktreeLeaseId,
6502        failure_kind: ManagedWorktreeCleanupFailure,
6503    ) {
6504        let snapshot = {
6505            let mut registry = self.managed_worktrees.lock()
6506                .unwrap_or_else(|poisoned| poisoned.into_inner());
6507            let Some(lease) = registry.get_mut(lease_id) else { return };
6508            lease.state = ManagedWorktreeLeaseState::RecoveryRequired;
6509            lease.cleanup_failure = Some(failure_kind);
6510            lease.updated_at_unix_ms = unix_time_ms();
6511            lease.snapshot()
6512        };
6513        let _ = self.persist_state();
6514        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
6515    }
6516
6517    fn set_persistence_error(&self, error: Option<String>) {
6518        *self
6519            .persistence_error
6520            .write()
6521            .unwrap_or_else(|poisoned| poisoned.into_inner()) = error
6522            .map(|message| session_registry::sanitized_persistence_summary(&message));
6523    }
6524
6525    fn persist_state(&self) -> io::Result<()> {
6526        let _transaction = self
6527            .state_transaction
6528            .lock()
6529            .unwrap_or_else(|poisoned| poisoned.into_inner());
6530        self.persist_state_locked()
6531    }
6532
6533    fn persist_state_locked(&self) -> io::Result<()> {
6534        let workspaces = self
6535            .workspaces
6536            .read()
6537            .unwrap_or_else(|poisoned| poisoned.into_inner())
6538            .clone();
6539        let records = self
6540            .session_records
6541            .lock()
6542            .unwrap_or_else(|poisoned| poisoned.into_inner())
6543            .records
6544            .values()
6545            .cloned()
6546            .collect::<Vec<_>>();
6547        let mut managed = self.managed_worktrees
6548            .lock()
6549            .unwrap_or_else(|poisoned| poisoned.into_inner());
6550        managed.reattach_record_holders(
6551            &managed_worktree_record_holders(&records),
6552            unix_time_ms(),
6553        );
6554        let materializations = self
6555            .materializations
6556            .lock()
6557            .unwrap_or_else(|poisoned| poisoned.into_inner())
6558            .values()
6559            .cloned()
6560            .collect::<Vec<_>>();
6561        let managed_spawn_replays = self.managed_spawn_replays
6562            .lock()
6563            .unwrap_or_else(|poisoned| poisoned.into_inner())
6564            .values()
6565            .cloned()
6566            .collect::<Vec<_>>();
6567        let result = session_registry::save_v10(
6568            self.state_path.as_deref(),
6569            &self.node_id,
6570            &workspaces,
6571            &records,
6572            &managed.records(),
6573            &managed.tombstones(),
6574            &materializations,
6575            &managed_spawn_replays,
6576        );
6577        match &result {
6578            Ok(warning) => self.set_persistence_error(warning.clone()),
6579            Err(_) => self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned())),
6580        }
6581        result.map(|_| ())
6582    }
6583
6584    async fn begin_shutdown(&self) -> Result<(), NodeFailure> {
6585        let _mutation_guard = self.mutation_gate.lock().await;
6586        self.begin_shutdown_locked().await
6587    }
6588
6589    async fn begin_shutdown_locked(&self) -> Result<(), NodeFailure> {
6590        if self.shutdown.swap(true, Ordering::AcqRel) {
6591            return Ok(());
6592        }
6593        if let Some(registry) = self.harness_mcp_registry.as_ref() {
6594            let instances = registry.shutdown();
6595            if let Some(control) = self.native_launch_profile_control.as_ref() {
6596                for instance_id in instances {
6597                    control.clear_native_mcp_server_launch_overlay(instance_id);
6598                }
6599            }
6600        }
6601        let deadline = Instant::now() + Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
6602        let mut first_error = None;
6603        for session in &self.handle.snapshot().sessions {
6604            let remaining = deadline.saturating_duration_since(Instant::now());
6605            if remaining.is_zero() {
6606                first_error.get_or_insert_with(|| {
6607                    failure(NodeFailureCode::BackendBusy, "stop-all dispatch exceeded its bounded deadline")
6608                });
6609                break;
6610            }
6611            if let Err(error) = self
6612                .dispatch_bounded(
6613                    ControlCommand::Stop {
6614                        instance_id: session.instance_id,
6615                        force: true,
6616                    },
6617                    remaining,
6618                )
6619                .await
6620            {
6621                first_error.get_or_insert(error);
6622            }
6623        }
6624        self.shutdown_notify.notify_waiters();
6625        match first_error {
6626            Some(error) => Err(error),
6627            None => Ok(()),
6628        }
6629    }
6630
6631    fn ensure_binding_capacity(&self) -> Result<(), NodeFailure> {
6632        let bindings = self
6633            .session_bindings
6634            .lock()
6635            .unwrap_or_else(|poisoned| poisoned.into_inner());
6636        if bindings.len() >= CONTROL_SESSIONS_MAX {
6637            Err(failure(
6638                NodeFailureCode::BackendBusy,
6639                "node session-binding capacity is full; remove an existing session first",
6640            ))
6641        } else {
6642            Ok(())
6643        }
6644    }
6645
6646    /// One-owner workspace/env collision guard: a live session on
6647    /// `workspace_id` with a different `environment_profile` (option
6648    /// equality on the resolved receipt, including Some vs None) blocks
6649    /// another spawn on that same cwd. Same profile may still share the
6650    /// workspace; distinct profiles need distinct workspace ids (managed
6651    /// worktree).
6652    fn ensure_workspace_environment_profile_compatible(
6653        &self,
6654        workspace_id: &WorkspaceId,
6655        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
6656    ) -> Result<(), NodeFailure> {
6657        let bindings = self
6658            .session_bindings
6659            .lock()
6660            .unwrap_or_else(|poisoned| poisoned.into_inner());
6661        for binding in bindings.values() {
6662            if &binding.workspace_id != workspace_id {
6663                continue;
6664            }
6665            if binding.environment_profile.as_ref() != environment_profile {
6666                return Err(failure(
6667                    NodeFailureCode::WorkspaceBusy,
6668                    "workspace already has a live session with a different environment profile; use a managed worktree for concurrent distinct ProviderHome/env bindings on one repo",
6669                ));
6670            }
6671        }
6672        Ok(())
6673    }
6674
6675    #[cfg(test)]
6676    fn bind_session(
6677        &self,
6678        instance_id: AgentInstanceId,
6679        workspace_id: WorkspaceId,
6680        generation: SessionGeneration,
6681    ) {
6682        self.bind_session_with_policy(
6683            &SessionAddress {
6684                workspace_id,
6685                session: SessionKey { instance_id, generation },
6686            },
6687            ProviderRuntimePolicy::raw_pty(),
6688            None,
6689        );
6690    }
6691
6692    #[cfg(test)]
6693    fn bind_session_with_policy(
6694        &self,
6695        address: &SessionAddress,
6696        runtime_policy: ProviderRuntimePolicy,
6697        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
6698    ) {
6699        self.bind_session_with_materialization(
6700            address,
6701            runtime_policy,
6702            environment_profile,
6703            None,
6704            None,
6705            None,
6706        );
6707    }
6708
6709    fn bind_session_with_materialization(
6710        &self,
6711        address: &SessionAddress,
6712        runtime_policy: ProviderRuntimePolicy,
6713        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
6714        bundle: Option<ResolvedBundleReceipt>,
6715        context: Option<ResolvedContextPackReceipt>,
6716        materialization_id: Option<MaterializationId>,
6717    ) {
6718        let mut bindings = self
6719            .session_bindings
6720            .lock()
6721            .unwrap_or_else(|poisoned| poisoned.into_inner());
6722        debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
6723        debug_assert!(!bindings.contains_key(&address.session.instance_id));
6724        bindings.insert(address.session.instance_id, SessionBinding {
6725            workspace_id: address.workspace_id.clone(),
6726            generation: address.session.generation,
6727            runtime_policy,
6728            pending_resume: None,
6729            record_id: None,
6730            managed_worktree_lease_id: None,
6731            environment_profile,
6732            bundle,
6733            context,
6734            materialization_id,
6735        });
6736    }
6737
6738    #[cfg(test)]
6739    fn bind_managed_session(
6740        &self,
6741        address: &SessionAddress,
6742        record_id: SessionRecordId,
6743        runtime_policy: ProviderRuntimePolicy,
6744        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
6745    ) {
6746        self.bind_managed_session_with_materialization(
6747            address,
6748            record_id,
6749            runtime_policy,
6750            environment_profile,
6751            None,
6752            None,
6753            None,
6754        );
6755    }
6756
6757    fn bind_managed_session_with_materialization(
6758        &self,
6759        address: &SessionAddress,
6760        record_id: SessionRecordId,
6761        runtime_policy: ProviderRuntimePolicy,
6762        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
6763        bundle: Option<ResolvedBundleReceipt>,
6764        context: Option<ResolvedContextPackReceipt>,
6765        materialization_id: Option<MaterializationId>,
6766    ) {
6767        let mut bindings = self
6768            .session_bindings
6769            .lock()
6770            .unwrap_or_else(|poisoned| poisoned.into_inner());
6771        debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
6772        debug_assert!(!bindings.contains_key(&address.session.instance_id));
6773        bindings.insert(
6774            address.session.instance_id,
6775            SessionBinding {
6776                workspace_id: address.workspace_id.clone(),
6777                generation: address.session.generation,
6778                runtime_policy,
6779                pending_resume: None,
6780                record_id: Some(record_id),
6781                managed_worktree_lease_id: None,
6782                environment_profile,
6783                bundle,
6784                context,
6785                materialization_id,
6786            },
6787        );
6788    }
6789
6790    #[cfg(test)]
6791    fn bind_spawn_session(
6792        &self,
6793        address: &SessionAddress,
6794        provider: AgentId,
6795        mode: SessionMode,
6796        runtime_policy: ProviderRuntimePolicy,
6797        record_policy: SpawnRecordPolicy,
6798        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
6799    ) -> Result<Option<SessionRecordId>, NodeFailure> {
6800        self.bind_spawn_session_with_materialization(
6801            address,
6802            provider,
6803            mode,
6804            runtime_policy,
6805            record_policy,
6806            environment_profile,
6807            None,
6808            None,
6809            None,
6810        )
6811    }
6812
6813    fn bind_spawn_session_with_materialization(
6814        &self,
6815        address: &SessionAddress,
6816        provider: AgentId,
6817        mode: SessionMode,
6818        runtime_policy: ProviderRuntimePolicy,
6819        record_policy: SpawnRecordPolicy,
6820        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
6821        bundle: Option<ResolvedBundleReceipt>,
6822        context: Option<ResolvedContextPackReceipt>,
6823        materialization_id: Option<MaterializationId>,
6824    ) -> Result<Option<SessionRecordId>, NodeFailure> {
6825        if record_policy == SpawnRecordPolicy::ProviderIdentityOnly
6826            && !runtime_policy.provider_session_identity
6827        {
6828            self.bind_session_with_materialization(
6829                address,
6830                runtime_policy,
6831                environment_profile,
6832                bundle,
6833                context,
6834                materialization_id,
6835            );
6836            return Ok(None);
6837        }
6838        let record_owns_lifecycle = runtime_policy.provider_session_identity;
6839        let record = self.new_record(
6840            address,
6841            provider,
6842            mode,
6843            active_record_state(runtime_policy.provider_session_identity, false),
6844            None,
6845            record_owns_lifecycle.then(|| environment_profile.clone()).flatten(),
6846            record_owns_lifecycle.then(|| bundle.clone()).flatten(),
6847            record_owns_lifecycle.then(|| context.clone()).flatten(),
6848        )?;
6849        let record_id = record.record_id.clone();
6850        let transaction = self
6851            .state_transaction
6852            .lock()
6853            .unwrap_or_else(|poisoned| poisoned.into_inner());
6854        self.insert_record(record.clone())?;
6855        let previous_materialization = if record_owns_lifecycle {
6856            if let Some(materialization_id) = materialization_id.as_ref() {
6857                let mut materializations = self
6858                    .materializations
6859                    .lock()
6860                    .unwrap_or_else(|poisoned| poisoned.into_inner());
6861                let Some(ownership) = materializations.get_mut(materialization_id) else {
6862                    drop(materializations);
6863                    self.remove_record_memory(&record_id);
6864                    return Err(failure(
6865                        NodeFailureCode::BackendOperationFailed,
6866                        "session environment ownership disappeared before binding",
6867                    ));
6868                };
6869                let previous = ownership.clone();
6870                if ownership
6871                    .transfer_to_record(record_id.clone(), unix_time_ms())
6872                    .is_err()
6873                {
6874                    drop(materializations);
6875                    self.remove_record_memory(&record_id);
6876                    return Err(failure(
6877                        NodeFailureCode::BackendOperationFailed,
6878                        "session environment ownership transfer failed",
6879                    ));
6880                }
6881                Some(previous)
6882            } else {
6883                None
6884            }
6885        } else {
6886            None
6887        };
6888        self.bind_managed_session_with_materialization(
6889            address,
6890            record_id.clone(),
6891            runtime_policy,
6892            environment_profile,
6893            bundle,
6894            context,
6895            materialization_id.clone(),
6896        );
6897        if let Err(error) = self.persist_state_locked() {
6898            self.remove_binding(address);
6899            self.remove_record_memory(&record_id);
6900            if let (Some(materialization_id), Some(previous)) =
6901                (materialization_id.as_ref(), previous_materialization)
6902            {
6903                self.materializations
6904                    .lock()
6905                    .unwrap_or_else(|poisoned| poisoned.into_inner())
6906                    .insert(materialization_id.clone(), previous);
6907            }
6908            return Err(persistence_failure(error));
6909        }
6910        drop(transaction);
6911        self.publish_record(record);
6912        Ok(Some(record_id))
6913    }
6914
6915    fn allocate_record_id(&self) -> Result<SessionRecordId, NodeFailure> {
6916        for _ in 0..8 {
6917            let bytes = random_nonce().map_err(|error| {
6918                failure(
6919                    NodeFailureCode::BackendOperationFailed,
6920                    &format!("session record identity generation failed: {error}"),
6921                )
6922            })?;
6923            let mut value = String::with_capacity(27);
6924            value.push_str("sr-");
6925            for byte in &bytes[..12] {
6926                use std::fmt::Write as _;
6927                let _ = write!(&mut value, "{byte:02x}");
6928            }
6929            let record_id = SessionRecordId::new(value).map_err(|error| {
6930                failure(NodeFailureCode::BackendOperationFailed, &error.to_string())
6931            })?;
6932            let records = self
6933                .session_records
6934                .lock()
6935                .unwrap_or_else(|poisoned| poisoned.into_inner());
6936            if !records.records.contains_key(&record_id) {
6937                return Ok(record_id);
6938            }
6939        }
6940        Err(failure(
6941            NodeFailureCode::BackendBusy,
6942            "could not allocate a unique session record ID",
6943        ))
6944    }
6945
6946    fn allocate_task_id(&self) -> Result<TaskId, NodeFailure> {
6947        for _ in 0..8 {
6948            let bytes = random_nonce().map_err(|error| {
6949                failure(
6950                    NodeFailureCode::BackendOperationFailed,
6951                    &format!("task identity generation failed: {error}"),
6952                )
6953            })?;
6954            let mut nonce = [0_u8; 12];
6955            nonce.copy_from_slice(&bytes[..12]);
6956            let task_id = TaskId::from_nonce(nonce);
6957            let records = self
6958                .session_records
6959                .lock()
6960                .unwrap_or_else(|poisoned| poisoned.into_inner());
6961            if !records.records.values().any(|record| {
6962                record.task_binding.as_ref().and_then(|binding| binding.task_id.as_ref())
6963                    == Some(&task_id)
6964            }) {
6965                return Ok(task_id);
6966            }
6967        }
6968        Err(failure(
6969            NodeFailureCode::BackendBusy,
6970            "could not allocate a unique task ID",
6971        ))
6972    }
6973
6974    fn default_record_name(&self, provider: &AgentId) -> String {
6975        let records = self
6976            .session_records
6977            .lock()
6978            .unwrap_or_else(|poisoned| poisoned.into_inner());
6979        let ordinal = records
6980            .records
6981            .values()
6982            .filter(|record| &record.provider == provider)
6983            .count()
6984            .saturating_add(1);
6985        format!("{} #{ordinal}", provider.as_str())
6986    }
6987
6988    fn new_record(
6989        &self,
6990        address: &SessionAddress,
6991        provider: AgentId,
6992        mode: SessionMode,
6993        state: ManagedSessionState,
6994        provider_session: Option<gate4agent_types::ProviderSessionIdentity>,
6995        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
6996        bundle: Option<ResolvedBundleReceipt>,
6997        context: Option<ResolvedContextPackReceipt>,
6998    ) -> Result<ManagedSessionRecord, NodeFailure> {
6999        let canonical_root = self.workspace_root(&address.workspace_id)?;
7000        let now = unix_time_ms();
7001        Ok(ManagedSessionRecord {
7002            record_id: self.allocate_record_id()?,
7003            display_name: self.default_record_name(&provider),
7004            provider,
7005            mode,
7006            state,
7007            workspace_id: address.workspace_id.clone(),
7008            canonical_root: opaque_windows_path(canonical_root),
7009            provider_session,
7010            active_session: Some(address.clone()),
7011            environment_profile,
7012            bundle,
7013            context_id: context.as_ref().map(|receipt| receipt.id.clone()),
7014            context,
7015            exported_context: None,
7016            task_binding: None,
7017            created_at_unix_ms: now,
7018            updated_at_unix_ms: now,
7019            last_error: None,
7020        })
7021    }
7022
7023    fn insert_record(&self, record: ManagedSessionRecord) -> Result<(), NodeFailure> {
7024        let mut records = self
7025            .session_records
7026            .lock()
7027            .unwrap_or_else(|poisoned| poisoned.into_inner());
7028        if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
7029            return Err(failure(
7030                NodeFailureCode::BackendBusy,
7031                "managed session record capacity is full; forget an inactive record first",
7032            ));
7033        }
7034        if let Some(identity) = record.provider_session.as_ref() {
7035            if records.records.values().any(|candidate| {
7036                candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
7037                    session_registry::same_provider_session(
7038                        &candidate.provider,
7039                        candidate_identity,
7040                        &record.provider,
7041                        identity,
7042                    )
7043                })
7044            }) {
7045                return Err(failure(
7046                    NodeFailureCode::SessionRecordConflict,
7047                    "provider session is already represented by another managed record",
7048                ));
7049            }
7050        }
7051        match records.records.entry(record.record_id.clone()) {
7052            std::collections::btree_map::Entry::Vacant(entry) => {
7053                entry.insert(record);
7054            }
7055            std::collections::btree_map::Entry::Occupied(_) => {
7056                return Err(failure(
7057                    NodeFailureCode::SessionRecordConflict,
7058                    "session record ID collision",
7059                ));
7060            }
7061        }
7062        Ok(())
7063    }
7064
7065    fn record(&self, record_id: &SessionRecordId) -> Result<ManagedSessionRecord, NodeFailure> {
7066        self.session_records
7067            .lock()
7068            .unwrap_or_else(|poisoned| poisoned.into_inner())
7069            .records
7070            .get(record_id)
7071            .cloned()
7072            .ok_or_else(|| {
7073                failure(
7074                    NodeFailureCode::UnknownSessionRecord,
7075                    "managed session record does not exist",
7076                )
7077            })
7078    }
7079
7080    fn revalidate_session_record_preview(
7081        &self,
7082        expected: &ManagedSessionRecord,
7083        identity: &ProviderSessionIdentity,
7084    ) -> Result<(), NodeFailure> {
7085        let current = self.record(&expected.record_id)?;
7086        let current_root = self.workspace_root(&current.workspace_id)?;
7087        if current.provider != expected.provider
7088            || current.workspace_id != expected.workspace_id
7089            || current.canonical_root != expected.canonical_root
7090            || current.provider_session.as_ref() != Some(identity)
7091            || !platform::roots_equal(
7092                &current_root,
7093                windows_path_text(&current.canonical_root),
7094            )
7095        {
7096            return Err(failure(
7097                NodeFailureCode::SessionRecordConflict,
7098                "managed session changed while its preview was loading",
7099            ));
7100        }
7101        Ok(())
7102    }
7103
7104    fn revalidate_session_record_context_export(
7105        &self,
7106        expected: &ManagedSessionRecord,
7107        identity: Option<&ProviderSessionIdentity>,
7108        session: &SessionAddress,
7109        allow_clean_detachment: bool,
7110    ) -> Result<(), NodeFailure> {
7111        let current = self.record(&expected.record_id)?;
7112        let current_root = self.workspace_root(&current.workspace_id)?;
7113        let snapshot = self.internal_session_snapshot(session)?;
7114        let binding = self
7115            .session_bindings
7116            .lock()
7117            .unwrap_or_else(|poisoned| poisoned.into_inner())
7118            .get(&session.session.instance_id)
7119            .cloned()
7120            .ok_or_else(|| {
7121                failure(
7122                    NodeFailureCode::SessionRecordConflict,
7123                    "managed session runtime binding disappeared during context export",
7124                )
7125            })?;
7126        if !session_record_context_export_binding_is_exact(
7127            expected,
7128            &current,
7129            identity,
7130            session,
7131            &binding,
7132            &snapshot.agent_id,
7133            &current_root,
7134            allow_clean_detachment,
7135        ) || !session_record_context_export_source_is_usable(
7136            &current,
7137            &snapshot.status,
7138            allow_clean_detachment,
7139        ) {
7140            return Err(failure(
7141                NodeFailureCode::SessionRecordConflict,
7142                "managed session binding changed during context export",
7143            ));
7144        }
7145        Ok(())
7146    }
7147
7148    fn revalidate_loaded_history(
7149        &self,
7150        session: &SessionAddress,
7151        candidate_id: &str,
7152        loaded: &HistorySessionRecord,
7153    ) -> Result<(), NodeFailure> {
7154        let snapshot = self.internal_session_snapshot(session)?;
7155        if snapshot.history.pending.is_some()
7156            || snapshot.history.loaded_candidate_id.as_deref() != Some(candidate_id)
7157            || snapshot.history.loaded.as_ref() != Some(loaded)
7158        {
7159            return Err(failure(
7160                NodeFailureCode::SessionRecordConflict,
7161                "managed session history changed during context export",
7162            ));
7163        }
7164        Ok(())
7165    }
7166
7167    fn remove_record_memory(&self, record_id: &SessionRecordId) -> Option<ManagedSessionRecord> {
7168        self.session_records
7169            .lock()
7170            .unwrap_or_else(|poisoned| poisoned.into_inner())
7171            .records
7172            .remove(record_id)
7173    }
7174
7175    fn discard_record(&self, record_id: &SessionRecordId) -> Result<(), NodeFailure> {
7176        if self.destroy_record_materialization(record_id)? {
7177            self.publish(NodeEvent::SessionRecordRemoved {
7178                record_id: record_id.clone(),
7179            });
7180            return Ok(());
7181        }
7182        let transaction = self
7183            .state_transaction
7184            .lock()
7185            .unwrap_or_else(|poisoned| poisoned.into_inner());
7186        let Some(removed) = self.remove_record_memory(record_id) else {
7187            return Ok(());
7188        };
7189        if let Err(error) = self.persist_state_locked() {
7190            let _ = self.insert_record(removed);
7191            return Err(persistence_failure(error));
7192        }
7193        drop(transaction);
7194        self.publish(NodeEvent::SessionRecordRemoved {
7195            record_id: record_id.clone(),
7196        });
7197        Ok(())
7198    }
7199
7200    fn publish_record(&self, record: ManagedSessionRecord) {
7201        self.publish(NodeEvent::SessionRecordUpserted { record });
7202    }
7203
7204    fn mark_record_error(
7205        &self,
7206        record_id: &SessionRecordId,
7207        message: &str,
7208    ) -> Result<(), NodeFailure> {
7209        let transaction = self
7210            .state_transaction
7211            .lock()
7212            .unwrap_or_else(|poisoned| poisoned.into_inner());
7213        let previous = {
7214            let mut records = self
7215                .session_records
7216                .lock()
7217                .unwrap_or_else(|poisoned| poisoned.into_inner());
7218            let Some(record) = records.records.get_mut(record_id) else {
7219                return Ok(());
7220            };
7221            let previous = record.clone();
7222            record.state = ManagedSessionState::Unavailable;
7223            record.last_error = Some(session_registry::sanitized_record_error_summary(message));
7224            record.updated_at_unix_ms = unix_time_ms();
7225            previous
7226        };
7227        if let Err(error) = self.persist_state_locked() {
7228            self.session_records
7229                .lock()
7230                .unwrap_or_else(|poisoned| poisoned.into_inner())
7231                .records
7232                .insert(record_id.clone(), previous);
7233            return Err(persistence_failure(error));
7234        }
7235        let record = self.record(record_id)?;
7236        drop(transaction);
7237        self.publish_record(record);
7238        Ok(())
7239    }
7240
7241    fn rename_session_record(
7242        &self,
7243        record_id: &SessionRecordId,
7244        display_name: String,
7245    ) -> Result<ManagedSessionRecord, NodeFailure> {
7246        validate_display_name(&display_name)
7247            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
7248        let _transaction = self
7249            .state_transaction
7250            .lock()
7251            .unwrap_or_else(|poisoned| poisoned.into_inner());
7252        let previous = {
7253            let mut records = self
7254                .session_records
7255                .lock()
7256                .unwrap_or_else(|poisoned| poisoned.into_inner());
7257            let record = records.records.get_mut(record_id).ok_or_else(|| {
7258                failure(
7259                    NodeFailureCode::UnknownSessionRecord,
7260                    "managed session record does not exist",
7261                )
7262            })?;
7263            let previous = record.clone();
7264            record.display_name = display_name;
7265            record.updated_at_unix_ms = unix_time_ms();
7266            previous
7267        };
7268        if let Err(error) = self.persist_state_locked() {
7269            self.session_records
7270                .lock()
7271                .unwrap_or_else(|poisoned| poisoned.into_inner())
7272                .records
7273                .insert(record_id.clone(), previous);
7274            return Err(persistence_failure(error));
7275        }
7276        let updated = self.record(record_id)?;
7277        self.publish_record(updated.clone());
7278        Ok(updated)
7279    }
7280
7281    fn set_session_task(
7282        &self,
7283        record_id: &SessionRecordId,
7284        expected_revision: u64,
7285        target: SessionTaskTargetV1,
7286    ) -> Result<ManagedSessionRecord, NodeFailure> {
7287        let transaction = self
7288            .state_transaction
7289            .lock()
7290            .unwrap_or_else(|poisoned| poisoned.into_inner());
7291        let current = self.record(record_id)?;
7292        let current_task_id = current
7293            .task_binding
7294            .as_ref()
7295            .and_then(|binding| binding.task_id.as_ref());
7296        let current_revision = current
7297            .task_binding
7298            .as_ref()
7299            .map_or(0, |binding| binding.revision);
7300        if expected_revision != current_revision {
7301            return Err(failure(
7302                NodeFailureCode::SessionRecordConflict,
7303                "managed session task binding revision changed",
7304            ));
7305        }
7306        let exact = match &target {
7307            SessionTaskTargetV1::Existing { task_id } => current_task_id == Some(task_id),
7308            SessionTaskTargetV1::Clear => current_task_id.is_none(),
7309            SessionTaskTargetV1::New => false,
7310        };
7311        if exact {
7312            return Ok(current);
7313        }
7314        let revision = current_revision.checked_add(1).ok_or_else(|| {
7315            failure(
7316                NodeFailureCode::SessionRecordConflict,
7317                "managed session task binding revision is exhausted",
7318            )
7319        })?;
7320        let task_id = match target {
7321            SessionTaskTargetV1::New => Some(self.allocate_task_id()?),
7322            SessionTaskTargetV1::Existing { task_id } => Some(task_id),
7323            SessionTaskTargetV1::Clear => None,
7324        };
7325        let changed_at_unix_ms = unix_time_ms()
7326            .max(current.updated_at_unix_ms.checked_add(1).ok_or_else(|| {
7327                failure(
7328                    NodeFailureCode::SessionRecordConflict,
7329                    "managed session record timestamp is exhausted",
7330                )
7331            })?);
7332        let updated = {
7333            let mut records = self
7334                .session_records
7335                .lock()
7336                .unwrap_or_else(|poisoned| poisoned.into_inner());
7337            let record = records.records.get_mut(record_id).ok_or_else(|| {
7338                failure(
7339                    NodeFailureCode::UnknownSessionRecord,
7340                    "managed session record does not exist",
7341                )
7342            })?;
7343            record.task_binding = Some(SessionTaskBindingV1 {
7344                revision,
7345                task_id,
7346                changed_at_unix_ms,
7347            });
7348            record.updated_at_unix_ms = changed_at_unix_ms;
7349            record.clone()
7350        };
7351        if let Err(error) = self.persist_state_locked() {
7352            self.session_records
7353                .lock()
7354                .unwrap_or_else(|poisoned| poisoned.into_inner())
7355                .records
7356                .insert(record_id.clone(), current);
7357            return Err(persistence_failure(error));
7358        }
7359        drop(transaction);
7360        self.publish_record(updated.clone());
7361        Ok(updated)
7362    }
7363
7364    fn index_provider_session(
7365        &self,
7366        workspace_id: WorkspaceId,
7367        provider: AgentId,
7368        identity: ProviderSessionIdentity,
7369        display_name: String,
7370    ) -> Result<ManagedSessionRecord, NodeFailure> {
7371        self.index_provider_session_with_policy(
7372            workspace_id,
7373            provider,
7374            identity,
7375            display_name,
7376            false,
7377        )
7378    }
7379
7380    fn index_provider_session_with_policy(
7381        &self,
7382        workspace_id: WorkspaceId,
7383        provider: AgentId,
7384        identity: ProviderSessionIdentity,
7385        display_name: String,
7386        allow_validated_transcript_path: bool,
7387    ) -> Result<ManagedSessionRecord, NodeFailure> {
7388        validate_display_name(&display_name)
7389            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
7390        identity
7391            .validate()
7392            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
7393        if identity.transcript_path.is_some() && !allow_validated_transcript_path {
7394            return Err(failure(
7395                NodeFailureCode::InvalidRequest,
7396                "provider session reference index v1 does not accept transcript paths",
7397            ));
7398        }
7399        let canonical_root = self.workspace_root(&workspace_id)?;
7400        if !self.enabled_providers.contains(&provider) {
7401            return Err(failure(
7402                NodeFailureCode::InvalidRequest,
7403                "provider is not enabled on this node",
7404            ));
7405        }
7406
7407        let transaction = self
7408            .state_transaction
7409            .lock()
7410            .unwrap_or_else(|poisoned| poisoned.into_inner());
7411        {
7412            let records = self
7413                .session_records
7414                .lock()
7415                .unwrap_or_else(|poisoned| poisoned.into_inner());
7416            if let Some(existing) = records.records.values().find(|candidate| {
7417                candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
7418                    candidate_identity.key == identity.key && candidate_identity.id == identity.id
7419                })
7420            }) {
7421                if existing.provider == provider && existing.workspace_id == workspace_id {
7422                    return Ok(existing.clone());
7423                }
7424                return Err(failure(
7425                    NodeFailureCode::SessionRecordConflict,
7426                    "provider session reference conflicts with another provider or workspace",
7427                ));
7428            }
7429            if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
7430                return Err(failure(
7431                    NodeFailureCode::BackendBusy,
7432                    "managed session record capacity is full; forget an inactive record first",
7433                ));
7434            }
7435        }
7436
7437        let now = unix_time_ms();
7438        let record = ManagedSessionRecord {
7439            record_id: self.allocate_record_id()?,
7440            display_name,
7441            provider,
7442            mode: SessionMode::Pty,
7443            state: ManagedSessionState::Dormant,
7444            workspace_id,
7445            canonical_root: opaque_windows_path(canonical_root),
7446            provider_session: Some(identity),
7447            active_session: None,
7448            environment_profile: None,
7449            bundle: None,
7450            context_id: None,
7451            context: None,
7452            exported_context: None,
7453            task_binding: None,
7454            created_at_unix_ms: now,
7455            updated_at_unix_ms: now,
7456            last_error: None,
7457        };
7458        self.insert_record(record.clone())?;
7459        if let Err(error) = self.persist_state_locked() {
7460            self.remove_record_memory(&record.record_id);
7461            return Err(persistence_failure(error));
7462        }
7463        drop(transaction);
7464        self.publish_record(record.clone());
7465        Ok(record)
7466    }
7467
7468    fn bound_address_for_record(&self, record_id: &SessionRecordId) -> Option<SessionAddress> {
7469        self.session_bindings
7470            .lock()
7471            .unwrap_or_else(|poisoned| poisoned.into_inner())
7472            .iter()
7473            .find_map(|(instance_id, binding)| {
7474                (binding.record_id.as_ref() == Some(record_id)).then(|| SessionAddress {
7475                    workspace_id: binding.workspace_id.clone(),
7476                    session: SessionKey {
7477                        instance_id: *instance_id,
7478                        generation: binding.generation,
7479                    },
7480                })
7481            })
7482    }
7483
7484    async fn forget_session_record(
7485        &self,
7486        record_id: &SessionRecordId,
7487    ) -> Result<(), NodeFailure> {
7488        let record = self.record(record_id)?;
7489        if record.active_session.is_some()
7490            || matches!(
7491                record.state,
7492                ManagedSessionState::Live | ManagedSessionState::IdentityPending
7493            )
7494        {
7495            return Err(failure(
7496                NodeFailureCode::SessionRecordBusy,
7497                "stop the managed session before forgetting it",
7498            ));
7499        }
7500        if let Some(address) = self.bound_address_for_record(record_id) {
7501            self.remove_session(&address).await?;
7502        }
7503        self.remove_managed_session_record(record_id, &record.workspace_id).await
7504    }
7505
7506    /// Removes one managed session record from durable state and notifies
7507    /// every listener -- the shared transaction body behind both
7508    /// `forget_session_record`'s operator-driven path (busy-checked above
7509    /// it, and clears any still-live session binding before calling in
7510    /// here) and the retention sweep (`retire_unavailable_session_records`,
7511    /// which only ever selects `ManagedSessionState::Unavailable` records
7512    /// that are already known never-busy and never bound, so it calls
7513    /// straight into this with neither check). Cleans a managed-worktree
7514    /// lease if the record held one, removes the record's environment
7515    /// materialization (or, absent one, removes it from memory directly),
7516    /// persists under `state_transaction` with rollback on persist failure,
7517    /// then publishes `NodeEvent::SessionRecordRemoved` so c2's own
7518    /// legacy-provider bookkeeping (`record_is_legacy`) stays in sync
7519    /// exactly as it does for an operator-driven forget.
7520    async fn remove_managed_session_record(
7521        &self,
7522        record_id: &SessionRecordId,
7523        workspace_id: &WorkspaceId,
7524    ) -> Result<(), NodeFailure> {
7525        let managed_lease_id = self.managed_worktrees.lock()
7526            .unwrap_or_else(|poisoned| poisoned.into_inner())
7527            .lease_for_workspace(workspace_id);
7528        if !self.destroy_record_materialization(record_id)? {
7529            let transaction = self
7530                .state_transaction
7531                .lock()
7532                .unwrap_or_else(|poisoned| poisoned.into_inner());
7533            let removed = self.remove_record_memory(record_id).ok_or_else(|| {
7534                failure(
7535                    NodeFailureCode::UnknownSessionRecord,
7536                    "managed session record does not exist",
7537                )
7538            })?;
7539            if let Err(error) = self.persist_state_locked() {
7540                let _ = self.insert_record(removed);
7541                return Err(persistence_failure(error));
7542            }
7543            drop(transaction);
7544        }
7545        self.publish(NodeEvent::SessionRecordRemoved {
7546            record_id: record_id.clone(),
7547        });
7548        if let Some(lease_id) = managed_lease_id {
7549            if let Some(snapshot) = self.managed_worktrees.lock()
7550                .unwrap_or_else(|poisoned| poisoned.into_inner())
7551                .get(&lease_id)
7552                .map(ManagedWorktreeLeaseRecord::snapshot)
7553            {
7554                self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
7555            }
7556            let _ = self.cleanup_managed_worktree(&lease_id, false).await;
7557        }
7558        Ok(())
7559    }
7560
7561    /// Retires `ManagedSessionState::Unavailable` records against the
7562    /// node's configured `session_record_retention` age/keep-N policy.
7563    /// Never touches `Live`, `IdentityPending`, or `Dormant` records --
7564    /// `Dormant` is resumable and folding it into this sweep would delete
7565    /// records a caller can still act on. Both policy fields default to
7566    /// `0` (disabled); when both are `0` this is a no-op. When both are
7567    /// set, a record is retired only if it fails BOTH tests (older than
7568    /// `age_ms` AND beyond the newest `keep_per_workspace` in its
7569    /// workspace); when only one is set, that one alone decides. Called
7570    /// once at startup (alongside `reconcile_managed_worktrees`) and then
7571    /// on a `SESSION_RECORD_RETENTION_SWEEP_PERIOD` cadence from the drive
7572    /// loop -- see both call sites.
7573    async fn retire_unavailable_session_records(&self) {
7574        let policy = self.session_record_retention;
7575        let age_enabled = policy.age_ms > 0;
7576        let keep_enabled = policy.keep_per_workspace > 0;
7577        if !age_enabled && !keep_enabled {
7578            return;
7579        }
7580        let now = unix_time_ms();
7581        let candidates: Vec<(SessionRecordId, WorkspaceId, AgentId, u64)> = {
7582            self.session_records
7583                .lock()
7584                .unwrap_or_else(|poisoned| poisoned.into_inner())
7585                .records
7586                .values()
7587                .filter(|record| record.state == ManagedSessionState::Unavailable)
7588                .map(|record| {
7589                    (
7590                        record.record_id.clone(),
7591                        record.workspace_id.clone(),
7592                        record.provider.clone(),
7593                        record.updated_at_unix_ms,
7594                    )
7595                })
7596                .collect()
7597        };
7598        if candidates.is_empty() {
7599            return;
7600        }
7601        let mut by_workspace: BTreeMap<WorkspaceId, Vec<(SessionRecordId, AgentId, u64)>> =
7602            BTreeMap::new();
7603        for (record_id, workspace_id, provider, updated_at_unix_ms) in candidates {
7604            by_workspace
7605                .entry(workspace_id)
7606                .or_default()
7607                .push((record_id, provider, updated_at_unix_ms));
7608        }
7609        let reason = if age_enabled && keep_enabled {
7610            "age+keep-n"
7611        } else if age_enabled {
7612            "age"
7613        } else {
7614            "keep-n"
7615        };
7616        let mut retired: u64 = 0;
7617        for (workspace_id, mut records) in by_workspace {
7618            // Newest `updated_at_unix_ms` first, so index `>=
7619            // keep_per_workspace` names exactly "beyond the newest N".
7620            records.sort_by(|left, right| right.2.cmp(&left.2));
7621            for (index, (record_id, provider, updated_at_unix_ms)) in
7622                records.into_iter().enumerate()
7623            {
7624                let record_age_ms = now.saturating_sub(updated_at_unix_ms);
7625                let age_hit = age_enabled && record_age_ms >= policy.age_ms;
7626                let keep_hit = keep_enabled && index >= policy.keep_per_workspace as usize;
7627                let should_retire = if age_enabled && keep_enabled {
7628                    age_hit && keep_hit
7629                } else {
7630                    age_hit || keep_hit
7631                };
7632                if !should_retire {
7633                    continue;
7634                }
7635                match self.remove_managed_session_record(&record_id, &workspace_id).await {
7636                    Ok(()) => {
7637                        self.retired_records_total.fetch_add(1, Ordering::Relaxed);
7638                        retired += 1;
7639                        tracing::info!(
7640                            node_id = %self.node_id,
7641                            record_id = %record_id,
7642                            workspace_id = %workspace_id,
7643                            provider = %provider,
7644                            age_ms = record_age_ms,
7645                            reason,
7646                            "managed session record retired"
7647                        );
7648                    }
7649                    Err(error) => tracing::warn!(
7650                        node_id = %self.node_id,
7651                        record_id = %record_id,
7652                        workspace_id = %workspace_id,
7653                        code = ?error.code,
7654                        cause = %error.message,
7655                        "session record retention sweep failed to retire a record"
7656                    ),
7657                }
7658            }
7659        }
7660        if retired > 0 {
7661            let remaining_unavailable = self
7662                .session_records
7663                .lock()
7664                .unwrap_or_else(|poisoned| poisoned.into_inner())
7665                .records
7666                .values()
7667                .filter(|record| record.state == ManagedSessionState::Unavailable)
7668                .count();
7669            tracing::info!(
7670                node_id = %self.node_id,
7671                retired,
7672                remaining_unavailable,
7673                "session record retention sweep completed"
7674            );
7675        }
7676    }
7677
7678    async fn resume_session_record(
7679        &self,
7680        record_id: &SessionRecordId,
7681        terminal_size: gate4agent_types::TerminalSize,
7682        initial_prompt: Option<String>,
7683    ) -> Result<(ManagedSessionRecord, SessionAddress), NodeFailure> {
7684        let record = self.record(record_id)?;
7685        if record.active_session.is_some()
7686            || matches!(
7687                record.state,
7688                ManagedSessionState::Live | ManagedSessionState::IdentityPending
7689            )
7690        {
7691            return Err(failure(
7692                NodeFailureCode::SessionRecordBusy,
7693                "managed session already has a live runtime binding",
7694            ));
7695        }
7696        let identity = record.provider_session.clone().ok_or_else(|| {
7697            failure(
7698                NodeFailureCode::SessionRecordNotResumable,
7699                "managed session has no verified provider session identity",
7700            )
7701        })?;
7702        let working_directory = self.workspace_root(&record.workspace_id)?;
7703        if !platform::roots_equal(
7704            &working_directory,
7705            windows_path_text(&record.canonical_root),
7706        ) {
7707            return Err(failure(
7708                NodeFailureCode::SessionRecordConflict,
7709                "managed session workspace root changed; refusing to resume in another directory",
7710            ));
7711        }
7712        {
7713            let records = self
7714                .session_records
7715                .lock()
7716                .unwrap_or_else(|poisoned| poisoned.into_inner());
7717            if records.records.values().any(|candidate| {
7718                candidate.record_id != *record_id
7719                    && candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
7720                        session_registry::same_provider_session(
7721                            &candidate.provider,
7722                            candidate_identity,
7723                            &record.provider,
7724                            &identity,
7725                        )
7726                    })
7727                    && (candidate.active_session.is_some()
7728                        || matches!(
7729                            candidate.state,
7730                            ManagedSessionState::Live | ManagedSessionState::IdentityPending
7731                        ))
7732            }) {
7733                return Err(failure(
7734                    NodeFailureCode::SessionRecordConflict,
7735                    "provider session is already active through another managed record",
7736                ));
7737            }
7738        }
7739        let runtime_requirement = if initial_prompt.is_some() {
7740            ProviderRuntimeRequirement::ResumeWithPrompt
7741        } else {
7742            ProviderRuntimeRequirement::Resume
7743        };
7744        let runtime_policy = self.admit_provider_runtime(
7745            &record.provider,
7746            runtime_requirement,
7747        )
7748        .await?;
7749        let request = ResumeLaunchRequest {
7750            working_directory,
7751            terminal_size,
7752            initial_prompt,
7753        };
7754        request
7755            .validate()
7756            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
7757        self.ensure_binding_capacity()?;
7758        let resolved_materialization = match self.resolve_record_materialization(
7759            record_id,
7760            &record.provider,
7761            record.mode,
7762            record.environment_profile.as_ref(),
7763            record.bundle.as_ref(),
7764            record.context.as_ref(),
7765        ) {
7766            Ok(materialization) => materialization,
7767            Err(error) => {
7768                let label = match error.code {
7769                    NodeFailureCode::UnknownBundle => "bundle-unavailable",
7770                    NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
7771                    NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
7772                    _ => "environment-profile-unavailable",
7773                };
7774                self.mark_record_error(record_id, label)?;
7775                return Err(error);
7776            }
7777        };
7778        let (materialization_id, environment_overlay) = match resolved_materialization {
7779            Some((id, overlay)) => (Some(id), overlay),
7780            None => (None, None),
7781        };
7782        let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
7783        let mut environment_selection = match self.select_environment_profile(
7784            instance_id,
7785            &record.provider,
7786            record.mode,
7787            record.environment_profile.as_ref(),
7788        ) {
7789            Ok(selection) => selection,
7790            Err(error) => {
7791                self.mark_record_error(record_id, "environment-profile-unavailable")?;
7792                return Err(error);
7793            }
7794        };
7795        let mut instance_overlay = environment_overlay
7796            .map(|overlay| self.install_prepared_launch_overlay(instance_id, overlay))
7797            .transpose()?
7798            .flatten();
7799        if let Some(stale_address) = self.bound_address_for_record(record_id) {
7800            self.remove_session(&stale_address).await?;
7801        }
7802        let address = SessionAddress {
7803            workspace_id: record.workspace_id.clone(),
7804            session: SessionKey {
7805                instance_id,
7806                generation: SessionGeneration(1),
7807            },
7808        };
7809        let transaction = self
7810            .state_transaction
7811            .lock()
7812            .unwrap_or_else(|poisoned| poisoned.into_inner());
7813        let current = self.record(record_id)?;
7814        if current.active_session.is_some()
7815            || matches!(
7816                current.state,
7817                ManagedSessionState::Live | ManagedSessionState::IdentityPending
7818            )
7819        {
7820            return Err(failure(
7821                NodeFailureCode::SessionRecordBusy,
7822                "managed session became active while resume was being prepared",
7823            ));
7824        }
7825        self.bind_managed_session_with_materialization(
7826            &address,
7827            record_id.clone(),
7828            runtime_policy,
7829            record.environment_profile.clone(),
7830            record.bundle.clone(),
7831            record.context.clone(),
7832            materialization_id,
7833        );
7834        if let Some(selection) = environment_selection.take() {
7835            selection.retain();
7836        }
7837        if let Some(overlay) = instance_overlay.take() {
7838            overlay.retain();
7839        }
7840        drop(transaction);
7841
7842        let transport = match record.mode {
7843            SessionMode::Pty => TransportKind::Pty,
7844            SessionMode::Inline => TransportKind::Pipe,
7845            SessionMode::Acp => TransportKind::Acp,
7846        };
7847        let agent_id = record.provider.clone();
7848        let dispatch_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
7849        if let Err(error) = self
7850            .dispatch_bounded(
7851                ControlCommand::Register {
7852                    instance_id,
7853                    agent_id,
7854                    transport,
7855                },
7856                dispatch_timeout,
7857            )
7858            .await
7859        {
7860            if let Some(binding) = self.remove_binding(&address) {
7861                self.cleanup_session_owned_materialization(&address, &binding)?;
7862            }
7863            return Err(error);
7864        }
7865        let command = self.prepare_command(ControlCommand::Resume {
7866            instance_id,
7867            target: ResumeTarget::ProviderSession { identity },
7868            runtime_policy,
7869            request,
7870        });
7871        let started_after = self.current_sequence();
7872        if let Err(error) = self.arm_resume(&address, command.id, runtime_policy) {
7873            self.rollback_spawn(&address, dispatch_timeout).await?;
7874            return Err(error);
7875        }
7876        if let Err(error) = self.dispatch_envelope(command) {
7877            self.clear_armed_resume(&address);
7878            self.rollback_spawn(&address, dispatch_timeout).await?;
7879            return Err(error);
7880        }
7881        match self
7882            .wait_for_record_resume(&address, started_after)
7883            .await
7884        {
7885            Ok(settled_address) => {
7886                let managed_lease_id = {
7887                    self.managed_worktrees.lock()
7888                        .unwrap_or_else(|poisoned| poisoned.into_inner())
7889                        .lease_for_workspace(&record.workspace_id)
7890                };
7891                if let Some(lease_id) = managed_lease_id {
7892                    {
7893                        let mut bindings = self.session_bindings.lock()
7894                            .unwrap_or_else(|poisoned| poisoned.into_inner());
7895                        let binding = bindings.get_mut(&settled_address.session.instance_id)
7896                            .ok_or_else(|| failure(
7897                                NodeFailureCode::ManagedWorktreeRecoveryRequired,
7898                                "resumed managed session lost its exact binding",
7899                            ))?;
7900                        if binding.generation != settled_address.session.generation {
7901                            return Err(failure(
7902                                NodeFailureCode::ManagedWorktreeRecoveryRequired,
7903                                "resumed managed session generation diverged",
7904                            ));
7905                        }
7906                        binding.managed_worktree_lease_id = Some(lease_id.clone());
7907                    }
7908                    let snapshot = self.managed_worktrees.lock()
7909                        .unwrap_or_else(|poisoned| poisoned.into_inner())
7910                        .bind_session(
7911                            &lease_id,
7912                            ManagedWorktreeSessionHolder {
7913                                incarnation_id: self.incarnation_id,
7914                                instance_id: settled_address.session.instance_id,
7915                                generation: settled_address.session.generation,
7916                            },
7917                            Some(record_id.clone()),
7918                            unix_time_ms(),
7919                        ).map_err(|message| failure(
7920                            NodeFailureCode::ManagedWorktreeRecoveryRequired,
7921                            &message,
7922                        ))?;
7923                    if let Err(error) = self.persist_state() {
7924                        let _ = self.remove_session(&settled_address).await;
7925                        return Err(persistence_failure(error));
7926                    }
7927                    self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
7928                }
7929                Ok((self.record(record_id)?, settled_address))
7930            }
7931            Err(error) => {
7932                if matches!(
7933                    error.code,
7934                    NodeFailureCode::SessionRecordNotResumable
7935                        | NodeFailureCode::BackendOperationFailed
7936                ) {
7937                    if let Some(bound) = self.bound_address_for_record(record_id) {
7938                        let _ = self.remove_session(&bound).await;
7939                    }
7940                }
7941                Err(error)
7942            }
7943        }
7944    }
7945
7946    async fn wait_for_record_resume(
7947        &self,
7948        initial_address: &SessionAddress,
7949        after_sequence: u64,
7950    ) -> Result<SessionAddress, NodeFailure> {
7951        let deadline = Instant::now()
7952            + Duration::from_millis(MANAGED_RESUME_SETTLE_TIMEOUT_MS);
7953        let mut scan_after = after_sequence;
7954        loop {
7955            let events = self
7956                .history
7957                .lock()
7958                .unwrap_or_else(|poisoned| poisoned.into_inner())
7959                .events
7960                .iter()
7961                .filter(|event| event.sequence > scan_after)
7962                .cloned()
7963                .collect::<Vec<_>>();
7964            for envelope in events {
7965                scan_after = scan_after.max(envelope.sequence);
7966                let NodeEvent::Control { address, event } = envelope.event else {
7967                    continue;
7968                };
7969                if address.session.instance_id != initial_address.session.instance_id {
7970                    continue;
7971                }
7972                match event.event {
7973                    ControlEventKind::Resumed { .. } => return Ok(address),
7974                    ControlEventKind::ResumeDenied { reason } => {
7975                        return Err(failure(
7976                            NodeFailureCode::SessionRecordNotResumable,
7977                            &reason,
7978                        ));
7979                    }
7980                    ControlEventKind::ResumeFailed { message }
7981                    | ControlEventKind::CommandRejected { message } => {
7982                        return Err(failure(
7983                            NodeFailureCode::BackendOperationFailed,
7984                            &message,
7985                        ));
7986                    }
7987                    _ => {}
7988                }
7989            }
7990            if Instant::now() >= deadline {
7991                return Err(failure(
7992                    NodeFailureCode::BackendBusy,
7993                    "managed resume did not settle before the bounded deadline",
7994                ));
7995            }
7996            sleep(Duration::from_millis(5)).await;
7997        }
7998    }
7999
8000    fn workspace_root(&self, workspace_id: &WorkspaceId) -> Result<String, NodeFailure> {
8001        self.workspaces
8002            .read()
8003            .unwrap_or_else(|poisoned| poisoned.into_inner())
8004            .get(workspace_id)
8005            .cloned()
8006            .ok_or_else(|| failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"))
8007    }
8008
8009    fn workspace_roots(&self) -> Vec<PathBuf> {
8010        self.workspaces
8011            .read()
8012            .unwrap_or_else(|poisoned| poisoned.into_inner())
8013            .values()
8014            .map(PathBuf::from)
8015            .collect()
8016    }
8017
8018    async fn inspect_workspace(
8019        &self,
8020        workspace_id: WorkspaceId,
8021    ) -> Result<WorkspaceInspection, NodeFailure> {
8022        let _permit = self
8023            .inspection_slots
8024            .clone()
8025            .try_acquire_owned()
8026            .map_err(|_| {
8027                tracing::warn!(
8028                    workspace_id = %workspace_id,
8029                    "workspace inspection rejected: inspection capacity is busy",
8030                );
8031                failure(
8032                    NodeFailureCode::BackendBusy,
8033                    "workspace inspection capacity is busy",
8034                )
8035            })?;
8036        let canonical_root = self.workspace_root(&workspace_id).map_err(|error| {
8037            tracing::warn!(
8038                workspace_id = %workspace_id,
8039                code = ?error.code,
8040                "workspace inspection rejected: workspace is not registered",
8041            );
8042            error
8043        })?;
8044        let tree_root = canonical_root.clone();
8045        let started_at = Instant::now();
8046        let time_budget_ms = workspace_inspection_time_budget_ms();
8047        let entry_cap = workspace_inspection_entry_cap();
8048        let deadline = started_at + Duration::from_millis(time_budget_ms);
8049        let (entries, tree_truncated, walk_budget) = tokio::task::spawn_blocking(move || {
8050            collect_workspace_entries(Path::new(&tree_root), deadline, entry_cap)
8051        })
8052        .await
8053        .map_err(|error| {
8054            tracing::warn!(
8055                workspace_id = %workspace_id,
8056                error = %error,
8057                "workspace inspection rejected: walk task failed",
8058            );
8059            failure(
8060                NodeFailureCode::BackendOperationFailed,
8061                &format!("workspace inspection task failed: {error}"),
8062            )
8063        })?;
8064        let (mut git, git_time_budget_exceeded) =
8065            inspect_git_workspace(&canonical_root, deadline).await;
8066        if !git.worktrees.is_empty() {
8067            let registered = self
8068                .workspaces
8069                .read()
8070                .unwrap_or_else(|poisoned| poisoned.into_inner())
8071                .iter()
8072                .map(|(workspace_id, root)| (workspace_id.clone(), root.clone()))
8073                .collect::<Vec<_>>();
8074            for worktree in &mut git.worktrees {
8075                worktree.workspace_id = registered
8076                    .iter()
8077                    .find(|(_, root)| {
8078                        worktree_paths_equal(root, windows_path_text(&worktree.path))
8079                    })
8080                    .map(|(workspace_id, _)| workspace_id.clone());
8081            }
8082        }
8083        if git.is_repository {
8084            git.managed_worktree = self
8085                .managed_worktrees
8086                .lock()
8087                .unwrap_or_else(|poisoned| poisoned.into_inner())
8088                .git_scope(&workspace_id, git.branch.as_deref());
8089        }
8090        let truncation = workspace_inspection_truncation(
8091            &walk_budget,
8092            git_time_budget_exceeded,
8093            started_at.elapsed(),
8094        );
8095        if let Some(truncation) = &truncation {
8096            tracing::info!(
8097                workspace_id = %workspace_id,
8098                walk_time_budget_exceeded = truncation.walk_time_budget_exceeded,
8099                walk_entry_cap_exceeded = truncation.walk_entry_cap_exceeded,
8100                git_time_budget_exceeded = truncation.git_time_budget_exceeded,
8101                entries_visited = truncation.entries_visited,
8102                elapsed_ms = truncation.elapsed_ms,
8103                "workspace inspection truncated by its inner budget",
8104            );
8105        }
8106        Ok(WorkspaceInspection {
8107            workspace_id,
8108            entries,
8109            tree_truncated,
8110            git,
8111            truncation,
8112        })
8113    }
8114
8115    async fn browse_host_directories(
8116        &self,
8117        directory: Option<OpaqueHostPath>,
8118        after: Option<OpaqueHostPath>,
8119    ) -> Result<HostDirectoryListing, NodeFailure> {
8120        let permit = self
8121            .inspection_slots
8122            .clone()
8123            .try_acquire_owned()
8124            .map_err(|_| {
8125                failure(
8126                    NodeFailureCode::BackendBusy,
8127                    "host directory browse capacity is busy",
8128                )
8129            })?;
8130        let task = tokio::task::spawn_blocking(move || {
8131            let _permit = permit;
8132            browse_host_directories(directory, after)
8133        });
8134        timeout(Duration::from_millis(HOST_DIRECTORY_BROWSE_TIMEOUT_MS), task)
8135            .await
8136            .map_err(|_| {
8137                failure(
8138                    NodeFailureCode::HostDirectoryReadTimedOut,
8139                    "host directory browse exceeded its bounded deadline",
8140                )
8141            })?
8142            .map_err(|_| {
8143                failure(
8144                    NodeFailureCode::HostDirectoryReadFailed,
8145                    "host directory browse task failed",
8146                )
8147            })?
8148            .map_err(host_directory_failure)
8149    }
8150
8151    async fn read_workspace_file(
8152        &self,
8153        workspace_id: WorkspaceId,
8154        path: RepositoryPath,
8155    ) -> Result<WorkspaceFileRead, NodeFailure> {
8156        let canonical_root = self.workspace_root(&workspace_id)?;
8157        let permit = self
8158            .inspection_slots
8159            .clone()
8160            .try_acquire_owned()
8161            .map_err(|_| {
8162                failure(
8163                    NodeFailureCode::BackendBusy,
8164                    "workspace file read capacity is busy",
8165                )
8166            })?;
8167        let path_to_read = path.clone();
8168        let task = tokio::task::spawn_blocking(move || {
8169            let _permit = permit;
8170            read_workspace_file_from_disk(Path::new(&canonical_root), &path_to_read)
8171        });
8172        let content = timeout(
8173            Duration::from_millis(WORKSPACE_FILE_READ_TIMEOUT_MS),
8174            task,
8175        )
8176        .await
8177        .map_err(|_| {
8178            failure(
8179                NodeFailureCode::RepositoryFileReadTimedOut,
8180                "repository file read exceeded its bounded deadline",
8181            )
8182        })?
8183        .map_err(|_| {
8184            failure(
8185                NodeFailureCode::RepositoryFileReadFailed,
8186                "repository file read task failed",
8187            )
8188        })?
8189        .map_err(workspace_file_failure)?;
8190        let content = match content {
8191            WorkspaceFileBytes::Utf8(text) => {
8192                let revision = Some(workspace_file_revision(text.as_bytes()));
8193                return Ok(WorkspaceFileRead {
8194                    workspace_id,
8195                    path,
8196                    content: WorkspaceFileContent::Utf8 {
8197                        byte_len: u32::try_from(text.len())
8198                            .expect("bounded workspace text length must fit u32"),
8199                        text,
8200                    },
8201                    revision,
8202                });
8203            }
8204            WorkspaceFileBytes::NonUtf8 { byte_length } => {
8205                WorkspaceFileContent::NonUtf8 {
8206                    byte_len: u32::try_from(byte_length)
8207                        .expect("bounded workspace file length must fit u32"),
8208                }
8209            }
8210            WorkspaceFileBytes::TooLarge => WorkspaceFileContent::TooLarge {
8211                limit_bytes: MAX_WORKSPACE_FILE_BYTES as u32,
8212            },
8213        };
8214        Ok(WorkspaceFileRead {
8215            workspace_id,
8216            path,
8217            content,
8218            revision: None,
8219        })
8220    }
8221
8222    async fn write_workspace_file(
8223        &self,
8224        workspace_id: WorkspaceId,
8225        path: RepositoryPath,
8226        expected_revision: WorkspaceFileRevision,
8227        text: String,
8228    ) -> Result<WorkspaceFileRead, NodeFailure> {
8229        let canonical_root = self.workspace_root(&workspace_id)?;
8230        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8231            failure(NodeFailureCode::BackendBusy, "workspace file write capacity is busy")
8232        })?;
8233        let path_to_write = path.clone();
8234        let expected = expected_revision.as_str().to_owned();
8235        let text_to_write = text.clone();
8236        let task = tokio::task::spawn_blocking(move || {
8237            let _permit = permit;
8238            write_workspace_file_to_disk(
8239                Path::new(&canonical_root),
8240                &path_to_write,
8241                &expected,
8242                &text_to_write,
8243            )
8244        });
8245        let revision = timeout(Duration::from_millis(WORKSPACE_FILE_WRITE_TIMEOUT_MS), task)
8246            .await
8247            .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteTimedOut, "repository file write exceeded its bounded deadline"))?
8248            .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteFailed, "repository file write task failed"))?
8249            .map_err(workspace_file_write_failure)?;
8250        Ok(WorkspaceFileRead {
8251            workspace_id,
8252            path,
8253            content: WorkspaceFileContent::Utf8 {
8254                byte_len: u32::try_from(text.len()).expect("bounded workspace text length must fit u32"),
8255                text,
8256            },
8257            revision: Some(WorkspaceFileRevision::new(revision)
8258                .expect("workspace writer returns a valid SHA-256 revision")),
8259        })
8260    }
8261
8262    async fn create_workspace_file(
8263        &self,
8264        workspace_id: WorkspaceId,
8265        path: RepositoryPath,
8266    ) -> Result<WorkspaceFileRead, NodeFailure> {
8267        let canonical_root = self.workspace_root(&workspace_id)?;
8268        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8269            failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
8270        })?;
8271        let path_to_create = path.clone();
8272        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
8273        let task_commit_state = Arc::clone(&commit_state);
8274        let task = tokio::task::spawn_blocking(move || {
8275            let _permit = permit;
8276            create_workspace_file_on_disk(
8277                Path::new(&canonical_root),
8278                &path_to_create,
8279                &task_commit_state,
8280            )
8281        });
8282        let revision = settle_workspace_entry_create(
8283            task,
8284            commit_state,
8285            Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
8286            "repository file create exceeded its bounded deadline",
8287            "repository file create task failed",
8288        )
8289        .await?;
8290        Ok(WorkspaceFileRead {
8291            workspace_id,
8292            path,
8293            content: WorkspaceFileContent::Utf8 {
8294                text: String::new(),
8295                byte_len: 0,
8296            },
8297            revision: Some(WorkspaceFileRevision::new(revision)
8298                .expect("workspace creator returns a valid SHA-256 revision")),
8299        })
8300    }
8301
8302    async fn create_workspace_directory(
8303        &self,
8304        workspace_id: WorkspaceId,
8305        path: RepositoryPath,
8306    ) -> Result<WorkspaceEntry, NodeFailure> {
8307        let canonical_root = self.workspace_root(&workspace_id)?;
8308        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8309            failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
8310        })?;
8311        let path_to_create = path.clone();
8312        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
8313        let task_commit_state = Arc::clone(&commit_state);
8314        let task = tokio::task::spawn_blocking(move || {
8315            let _permit = permit;
8316            create_workspace_directory_on_disk(
8317                Path::new(&canonical_root),
8318                &path_to_create,
8319                &task_commit_state,
8320            )
8321        });
8322        settle_workspace_entry_create(
8323            task,
8324            commit_state,
8325            Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
8326            "repository directory create exceeded its bounded deadline",
8327            "repository directory create task failed",
8328        )
8329        .await?;
8330        Ok(WorkspaceEntry {
8331            relative_path: path,
8332            kind: WorkspaceEntryKind::Directory,
8333        })
8334    }
8335
8336    async fn read_git_history(
8337        &self,
8338        workspace_id: WorkspaceId,
8339        path: Option<RepositoryPath>,
8340        before: Option<GitObjectId>,
8341        limit: u16,
8342    ) -> Result<GitHistoryPage, NodeFailure> {
8343        let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8344            failure(NodeFailureCode::BackendBusy, "git history capacity is busy")
8345        })?;
8346        let root = self.workspace_root(&workspace_id)?;
8347        read_git_history_bounded(&root, path.as_ref(), before.as_ref(), limit).await
8348    }
8349
8350    async fn read_git_diff(
8351        &self,
8352        workspace_id: WorkspaceId,
8353        request: GitDiffRequest,
8354    ) -> Result<GitDiff, NodeFailure> {
8355        let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8356            failure(NodeFailureCode::BackendBusy, "git diff capacity is busy")
8357        })?;
8358        let root = self.workspace_root(&workspace_id)?;
8359        read_git_diff_bounded(&root, request).await
8360    }
8361
8362    fn native_session_route_root(
8363        &self,
8364        route: &NativeSessionCatalogRoute,
8365    ) -> Result<Option<String>, NodeFailure> {
8366        route
8367            .validate()
8368            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
8369        match route.scope {
8370            gate4agent_types::NativeSessionCatalogScope::Workspace => route
8371                .workspace_id
8372                .as_ref()
8373                .map(|workspace_id| self.workspace_root(workspace_id))
8374                .transpose(),
8375            gate4agent_types::NativeSessionCatalogScope::Unregistered => Ok(None),
8376        }
8377    }
8378
8379    fn project_native_session_entries(
8380        &self,
8381        route: &NativeSessionCatalogRoute,
8382        entries: Vec<ScopedNativeSessionCatalogEntry>,
8383    ) -> Vec<crate::protocol::NativeSessionCatalogEntry> {
8384        let current_root = route
8385            .workspace_id
8386            .as_ref()
8387            .and_then(|workspace_id| self.workspace_root(workspace_id).ok());
8388        let records = self
8389            .session_records
8390            .lock()
8391            .unwrap_or_else(|poisoned| poisoned.into_inner());
8392        let mut seen_records = std::collections::HashSet::new();
8393        entries
8394            .into_iter()
8395            .map(|entry| {
8396                let record_id = route.workspace_id.as_ref().and_then(|workspace_id| {
8397                    let matches = records
8398                        .records
8399                        .values()
8400                        .filter(|record| {
8401                            record.provider == route.provider
8402                                && record.workspace_id == *workspace_id
8403                                && current_root.as_deref().is_some_and(|root| {
8404                                    platform::roots_equal(
8405                                        root,
8406                                        windows_path_text(&record.canonical_root),
8407                                    )
8408                                })
8409                                && record.provider_session.as_ref().is_some_and(|identity| {
8410                                    session_registry::same_provider_session(
8411                                        &record.provider,
8412                                        identity,
8413                                        &route.provider,
8414                                        &entry.provider_session,
8415                                    )
8416                                })
8417                        })
8418                        .map(|record| record.record_id.clone())
8419                        .collect::<Vec<_>>();
8420                    (matches.len() == 1
8421                        && seen_records.insert(matches[0].clone()))
8422                        .then(|| matches[0].clone())
8423                });
8424                let metadata = entry.metadata;
8425                crate::protocol::NativeSessionCatalogEntry {
8426                    selection_id: metadata.selection_id,
8427                    title: metadata.title,
8428                    modified_at_unix_ms: metadata.modified_at_unix_ms,
8429                    model: metadata.model,
8430                    message_count: metadata.message_count,
8431                    completed_turn_count: metadata.completed_turn_count,
8432                    external_group: entry.external_group,
8433                    record_id,
8434                }
8435            })
8436            .collect()
8437    }
8438
8439    async fn catalog_native_sessions(
8440        &self,
8441        route: NativeSessionCatalogRoute,
8442        limit: u16,
8443    ) -> Result<(
8444        Vec<crate::protocol::NativeSessionCatalogEntry>,
8445        crate::protocol::NativeSessionCatalogSummary,
8446    ), NodeFailure> {
8447        let canonical_root = self.native_session_route_root(&route)?;
8448        let registered_workspace_roots = self.workspace_roots();
8449        let provider = route.provider.clone();
8450        let scope = route.scope;
8451        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
8452            failure(
8453                NodeFailureCode::InvalidRequest,
8454                "native session catalog is not configured",
8455            )
8456        })?;
8457        let _permit = Arc::clone(&self.inspection_slots)
8458            .try_acquire_owned()
8459            .map_err(|_| {
8460                failure(
8461                    NodeFailureCode::BackendBusy,
8462                    "native session catalog capacity is busy",
8463                )
8464            })?;
8465        let page = run_native_session_catalog_operation(
8466            catalog,
8467            "native session catalog",
8468            move |catalog| {
8469                catalog.catalog_initial_for_scope(
8470                    &provider,
8471                    scope,
8472                    canonical_root.as_deref().map(Path::new),
8473                    &registered_workspace_roots,
8474                    limit,
8475                )
8476            },
8477        )
8478        .await?;
8479        let summary = crate::protocol::NativeSessionCatalogSummary {
8480            catalog_revision: page.revision,
8481            recent_cutoff_unix_ms: page.cutoff_unix_ms,
8482            recent_total_count: page.recent_total_count,
8483            older_total_count: page.older_total_count,
8484            recent_next_after_selection_id: page.next_after_selection_id,
8485            recent_has_more: page.remaining_count > 0,
8486        };
8487        Ok((self.project_native_session_entries(&route, page.entries), summary))
8488    }
8489
8490    async fn page_native_sessions(
8491        &self,
8492        route: NativeSessionCatalogRoute,
8493        window: crate::protocol::NativeSessionCatalogWindow,
8494        catalog_revision: u64,
8495        recent_cutoff_unix_ms: u64,
8496        after_selection_id: Option<String>,
8497        limit: u16,
8498    ) -> Result<crate::protocol::NativeSessionCatalogPage, NodeFailure> {
8499        let canonical_root = self.native_session_route_root(&route)?;
8500        let registered_workspace_roots = self.workspace_roots();
8501        let provider = route.provider.clone();
8502        let scope = route.scope;
8503        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
8504            failure(
8505                NodeFailureCode::InvalidRequest,
8506                "native session catalog is not configured",
8507            )
8508        })?;
8509        let _permit = Arc::clone(&self.inspection_slots)
8510            .try_acquire_owned()
8511            .map_err(|_| {
8512                failure(
8513                    NodeFailureCode::BackendBusy,
8514                    "native session catalog capacity is busy",
8515                )
8516            })?;
8517        let page = run_native_session_catalog_operation(
8518            catalog,
8519            "native session catalog page",
8520            move |catalog| {
8521                Ok::<_, std::convert::Infallible>(catalog.catalog_page_for_scope(
8522                    &provider,
8523                    scope,
8524                    canonical_root.as_deref().map(Path::new),
8525                    &registered_workspace_roots,
8526                    window,
8527                    catalog_revision,
8528                    recent_cutoff_unix_ms,
8529                    after_selection_id.as_deref(),
8530                    limit,
8531                ))
8532            },
8533        )
8534        .await?;
8535        let page = page.map_err(|error| match error {
8536            NativeSessionCatalogError::StaleCatalog => failure(
8537                NodeFailureCode::StaleNativeSessionCatalog,
8538                "native session catalog revision or cursor is stale",
8539            ),
8540            _ => failure(
8541                NodeFailureCode::BackendOperationFailed,
8542                "native session catalog page failed",
8543            ),
8544        })?;
8545        let entries = self.project_native_session_entries(&route, page.entries);
8546        Ok(crate::protocol::NativeSessionCatalogPage {
8547            window: page.window,
8548            revision: page.revision,
8549            entries,
8550            next_after_selection_id: page.next_after_selection_id,
8551            remaining_count: page.remaining_count,
8552            has_more: page.remaining_count > 0,
8553        })
8554    }
8555
8556    async fn preview_native_session(
8557        &self,
8558        selection: NativeSessionSelection,
8559        message_limit: u16,
8560    ) -> Result<crate::protocol::NativeSessionPreview, NodeFailure> {
8561        selection
8562            .validate()
8563            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
8564        let canonical_root = self.native_session_route_root(&selection.route)?;
8565        let registered_workspace_roots = self.workspace_roots();
8566        let provider = selection.route.provider.clone();
8567        let scope = selection.route.scope;
8568        let catalog_revision = selection.catalog_revision;
8569        let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
8570        let selection_id = selection.selection_id.clone();
8571        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
8572            failure(
8573                NodeFailureCode::InvalidRequest,
8574                "native session preview is not configured",
8575            )
8576        })?;
8577        let _permit = Arc::clone(&self.inspection_slots)
8578            .try_acquire_owned()
8579            .map_err(|_| {
8580                failure(
8581                    NodeFailureCode::BackendBusy,
8582                    "native session preview capacity is busy",
8583                )
8584            })?;
8585        let preview = run_native_session_catalog_operation(
8586            catalog,
8587            "native session preview",
8588            move |catalog| {
8589                Ok::<_, std::convert::Infallible>(catalog.preview_for_scope(
8590                    &provider,
8591                    scope,
8592                    canonical_root.as_deref().map(Path::new),
8593                    &registered_workspace_roots,
8594                    catalog_revision,
8595                    recent_cutoff_unix_ms,
8596                    &selection_id,
8597                    message_limit,
8598                ))
8599            },
8600        )
8601        .await?;
8602        preview
8603            .map(Into::into)
8604            .map_err(native_session_preview_failure)
8605    }
8606
8607    async fn index_native_session(
8608        &self,
8609        selection: NativeSessionSelection,
8610        display_name: String,
8611    ) -> Result<ManagedSessionRecord, NodeFailure> {
8612        selection
8613            .validate()
8614            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
8615        if selection.route.scope
8616            != gate4agent_types::NativeSessionCatalogScope::Workspace
8617        {
8618            return Err(failure(
8619                NodeFailureCode::WorkspaceRegistrationRequired,
8620                "register the external project as a workspace before indexing or resuming it",
8621            ));
8622        }
8623        let workspace_id = selection
8624            .route
8625            .workspace_id
8626            .clone()
8627            .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
8628        let canonical_root = self
8629            .native_session_route_root(&selection.route)?
8630            .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
8631        let registered_workspace_roots = self.workspace_roots();
8632        let provider = selection.route.provider.clone();
8633        let catalog_revision = selection.catalog_revision;
8634        let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
8635        let selection_id = selection.selection_id.clone();
8636        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
8637            failure(
8638                NodeFailureCode::InvalidRequest,
8639                "native session index is not configured",
8640            )
8641        })?;
8642        let _permit = Arc::clone(&self.inspection_slots)
8643            .try_acquire_owned()
8644            .map_err(|_| {
8645                failure(
8646                    NodeFailureCode::BackendBusy,
8647                    "native session index capacity is busy",
8648                )
8649            })?;
8650        let resolved = run_native_session_catalog_operation(
8651            catalog,
8652            "native session index resolution",
8653            move |catalog| {
8654                Ok::<_, std::convert::Infallible>(catalog.resolve_selection_for_scope(
8655                    &provider,
8656                    gate4agent_types::NativeSessionCatalogScope::Workspace,
8657                    Some(Path::new(&canonical_root)),
8658                    &registered_workspace_roots,
8659                    catalog_revision,
8660                    recent_cutoff_unix_ms,
8661                    &selection_id,
8662                ))
8663            },
8664        )
8665        .await?
8666        .map_err(native_session_preview_failure)?;
8667        self.index_provider_session_with_policy(
8668            workspace_id,
8669            selection.route.provider,
8670            resolved.identity,
8671            display_name,
8672            true,
8673        )
8674    }
8675
8676    async fn preview_native_session_by_session_id(
8677        &self,
8678        workspace_id: WorkspaceId,
8679        provider: AgentId,
8680        session_id: String,
8681        message_limit: u16,
8682    ) -> Result<gate4agent_types::NativeSessionPreview, NodeFailure> {
8683        let canonical_root = self.workspace_root(&workspace_id)?;
8684        let registered_workspace_roots = self.workspace_roots();
8685        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
8686            failure(
8687                NodeFailureCode::InvalidRequest,
8688                "native session preview is not configured",
8689            )
8690        })?;
8691        let _permit = Arc::clone(&self.inspection_slots)
8692            .try_acquire_owned()
8693            .map_err(|_| {
8694                failure(
8695                    NodeFailureCode::BackendBusy,
8696                    "native session preview capacity is busy",
8697                )
8698            })?;
8699        run_native_session_catalog_operation(
8700            catalog,
8701            "native session preview",
8702            move |catalog| {
8703                catalog.preview_session_id_for_workspace(
8704                    &provider,
8705                    Path::new(&canonical_root),
8706                    &registered_workspace_roots,
8707                    &session_id,
8708                    message_limit,
8709                )
8710            },
8711        )
8712        .await
8713    }
8714
8715    async fn register_workspace(
8716        &self,
8717        workspace_id: WorkspaceId,
8718        root: String,
8719    ) -> Result<WorkspaceSnapshot, NodeFailure> {
8720        validate_workspace_request_root(&root)?;
8721        let canonicalize_workspace_id = workspace_id.clone();
8722        let workspace = tokio::task::spawn_blocking(move || {
8723            WorkspaceConfig::new(canonicalize_workspace_id, root)
8724        })
8725        .await
8726        .map_err(|error| {
8727            failure(
8728                NodeFailureCode::BackendOperationFailed,
8729                &format!("workspace canonicalization task failed: {error}"),
8730            )
8731        })?
8732        .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
8733        let transaction = self
8734            .state_transaction
8735            .lock()
8736            .unwrap_or_else(|poisoned| poisoned.into_inner());
8737        let mut workspaces = self
8738            .workspaces
8739            .write()
8740            .unwrap_or_else(|poisoned| poisoned.into_inner());
8741        if workspaces.contains_key(&workspace_id) {
8742            return Err(failure(
8743                NodeFailureCode::DuplicateWorkspaceId,
8744                "workspace ID is already registered",
8745            ));
8746        }
8747        if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
8748            platform::roots_equal(existing_root, workspace.canonical_root())
8749        }) {
8750            return Err(failure(
8751                NodeFailureCode::DuplicateWorkspaceRoot,
8752                &format!("workspace root is already registered as '{existing_id}'"),
8753            ));
8754        }
8755        {
8756            let records = self
8757                .session_records
8758                .lock()
8759                .unwrap_or_else(|poisoned| poisoned.into_inner());
8760            if let Some(record) = records.records.values().find(|record| {
8761                record.workspace_id == workspace_id
8762                    && !record
8763                        .canonical_root
8764                        .as_utf8()
8765                        .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
8766            }) {
8767                return Err(failure(
8768                    NodeFailureCode::SessionRecordConflict,
8769                    &format!(
8770                        "workspace ID is retained by session record '{}' at another root",
8771                        record.record_id,
8772                    ),
8773                ));
8774            }
8775        }
8776        let snapshot = WorkspaceSnapshot {
8777            workspace_id: workspace_id.clone(),
8778            canonical_root: opaque_windows_path(workspace.canonical_root().to_owned()),
8779            sessions: Vec::new(),
8780            worktree_service_mode: Some(WorktreeServiceMode::Manual),
8781            managed_worktree_profiles: Some(WorktreeProfileInventory {
8782                profiles: Vec::new(),
8783            }),
8784        };
8785        workspaces.insert(workspace_id.clone(), workspace.canonical_root().to_owned());
8786        drop(workspaces);
8787        let previous_records = {
8788            let mut records = self
8789                .session_records
8790                .lock()
8791                .unwrap_or_else(|poisoned| poisoned.into_inner());
8792            let previous = records.records.clone();
8793            for record in records.records.values_mut().filter(|record| {
8794                record.workspace_id == workspace_id
8795                    && record
8796                        .canonical_root
8797                        .as_utf8()
8798                        .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
8799            }) {
8800                if record.active_session.is_none() {
8801                    record.state = detached_record_state(record);
8802                }
8803                if record.last_error.as_deref() == Some(WORKSPACE_UNAVAILABLE_ERROR) {
8804                    record.last_error = None;
8805                }
8806                record.updated_at_unix_ms = unix_time_ms();
8807            }
8808            previous
8809        };
8810        if let Err(error) = self.persist_state_locked() {
8811            self.workspaces
8812                .write()
8813                .unwrap_or_else(|poisoned| poisoned.into_inner())
8814                .remove(&workspace_id);
8815            self.session_records
8816                .lock()
8817                .unwrap_or_else(|poisoned| poisoned.into_inner())
8818                .records = previous_records;
8819            return Err(persistence_failure(error));
8820        }
8821        let restored = self
8822            .session_records
8823            .lock()
8824            .unwrap_or_else(|poisoned| poisoned.into_inner())
8825            .records
8826            .values()
8827            .filter(|record| record.workspace_id == workspace_id)
8828            .cloned()
8829            .collect::<Vec<_>>();
8830        drop(transaction);
8831        self.publish(NodeEvent::WorkspaceAdded {
8832            workspace: snapshot.clone(),
8833        });
8834        for record in restored {
8835            self.publish_record(record);
8836        }
8837        Ok(snapshot)
8838    }
8839
8840    async fn create_standalone_workspace(
8841        &self,
8842        workspace_id: WorkspaceId,
8843        root: String,
8844        initial_branch: Option<String>,
8845    ) -> Result<WorkspaceSnapshot, NodeFailure> {
8846        validate_workspace_request_root(&root)?;
8847        validate_git_revision("initial branch", initial_branch.as_deref())?;
8848        let preflight_workspace_id = workspace_id.clone();
8849        let preflight_root = root.clone();
8850        let canonical_candidate = tokio::task::spawn_blocking(move || {
8851            canonical_standalone_workspace_candidate(&preflight_workspace_id, &preflight_root)
8852        })
8853        .await
8854        .map_err(|_| failure(
8855            NodeFailureCode::BackendOperationFailed,
8856            "standalone workspace root preflight task failed",
8857        ))?
8858        .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
8859        {
8860            let workspaces = self
8861                .workspaces
8862                .read()
8863                .unwrap_or_else(|poisoned| poisoned.into_inner());
8864            if workspaces.contains_key(&workspace_id) {
8865                return Err(failure(
8866                    NodeFailureCode::DuplicateWorkspaceId,
8867                    "workspace ID is already registered",
8868                ));
8869            }
8870            if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
8871                platform::roots_equal(existing_root, &canonical_candidate)
8872            }) {
8873                return Err(failure(
8874                    NodeFailureCode::DuplicateWorkspaceRoot,
8875                    &format!("workspace root is already registered as '{existing_id}'"),
8876                ));
8877            }
8878        }
8879        let prepared = prepare_standalone_workspace(root, initial_branch.as_deref())
8880            .await
8881            .map_err(standalone_workspace_failure)?;
8882        prepared
8883            .verify_for_registration()
8884            .await
8885            .map_err(standalone_workspace_failure)?;
8886        match self
8887            .register_workspace(workspace_id, prepared.root().to_owned())
8888            .await
8889        {
8890            Ok(workspace) => Ok(workspace),
8891            Err(registration_error) => {
8892                if prepared.compensate_registration_failure().await {
8893                    Err(registration_error)
8894                } else {
8895                    Err(failure(
8896                        NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
8897                        "standalone workspace initialization succeeded but registration failed; recovery is required",
8898                    ))
8899                }
8900            }
8901        }
8902    }
8903
8904    fn unregister_workspace(&self, workspace_id: &WorkspaceId) -> Result<(), NodeFailure> {
8905        let bindings = self
8906            .session_bindings
8907            .lock()
8908            .unwrap_or_else(|poisoned| poisoned.into_inner());
8909        if bindings.values().any(|binding| &binding.workspace_id == workspace_id) {
8910            return Err(failure(
8911                NodeFailureCode::WorkspaceBusy,
8912                "workspace retains a bound session; remove every session before unregistering it",
8913            ));
8914        }
8915        drop(bindings);
8916        let transaction = self
8917            .state_transaction
8918            .lock()
8919            .unwrap_or_else(|poisoned| poisoned.into_inner());
8920        let mut workspaces = self
8921            .workspaces
8922            .write()
8923            .unwrap_or_else(|poisoned| poisoned.into_inner());
8924        if !workspaces.contains_key(workspace_id) {
8925            return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
8926        }
8927        if workspaces.len() == 1 {
8928            return Err(failure(
8929                NodeFailureCode::LastWorkspace,
8930                "node must retain at least one workspace",
8931            ));
8932        }
8933        let removed_root = workspaces
8934            .remove(workspace_id)
8935            .expect("workspace presence was validated");
8936        drop(workspaces);
8937        let previous_records = {
8938            let mut records = self
8939                .session_records
8940                .lock()
8941                .unwrap_or_else(|poisoned| poisoned.into_inner());
8942            let previous = records.records.clone();
8943            for record in records
8944                .records
8945                .values_mut()
8946                .filter(|record| &record.workspace_id == workspace_id)
8947            {
8948                record.active_session = None;
8949                record.state = ManagedSessionState::Unavailable;
8950                record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
8951                record.updated_at_unix_ms = unix_time_ms();
8952            }
8953            previous
8954        };
8955        if let Err(error) = self.persist_state_locked() {
8956            self.workspaces
8957                .write()
8958                .unwrap_or_else(|poisoned| poisoned.into_inner())
8959                .insert(workspace_id.clone(), removed_root);
8960            self.session_records
8961                .lock()
8962                .unwrap_or_else(|poisoned| poisoned.into_inner())
8963                .records = previous_records;
8964            return Err(persistence_failure(error));
8965        }
8966        let unavailable = self
8967            .session_records
8968            .lock()
8969            .unwrap_or_else(|poisoned| poisoned.into_inner())
8970            .records
8971            .values()
8972            .filter(|record| &record.workspace_id == workspace_id)
8973            .cloned()
8974            .collect::<Vec<_>>();
8975        drop(transaction);
8976        self.publish(NodeEvent::WorkspaceRemoved {
8977            workspace_id: workspace_id.clone(),
8978        });
8979        for record in unavailable {
8980            self.publish_record(record);
8981        }
8982        Ok(())
8983    }
8984
8985    async fn create_worktree(
8986        &self,
8987        source_workspace_id: WorkspaceId,
8988        workspace_id: WorkspaceId,
8989        target_root: String,
8990        branch: String,
8991        base: Option<String>,
8992    ) -> Result<(GitWorktreeSnapshot, WorkspaceSnapshot), NodeFailure> {
8993        self.require_worktree_service(&source_workspace_id)?;
8994        validate_workspace_request_root(&target_root)?;
8995        validate_git_revision("worktree base", base.as_deref())?;
8996        let source_root = self.workspace_root(&source_workspace_id)?;
8997        {
8998            let workspaces = self
8999                .workspaces
9000                .read()
9001                .unwrap_or_else(|poisoned| poisoned.into_inner());
9002            if workspaces.contains_key(&workspace_id) {
9003                return Err(failure(
9004                    NodeFailureCode::DuplicateWorkspaceId,
9005                    "workspace ID is already registered",
9006                ));
9007            }
9008            if let Some((existing_id, _)) = workspaces
9009                .iter()
9010                .find(|(_, root)| worktree_paths_equal(root, &target_root))
9011            {
9012                return Err(failure(
9013                    NodeFailureCode::DuplicateWorkspaceRoot,
9014                    &format!("workspace root is already registered as '{existing_id}'"),
9015                ));
9016            }
9017        }
9018        let mut worktree = create_git_worktree(
9019            &source_root,
9020            &target_root,
9021            &branch,
9022            base.as_deref(),
9023        )
9024        .await
9025        .map_err(git_worktree_failure)?;
9026        let workspace = match self
9027            .register_workspace(workspace_id, worktree.path.clone())
9028            .await
9029        {
9030            Ok(workspace) => workspace,
9031            Err(registration_error) => {
9032                if remove_git_worktree(&source_root, &worktree.path).await.is_err() {
9033                    return Err(failure(
9034                        NodeFailureCode::ManagedWorktreeRecoveryRequired,
9035                        "worktree registration failed and compensation requires recovery",
9036                    ));
9037                }
9038                return Err(failure(
9039                    registration_error.code,
9040                    &registration_error.message,
9041                ));
9042            }
9043        };
9044        worktree.workspace_id = Some(workspace.workspace_id.clone());
9045        Ok((protocol_worktree(worktree), workspace))
9046    }
9047
9048    async fn remove_worktree(
9049        &self,
9050        source_workspace_id: WorkspaceId,
9051        target_root: String,
9052    ) -> Result<Option<WorkspaceId>, NodeFailure> {
9053        self.require_worktree_service(&source_workspace_id)?;
9054        validate_workspace_request_root(&target_root)?;
9055        let source_root = self.workspace_root(&source_workspace_id)?;
9056        let target_root = normalize_worktree_removal_target(&target_root)
9057            .map_err(git_worktree_failure)?;
9058        let target = list_git_worktrees(&source_root)
9059            .await
9060            .map_err(git_worktree_failure)?
9061            .into_iter()
9062            .find(|worktree| worktree_paths_equal(&worktree.path, &target_root))
9063            .ok_or_else(|| {
9064                failure(
9065                    NodeFailureCode::WorktreeProtected,
9066                    "refusing to remove a path that is not in Git's worktree listing",
9067                )
9068            })?;
9069        let registered_workspace_id = {
9070            let workspaces = self
9071                .workspaces
9072                .read()
9073                .unwrap_or_else(|poisoned| poisoned.into_inner());
9074            let matched = workspaces
9075                .iter()
9076                .find(|(_, root)| worktree_paths_equal(root, &target.path))
9077                .map(|(workspace_id, _)| workspace_id.clone());
9078            if matched.is_some() && workspaces.len() == 1 {
9079                return Err(failure(
9080                    NodeFailureCode::LastWorkspace,
9081                    "node must retain at least one workspace",
9082                ));
9083            }
9084            matched
9085        };
9086        if let Some(workspace_id) = registered_workspace_id.as_ref() {
9087            let bindings = self
9088                .session_bindings
9089                .lock()
9090                .unwrap_or_else(|poisoned| poisoned.into_inner());
9091            if bindings
9092                .values()
9093                .any(|binding| &binding.workspace_id == workspace_id)
9094            {
9095                return Err(failure(
9096                    NodeFailureCode::WorkspaceBusy,
9097                    "worktree workspace retains a bound session; remove every session first",
9098                ));
9099            }
9100        }
9101        remove_git_worktree(&source_root, &target.path)
9102            .await
9103            .map_err(git_worktree_failure)?;
9104        if let Some(workspace_id) = registered_workspace_id.as_ref() {
9105            self.unregister_workspace(workspace_id)
9106                .map_err(|error| failure(error.code, &error.message))?;
9107        }
9108        Ok(registered_workspace_id)
9109    }
9110
9111    async fn cleanup_managed_worktree(
9112        &self,
9113        lease_id: &ManagedWorktreeLeaseId,
9114        explicit: bool,
9115    ) -> Result<ManagedWorktreeLeaseSnapshot, NodeFailure> {
9116        let lease = self.managed_worktrees.lock()
9117            .unwrap_or_else(|poisoned| poisoned.into_inner())
9118            .get(lease_id)
9119            .cloned()
9120            .ok_or_else(|| failure(
9121                NodeFailureCode::UnknownManagedWorktreeLease,
9122                "managed worktree lease does not exist",
9123            ))?;
9124        if lease.state == ManagedWorktreeLeaseState::Removed {
9125            return Ok(lease.snapshot());
9126        }
9127        if lease.has_holders() {
9128            return Err(failure(
9129                NodeFailureCode::ManagedWorktreeBusy,
9130                "managed worktree lease still has a session or durable-record holder",
9131            ));
9132        }
9133        if self
9134            .materializations
9135            .lock()
9136            .unwrap_or_else(|poisoned| poisoned.into_inner())
9137            .values()
9138            .any(|record| record.managed_lease_id() == Some(lease_id))
9139        {
9140            return Err(failure(
9141                NodeFailureCode::ManagedWorktreeRecoveryRequired,
9142                "managed worktree cleanup is blocked by session environment ownership",
9143            ));
9144        }
9145        if !explicit && lease.retention == ManagedWorktreeRetention::Retain {
9146            let snapshot = {
9147                let mut registry = self.managed_worktrees.lock()
9148                    .unwrap_or_else(|poisoned| poisoned.into_inner());
9149                let current = registry.get_mut(lease_id)
9150                    .expect("managed worktree lease remains present");
9151                current.state = ManagedWorktreeLeaseState::Retained;
9152                current.cleanup_failure = None;
9153                current.updated_at_unix_ms = unix_time_ms();
9154                current.snapshot()
9155            };
9156            if self.persist_state().is_err() {
9157                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
9158            }
9159            self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
9160            return Ok(snapshot);
9161        }
9162        let profile = self.managed_profile(&lease.source_workspace_id, &lease.profile_id)?;
9163        if profile.revision() != &lease.profile_revision {
9164            self.mark_managed_recovery_required(
9165                lease_id,
9166                ManagedWorktreeCleanupFailure::OwnershipConflict,
9167            );
9168            return Err(failure(
9169                NodeFailureCode::ManagedWorktreeRecoveryRequired,
9170                "managed worktree profile revision changed",
9171            ));
9172        }
9173        profile.validate_target_authority(&lease.lease_id, &lease.target_root).map_err(|message| {
9174            self.mark_managed_recovery_required(
9175                lease_id,
9176                ManagedWorktreeCleanupFailure::OwnershipConflict,
9177            );
9178            failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message)
9179        })?;
9180        let source_root = self.workspace_root(&lease.source_workspace_id)?;
9181        let listed = list_git_worktrees(&source_root).await.map_err(|error| {
9182            self.mark_managed_cleanup_blocked(lease_id, ManagedWorktreeCleanupFailure::Backend);
9183            managed_git_worktree_failure(error)
9184        })?;
9185        let target = listed.iter()
9186            .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
9187        if let Some(target) = target {
9188            if target.is_main || target.is_bare || !exact_owned_worktree(
9189                &lease,
9190                &target.path,
9191                target.branch.as_deref(),
9192                &target.head,
9193            ) {
9194                self.mark_managed_recovery_required(
9195                    lease_id,
9196                    ManagedWorktreeCleanupFailure::OwnershipConflict,
9197                );
9198                return Err(failure(
9199                    NodeFailureCode::ManagedWorktreeOwnershipConflict,
9200                    "Git worktree identity no longer matches the managed lease",
9201                ));
9202            }
9203            if target.prunable {
9204                self.mark_managed_cleanup_blocked(
9205                    lease_id,
9206                    ManagedWorktreeCleanupFailure::Prunable,
9207                );
9208                return Err(failure(
9209                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
9210                    "managed worktree is prunable and requires recovery",
9211                ));
9212            }
9213            if let Err(error) = remove_git_worktree(&source_root, &target.path).await {
9214                let failure_kind = match error.kind {
9215                    GitWorktreeErrorKind::Dirty => ManagedWorktreeCleanupFailure::Dirty,
9216                    GitWorktreeErrorKind::Locked => ManagedWorktreeCleanupFailure::Locked,
9217                    GitWorktreeErrorKind::Protected => ManagedWorktreeCleanupFailure::OwnershipConflict,
9218                    GitWorktreeErrorKind::Conflict => ManagedWorktreeCleanupFailure::Busy,
9219                    GitWorktreeErrorKind::Invalid | GitWorktreeErrorKind::NotRepository
9220                    | GitWorktreeErrorKind::Failed => ManagedWorktreeCleanupFailure::Backend,
9221                };
9222                if failure_kind == ManagedWorktreeCleanupFailure::OwnershipConflict {
9223                    self.mark_managed_recovery_required(lease_id, failure_kind);
9224                } else {
9225                    self.mark_managed_cleanup_blocked(lease_id, failure_kind);
9226                }
9227                return Err(managed_git_worktree_failure(error));
9228            }
9229        } else {
9230            match std::fs::symlink_metadata(&lease.target_root) {
9231                Err(error) if error.kind() == io::ErrorKind::NotFound => {}
9232                Ok(_) => {
9233                    self.mark_managed_recovery_required(
9234                        lease_id,
9235                        ManagedWorktreeCleanupFailure::OwnershipConflict,
9236                    );
9237                    return Err(failure(
9238                        NodeFailureCode::ManagedWorktreeOwnershipConflict,
9239                        "managed target exists but is not owned by Git",
9240                    ));
9241                }
9242                Err(_) => {
9243                    self.mark_managed_cleanup_blocked(
9244                        lease_id,
9245                        ManagedWorktreeCleanupFailure::Backend,
9246                    );
9247                    return Err(failure(
9248                        NodeFailureCode::BackendOperationFailed,
9249                        "managed target identity could not be inspected",
9250                    ));
9251                }
9252            }
9253        }
9254        let registered = self.workspaces.read()
9255            .unwrap_or_else(|poisoned| poisoned.into_inner())
9256            .contains_key(&lease.workspace_id);
9257        if registered {
9258            self.unregister_workspace(&lease.workspace_id)?;
9259        }
9260        let removed = {
9261            self.managed_worktrees.lock()
9262                .unwrap_or_else(|poisoned| poisoned.into_inner())
9263                .tombstone(lease_id, unix_time_ms())
9264                .expect("managed worktree lease remains present during cleanup")
9265        };
9266        self.persist_state().map_err(persistence_failure)?;
9267        self.publish(NodeEvent::ManagedWorktreeRemoved { lease_id: lease_id.clone() });
9268        Ok(removed.snapshot())
9269    }
9270
9271    fn mark_managed_cleanup_blocked(
9272        &self,
9273        lease_id: &ManagedWorktreeLeaseId,
9274        failure_kind: ManagedWorktreeCleanupFailure,
9275    ) {
9276        let snapshot = {
9277            let mut registry = self.managed_worktrees.lock()
9278                .unwrap_or_else(|poisoned| poisoned.into_inner());
9279            let Some(lease) = registry.get_mut(lease_id) else { return };
9280            lease.state = ManagedWorktreeLeaseState::CleanupBlocked;
9281            lease.cleanup_failure = Some(failure_kind);
9282            lease.updated_at_unix_ms = unix_time_ms();
9283            lease.snapshot()
9284        };
9285        let _ = self.persist_state();
9286        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
9287    }
9288
9289    fn require_worktree_service(
9290        &self,
9291        source_workspace_id: &WorkspaceId,
9292    ) -> Result<(), NodeFailure> {
9293        if self.managed_worktrees.lock()
9294            .unwrap_or_else(|poisoned| poisoned.into_inner())
9295            .lease_for_workspace(source_workspace_id)
9296            .is_some()
9297        {
9298            return Err(failure(
9299                NodeFailureCode::ManagedWorktreeOwnershipConflict,
9300                "managed worktree targets are internal-only workspaces",
9301            ));
9302        }
9303        match self
9304            .worktree_service_modes
9305            .get(source_workspace_id)
9306            .copied()
9307            .unwrap_or(WorktreeServiceMode::Manual)
9308        {
9309            WorktreeServiceMode::Manual => Ok(()),
9310            WorktreeServiceMode::Managed | WorktreeServiceMode::Off => Err(failure(
9311                NodeFailureCode::UnsupportedSpawnCapability,
9312                "Git worktree service mode is unavailable in this node build",
9313            )),
9314        }
9315    }
9316
9317    fn managed_reservation_conflict(
9318        &self,
9319        workspace_id: Option<&WorkspaceId>,
9320        root: Option<&str>,
9321    ) -> bool {
9322        self.managed_worktrees.lock()
9323            .unwrap_or_else(|poisoned| poisoned.into_inner())
9324            .active_records()
9325            .any(|lease| {
9326                workspace_id.is_some_and(|workspace_id| {
9327                    workspace_id == &lease.source_workspace_id
9328                        || workspace_id == &lease.workspace_id
9329                }) || root.is_some_and(|root| worktree_paths_equal(root, &lease.target_root))
9330            })
9331    }
9332
9333    fn reject_managed_reservation(
9334        &self,
9335        workspace_id: Option<&WorkspaceId>,
9336        root: Option<&str>,
9337    ) -> Result<(), NodeFailure> {
9338        if self.managed_reservation_conflict(workspace_id, root) {
9339            Err(failure(
9340                NodeFailureCode::ManagedWorktreeOwnershipConflict,
9341                "managed worktree ownership may only be mutated through its lease cleanup",
9342            ))
9343        } else {
9344            Ok(())
9345        }
9346    }
9347
9348    fn bound_workspace_root(&self, address: &SessionAddress) -> Result<String, NodeFailure> {
9349        self.validate_address(address)?;
9350        self.workspace_root(&address.workspace_id)
9351    }
9352
9353    /// `Resume`/`ResumeWithPrompt` are always a raw-PTY relaunch (see
9354    /// `require_policy`'s own doc on those two requirements), so they keep
9355    /// gating on `binding.runtime_policy`'s PTY-terminal-text-inference
9356    /// flags exactly as before; `pipe_transport`/`acp_transport` stay
9357    /// `false` on the `require_policy` call below for them because a PTY
9358    /// resume never needs those facts, not because this method cannot look
9359    /// them up.
9360    ///
9361    /// `SemanticPrompt` -- the requirement `Prompt`/`Paste` share -- reads
9362    /// the SAME `binding.runtime_policy` every other requirement reads here
9363    /// (one computed policy, read by every gate). That policy is no longer
9364    /// PTY-terminal-verification-shaped for an ACP session --
9365    /// `spawn_session_with_deadline` computes it BY TRANSPORT now, through
9366    /// `provider_runtime::policy_for_transport` -- so consulting it for
9367    /// `Prompt`/`Paste` on ACP is sound where it used to gate the wrong fact
9368    /// (this is what made an ACP `Prompt` come back `UnsupportedCapability`
9369    /// even though a prompt is an ACP session's only interface). Pipe keeps
9370    /// reading the same PTY-probe-derived value it always has --
9371    /// `policy_for_transport` passes Pipe through unchanged this pass (see
9372    /// its own doc for why).
9373    ///
9374    /// What this method still adds on top, for ACP/Pipe only, is a
9375    /// turn-in-flight PRECONDITION ahead of that policy check: a session
9376    /// takes one turn at a time, so a `Prompt`/`Paste` is refused by name
9377    /// (`NodeFailureCode::TurnInFlight`) while a turn is already running,
9378    /// read off the same live `SessionSnapshot` state `require_acp_session`
9379    /// already reads for the ACP control verbs -- `TurnStarted` observed
9380    /// with no matching `TurnCompleted` yet is exactly `ProviderSnapshot::
9381    /// lead_activity != Idle`. Sending a second prompt mid-turn would put
9382    /// its fate in vendor-specific behaviour this node cannot observe (one
9383    /// agent may queue it, another drop it, another interleave it), so this
9384    /// refuses it outright rather than gambling on that -- a rule with no
9385    /// PTY analogue, which is why PTY does not take this extra precondition.
9386    fn require_session_runtime_policy(
9387        &self,
9388        address: &SessionAddress,
9389        requirement: ProviderRuntimeRequirement,
9390    ) -> Result<ProviderRuntimePolicy, NodeFailure> {
9391        let runtime_policy = {
9392            let bindings = self
9393                .session_bindings
9394                .lock()
9395                .unwrap_or_else(|poisoned| poisoned.into_inner());
9396            let binding = bindings
9397                .get(&address.session.instance_id)
9398                .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
9399            if binding.workspace_id != address.workspace_id {
9400                return Err(failure(
9401                    NodeFailureCode::SessionWorkspaceMismatch,
9402                    "session belongs to another workspace",
9403                ));
9404            }
9405            if binding.generation != address.session.generation {
9406                return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
9407            }
9408            binding.runtime_policy
9409        };
9410        if requirement == ProviderRuntimeRequirement::SemanticPrompt {
9411            let snapshot = self.internal_session_snapshot(address)?;
9412            if matches!(snapshot.transport, TransportKind::Acp | TransportKind::Pipe)
9413                && snapshot.provider.lead_activity != ProviderActivity::Idle
9414            {
9415                return Err(failure(
9416                    NodeFailureCode::TurnInFlight,
9417                    "session already has a turn in flight; wait for it to complete before sending another prompt",
9418                ));
9419            }
9420        }
9421        require_policy(runtime_policy, false, false, requirement)
9422            .map(|()| runtime_policy)
9423            .map_err(|_| failure(
9424                NodeFailureCode::UnsupportedCapability,
9425                "session runtime policy does not admit this semantic operation",
9426            ))
9427    }
9428
9429    fn remove_binding(&self, address: &SessionAddress) -> Option<SessionBinding> {
9430        let mut bindings = self
9431            .session_bindings
9432            .lock()
9433            .unwrap_or_else(|poisoned| poisoned.into_inner());
9434        if bindings.get(&address.session.instance_id).is_some_and(|binding| {
9435            binding.workspace_id == address.workspace_id
9436                && binding.generation == address.session.generation
9437        }) {
9438            let removed = bindings.remove(&address.session.instance_id);
9439            // Exclusive dig2browser station profile lease is node-local; clear
9440            // on every session unbind / rollback. Never ImportSession/cookies.
9441            #[cfg(feature = "dig2-station-probe")]
9442            {
9443                crate::dig2_station_lease::release_lease_for_holder(
9444                    &self.browser_station_leases,
9445                    address.session.instance_id,
9446                );
9447            }
9448            self.clear_terminal_frame_watermark(address);
9449            if let Some(control) = self.native_launch_profile_control.as_ref() {
9450                control.clear_native_mcp_server_launch_overlay(
9451                    address.session.instance_id,
9452                );
9453            }
9454            if let Some(registry) = self.harness_mcp_registry.as_ref() {
9455                registry.revoke_session(address);
9456            }
9457            if removed
9458                .as_ref()
9459                .is_some_and(|binding| {
9460                    binding.environment_profile.is_some()
9461                        || binding.bundle.is_some()
9462                        || binding.context.is_some()
9463                })
9464            {
9465                if let Some(control) = self.native_launch_profile_control.as_ref() {
9466                    control.clear_native_launch_profile_selection(
9467                        address.session.instance_id,
9468                    );
9469                    control.clear_native_instance_launch_overlay(
9470                        address.session.instance_id,
9471                    );
9472                }
9473            }
9474            return removed;
9475        }
9476        None
9477    }
9478
9479    fn cleanup_session_owned_materialization(
9480        &self,
9481        address: &SessionAddress,
9482        binding: &SessionBinding,
9483    ) -> Result<(), NodeFailure> {
9484        let Some(id) = binding.materialization_id.as_ref() else {
9485            return Ok(());
9486        };
9487        let owner = self
9488            .materializations
9489            .lock()
9490            .unwrap_or_else(|poisoned| poisoned.into_inner())
9491            .get(id)
9492            .map(|record| record.owner().clone());
9493        match owner {
9494            None | Some(MaterializationOwner::Record { .. }) => Ok(()),
9495            Some(MaterializationOwner::Session {
9496                incarnation_id,
9497                instance_id,
9498                generation,
9499            }) if incarnation_id == self.incarnation_id
9500                && instance_id == address.session.instance_id
9501                && generation == address.session.generation =>
9502            {
9503                self.cleanup_materialization(id)
9504            }
9505            Some(MaterializationOwner::Session { .. }) => Err(failure(
9506                NodeFailureCode::BackendOperationFailed,
9507                "session environment ownership requires recovery",
9508            )),
9509        }
9510    }
9511
9512    fn clear_terminal_frame_watermark(&self, address: &SessionAddress) {
9513        let mut watermarks = self
9514            .terminal_frame_watermarks
9515            .lock()
9516            .unwrap_or_else(|poisoned| poisoned.into_inner());
9517        if watermarks
9518            .get(&address.session.instance_id)
9519            .is_some_and(|(current, _)| current == address)
9520        {
9521            watermarks.remove(&address.session.instance_id);
9522        }
9523    }
9524
9525    #[cfg(test)]
9526    fn address_for(
9527        &self,
9528        instance_id: AgentInstanceId,
9529        generation: SessionGeneration,
9530    ) -> Option<SessionAddress> {
9531        let binding = self.session_bindings
9532            .lock()
9533            .unwrap_or_else(|poisoned| poisoned.into_inner())
9534            .get(&instance_id)
9535            .cloned()?;
9536        (binding.generation == generation).then_some(SessionAddress {
9537            workspace_id: binding.workspace_id,
9538            session: SessionKey { instance_id, generation },
9539        })
9540    }
9541
9542    fn arm_resume(
9543        &self,
9544        address: &SessionAddress,
9545        command_id: CommandId,
9546        runtime_policy: ProviderRuntimePolicy,
9547    ) -> Result<(), NodeFailure> {
9548        let mut bindings = self
9549            .session_bindings
9550            .lock()
9551            .unwrap_or_else(|poisoned| poisoned.into_inner());
9552        let binding = bindings
9553            .get_mut(&address.session.instance_id)
9554            .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
9555        if binding.workspace_id != address.workspace_id {
9556            return Err(failure(
9557                NodeFailureCode::SessionWorkspaceMismatch,
9558                "session belongs to another workspace",
9559            ));
9560        }
9561        if binding.generation != address.session.generation {
9562            return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
9563        }
9564        binding.pending_resume = Some((binding.generation, command_id, runtime_policy));
9565        Ok(())
9566    }
9567
9568    fn clear_armed_resume(&self, address: &SessionAddress) {
9569        let mut bindings = self
9570            .session_bindings
9571            .lock()
9572            .unwrap_or_else(|poisoned| poisoned.into_inner());
9573        if let Some(binding) = bindings.get_mut(&address.session.instance_id) {
9574            if binding.workspace_id == address.workspace_id
9575                && binding.generation == address.session.generation
9576            {
9577                binding.pending_resume = None;
9578            }
9579        }
9580    }
9581
9582    fn publish_control(&self, event: ControlEvent) {
9583        let revokes_harness_mcp = matches!(
9584            &event.event,
9585            ControlEventKind::Exited { .. }
9586                | ControlEventKind::Failed { .. }
9587                | ControlEventKind::Removed
9588        );
9589        let (address, previous_address, record_id, identity_admitted, resume_command_rejected) = {
9590            let mut bindings = self
9591                .session_bindings
9592                .lock()
9593                .unwrap_or_else(|poisoned| poisoned.into_inner());
9594            let Some(binding) = bindings.get_mut(&event.instance_id) else {
9595                return;
9596            };
9597            let previous_address = SessionAddress {
9598                workspace_id: binding.workspace_id.clone(),
9599                session: SessionKey {
9600                    instance_id: event.instance_id,
9601                    generation: binding.generation,
9602                },
9603            };
9604            let mut resume_command_rejected = false;
9605            if binding.generation == event.generation {
9606                if let Some((pending_generation, pending_command_id, _)) = binding.pending_resume {
9607                    let rejected_resume = matches!(
9608                        &event.event,
9609                        ControlEventKind::CommandRejected { .. }
9610                    ) && event.command_id == Some(pending_command_id);
9611                    let resume_failed = matches!(
9612                        &event.event,
9613                        ControlEventKind::ResumeDenied { .. }
9614                            | ControlEventKind::ResumeFailed { .. }
9615                    );
9616                    if pending_generation == binding.generation
9617                        && (rejected_resume || resume_failed)
9618                    {
9619                        resume_command_rejected = rejected_resume;
9620                        binding.pending_resume = None;
9621                    }
9622                }
9623            } else {
9624                let expected = binding.generation.0.checked_add(1);
9625                let authorized = matches!(&event.event, ControlEventKind::ResumeAuthorized { .. });
9626                if !binding.pending_resume.is_some_and(|(generation, _, _)| {
9627                    generation == binding.generation
9628                })
9629                    || expected != Some(event.generation.0)
9630                    || !authorized
9631                {
9632                    return;
9633                }
9634                let (_, _, runtime_policy) = binding
9635                    .pending_resume
9636                    .expect("authorized resume retains its pending runtime policy");
9637                binding.generation = event.generation;
9638                binding.runtime_policy = runtime_policy;
9639                binding.pending_resume = None;
9640            }
9641            (
9642                SessionAddress {
9643                    workspace_id: binding.workspace_id.clone(),
9644                    session: SessionKey {
9645                        instance_id: event.instance_id,
9646                        generation: event.generation,
9647                    },
9648                },
9649                (previous_address.session.generation != event.generation)
9650                    .then_some(previous_address),
9651                binding.record_id.clone(),
9652                binding.runtime_policy.provider_session_identity,
9653                resume_command_rejected,
9654            )
9655        };
9656        if let Some(previous_address) = previous_address.as_ref() {
9657            self.clear_terminal_frame_watermark(previous_address);
9658        }
9659        if let Some(record_id) = record_id {
9660            self.reconcile_managed_record(
9661                &record_id,
9662                &address,
9663                &event,
9664                identity_admitted,
9665                resume_command_rejected,
9666            );
9667        }
9668        // Every state change a session's process lifecycle can reach gets a
9669        // line here: this is the single chokepoint every `ControlEventKind`
9670        // passes through regardless of transport (PTY/pipe/ACP) or whether it
9671        // came from a fresh spawn or a resume. Per-message provider chatter
9672        // (`ProviderEvent`/`ProviderGap`/terminal frames) is deliberately not
9673        // matched here -- that is exactly the per-frame noise this crate
9674        // family's level discipline forbids at INFO.
9675        match &event.event {
9676            ControlEventKind::Running { process_id } => {
9677                tracing::info!(
9678                    node_id = %self.node_id,
9679                    workspace_id = %address.workspace_id,
9680                    session = ?address.session,
9681                    pid = ?process_id,
9682                    "session process is running",
9683                );
9684            }
9685            ControlEventKind::Exited { exit_code, forced } => {
9686                tracing::info!(
9687                    node_id = %self.node_id,
9688                    workspace_id = %address.workspace_id,
9689                    session = ?address.session,
9690                    exit_code = ?exit_code,
9691                    forced = forced,
9692                    "session process exited",
9693                );
9694            }
9695            ControlEventKind::Failed { message } => {
9696                tracing::warn!(
9697                    node_id = %self.node_id,
9698                    workspace_id = %address.workspace_id,
9699                    session = ?address.session,
9700                    cause = %message,
9701                    "session failed",
9702                );
9703            }
9704            ControlEventKind::CommandRejected { message } => {
9705                tracing::warn!(
9706                    node_id = %self.node_id,
9707                    workspace_id = %address.workspace_id,
9708                    session = ?address.session,
9709                    cause = %message,
9710                    "session command rejected",
9711                );
9712            }
9713            ControlEventKind::Removed => {
9714                tracing::info!(
9715                    node_id = %self.node_id,
9716                    workspace_id = %address.workspace_id,
9717                    session = ?address.session,
9718                    "session removed",
9719                );
9720            }
9721            ControlEventKind::ResumeAuthorized { session: provider_session } => {
9722                tracing::info!(
9723                    node_id = %self.node_id,
9724                    workspace_id = %address.workspace_id,
9725                    session = ?address.session,
9726                    provider_session = ?provider_session,
9727                    "session resume authorized",
9728                );
9729            }
9730            ControlEventKind::Resumed { session: provider_session, process_id } => {
9731                tracing::info!(
9732                    node_id = %self.node_id,
9733                    workspace_id = %address.workspace_id,
9734                    session = ?address.session,
9735                    provider_session = ?provider_session,
9736                    pid = ?process_id,
9737                    "session resumed",
9738                );
9739            }
9740            ControlEventKind::ResumeDenied { reason } => {
9741                tracing::warn!(
9742                    node_id = %self.node_id,
9743                    workspace_id = %address.workspace_id,
9744                    session = ?address.session,
9745                    cause = %reason,
9746                    "session resume denied",
9747                );
9748            }
9749            ControlEventKind::ResumeFailed { message } => {
9750                tracing::warn!(
9751                    node_id = %self.node_id,
9752                    workspace_id = %address.workspace_id,
9753                    session = ?address.session,
9754                    cause = %message,
9755                    "session resume failed",
9756                );
9757            }
9758            _ => {}
9759        }
9760        let stream_chunk = agent_stream_chunk(&event);
9761        self.publish(NodeEvent::Control {
9762            address: address.clone(),
9763            event,
9764        });
9765        if let Some(chunk) = stream_chunk {
9766            self.publish_agent_stream_chunk(address.clone(), chunk);
9767        }
9768        if revokes_harness_mcp {
9769            if let Some(registry) = self.harness_mcp_registry.as_ref() {
9770                registry.revoke_session(&address);
9771            }
9772            if let Some(control) = self.native_launch_profile_control.as_ref() {
9773                control.clear_native_mcp_server_launch_overlay(
9774                    address.session.instance_id,
9775                );
9776            }
9777        }
9778    }
9779
9780    fn reconcile_managed_record(
9781        &self,
9782        record_id: &SessionRecordId,
9783        address: &SessionAddress,
9784        event: &ControlEvent,
9785        identity_admitted: bool,
9786        resume_command_rejected: bool,
9787    ) {
9788        let _transaction = self
9789            .state_transaction
9790            .lock()
9791            .unwrap_or_else(|poisoned| poisoned.into_inner());
9792        let observed_identity = match &event.event {
9793            ControlEventKind::ProviderEvent {
9794                event: gate4agent_types::ProviderEvent::SessionIdentityObserved { identity },
9795                ..
9796            } if identity_admitted => Some(identity.clone()),
9797            _ => None,
9798        };
9799        let identity_was_observed = observed_identity.is_some();
9800        let replacement_id = observed_identity
9801            .as_ref()
9802            .and_then(|_| self.allocate_record_id().ok());
9803        let mut upserts = Vec::new();
9804        let mut removals = Vec::new();
9805        let mut rebind = None;
9806        let original_records;
9807        let original_materializations = self
9808            .materializations
9809            .lock()
9810            .unwrap_or_else(|poisoned| poisoned.into_inner())
9811            .clone();
9812        {
9813            let mut records = self
9814                .session_records
9815                .lock()
9816                .unwrap_or_else(|poisoned| poisoned.into_inner());
9817            original_records = records.records.clone();
9818            let Some(current_snapshot) = records.records.get(record_id).cloned() else {
9819                return;
9820            };
9821            if let Some(identity) = observed_identity {
9822                let existing_id = records
9823                    .records
9824                    .values()
9825                    .find(|record| {
9826                        record.record_id != *record_id
9827                            && record.provider_session.as_ref().is_some_and(|record_identity| {
9828                                session_registry::same_provider_session(
9829                                    &record.provider,
9830                                    record_identity,
9831                                    &current_snapshot.provider,
9832                                    &identity,
9833                                )
9834                            })
9835                    })
9836                    .map(|record| record.record_id.clone());
9837                if let Some(existing_id) = existing_id {
9838                    let scope_matches = records.records.get(&existing_id).is_some_and(|record| {
9839                        record.mode == current_snapshot.mode
9840                            && record.workspace_id == current_snapshot.workspace_id
9841                            && record.environment_profile == current_snapshot.environment_profile
9842                            && record.bundle == current_snapshot.bundle
9843                            && record.context_id == current_snapshot.context_id
9844                            && record.context == current_snapshot.context
9845                            && record
9846                                .canonical_root
9847                                .as_utf8()
9848                                .zip(current_snapshot.canonical_root.as_utf8())
9849                                .is_some_and(|(left, right)| platform::roots_equal(left, right))
9850                    });
9851                    if !scope_matches {
9852                        if let Some(current) = records.records.get_mut(record_id) {
9853                            current.state = ManagedSessionState::Unavailable;
9854                            current.last_error =
9855                                Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR.to_owned());
9856                            current.updated_at_unix_ms = unix_time_ms();
9857                            upserts.push(current.clone());
9858                        }
9859                    } else {
9860                        let existing_is_active = records
9861                            .records
9862                            .get(&existing_id)
9863                            .is_some_and(|record| record.active_session.is_some());
9864                        if existing_is_active {
9865                            if let Some(current) = records.records.get_mut(record_id) {
9866                                current.state = ManagedSessionState::Unavailable;
9867                                current.last_error =
9868                                    Some(PROVIDER_SESSION_LIVE_CONFLICT_ERROR.to_owned());
9869                                current.updated_at_unix_ms = unix_time_ms();
9870                                upserts.push(current.clone());
9871                            }
9872                        } else {
9873                            if current_snapshot.provider_session.is_some() {
9874                                if let Some(current) = records.records.get_mut(record_id) {
9875                                    current.active_session = None;
9876                                    current.state = ManagedSessionState::Dormant;
9877                                    current.updated_at_unix_ms = unix_time_ms();
9878                                    upserts.push(current.clone());
9879                                }
9880                            } else {
9881                                records.records.remove(record_id);
9882                                removals.push(record_id.clone());
9883                            }
9884                            if let Some(existing) = records.records.get_mut(&existing_id) {
9885                                existing.provider_session = Some(identity.clone());
9886                                existing.active_session = Some(address.clone());
9887                                existing.state = ManagedSessionState::Live;
9888                                existing.updated_at_unix_ms = unix_time_ms();
9889                                existing.last_error = None;
9890                                upserts.push(existing.clone());
9891                            }
9892                            rebind = Some(existing_id);
9893                        }
9894                    }
9895                } else if current_snapshot
9896                    .provider_session
9897                    .as_ref()
9898                    .is_some_and(|current| {
9899                        !session_registry::same_provider_session(
9900                            &current_snapshot.provider,
9901                            current,
9902                            &current_snapshot.provider,
9903                            &identity,
9904                        )
9905                    })
9906                {
9907                    if let Some(current) = records.records.get_mut(record_id) {
9908                        current.active_session = None;
9909                        if current.bundle.is_some() || current.context.is_some() {
9910                            let had_bundle = current.bundle.is_some();
9911                            current.provider_session = None;
9912                            current.bundle = None;
9913                            current.context_id = None;
9914                            current.context = None;
9915                            current.state = ManagedSessionState::Unavailable;
9916                            current.last_error = Some(if had_bundle {
9917                                "bundle-unavailable".to_owned()
9918                            } else {
9919                                "context-unavailable".to_owned()
9920                            });
9921                        } else {
9922                            current.state = ManagedSessionState::Dormant;
9923                        }
9924                        current.updated_at_unix_ms = unix_time_ms();
9925                        upserts.push(current.clone());
9926                    }
9927                    if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
9928                        if let Some(current) = records.records.get_mut(record_id) {
9929                            current.state = ManagedSessionState::Unavailable;
9930                            current.last_error =
9931                                Some(MANAGED_SESSION_CAPACITY_ERROR.to_owned());
9932                            current.updated_at_unix_ms = unix_time_ms();
9933                            upserts.push(current.clone());
9934                        }
9935                    } else if let Some(new_record_id) = replacement_id {
9936                        let now = unix_time_ms();
9937                        let replacement = ManagedSessionRecord {
9938                            record_id: new_record_id.clone(),
9939                            display_name: format!(
9940                                "{} #{}",
9941                                current_snapshot.provider.as_str(),
9942                                records
9943                                    .records
9944                                    .values()
9945                                    .filter(|record| {
9946                                        record.provider == current_snapshot.provider
9947                                    })
9948                                    .count()
9949                                    .saturating_add(1),
9950                            ),
9951                            provider: current_snapshot.provider.clone(),
9952                            mode: current_snapshot.mode,
9953                            state: ManagedSessionState::Live,
9954                            workspace_id: current_snapshot.workspace_id,
9955                            canonical_root: current_snapshot.canonical_root,
9956                            provider_session: Some(identity),
9957                            active_session: Some(address.clone()),
9958                            environment_profile: current_snapshot.environment_profile,
9959                            bundle: current_snapshot.bundle,
9960                            context_id: current_snapshot.context_id,
9961                            context: current_snapshot.context,
9962                            // This is a fresh record_id representing the session under its
9963                            // newly-observed identity; it has not yet produced any clean
9964                            // exit of its own, so it starts with no exported pack (the old
9965                            // record, still addressable under its own record_id, keeps
9966                            // whatever exported_context it already had).
9967                            exported_context: None,
9968                            task_binding: current_snapshot.task_binding,
9969                            created_at_unix_ms: now,
9970                            updated_at_unix_ms: now,
9971                            last_error: None,
9972                        };
9973                        records
9974                            .records
9975                            .insert(new_record_id.clone(), replacement.clone());
9976                        upserts.push(replacement);
9977                        rebind = Some(new_record_id);
9978                    } else if let Some(current) = records.records.get_mut(record_id) {
9979                        current.state = ManagedSessionState::Unavailable;
9980                        current.last_error =
9981                            Some(PROVIDER_IDENTITY_ALLOCATION_ERROR.to_owned());
9982                        upserts.push(current.clone());
9983                    }
9984                } else if let Some(current) = records.records.get_mut(record_id) {
9985                    current.provider_session = Some(identity);
9986                    current.active_session = Some(address.clone());
9987                    current.state = ManagedSessionState::Live;
9988                    current.updated_at_unix_ms = unix_time_ms();
9989                    current.last_error = None;
9990                    upserts.push(current.clone());
9991                }
9992            } else if let Some(current) = records.records.get_mut(record_id) {
9993                if resume_command_rejected {
9994                    current.active_session = None;
9995                    current.state = detached_record_state(current);
9996                    current.updated_at_unix_ms = unix_time_ms();
9997                    current.last_error =
9998                        Some(PROVIDER_RESUME_REJECTED_ERROR.to_owned());
9999                    upserts.push(current.clone());
10000                } else {
10001                    match &event.event {
10002                    ControlEventKind::Running { .. }
10003                    | ControlEventKind::ResumeAuthorized { .. }
10004                    | ControlEventKind::Resumed { .. } => {
10005                        current.active_session = Some(address.clone());
10006                        current.state = active_record_state(
10007                            identity_admitted,
10008                            current.provider_session.is_some(),
10009                        );
10010                        current.updated_at_unix_ms = unix_time_ms();
10011                        current.last_error = None;
10012                        upserts.push(current.clone());
10013                    }
10014                    ControlEventKind::Exited { .. } | ControlEventKind::Removed => {
10015                        current.active_session = None;
10016                        current.state = detached_record_state(current);
10017                        current.updated_at_unix_ms = unix_time_ms();
10018                        upserts.push(current.clone());
10019                    }
10020                    ControlEventKind::Failed { message }
10021                    | ControlEventKind::ResumeFailed { message } => {
10022                        current.active_session = None;
10023                        current.state = detached_record_state(current);
10024                        current.updated_at_unix_ms = unix_time_ms();
10025                        current.last_error = Some(
10026                            session_registry::sanitized_record_error_summary(message),
10027                        );
10028                        upserts.push(current.clone());
10029                    }
10030                    ControlEventKind::ResumeDenied { reason } => {
10031                        current.active_session = None;
10032                        current.state = detached_record_state(current);
10033                        current.updated_at_unix_ms = unix_time_ms();
10034                        current.last_error = Some(
10035                            session_registry::sanitized_record_error_summary(reason),
10036                        );
10037                        upserts.push(current.clone());
10038                    }
10039                        _ => {}
10040                    }
10041                }
10042            }
10043        }
10044        let materialization_owner_record = identity_was_observed.then(|| {
10045            rebind.clone().unwrap_or_else(|| record_id.clone())
10046        }).and_then(|candidate| {
10047            self.session_records
10048                .lock()
10049                .unwrap_or_else(|poisoned| poisoned.into_inner())
10050                .records
10051                .get(&candidate)
10052                .filter(|record| {
10053                    record.provider_session.is_some()
10054                        && record.active_session.as_ref() == Some(address)
10055                })
10056                .map(|_| candidate)
10057        });
10058        if let Some(owner_record_id) = materialization_owner_record {
10059            let materialization_id = self
10060                .session_bindings
10061                .lock()
10062                .unwrap_or_else(|poisoned| poisoned.into_inner())
10063                .get(&address.session.instance_id)
10064                .and_then(|binding| binding.materialization_id.clone());
10065            if let Some(materialization_id) = materialization_id {
10066                let mut transfer_failed = false;
10067                {
10068                    let mut materializations = self
10069                        .materializations
10070                        .lock()
10071                        .unwrap_or_else(|poisoned| poisoned.into_inner());
10072                    if let Some(ownership) = materializations.get_mut(&materialization_id) {
10073                        match ownership.owner() {
10074                            MaterializationOwner::Session {
10075                                incarnation_id,
10076                                instance_id,
10077                                generation,
10078                            } if *incarnation_id == self.incarnation_id
10079                                && *instance_id == address.session.instance_id
10080                                && *generation == address.session.generation =>
10081                            {
10082                                transfer_failed = ownership
10083                                    .transfer_to_record(owner_record_id.clone(), unix_time_ms())
10084                                    .is_err();
10085                            }
10086                            MaterializationOwner::Record { record_id }
10087                                if record_id == &owner_record_id => {}
10088                            MaterializationOwner::Record { record_id } => {
10089                                let expected = record_id.clone();
10090                                transfer_failed = ownership
10091                                    .transfer_record_owner(
10092                                        &expected,
10093                                        owner_record_id.clone(),
10094                                        unix_time_ms(),
10095                                    )
10096                                    .is_err();
10097                            }
10098                            _ => transfer_failed = true,
10099                        }
10100                        if transfer_failed {
10101                            let _ = ownership.mark_recovery_required(unix_time_ms());
10102                        }
10103                    } else {
10104                        transfer_failed = true;
10105                    }
10106                }
10107                if transfer_failed {
10108                    if let Some(record) = self
10109                        .session_records
10110                        .lock()
10111                        .unwrap_or_else(|poisoned| poisoned.into_inner())
10112                        .records
10113                        .get_mut(&owner_record_id)
10114                    {
10115                        record.state = ManagedSessionState::Unavailable;
10116                        record.last_error = Some("environment-profile-unavailable".to_owned());
10117                        record.updated_at_unix_ms = unix_time_ms();
10118                        upserts.push(record.clone());
10119                    }
10120                }
10121            }
10122        }
10123        if let Some(new_record_id) = rebind.clone() {
10124            if let Some(binding) = self
10125                .session_bindings
10126                .lock()
10127                .unwrap_or_else(|poisoned| poisoned.into_inner())
10128                .get_mut(&address.session.instance_id)
10129            {
10130                binding.record_id = Some(new_record_id);
10131            }
10132        }
10133        if upserts.is_empty() && removals.is_empty() {
10134            return;
10135        }
10136        if self.persist_state_locked().is_err() {
10137            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
10138            let mut records = self
10139                .session_records
10140                .lock()
10141                .unwrap_or_else(|poisoned| poisoned.into_inner());
10142            records.records = original_records;
10143            *self
10144                .materializations
10145                .lock()
10146                .unwrap_or_else(|poisoned| poisoned.into_inner()) = original_materializations;
10147            if let Some(current) = records.records.get_mut(record_id) {
10148                current.state = ManagedSessionState::Unavailable;
10149                current.last_error = Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned());
10150                current.updated_at_unix_ms = unix_time_ms();
10151                upserts = vec![current.clone()];
10152            } else {
10153                upserts.clear();
10154            }
10155            removals.clear();
10156            drop(records);
10157            if let Some(binding) = self
10158                .session_bindings
10159                .lock()
10160                .unwrap_or_else(|poisoned| poisoned.into_inner())
10161                .get_mut(&address.session.instance_id)
10162            {
10163                binding.record_id = Some(record_id.clone());
10164            }
10165            eprintln!("gate4agent-node: {DURABLE_STATE_COMMIT_FAILED_ERROR}");
10166        }
10167        for record in upserts {
10168            self.publish_record(record);
10169        }
10170        for record_id in removals {
10171            self.publish(NodeEvent::SessionRecordRemoved { record_id });
10172        }
10173    }
10174
10175    fn snapshot(&self) -> NodeSnapshot {
10176        let control = self.handle.snapshot();
10177        let bindings = self
10178            .session_bindings
10179            .lock()
10180            .unwrap_or_else(|poisoned| poisoned.into_inner())
10181            .clone();
10182        let workspace_roots = self
10183            .workspaces
10184            .read()
10185            .unwrap_or_else(|poisoned| poisoned.into_inner())
10186            .clone();
10187        let environment_profiles = self
10188            .environment_profiles
10189            .read()
10190            .unwrap_or_else(|poisoned| poisoned.into_inner());
10191        let mut workspaces = workspace_roots
10192            .iter()
10193            .map(|(workspace_id, canonical_root)| {
10194                (
10195                    workspace_id.clone(),
10196                    WorkspaceSnapshot {
10197                        workspace_id: workspace_id.clone(),
10198                        canonical_root: opaque_windows_path(canonical_root.clone()),
10199                        sessions: Vec::new(),
10200                        worktree_service_mode: Some(
10201                            self.worktree_service_modes
10202                                .get(workspace_id)
10203                                .copied()
10204                                .unwrap_or(WorktreeServiceMode::Manual),
10205                        ),
10206                        managed_worktree_profiles: Some(WorktreeProfileInventory {
10207                            profiles: self
10208                                .managed_worktree_profiles
10209                                .get(workspace_id)
10210                                .into_iter()
10211                                .flat_map(BTreeMap::values)
10212                                .map(|profile| ManagedWorktreeProfileSummary {
10213                                    id: profile.profile_id().clone(),
10214                                    revision: profile.revision().clone(),
10215                                    retention: profile.retention(),
10216                                })
10217                                .collect(),
10218                        }),
10219                    },
10220                )
10221            })
10222            .collect::<BTreeMap<_, _>>();
10223        let mut agent_progress = Vec::new();
10224        for session in &control.sessions {
10225            let Some(binding) = bindings.get(&session.instance_id) else {
10226                continue;
10227            };
10228            if binding.generation != session.generation {
10229                continue;
10230            }
10231            if let Some(workspace) = workspaces.get_mut(&binding.workspace_id) {
10232                let address = SessionAddress {
10233                    workspace_id: binding.workspace_id.clone(),
10234                    session: SessionKey {
10235                        instance_id: session.instance_id,
10236                        generation: session.generation,
10237                    },
10238                };
10239                if let Some(progress) =
10240                    agent_progress_from_provider_snapshot(address, &session.provider)
10241                {
10242                    agent_progress.push(progress);
10243                }
10244                workspace.sessions.push(session.clone());
10245            }
10246        }
10247        NodeSnapshot {
10248            node_id: self.node_id.clone(),
10249            enabled_providers: self.enabled_providers.clone(),
10250            provider_runtime_statuses: {
10251                let updates = self
10252                    .provider_runtime_status_updates
10253                    .read()
10254                    .unwrap_or_else(|poisoned| poisoned.into_inner());
10255                ProviderRuntimeStatuses::new(
10256                    self.provider_runtime_statuses
10257                        .iter()
10258                        .filter(|status| !updates.contains_key(status.provider()))
10259                        .cloned()
10260                        .chain(updates.values().cloned()),
10261                )
10262                .expect("refreshed provider runtime inventory remains bounded and unique")
10263            },
10264            workspaces: workspaces.into_values().collect(),
10265            session_records: self
10266                .session_records
10267                .lock()
10268                .unwrap_or_else(|poisoned| poisoned.into_inner())
10269                .records
10270                .values()
10271                .cloned()
10272                .collect(),
10273            managed_worktrees: self.managed_worktrees
10274                .lock()
10275                .unwrap_or_else(|poisoned| poisoned.into_inner())
10276                .snapshots(),
10277            launch_inventory: Some(LaunchInventory {
10278                spawn_profiles: Some(
10279                    self.spawn_profiles
10280                        .iter()
10281                        .filter_map(|profile| {
10282                            resolved_spawn_profile_summary(profile, &environment_profiles)
10283                        })
10284                        .collect(),
10285                ),
10286                bundles: Some(
10287                    self.bundle_catalog
10288                        .read()
10289                        .unwrap_or_else(|poisoned| poisoned.into_inner())
10290                        .iter()
10291                        .map(NodeBundle::receipt)
10292                        .collect(),
10293                ),
10294                // Opaque station allowlist ids from empty-default catalog /
10295                // optional startup loader. Never secrets. Dig2 probe stays stub.
10296                network_allowlists: {
10297                    let catalog = self
10298                        .network_allowlist_catalog
10299                        .read()
10300                        .unwrap_or_else(|poisoned| poisoned.into_inner());
10301                    if catalog.is_empty() {
10302                        None
10303                    } else {
10304                        Some(catalog.ids().cloned().collect())
10305                    }
10306                },
10307            }),
10308            agent_progress,
10309        }
10310    }
10311
10312    fn publish(&self, event: NodeEvent) -> NodeEventEnvelope {
10313        assert!(
10314            !matches!(
10315                &event,
10316                NodeEvent::TerminalFrame { .. } | NodeEvent::AgentStream { .. }
10317            ),
10318            "terminal frame and agent stream events must use their own dedicated live channel",
10319        );
10320        let mut history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
10321        let sequence = history
10322            .last_sequence
10323            .checked_add(1)
10324            .expect("node event sequence exhausted");
10325        if history.events.len() == NODE_EVENT_HISTORY_MAX {
10326            if let Some(evicted) = history.events.pop_front() {
10327                history.replay_floor_sequence = evicted
10328                    .sequence
10329                    .checked_add(1)
10330                    .expect("node replay floor sequence exhausted");
10331                history.removed_record_providers.remove(&evicted.sequence);
10332            }
10333        }
10334        match &event {
10335            NodeEvent::HarnessMcpReadCall { .. } => {
10336                panic!("transient harness MCP calls must not enter durable event history")
10337            }
10338            NodeEvent::SessionRecordUpserted { record } => {
10339                history
10340                    .record_providers
10341                    .insert(record.record_id.clone(), record.provider.clone());
10342            }
10343            NodeEvent::SessionRecordRemoved { record_id } => {
10344                if let Some(provider) = history.record_providers.remove(record_id) {
10345                    history.removed_record_providers.insert(sequence, provider);
10346                }
10347            }
10348            NodeEvent::ManagedWorktreeUpserted { .. }
10349            | NodeEvent::ManagedWorktreeRemoved { .. }
10350            | NodeEvent::Control { .. }
10351            | NodeEvent::ControllerChanged { .. }
10352            | NodeEvent::SessionRecordHistorySummarized { .. }
10353            | NodeEvent::WorkspaceAdded { .. }
10354            | NodeEvent::WorkspaceRemoved { .. }
10355            | NodeEvent::ResyncRequired { .. }
10356            | NodeEvent::AgentStream { .. }
10357            | NodeEvent::TerminalFrame { .. } => {}
10358        }
10359        let envelope = NodeEventEnvelope { sequence, event };
10360        history.events.push_back(envelope.clone());
10361        history.last_sequence = sequence;
10362        let _ = self.event_tx.send(envelope.clone());
10363        drop(history);
10364        envelope
10365    }
10366
10367    /// Publishes a transient (never durable) `NodeEvent` -- today only
10368    /// `NodeEvent::HarnessMcpReadCall` -- onto the dedicated
10369    /// `harness_mcp_proxy_event_tx` broadcast (see that field's own doc for
10370    /// why this must never ride the shared `event_tx` bus). Returns whether
10371    /// the send reached at least one subscriber: `broadcast::Sender::send`
10372    /// fails in exactly one way, no active receiver at that instant, and
10373    /// the caller (`HarnessMcpProxyRegistry::begin_call`) uses a `false`
10374    /// return to fail the pending call immediately rather than leave it to
10375    /// expire silently at `LOCAL_READ_TIMEOUT`/
10376    /// `MAX_HARNESS_MCP_CALL_DEADLINE_MS`.
10377    fn publish_transient(&self, event: NodeEvent) -> bool {
10378        assert!(event.requires_harness_mcp_proxy_capability());
10379        assert!(event.harness_mcp_contract_is_valid_at(unix_time_ms()));
10380        let envelope = NodeEventEnvelope { sequence: 0, event };
10381        match self.harness_mcp_proxy_event_tx.send(Arc::new(vec![envelope])) {
10382            Ok(_) => true,
10383            Err(broadcast::error::SendError(undelivered)) => {
10384                if let Some(NodeEventEnvelope {
10385                    event: NodeEvent::HarnessMcpReadCall { call_id, request, .. },
10386                    ..
10387                }) = undelivered.first() {
10388                    // `request.content_type` only, never a per-tool label --
10389                    // this crate does not decode `request.body` and does not
10390                    // hold `hatchery-harness-api` (Nested Control Plane
10391                    // doctrine, Law 3; see this crate's dependency on
10392                    // `gate4agent-node-protocol`'s own `Forbidden:` line).
10393                    tracing::warn!(
10394                        call_id = call_id.as_str(),
10395                        content_type = ?request.content_type,
10396                        cause = "no-subscribers",
10397                        "harness MCP proxy event could not be published",
10398                    );
10399                }
10400                false
10401            }
10402        }
10403    }
10404
10405    fn publish_terminal_frames(&self) {
10406        let snapshot = self.handle.snapshot();
10407        let bindings = self
10408            .session_bindings
10409            .lock()
10410            .unwrap_or_else(|poisoned| poisoned.into_inner());
10411        let candidates = snapshot
10412            .sessions
10413            .iter()
10414            .filter_map(|session| {
10415                let binding = bindings.get(&session.instance_id)?;
10416                if binding.generation != session.generation {
10417                    return None;
10418                }
10419                let frame = session.terminal_frame.clone()?;
10420                Some((
10421                    SessionAddress {
10422                        workspace_id: binding.workspace_id.clone(),
10423                        session: SessionKey {
10424                            instance_id: session.instance_id,
10425                            generation: session.generation,
10426                        },
10427                    },
10428                    frame,
10429                ))
10430            })
10431            .collect();
10432        drop(bindings);
10433        self.publish_terminal_frame_candidates(candidates);
10434    }
10435
10436    fn publish_terminal_frame_candidates(
10437        &self,
10438        candidates: Vec<(SessionAddress, TerminalFrame)>,
10439    ) {
10440        let mut watermarks = self
10441            .terminal_frame_watermarks
10442            .lock()
10443            .unwrap_or_else(|poisoned| poisoned.into_inner());
10444        let mut events = Vec::with_capacity(candidates.len());
10445        for (address, frame) in candidates {
10446            let is_first_frame = !watermarks.contains_key(&address.session.instance_id);
10447            let advanced = watermarks
10448                .get(&address.session.instance_id)
10449                .map_or(true, |(current_address, sequence)| {
10450                    current_address != &address || frame.sequence > *sequence
10451                });
10452            if !advanced {
10453                continue;
10454            }
10455            if is_first_frame {
10456                // This is the first PTY output this node has observed for
10457                // this session -- a one-time event per spawn, not per-frame
10458                // chatter (the watermark makes sure of that). A session that
10459                // reaches this line with no controller attached anywhere on
10460                // the node is running unobserved: exactly how processes from
10461                // repeated spawn attempts went orphaned for hours without a
10462                // single line about it.
10463                if self.controller_state().is_none() {
10464                    tracing::warn!(
10465                        node_id = %self.node_id,
10466                        workspace_id = %address.workspace_id,
10467                        session = ?address.session,
10468                        terminal_size = ?frame.size,
10469                        "session producing output with no controller attached",
10470                    );
10471                } else {
10472                    tracing::info!(
10473                        node_id = %self.node_id,
10474                        workspace_id = %address.workspace_id,
10475                        session = ?address.session,
10476                        terminal_size = ?frame.size,
10477                        "session produced its first output",
10478                    );
10479                }
10480            }
10481            watermarks.insert(
10482                address.session.instance_id,
10483                (address.clone(), frame.sequence),
10484            );
10485            events.push(NodeEvent::TerminalFrame { address, frame });
10486        }
10487        drop(watermarks);
10488        if events.is_empty() {
10489            return;
10490        }
10491        let mut history = self
10492            .history
10493            .lock()
10494            .unwrap_or_else(|poisoned| poisoned.into_inner());
10495        let mut envelopes = Vec::with_capacity(events.len());
10496        for event in events {
10497            let sequence = history
10498                .last_sequence
10499                .checked_add(1)
10500                .expect("node event sequence exhausted");
10501            history.last_sequence = sequence;
10502            envelopes.push(NodeEventEnvelope { sequence, event });
10503        }
10504        let _ = self.terminal_event_tx.send(Arc::new(envelopes));
10505        drop(history);
10506    }
10507
10508    /// Publishes one `AgentStreamChunkV1` onto the dedicated agent-stream
10509    /// channel, mirroring `publish_terminal_frame_candidates` above: the
10510    /// envelope's `sequence` is drawn from the same `history.last_sequence`
10511    /// counter every other event kind shares, never a counter of its own --
10512    /// `queue_connection_event`/`queue_connection_event_batch` compare
10513    /// `envelope.sequence` against one `discard_events_through` watermark
10514    /// regardless of which broadcast channel it arrived on, so a
10515    /// per-channel counter would desynchronize that comparison.
10516    fn publish_agent_stream_chunk(&self, address: SessionAddress, chunk: AgentStreamChunkV1) {
10517        let mut history = self
10518            .history
10519            .lock()
10520            .unwrap_or_else(|poisoned| poisoned.into_inner());
10521        let sequence = history
10522            .last_sequence
10523            .checked_add(1)
10524            .expect("node event sequence exhausted");
10525        history.last_sequence = sequence;
10526        drop(history);
10527        // Debug, not info/warn -- this fires on every single chunk, but it
10528        // is exactly the counter the next drop-measurement pass needs: what
10529        // the node actually emitted (`source_sequence`, `kind`) versus what
10530        // a harness subscriber's own forwarder later logs having received
10531        // (`hatchery-harness-service::runtime`'s "forwarding agent stream
10532        // event"), so a gap between the two logs names itself instead of
10533        // needing a new counter added ad hoc.
10534        tracing::debug!(
10535            source_sequence = chunk.source_sequence,
10536            kind = agent_stream_chunk_kind_label(&chunk.kind),
10537            envelope_sequence = sequence,
10538            "publishing agent stream chunk",
10539        );
10540        let envelope = NodeEventEnvelope {
10541            sequence,
10542            event: NodeEvent::AgentStream { address, chunk },
10543        };
10544        let _ = self.agent_stream_event_tx.send(Arc::new(vec![envelope]));
10545    }
10546
10547    fn current_sequence(&self) -> u64 {
10548        self.history
10549            .lock()
10550            .unwrap_or_else(|poisoned| poisoned.into_inner())
10551            .last_sequence
10552    }
10553
10554    fn controller_state(&self) -> Option<ControllerState> {
10555        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
10556        let lease = controller.as_ref().copied()?;
10557        let remaining = lease.expires_at.saturating_duration_since(Instant::now());
10558        if remaining.is_zero() {
10559            *controller = None;
10560            return None;
10561        }
10562        Some(ControllerState {
10563            connection_id: lease.connection_id,
10564            lease_remaining_ms: remaining.as_millis().min(u64::MAX as u128) as u64,
10565        })
10566    }
10567
10568    fn acquire_controller(&self, connection_id: u64, role: ClientRole, lease_ms: u64) -> Result<ControllerState, NodeFailure> {
10569        if role != ClientRole::Operator {
10570            tracing::warn!(
10571                node_id = %self.node_id,
10572                connection_id,
10573                "controller lease rejected: observer connections cannot acquire control",
10574            );
10575            return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections cannot acquire control"));
10576        }
10577        let lease_ms = if lease_ms == 0 {
10578            DEFAULT_CONTROLLER_LEASE_MS
10579        } else {
10580            lease_ms.clamp(MIN_CONTROLLER_LEASE_MS, MAX_CONTROLLER_LEASE_MS)
10581        };
10582        let now = Instant::now();
10583        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
10584        if let Some(active) = controller.as_ref() {
10585            if active.connection_id != connection_id && active.expires_at > now {
10586                tracing::warn!(
10587                    node_id = %self.node_id,
10588                    connection_id,
10589                    holder_connection_id = active.connection_id,
10590                    "controller lease rejected: another operator holds it",
10591                );
10592                return Err(failure(NodeFailureCode::ControllerBusy, "another operator holds the controller lease"));
10593            }
10594        }
10595        // A reconnect (this connection already holds the lease, renewing
10596        // before expiry) happens on a periodic cadence; logging it at INFO
10597        // every renewal would be exactly the heartbeat chatter this crate
10598        // family's level discipline forbids. A genuine attach -- the
10599        // controller identity actually changing -- is a state change and
10600        // gets INFO; a same-connection renewal gets DEBUG.
10601        let is_new_attach = match controller.as_ref() {
10602            Some(active) => active.connection_id != connection_id,
10603            None => true,
10604        };
10605        *controller = Some(ControllerLease {
10606            connection_id,
10607            expires_at: now + Duration::from_millis(lease_ms),
10608        });
10609        drop(controller);
10610        let state = ControllerState { connection_id, lease_remaining_ms: lease_ms };
10611        if is_new_attach {
10612            tracing::info!(
10613                node_id = %self.node_id,
10614                connection_id,
10615                lease_ms,
10616                "controller lease acquired",
10617            );
10618        } else {
10619            tracing::debug!(
10620                node_id = %self.node_id,
10621                connection_id,
10622                lease_ms,
10623                "controller lease renewed",
10624            );
10625        }
10626        self.publish(NodeEvent::ControllerChanged { controller: Some(state.clone()) });
10627        Ok(state)
10628    }
10629
10630    fn release_controller(&self, connection_id: u64) -> bool {
10631        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
10632        let released = controller.as_ref().is_some_and(|active| active.connection_id == connection_id);
10633        if released {
10634            *controller = None;
10635        }
10636        drop(controller);
10637        if released {
10638            tracing::info!(
10639                node_id = %self.node_id,
10640                connection_id,
10641                "controller lease released",
10642            );
10643            self.publish(NodeEvent::ControllerChanged { controller: None });
10644        }
10645        released
10646    }
10647
10648    fn require_controller(&self, connection_id: u64, role: ClientRole) -> Result<(), NodeFailure> {
10649        if role != ClientRole::Operator {
10650            return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections are read-only"));
10651        }
10652        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
10653        let now = Instant::now();
10654        match controller.as_ref().copied() {
10655            Some(active) if active.connection_id == connection_id && active.expires_at > now => Ok(()),
10656            Some(active) if active.expires_at <= now => {
10657                *controller = None;
10658                Err(failure(NodeFailureCode::ControllerRequired, "controller lease expired"))
10659            }
10660            _ => Err(failure(NodeFailureCode::ControllerRequired, "controller lease is required")),
10661        }
10662    }
10663
10664    fn validate_address(&self, address: &SessionAddress) -> Result<AgentId, NodeFailure> {
10665        if !self
10666            .workspaces
10667            .read()
10668            .unwrap_or_else(|poisoned| poisoned.into_inner())
10669            .contains_key(&address.workspace_id)
10670        {
10671            return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
10672        }
10673        let binding = self
10674            .session_bindings
10675            .lock()
10676            .unwrap_or_else(|poisoned| poisoned.into_inner())
10677            .get(&address.session.instance_id)
10678            .cloned()
10679            .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10680        if binding.workspace_id != address.workspace_id {
10681            return Err(failure(
10682                NodeFailureCode::SessionWorkspaceMismatch,
10683                "session belongs to another workspace",
10684            ));
10685        }
10686        if binding.generation != address.session.generation {
10687            return Err(failure(
10688                NodeFailureCode::StaleGeneration,
10689                &format!(
10690                    "stale session generation: expected {}, received {}",
10691                    binding.generation.0,
10692                    address.session.generation.0,
10693                ),
10694            ));
10695        }
10696        let snapshot = self.handle.snapshot();
10697        let Some(current) = snapshot
10698            .sessions
10699            .iter()
10700            .find(|item| item.instance_id == address.session.instance_id)
10701        else {
10702            return Err(failure(NodeFailureCode::UnknownSession, "session instance does not exist"));
10703        };
10704        if current.generation != address.session.generation {
10705            return Err(failure(
10706                NodeFailureCode::StaleGeneration,
10707                &format!(
10708                    "stale session generation: expected {}, received {}",
10709                    current.generation.0,
10710                    address.session.generation.0,
10711                ),
10712            ));
10713        }
10714        Ok(current.agent_id.clone())
10715    }
10716
10717    fn dispatch(&self, command: ControlCommand) -> Result<CommandId, NodeFailure> {
10718        self.dispatch_envelope(self.prepare_command(command))
10719    }
10720
10721    fn prepare_command(&self, command: ControlCommand) -> CommandEnvelope {
10722        let id = self.next_command_id.fetch_add(1, Ordering::AcqRel);
10723        CommandEnvelope {
10724            id: CommandId(id),
10725            command,
10726        }
10727    }
10728
10729    fn dispatch_envelope(&self, envelope: CommandEnvelope) -> Result<CommandId, NodeFailure> {
10730        let command_id = envelope.id;
10731        self.handle
10732            .dispatch(envelope)
10733            .map_err(|error| match error {
10734                PortDispatchError::Full => failure(NodeFailureCode::BackendBusy, "backend command ingress is full"),
10735                PortDispatchError::Disconnected => failure(NodeFailureCode::BackendDisconnected, "backend command ingress is disconnected"),
10736            })?;
10737        Ok(command_id)
10738    }
10739
10740    /// Records the kernel's own reason for asynchronously refusing
10741    /// `command_id` -- see `rejected_command_reasons`'s doc for why a
10742    /// spawn-dispatch waiter otherwise has no way to learn it. Bounded by
10743    /// `REJECTED_COMMAND_REASON_MAX_ENTRIES`: expired entries are swept
10744    /// first, and if the map is still at capacity after that, the single
10745    /// oldest surviving entry is evicted -- `CommandId` is monotonically
10746    /// assigned (`next_command_id.fetch_add`), so the `BTreeMap`'s first key
10747    /// is always the oldest.
10748    fn record_rejected_command(
10749        &self,
10750        command_id: CommandId,
10751        message: String,
10752        unsupported_transport: bool,
10753    ) {
10754        let now = Instant::now();
10755        let mut reasons = self.rejected_command_reasons.lock()
10756            .unwrap_or_else(|poisoned| poisoned.into_inner());
10757        reasons.retain(|_, reason| reason.expires_at > now);
10758        if reasons.len() >= REJECTED_COMMAND_REASON_MAX_ENTRIES {
10759            if let Some(&oldest) = reasons.keys().next() {
10760                reasons.remove(&oldest);
10761            }
10762        }
10763        reasons.insert(
10764            command_id,
10765            RejectedCommandReason {
10766                message,
10767                unsupported_transport,
10768                expires_at: now + Duration::from_millis(REJECTED_COMMAND_REASON_TTL_MS),
10769            },
10770        );
10771    }
10772
10773    /// Removes and returns the kernel's reason for refusing `command_id`,
10774    /// if the drive loop has recorded one and it has not expired -- see
10775    /// `rejected_command_reasons`'s doc. Consumed at most once: a spawn
10776    /// wait loop calls this instead of blindly polling out its whole
10777    /// deadline when the command it is waiting on turns out to have been
10778    /// refused rather than merely slow.
10779    fn take_rejected_command_reason(&self, command_id: CommandId) -> Option<RejectedCommandReason> {
10780        let now = Instant::now();
10781        let mut reasons = self.rejected_command_reasons.lock()
10782            .unwrap_or_else(|poisoned| poisoned.into_inner());
10783        match reasons.remove(&command_id) {
10784            Some(reason) if reason.expires_at > now => Some(reason),
10785            _ => None,
10786        }
10787    }
10788
10789    async fn dispatch_bounded(
10790        &self,
10791        command: ControlCommand,
10792        timeout_duration: Duration,
10793    ) -> Result<CommandId, NodeFailure> {
10794        let deadline = Instant::now() + timeout_duration;
10795        loop {
10796            match self.dispatch(command.clone()) {
10797                Ok(command_id) => return Ok(command_id),
10798                Err(error) if error.code == NodeFailureCode::BackendBusy && Instant::now() < deadline => {
10799                    sleep(Duration::from_millis(2)).await;
10800                }
10801                Err(error) => return Err(error),
10802            }
10803        }
10804    }
10805
10806    fn internal_session_snapshot(
10807        &self,
10808        address: &SessionAddress,
10809    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
10810        self.validate_address(address)?;
10811        self.handle
10812            .snapshot()
10813            .sessions
10814            .clone()
10815            .into_iter()
10816            .find(|session| {
10817                session.instance_id == address.session.instance_id
10818                    && session.generation == address.session.generation
10819            })
10820            .ok_or_else(|| {
10821                failure(
10822                    NodeFailureCode::UnknownSession,
10823                    "session snapshot is unavailable",
10824                )
10825            })
10826    }
10827
10828    /// Confirms `address` names a live session spawned over the ACP
10829    /// transport and returns its current snapshot -- the gate every
10830    /// `acp-control-v1` verb shares. `require_session_runtime_policy`
10831    /// cannot answer this by itself: `ProviderRuntimePolicy` carries no
10832    /// transport bit for `Acp`/`Inline` (see that method's own doc), only
10833    /// the session's own recorded `TransportKind` does, and that lives on
10834    /// the live snapshot, not the binding.
10835    fn require_acp_session(
10836        &self,
10837        address: &SessionAddress,
10838    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
10839        let snapshot = self.internal_session_snapshot(address)?;
10840        if snapshot.transport != TransportKind::Acp {
10841            return Err(failure(
10842                NodeFailureCode::UnsupportedTransport,
10843                "this verb requires a session spawned over the ACP transport",
10844            ));
10845        }
10846        Ok(snapshot)
10847    }
10848
10849    async fn dispatch_history_bounded(
10850        &self,
10851        address: &SessionAddress,
10852        command: ControlCommand,
10853        operation: HistoryOperation,
10854    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
10855        self.validate_address(address)?;
10856        let started_after = self.current_sequence();
10857        let command_id = self
10858            .dispatch_bounded(
10859                command,
10860                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
10861            )
10862            .await?;
10863        let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
10864        let mut scan_after = started_after;
10865        let mut accepted_after = None;
10866        loop {
10867            let events = self
10868                .history
10869                .lock()
10870                .unwrap_or_else(|poisoned| poisoned.into_inner())
10871                .events
10872                .iter()
10873                .filter(|envelope| envelope.sequence > scan_after)
10874                .cloned()
10875                .collect::<Vec<_>>();
10876            for envelope in events {
10877                scan_after = scan_after.max(envelope.sequence);
10878                let NodeEvent::Control {
10879                    address: event_address,
10880                    event,
10881                } = envelope.event
10882                else {
10883                    continue;
10884                };
10885                if event_address != *address {
10886                    continue;
10887                }
10888                if event.command_id == Some(command_id) {
10889                    match &event.event {
10890                        ControlEventKind::CommandRejected { .. } => {
10891                            return Err(failure(
10892                                NodeFailureCode::InvalidRequest,
10893                                "history request was rejected",
10894                            ));
10895                        }
10896                        ControlEventKind::HistoryRequested {
10897                            operation: requested,
10898                            ..
10899                        } if requested == &operation => {
10900                            accepted_after = Some(envelope.sequence);
10901                            continue;
10902                        }
10903                        _ => {}
10904                    }
10905                }
10906                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
10907                    match (&operation, &event.event) {
10908                        (
10909                            HistoryOperation::Discover { .. },
10910                            ControlEventKind::HistoryDiscovered { .. },
10911                        )
10912                        | (
10913                            HistoryOperation::Load { .. },
10914                            ControlEventKind::HistoryLoaded { .. },
10915                        ) => return self.internal_session_snapshot(address),
10916                        (_, ControlEventKind::HistoryFailed { .. }) => {
10917                            return Err(failure(
10918                                NodeFailureCode::BackendOperationFailed,
10919                                "history operation failed",
10920                            ));
10921                        }
10922                        _ => {}
10923                    }
10924                }
10925            }
10926            if Instant::now() >= deadline {
10927                return Err(failure(
10928                    NodeFailureCode::BackendBusy,
10929                    if accepted_after.is_some() {
10930                        "history operation did not settle before the bounded deadline"
10931                    } else {
10932                        "history operation was not accepted before the bounded deadline"
10933                    },
10934                ));
10935            }
10936            sleep(Duration::from_millis(2)).await;
10937        }
10938    }
10939
10940    async fn discover_history(
10941        &self,
10942        address: &SessionAddress,
10943        limit: u16,
10944    ) -> Result<Vec<HistoryCandidateSummary>, NodeFailure> {
10945        let query = HistoryQuery {
10946            working_directory: Some(self.workspace_root(&address.workspace_id)?),
10947            limit,
10948        };
10949        query
10950            .validate()
10951            .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history query"))?;
10952        let snapshot = self
10953            .dispatch_history_bounded(
10954                address,
10955                ControlCommand::DiscoverHistory {
10956                    instance_id: address.session.instance_id,
10957                    query: query.clone(),
10958                },
10959                HistoryOperation::Discover { query },
10960            )
10961            .await?;
10962        if snapshot.history.pending.is_some() || snapshot.history.loaded.is_some() {
10963            return Err(failure(
10964                NodeFailureCode::BackendOperationFailed,
10965                "history discovery settled into an invalid state",
10966            ));
10967        }
10968        Ok(snapshot.history.candidates)
10969    }
10970
10971    async fn load_history(
10972        &self,
10973        address: &SessionAddress,
10974        candidate_id: String,
10975    ) -> Result<HistorySessionRecord, NodeFailure> {
10976        validate_candidate_id(&candidate_id)
10977            .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history candidate"))?;
10978        let snapshot = self
10979            .dispatch_history_bounded(
10980                address,
10981                ControlCommand::LoadHistory {
10982                    instance_id: address.session.instance_id,
10983                    candidate_id: candidate_id.clone(),
10984                },
10985                HistoryOperation::Load {
10986                    candidate_id: candidate_id.clone(),
10987                },
10988            )
10989            .await?;
10990        if snapshot.history.pending.is_some()
10991            || snapshot.history.loaded_candidate_id.as_ref() != Some(&candidate_id)
10992        {
10993            return Err(failure(
10994                NodeFailureCode::BackendOperationFailed,
10995                "history load settled into an invalid state",
10996            ));
10997        }
10998        snapshot.history.loaded.ok_or_else(|| {
10999            failure(
11000                NodeFailureCode::BackendOperationFailed,
11001                "history load produced no bounded session",
11002            )
11003        })
11004    }
11005
11006    /// `allow_clean_detachment`: `false` for the on-demand
11007    /// `NodeRequest::ExportContextPackForSessionRecord` API path (the
11008    /// managed record must stay untouched, still live, for the whole call);
11009    /// `true` only for the reactive auto-export-at-exit path (`§2.1`), which
11010    /// reacts to the very clean-exit event that detaches the record out from
11011    /// under this same call — see `session_record_export_target_matches`.
11012    async fn export_context_pack_for_session_record(
11013        &self,
11014        record_id: &SessionRecordId,
11015        session: &SessionAddress,
11016        allow_clean_detachment: bool,
11017    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11018        // C2 replaces a relayed failure's prose with a canned per-code
11019        // string (`C2NodeFailure`'s own `From`), so this path's dozen
11020        // distinct refusals are indistinguishable to anyone downstream --
11021        // and a refusal here expires a continuation, which keeps no field
11022        // for why. The prose stays on the node, where it was written.
11023        let outcome = self
11024            .export_context_pack_for_session_record_inner(record_id, session, allow_clean_detachment)
11025            .await;
11026        if let Err(failure) = &outcome {
11027            tracing::warn!(
11028                record = %record_id,
11029                code = ?failure.code,
11030                reason = %failure.message,
11031                "session-record context-pack export refused",
11032            );
11033        }
11034        outcome
11035    }
11036
11037    async fn export_context_pack_for_session_record_inner(
11038        &self,
11039        record_id: &SessionRecordId,
11040        session: &SessionAddress,
11041        allow_clean_detachment: bool,
11042    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11043        // Several steps here refuse with the same BackendOperationFailed
11044        // code and no prose of their own, so which step failed is
11045        // otherwise unrecoverable -- and a refusal here expires a
11046        // continuation.
11047        let step = |name: &'static str| move |failure: NodeFailure| {
11048            tracing::warn!(step = name, code = ?failure.code, "context-pack export step refused");
11049            failure
11050        };
11051        let record = self.record(record_id).map_err(step("record-lookup"))?;
11052        // `provider_session` is `Some` only for a provider whose resolved
11053        // runtime policy admits live identity verification (raw PTY plus a
11054        // semantic PTY adapter -- `provider_runtime::policy_from_capability_flags`).
11055        // The PTY-sidecar-observation shape (no fleet provider today, but
11056        // capability-keyed rather than vendor-locked) hardcodes
11057        // `provider_session_identity: false` in `provider_runtime::evaluate`,
11058        // so its records go straight to `Live` with `provider_session: None`
11059        // and stay that way for the record's entire life -- that is this
11060        // shape's permanent resting state, not a race with observation, and
11061        // `discover_history`/`load_history` below do not need an identity to
11062        // do their job for it. `IdentityPending` is the actual race (an
11063        // identity-admitted provider whose observation has not landed yet):
11064        // it keeps refusing here rather than falling back, because that
11065        // provider's own history the fallback would otherwise trust is not
11066        // guaranteed to be exact without the identity it is still waiting on.
11067        let identity = match record.provider_session.clone() {
11068            Some(identity) => {
11069                if identity.key != ProviderSessionKey::SessionId {
11070                    return Err(step("provider-identity-key")(failure(
11071                        NodeFailureCode::BackendOperationFailed,
11072                        "managed session provider identity cannot bind history exactly",
11073                    )));
11074                }
11075                Some(identity)
11076            }
11077            None if record.state == ManagedSessionState::IdentityPending => {
11078                return Err(step("provider-session-identity")(failure(
11079                    NodeFailureCode::BackendOperationFailed,
11080                    "managed session has not yet observed its provider session identity",
11081                )));
11082            }
11083            None => None,
11084        };
11085        self.revalidate_session_record_context_export(
11086            &record,
11087            identity.as_ref(),
11088            session,
11089            allow_clean_detachment,
11090        ).map_err(step("revalidate-before-discovery"))?;
11091        let candidates = self
11092            .discover_history(session, HISTORY_DISCOVERY_LIMIT_MAX)
11093            .await
11094            .map_err(step("discover-history"))?;
11095        // With no verified identity to filter by, the discovered set must
11096        // still resolve to exactly one candidate: the same ambiguity refusal
11097        // an identity filter would otherwise produce is the only safety
11098        // property left available for this shape.
11099        let candidate_id = select_history_candidate(
11100            &candidates,
11101            identity.as_ref().map(|identity| identity.id.as_str()),
11102        ).map_err(step("unique-candidate"))?;
11103        let loaded = self.load_history(session, candidate_id.clone()).await
11104            .map_err(step("load-history"))?;
11105        if let Some(identity) = identity.as_ref() {
11106            if loaded.session_id != identity.id {
11107                return Err(failure(
11108                    NodeFailureCode::SessionRecordConflict,
11109                    "loaded history identity does not match the managed session record",
11110                ));
11111            }
11112        }
11113        self.revalidate_session_record_context_export(
11114            &record,
11115            identity.as_ref(),
11116            session,
11117            allow_clean_detachment,
11118        )?;
11119        self.revalidate_loaded_history(session, &candidate_id, &loaded)
11120            .map_err(step("revalidate-loaded-history"))?;
11121        let pack = self.materialize_context_pack(session).await
11122            .map_err(step("materialize-context-pack"))?;
11123        self.commit_context_pack_for_session_record(
11124            &record,
11125            identity.as_ref(),
11126            session,
11127            &candidate_id,
11128            &loaded,
11129            pack,
11130            allow_clean_detachment,
11131        )
11132    }
11133
11134    async fn export_context_pack(
11135        &self,
11136        address: &SessionAddress,
11137    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11138        let pack = self.materialize_context_pack(address).await?;
11139        self.commit_context_pack(pack)
11140    }
11141
11142    async fn materialize_context_pack(
11143        &self,
11144        address: &SessionAddress,
11145    ) -> Result<NodeContextPack, NodeFailure> {
11146        let snapshot = self.internal_session_snapshot(address)?;
11147        if !context_pack_source_status_is_usable(&snapshot.status) {
11148            return Err(failure(
11149                NodeFailureCode::InvalidRequest,
11150                "context export requires a running or successfully exited source session",
11151            ));
11152        }
11153        if snapshot.history.pending.is_some()
11154            || snapshot.history.loaded_candidate_id.is_none()
11155        {
11156            return Err(failure(
11157                NodeFailureCode::InvalidRequest,
11158                "context export requires settled loaded history",
11159            ));
11160        }
11161        let loaded = snapshot.history.loaded.clone().ok_or_else(|| {
11162            failure(
11163                NodeFailureCode::InvalidRequest,
11164                "context export requires settled loaded history",
11165            )
11166        })?;
11167        let source_provider = snapshot.agent_id.clone();
11168        let loaded_candidate_id = snapshot.history.loaded_candidate_id.clone();
11169        let repository = self
11170            .context_pack_repository(&address.workspace_id)
11171            .await?;
11172        let current = self.internal_session_snapshot(address)?;
11173        if !context_pack_source_status_is_usable(&current.status)
11174            || current.agent_id != source_provider
11175            || current.history.pending.is_some()
11176            || current.history.loaded_candidate_id != loaded_candidate_id
11177            || current.history.loaded.as_ref() != Some(&loaded)
11178        {
11179            return Err(failure(
11180                NodeFailureCode::ContextPackMaterializationFailed,
11181                "context source changed during bounded repository capture",
11182            ));
11183        }
11184        NodeContextPack::export_with_repository(
11185            ContextPackLineageReceipt {
11186                source_node_id: self.node_id.clone(),
11187                source_session: address.clone(),
11188                source_provider,
11189            },
11190            &loaded,
11191            Some(repository),
11192        )
11193        .map_err(|_| {
11194            failure(
11195                NodeFailureCode::ContextPackMaterializationFailed,
11196                "bounded context pack export failed",
11197            )
11198        })
11199    }
11200
11201    /// Write-through to the durable [`ContextPackStore`], if one is
11202    /// configured (`state_path` is `Some`). A node with no durable state
11203    /// path — fixtures, tests, ephemeral runs — has no `ContextPackStore`
11204    /// open at all and this is a no-op, matching `persist_state`'s own
11205    /// "durability optional, degrade gracefully when unconfigured" idiom.
11206    fn commit_context_pack_durably(&self, pack: &NodeContextPack) -> Result<(), NodeFailure> {
11207        let mut store = self
11208            .context_pack_store
11209            .lock()
11210            .unwrap_or_else(|poisoned| poisoned.into_inner());
11211        let Some(store) = store.as_mut() else {
11212            return Ok(());
11213        };
11214        store.commit(pack).map_err(|_| {
11215            failure(
11216                NodeFailureCode::ContextPackMaterializationFailed,
11217                "durable context pack commit failed",
11218            )
11219        })
11220    }
11221
11222    fn commit_context_pack(
11223        &self,
11224        pack: NodeContextPack,
11225    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11226        self.commit_context_pack_durably(&pack)?;
11227        self.context_catalog
11228            .write()
11229            .unwrap_or_else(|poisoned| poisoned.into_inner())
11230            .insert(pack)
11231            .map_err(|_| {
11232                failure(
11233                    NodeFailureCode::ContextPackMaterializationFailed,
11234                    "context pack catalog is full",
11235                )
11236            })
11237    }
11238
11239    fn commit_context_pack_for_session_record(
11240        &self,
11241        expected: &ManagedSessionRecord,
11242        identity: Option<&ProviderSessionIdentity>,
11243        session: &SessionAddress,
11244        candidate_id: &str,
11245        loaded: &HistorySessionRecord,
11246        pack: NodeContextPack,
11247        allow_clean_detachment: bool,
11248    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11249        let current_root = self.workspace_root(&expected.workspace_id)?;
11250        let _transaction = self
11251            .state_transaction
11252            .lock()
11253            .unwrap_or_else(|poisoned| poisoned.into_inner());
11254        let records = self
11255            .session_records
11256            .lock()
11257            .unwrap_or_else(|poisoned| poisoned.into_inner());
11258        let current = records.records.get(&expected.record_id).ok_or_else(|| {
11259            failure(
11260                NodeFailureCode::SessionRecordConflict,
11261                "managed session record disappeared during context export",
11262            )
11263        })?;
11264        if !session_record_export_target_matches(expected, current, session, allow_clean_detachment) {
11265            return Err(failure(
11266                NodeFailureCode::SessionRecordConflict,
11267                "managed session record changed during context export",
11268            ));
11269        }
11270        let bindings = self
11271            .session_bindings
11272            .lock()
11273            .unwrap_or_else(|poisoned| poisoned.into_inner());
11274        let binding = bindings.get(&session.session.instance_id).ok_or_else(|| {
11275            failure(
11276                NodeFailureCode::SessionRecordConflict,
11277                "managed session runtime binding disappeared during context export",
11278            )
11279        })?;
11280        let runtime = self
11281            .handle
11282            .snapshot()
11283            .sessions
11284            .iter()
11285            .find(|snapshot| {
11286                snapshot.instance_id == session.session.instance_id
11287                    && snapshot.generation == session.session.generation
11288            })
11289            .cloned()
11290            .ok_or_else(|| {
11291                failure(
11292                    NodeFailureCode::SessionRecordConflict,
11293                    "managed session runtime disappeared during context export",
11294                )
11295            })?;
11296        let receipt = pack.receipt();
11297        if !session_record_context_export_binding_is_exact(
11298            expected,
11299            current,
11300            identity,
11301            session,
11302            binding,
11303            &runtime.agent_id,
11304            &current_root,
11305            allow_clean_detachment,
11306        ) || !session_record_context_export_source_is_usable(
11307            current,
11308            &runtime.status,
11309            allow_clean_detachment,
11310        ) || runtime.history.pending.is_some()
11311            || runtime.history.loaded_candidate_id.as_deref() != Some(candidate_id)
11312            || runtime.history.loaded.as_ref() != Some(loaded)
11313            || receipt.lineage.source_node_id != self.node_id
11314            || receipt.lineage.source_session != *session
11315            || receipt.lineage.source_provider != current.provider
11316            || receipt.source_message_count != loaded.message_count
11317        {
11318            return Err(failure(
11319                NodeFailureCode::SessionRecordConflict,
11320                "managed session source changed before context catalog commit",
11321            ));
11322        }
11323        self.commit_context_pack_durably(&pack)?;
11324        self.context_catalog
11325            .write()
11326            .unwrap_or_else(|poisoned| poisoned.into_inner())
11327            .insert(pack)
11328            .map_err(|_| {
11329                failure(
11330                    NodeFailureCode::ContextPackMaterializationFailed,
11331                    "context pack catalog is full",
11332                )
11333            })
11334    }
11335
11336    async fn context_pack_repository(
11337        &self,
11338        workspace_id: &WorkspaceId,
11339    ) -> Result<ContextPackRepository, NodeFailure> {
11340        let first = self
11341            .context_pack_repository_observation(workspace_id)
11342            .await?;
11343        let second = self
11344            .context_pack_repository_observation(workspace_id)
11345            .await?;
11346        stable_context_pack_repository(first, second)
11347    }
11348
11349    async fn context_pack_repository_observation(
11350        &self,
11351        workspace_id: &WorkspaceId,
11352    ) -> Result<ContextPackRepositoryObservation, NodeFailure> {
11353        let git = self.inspect_workspace(workspace_id.clone()).await?.git;
11354        let head = if git.is_repository {
11355            let root = self.workspace_root(workspace_id)?;
11356            match resolve_base_commit_with_timeout(
11357                &root,
11358                "HEAD",
11359                GIT_COMMAND_TIMEOUT_MS,
11360            )
11361            .await
11362            {
11363                Ok(commit) => ContextPackRepositoryHead::Commit(commit),
11364                Err(error) if error.kind == GitWorktreeErrorKind::Conflict => {
11365                    ContextPackRepositoryHead::Unborn
11366                }
11367                Err(_) => {
11368                    return Err(failure(
11369                        NodeFailureCode::ContextPackMaterializationFailed,
11370                        "context pack HEAD observation failed",
11371                    ));
11372                }
11373            }
11374        } else {
11375            ContextPackRepositoryHead::NotRepository
11376        };
11377        let mut files = Vec::with_capacity(CONTEXT_PACK_SELECTED_FILES.len());
11378        for selected_path in CONTEXT_PACK_SELECTED_FILES {
11379            let path = RepositoryPath::utf8(selected_path.to_owned()).map_err(|_| {
11380                failure(
11381                    NodeFailureCode::InvalidRepositoryPath,
11382                    "context pack selected file path is invalid",
11383                )
11384            })?;
11385            match self.read_workspace_file(workspace_id.clone(), path).await {
11386                Ok(WorkspaceFileRead {
11387                    content: WorkspaceFileContent::Utf8 { text, byte_len },
11388                    ..
11389                }) => files.push(ContextPackRepositoryFileSource::utf8(
11390                    selected_path,
11391                    text,
11392                    byte_len,
11393                )),
11394                Ok(WorkspaceFileRead {
11395                    content: WorkspaceFileContent::NonUtf8 { byte_len },
11396                    ..
11397                }) => files.push(ContextPackRepositoryFileSource::skipped(
11398                    selected_path,
11399                    ContextPackSelectedFileSkipReason::NonUtf8,
11400                    Some(byte_len),
11401                )),
11402                Ok(WorkspaceFileRead {
11403                    content: WorkspaceFileContent::TooLarge { .. },
11404                    ..
11405                }) => files.push(ContextPackRepositoryFileSource::skipped(
11406                    selected_path,
11407                    ContextPackSelectedFileSkipReason::TooLarge,
11408                    None,
11409                )),
11410                Err(error) => {
11411                    let reason = if matches!(
11412                        error.code,
11413                        NodeFailureCode::InvalidRepositoryPath
11414                            | NodeFailureCode::RepositoryFileNotRegular
11415                            | NodeFailureCode::RepositoryPathUnsafe
11416                    ) {
11417                        ContextPackSelectedFileSkipReason::Unsafe
11418                    } else {
11419                        ContextPackSelectedFileSkipReason::Unavailable
11420                    };
11421                    files.push(ContextPackRepositoryFileSource::skipped(
11422                        selected_path,
11423                        reason,
11424                        None,
11425                    ));
11426                }
11427            }
11428        }
11429        Ok(ContextPackRepositoryObservation { head, git, files })
11430    }
11431
11432    fn forget_context_pack(&self, context_id: &SpawnContextId) -> Result<(), NodeFailure> {
11433        let referenced_by_binding = self
11434            .session_bindings
11435            .lock()
11436            .unwrap_or_else(|poisoned| poisoned.into_inner())
11437            .values()
11438            .any(|binding| {
11439                binding.context.as_ref().map(|receipt| &receipt.id) == Some(context_id)
11440            });
11441        let referenced_by_record = self
11442            .session_records
11443            .lock()
11444            .unwrap_or_else(|poisoned| poisoned.into_inner())
11445            .records
11446            .values()
11447            .any(|record| record.context_id.as_ref() == Some(context_id));
11448        let referenced_by_materialization = self
11449            .materializations
11450            .lock()
11451            .unwrap_or_else(|poisoned| poisoned.into_inner())
11452            .values()
11453            .any(|ownership| {
11454                ownership.context().map(|receipt| &receipt.id) == Some(context_id)
11455            });
11456        if referenced_by_binding
11457            || referenced_by_record
11458            || referenced_by_materialization
11459        {
11460            return Err(failure(
11461                NodeFailureCode::ContextPackBusy,
11462                "context pack is still referenced",
11463            ));
11464        }
11465        let mut catalog = self.context_catalog
11466            .write()
11467            .unwrap_or_else(|poisoned| poisoned.into_inner());
11468        if catalog.get(context_id).is_none() {
11469            return Err(failure(
11470                NodeFailureCode::UnknownContextPack,
11471                "context pack is unavailable",
11472            ));
11473        }
11474        self.spawn_idempotency
11475            .lock()
11476            .unwrap_or_else(|poisoned| poisoned.into_inner())
11477            .entries
11478            .retain(|_, entry| !entry.value.references_context(context_id));
11479        let removed = catalog.remove(context_id);
11480        debug_assert!(removed.is_some(), "validated context pack remains present");
11481        Ok(())
11482    }
11483
11484    async fn dispatch_input_bounded(
11485        &self,
11486        address: &SessionAddress,
11487        action: InputAction,
11488    ) -> Result<(), NodeFailure> {
11489        let started_after = self.current_sequence();
11490        let command_id = self
11491            .dispatch_bounded(
11492                ControlCommand::SendInput {
11493                    instance_id: address.session.instance_id,
11494                    action,
11495                },
11496                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
11497            )
11498            .await?;
11499        // A semantic prompt may spend the provider's full readiness budget before
11500        // the runtime emits InputCompleted. Keep the node request deadline bounded,
11501        // but strictly beyond every readiness policy in this node's catalog.
11502        let deadline = Instant::now() + Duration::from_millis(self.input_settle_timeout_ms);
11503        let mut scan_after = started_after;
11504        let mut accepted_after = None;
11505        loop {
11506            let events = self
11507                .history
11508                .lock()
11509                .unwrap_or_else(|poisoned| poisoned.into_inner())
11510                .events
11511                .iter()
11512                .filter(|envelope| envelope.sequence > scan_after)
11513                .cloned()
11514                .collect::<Vec<_>>();
11515            for envelope in events {
11516                scan_after = scan_after.max(envelope.sequence);
11517                let NodeEvent::Control {
11518                    address: event_address,
11519                    event,
11520                } = envelope.event
11521                else {
11522                    continue;
11523                };
11524                if event_address != *address {
11525                    continue;
11526                }
11527                if event.command_id == Some(command_id) {
11528                    match &event.event {
11529                        ControlEventKind::CommandRejected { message } => {
11530                            return Err(failure(NodeFailureCode::InvalidRequest, message));
11531                        }
11532                        ControlEventKind::InputRequested { .. } => {
11533                            accepted_after = Some(envelope.sequence);
11534                            continue;
11535                        }
11536                        _ => {}
11537                    }
11538                }
11539                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
11540                    match &event.event {
11541                        ControlEventKind::InputCompleted { .. } => return Ok(()),
11542                        ControlEventKind::InputFailed { message, .. } => {
11543                            return Err(failure(
11544                                NodeFailureCode::BackendOperationFailed,
11545                                message,
11546                            ));
11547                        }
11548                        _ => {}
11549                    }
11550                }
11551            }
11552            if Instant::now() >= deadline {
11553                return Err(failure(
11554                    NodeFailureCode::BackendBusy,
11555                    if accepted_after.is_some() {
11556                        "input was accepted but did not settle before the bounded deadline"
11557                    } else {
11558                        "input was not accepted before the bounded deadline"
11559                    },
11560                ));
11561            }
11562            sleep(Duration::from_millis(2)).await;
11563        }
11564    }
11565
11566    async fn dispatch_resize_bounded(
11567        &self,
11568        address: &SessionAddress,
11569        size: gate4agent_types::TerminalSize,
11570    ) -> Result<(), NodeFailure> {
11571        let started_after = self.current_sequence();
11572        let command_id = self
11573            .dispatch_bounded(
11574                ControlCommand::Resize {
11575                    instance_id: address.session.instance_id,
11576                    size,
11577                },
11578                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
11579            )
11580            .await?;
11581        let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
11582        let mut scan_after = started_after;
11583        let mut accepted_after = None;
11584        loop {
11585            let events = self
11586                .history
11587                .lock()
11588                .unwrap_or_else(|poisoned| poisoned.into_inner())
11589                .events
11590                .iter()
11591                .filter(|envelope| envelope.sequence > scan_after)
11592                .cloned()
11593                .collect::<Vec<_>>();
11594            for envelope in events {
11595                scan_after = scan_after.max(envelope.sequence);
11596                let NodeEvent::Control {
11597                    address: event_address,
11598                    event,
11599                } = envelope.event
11600                else {
11601                    continue;
11602                };
11603                if event_address != *address {
11604                    continue;
11605                }
11606                if event.command_id == Some(command_id) {
11607                    match &event.event {
11608                        ControlEventKind::CommandRejected { message } => {
11609                            return Err(failure(NodeFailureCode::InvalidRequest, message));
11610                        }
11611                        ControlEventKind::ResizeRequested {
11612                            size: requested_size,
11613                            ..
11614                        } if *requested_size == size => {
11615                            accepted_after = Some(envelope.sequence);
11616                            continue;
11617                        }
11618                        _ => {}
11619                    }
11620                }
11621                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
11622                    match &event.event {
11623                        ControlEventKind::Resized { size: settled_size }
11624                            if *settled_size == size =>
11625                        {
11626                            return Ok(());
11627                        }
11628                        ControlEventKind::ResizeFailed { message } => {
11629                            return Err(failure(
11630                                NodeFailureCode::BackendOperationFailed,
11631                                message,
11632                            ));
11633                        }
11634                        _ => {}
11635                    }
11636                }
11637            }
11638            if Instant::now() >= deadline {
11639                return Err(failure(
11640                    NodeFailureCode::BackendBusy,
11641                    if accepted_after.is_some() {
11642                        "resize was accepted but did not settle before the bounded deadline"
11643                    } else {
11644                        "resize was not accepted before the bounded deadline"
11645                    },
11646                ));
11647            }
11648            sleep(Duration::from_millis(2)).await;
11649        }
11650    }
11651
11652    async fn spawn_session(
11653        &self,
11654        workspace_id: WorkspaceId,
11655        provider: AgentId,
11656        mode: SessionMode,
11657        terminal_size: gate4agent_types::TerminalSize,
11658        initial_prompt: Option<String>,
11659    ) -> Result<SessionAddress, NodeFailure> {
11660        if self.managed_worktrees.lock()
11661            .unwrap_or_else(|poisoned| poisoned.into_inner())
11662            .lease_for_workspace(&workspace_id)
11663            .is_some()
11664        {
11665            return Err(failure(
11666                NodeFailureCode::ManagedWorktreeOwnershipConflict,
11667                "managed worktree targets may only be spawned through their lease request",
11668            ));
11669        }
11670        self
11671            .spawn_session_with_deadline(
11672                workspace_id,
11673                provider,
11674                mode,
11675                terminal_size,
11676                initial_prompt,
11677                None,
11678                None,
11679                None,
11680                None,
11681                None,
11682                SpawnRecordPolicy::ProviderIdentityOnly,
11683                None,
11684                &[],
11685                None,
11686                // `NodeRequest::Spawn` (this method's only caller) carries no
11687                // approval-level selection of its own -- see
11688                // `HarnessOperatorRequestV1::SpawnSession`'s own field for
11689                // the one operator-wire path that can choose something other
11690                // than the axis default.
11691                ApprovalLevel::default(),
11692                None,
11693                None,
11694            )
11695            .await
11696            .map(|(session, _runtime_policy)| session)
11697    }
11698
11699    async fn spawn_session_with_deadline(
11700        &self,
11701        workspace_id: WorkspaceId,
11702        provider: AgentId,
11703        mode: SessionMode,
11704        terminal_size: gate4agent_types::TerminalSize,
11705        initial_prompt: Option<String>,
11706        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
11707        bundle: Option<ResolvedBundleReceipt>,
11708        context: Option<ResolvedContextPackReceipt>,
11709        managed_authority: Option<ManagedWorktreeLeaseId>,
11710        admitted_runtime_policy: Option<ProviderRuntimePolicy>,
11711        record_policy: SpawnRecordPolicy,
11712        deadline: Option<Instant>,
11713        required_capabilities: &[ProviderRuntimeCapability],
11714        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
11715        approval_level: ApprovalLevel,
11716        network_allowlist: Option<crate::protocol::SpawnNetworkAllowlistId>,
11717        // Opaque dig2browser station profile id (ids only). Feature
11718        // `dig2-station-probe`: after probe at resolve, spawn takes an
11719        // exclusive node-local lease; cleared on session end. Never
11720        // ImportSession / cookies on C2. Feature off: ignored (stub echo
11721        // already happened on the receipt path).
11722        browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
11723    ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
11724        let reserved_lease = self.managed_worktrees.lock()
11725            .unwrap_or_else(|poisoned| poisoned.into_inner())
11726            .lease_for_workspace(&workspace_id);
11727        match (reserved_lease.as_ref(), managed_authority.as_ref()) {
11728            (Some(expected), Some(provided)) if expected == provided => {}
11729            (None, None) => {}
11730            _ => {
11731                return Err(failure(
11732                    NodeFailureCode::ManagedWorktreeOwnershipConflict,
11733                    "spawn lacks exact managed worktree lease authority",
11734                ));
11735            }
11736        }
11737        // Refuse concurrent distinct environment profiles (incl. ProviderHome
11738        // bindings) on the same workspace cwd. Parallel path: managed worktree
11739        // spawn so each profile gets its own workspace_id.
11740        self.ensure_workspace_environment_profile_compatible(
11741            &workspace_id,
11742            environment_profile.as_ref(),
11743        )?;
11744        let runtime_requirement = match (mode, initial_prompt.is_some()) {
11745            (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
11746            (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
11747            (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
11748            (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
11749        };
11750        // This session's own transport -- computed once, here, from `mode`,
11751        // and reused both to admit spawn (below) and, further down, to
11752        // compute the final `runtime_policy` this session actually operates
11753        // under (`provider_runtime::policy_for_transport`). Registration
11754        // reuses this exact value rather than recomputing it a second time.
11755        let transport = match mode {
11756            SessionMode::Pty => TransportKind::Pty,
11757            SessionMode::Inline => TransportKind::Pipe,
11758            SessionMode::Acp => TransportKind::Acp,
11759        };
11760        let pipe_transport = self.provider_supports_pipe_transport(&provider);
11761        let acp_transport = self.provider_supports_acp_transport(&provider);
11762        let runtime_policy = if let Some(runtime_policy) = admitted_runtime_policy {
11763            require_policy(runtime_policy, pipe_transport, acp_transport, runtime_requirement).map_err(|_| failure(
11764                NodeFailureCode::UnsupportedSpawnCapability,
11765                "pre-admitted provider runtime no longer satisfies spawn requirements",
11766            ))?;
11767            runtime_policy
11768        } else if let Some(deadline) = deadline {
11769            let remaining = spawn_deadline_remaining(deadline)?;
11770            timeout(
11771                remaining,
11772                self.admit_provider_runtime(&provider, runtime_requirement),
11773            )
11774            .await
11775            .map_err(|_| {
11776                failure(
11777                    NodeFailureCode::SpawnDeadlineExceeded,
11778                    "provider admission exceeded the spawn deadline",
11779                )
11780            })??
11781        } else {
11782            self.admit_provider_runtime(&provider, runtime_requirement).await?
11783        };
11784        let runtime_policy = self.effective_spawn_runtime_policy(runtime_policy);
11785        // The single point where this session's `runtime_policy` becomes
11786        // transport-correct: `admit_provider_runtime` (and the pre-admitted
11787        // branch above) only ever compute a PTY-terminal-verification-shaped
11788        // value, sound outright only for `TransportKind::Pty` (Pipe still
11789        // relies on that same value downstream -- see `policy_for_transport`'s
11790        // own doc for why). `policy_for_transport` is the explicit,
11791        // exhaustive match Item 1 asks for -- this is the ONE place its
11792        // output becomes the session's `runtime_policy`, so every later
11793        // reader (`ControlCommand::Start`, `session_bindings`, and every
11794        // engine gate that reads `state.runtime_policy`) sees the same,
11795        // correctly-computed value.
11796        let runtime_policy =
11797            crate::provider_runtime::policy_for_transport(transport, runtime_policy);
11798        if required_capabilities
11799            .iter()
11800            .any(|capability| !runtime_policy.admits(*capability))
11801        {
11802            return Err(failure(
11803                NodeFailureCode::UnsupportedSpawnCapability,
11804                "provider runtime does not admit a required spawn capability",
11805            ));
11806        }
11807        if let Some(deadline) = deadline {
11808            spawn_deadline_remaining(deadline)?;
11809        }
11810        self.ensure_binding_capacity()?;
11811        let working_directory = self.workspace_root(&workspace_id)?;
11812        let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
11813        let session = SessionKey {
11814            instance_id,
11815            generation: SessionGeneration(1),
11816        };
11817        let address = SessionAddress {
11818            workspace_id: workspace_id.clone(),
11819            session,
11820        };
11821        // This is the request-accepted checkpoint: admission (managed
11822        // worktree authority, runtime capability, dispatch deadline) already
11823        // passed and a concrete session identity is minted. It is a distinct
11824        // state from the process actually running (`ControlEventKind::Running`,
11825        // logged in `publish_control`) or the terminal outcome (`Exited`/
11826        // `Failed`, logged there too) -- this line exists so that gap is no
11827        // longer silent.
11828        tracing::info!(
11829            node_id = %self.node_id,
11830            workspace_id = %workspace_id,
11831            provider = %provider,
11832            environment_profile = ?environment_profile.as_ref().map(|receipt| &receipt.profile_id),
11833            session = ?address.session,
11834            "session spawn request accepted",
11835        );
11836        // Track A follow-on: exclusive node-local BrowserStationLease after
11837        // dig2 probe (feature dig2-station-probe). Opaque profile id only —
11838        // never ImportSession / cookies on C2. Linux/feature-off: probe
11839        // already refused or stub; this block is cfg'd out when feature off.
11840        #[cfg(feature = "dig2-station-probe")]
11841        let mut browser_station_lease_guard = if let Some(profile_id) = browser_profile_id {
11842            use crate::dig2_station_lease::{acquire_lease, BrowserStationLeaseError};
11843            match acquire_lease(
11844                &self.browser_station_leases,
11845                profile_id,
11846                instance_id,
11847                unix_time_ms(),
11848            ) {
11849                Ok(guard) => Some(guard),
11850                Err(BrowserStationLeaseError::ProfileBusy { .. }) => {
11851                    return Err(failure(
11852                        NodeFailureCode::BrowserStationProfileBusy,
11853                        "dig2browser station profile is already leased by another session",
11854                    ));
11855                }
11856                Err(BrowserStationLeaseError::CapacityExceeded) => {
11857                    return Err(failure(
11858                        NodeFailureCode::BrowserStationProfileBusy,
11859                        "dig2browser station profile lease capacity exhausted",
11860                    ));
11861                }
11862                Err(BrowserStationLeaseError::HolderAlreadyLeased) => {
11863                    return Err(failure(
11864                        NodeFailureCode::BrowserStationProfileBusy,
11865                        "session already holds a dig2browser station profile lease",
11866                    ));
11867                }
11868            }
11869        } else {
11870            None
11871        };
11872        #[cfg(not(feature = "dig2-station-probe"))]
11873        let _ = browser_profile_id;
11874        let mut harness_mcp_overlay = if let Some(prepared) = harness_mcp {
11875            // Slice A(ii) of gate4agent-arc-mailbox-and-task-layer: the door
11876            // exists for PTY (env vars) and ACP (`session/new.mcpServers`,
11877            // `gate4agent-shell-native`'s `mcp_server_acp_entry`) alike --
11878            // both read the identical overlay resolved below, one as child
11879            // environment, the other translated into one stdio MCP server
11880            // entry. `Inline`/`Pipe` stays refused: this arc's proof is ACP
11881            // only, and nothing downstream reads a harness-MCP overlay for
11882            // that transport yet.
11883            if !matches!(mode, SessionMode::Pty | SessionMode::Acp) || prepared.provider != provider
11884            {
11885                return Err(failure(
11886                    NodeFailureCode::BindingMismatch,
11887                    "harness MCP reservation does not match the exact PTY/ACP provider",
11888                ));
11889            }
11890            prepared.verify_helper_program().map_err(harness_mcp_failure)?;
11891            let control = self.native_launch_profile_control.clone().ok_or_else(|| {
11892                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP launch control is unavailable")
11893            })?;
11894            // Every name below is the caller's, carried in the reservation's
11895            // launch description: which environment variables receive the
11896            // endpoint, token and helper program, what the MCP server is called,
11897            // which argv it gets and which stale variables are erased.
11898            let launch = prepared.launch();
11899            let mut env_entries = vec![
11900                (
11901                    OsString::from(launch.endpoint_env.as_str()),
11902                    prepared.endpoint().as_os_str().to_os_string(),
11903                ),
11904                (
11905                    OsString::from(launch.token_env.as_str()),
11906                    prepared.token().expose().into(),
11907                ),
11908                (
11909                    OsString::from(launch.program_env.as_str()),
11910                    prepared.helper_program().as_os_str().to_os_string(),
11911                ),
11912            ];
11913            if let Some(trace_entry) = harness_mcp_trace_env_entry(launch) {
11914                env_entries.push(trace_entry);
11915            }
11916            let overlay = NativeMcpServerLaunchOverlay::new(
11917                provider.clone(),
11918                launch.server_name.as_str(),
11919                prepared.helper_program().as_os_str().to_os_string(),
11920                launch.args.iter().map(OsString::from).collect(),
11921                env_entries,
11922                launch.scrub_env.iter().map(OsString::from).collect(),
11923            ).map_err(|_| failure(
11924                NodeFailureCode::HarnessMcpUnavailable,
11925                "harness MCP launch overlay is unavailable",
11926            ))?;
11927            control
11928                .install_native_mcp_server_launch_overlay(instance_id, overlay)
11929                .map_err(|_| failure(
11930                    NodeFailureCode::HarnessMcpUnavailable,
11931                    "harness MCP launch overlay could not be installed",
11932                ))?;
11933            // State-change line: the overlay is installed and will be read
11934            // back by the PTY child's environment or, for ACP,
11935            // `gate4agent-shell-native`'s `mcp_server_acp_entry` at spawn
11936            // time -- no secrets here, only which instance/provider/mode
11937            // now carries a harness-MCP door.
11938            tracing::info!(
11939                node_id = %self.node_id,
11940                instance_id = ?instance_id,
11941                provider = %provider,
11942                mode = ?mode,
11943                "harness MCP overlay installed for spawn",
11944            );
11945            Some(NativeHarnessMcpOverlayGuard { control, instance_id })
11946        } else {
11947            None
11948        };
11949        let prepared_materialization = self.prepare_session_materialization(
11950            &address,
11951            &provider,
11952            mode,
11953            environment_profile.as_ref(),
11954            bundle.as_ref(),
11955            context.as_ref(),
11956            managed_authority.clone(),
11957        )?;
11958        let (environment_overlay, mut materialization_guard) = match prepared_materialization {
11959            Some((overlay, guard)) => (Some(overlay), Some(guard)),
11960            None => (None, None),
11961        };
11962        let materialization_id = materialization_guard
11963            .as_ref()
11964            .and_then(SessionMaterializationGuard::id)
11965            .cloned();
11966        let mut environment_selection = self.select_environment_profile(
11967            instance_id,
11968            &provider,
11969            mode,
11970            environment_profile.as_ref(),
11971        )?;
11972        // Station network overlays: Codex `-c network_access` and/or Claude
11973        // ProviderHome `--settings` sidecar (permits → allowedDomains).
11974        let provider_home = materialization_guard
11975            .as_ref()
11976            .and_then(SessionMaterializationGuard::provider_home);
11977        let network_overlay_args = self.resolve_network_allowlist_overlay_args(
11978            &provider,
11979            mode,
11980            approval_level,
11981            network_allowlist.as_ref(),
11982            provider_home,
11983        )?;
11984        let environment_overlay = match (environment_overlay.flatten(), network_overlay_args) {
11985            (prepared, args) if args.is_empty() => prepared,
11986            (Some(PreparedNativeLaunchOverlay::Instance(mut overlay)), args) => {
11987                overlay.append_extra_args(args).map_err(|_| {
11988                    failure(
11989                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
11990                        "network allowlist overlay could not merge with instance launch args",
11991                    )
11992                })?;
11993                Some(PreparedNativeLaunchOverlay::Instance(overlay))
11994            }
11995            (Some(PreparedNativeLaunchOverlay::Environment(overlay)), args) => {
11996                Some(PreparedNativeLaunchOverlay::Instance(
11997                    overlay
11998                        .into_instance_with_extra_args(args)
11999                        .map_err(|_| {
12000                            failure(
12001                                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
12002                                "network allowlist overlay could not merge with environment overlay",
12003                            )
12004                        })?,
12005                ))
12006            }
12007            (None, args) => Some(PreparedNativeLaunchOverlay::Instance(
12008                NativeInstanceLaunchOverlay::new(
12009                    provider.clone(),
12010                    match mode {
12011                        SessionMode::Pty => TransportKind::Pty,
12012                        SessionMode::Acp => TransportKind::Acp,
12013                        SessionMode::Inline => TransportKind::Pipe,
12014                    },
12015                    Vec::new(),
12016                    args,
12017                )
12018                .map_err(|_| {
12019                    failure(
12020                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
12021                        "network allowlist overlay is invalid for this transport",
12022                    )
12023                })?,
12024            )),
12025        };
12026        let mut instance_overlay = environment_overlay
12027            .map(|overlay| self.install_prepared_launch_overlay(instance_id, overlay))
12028            .transpose()?
12029            .flatten();
12030        let dispatch_timeout = spawn_dispatch_timeout(deadline)?;
12031        let record_id = self.bind_spawn_session_with_materialization(
12032            &address,
12033            provider.clone(),
12034            mode,
12035            runtime_policy,
12036            record_policy,
12037            environment_profile,
12038            bundle,
12039            context,
12040            materialization_id,
12041        )?;
12042        if let Some(selection) = environment_selection.take() {
12043            selection.retain();
12044        }
12045        if let Some(overlay) = instance_overlay.take() {
12046            overlay.retain();
12047        }
12048        if let Some(guard) = materialization_guard.take() {
12049            guard.retain();
12050        }
12051        // `transport` was already computed above, alongside this session's
12052        // `runtime_policy`; reused here for `Register` rather than derived a
12053        // second time.
12054        let agent_id = provider;
12055        let register_command_id = match self.dispatch_bounded(
12056            ControlCommand::Register {
12057                instance_id,
12058                agent_id,
12059                transport,
12060            },
12061            dispatch_timeout,
12062        )
12063        .await {
12064            Ok(command_id) => command_id,
12065            Err(error) => {
12066                if let Some(binding) = self.remove_binding(&address) {
12067                    self.cleanup_session_owned_materialization(&address, &binding)?;
12068                }
12069                if let Some(record_id) = record_id.as_ref() {
12070                    self.discard_record(record_id)?;
12071                }
12072                return Err(spawn_dispatch_error(error, deadline));
12073            }
12074        };
12075        let start_timeout = match spawn_dispatch_timeout(deadline) {
12076            Ok(timeout) => timeout,
12077            Err(error) => {
12078                let recovery = self
12079                    .rollback_spawn(
12080                        &address,
12081                        Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12082                    )
12083                    .await;
12084                return match recovery {
12085                    Ok(()) => {
12086                        if let Some(record_id) = record_id.as_ref() {
12087                            self.discard_record(record_id)?;
12088                        }
12089                        Err(error)
12090                    }
12091                    Err(recovery_error) => {
12092                        if let Some(record_id) = record_id.as_ref() {
12093                            self.mark_record_error(
12094                                record_id,
12095                                "spawn deadline elapsed and registration recovery failed",
12096                            )?;
12097                        }
12098                        Err(recovery_error)
12099                    }
12100                };
12101            }
12102        };
12103        if let Err(start_error) = self.verify_harness_mcp_before_start(harness_mcp) {
12104            if let Err(recovery_error) = self
12105                .rollback_spawn(&address, Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS))
12106                .await
12107            {
12108                return Err(recovery_error);
12109            }
12110            if let Some(record_id) = record_id.as_ref() {
12111                self.discard_record(record_id)?;
12112            }
12113            return Err(start_error);
12114        }
12115        let start_result = self
12116            .dispatch_bounded(
12117                ControlCommand::Start {
12118                    instance_id,
12119                    runtime_policy,
12120                    request: StartRequest {
12121                        working_directory,
12122                        terminal_size,
12123                        initial_prompt,
12124                        session_options: None,
12125                        approval_level,
12126                    },
12127                },
12128                start_timeout,
12129            )
12130            .await;
12131        let start_command_id = match start_result {
12132            Ok(command_id) => command_id,
12133            Err(start_error) => {
12134                let start_error = spawn_dispatch_error(start_error, deadline);
12135                let recovery = self
12136                    .rollback_spawn(
12137                        &address,
12138                        Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12139                    )
12140                    .await;
12141                return match recovery {
12142                    Ok(()) => {
12143                        if let Some(record_id) = record_id.as_ref() {
12144                            self.discard_record(record_id)?;
12145                        }
12146                        Err(start_error)
12147                    }
12148                    Err(recovery_error) => {
12149                        let diagnostic = format!(
12150                            "start failed and registration recovery failed: {}",
12151                            recovery_error.message,
12152                        );
12153                        if let Some(record_id) = record_id.as_ref() {
12154                            self.mark_record_error(record_id, &diagnostic)?;
12155                        }
12156                        Err(failure(recovery_error.code, &diagnostic))
12157                    }
12158                };
12159            }
12160        };
12161
12162        let commit_deadline = deadline.unwrap_or_else(|| Instant::now() + dispatch_timeout);
12163        loop {
12164            // `Register`/`Start` only fail synchronously for a port-level
12165            // ingress problem (see `dispatch_bounded`); the kernel refusing
12166            // either command's content -- an unsupported transport, an
12167            // unknown agent -- lands one drive-loop tick later, and no
12168            // session ever appears in `self.handle.snapshot()` for it to
12169            // notice below. Check for that named reason before falling
12170            // through to the blind presence poll, so a rejected spawn
12171            // reports why instead of just timing out.
12172            if let Some(reason) = self.take_rejected_command_reason(register_command_id)
12173                .or_else(|| self.take_rejected_command_reason(start_command_id))
12174            {
12175                self.rollback_spawn(
12176                    &address,
12177                    Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12178                ).await.map_err(|error| {
12179                        failure(
12180                            error.code,
12181                            &format!(
12182                                "spawn was rejected ({}) and registration recovery failed: {}",
12183                                reason.message, error.message
12184                            ),
12185                        )
12186                    })?;
12187                if let Some(record_id) = record_id.as_ref() {
12188                    self.discard_record(record_id)?;
12189                }
12190                let code = if reason.unsupported_transport {
12191                    NodeFailureCode::UnsupportedTransport
12192                } else {
12193                    NodeFailureCode::BackendOperationFailed
12194                };
12195                return Err(failure(code, &reason.message));
12196            }
12197            if let Some(current) = self
12198                .handle
12199                .snapshot()
12200                .sessions
12201                .iter()
12202                .find(|current| current.instance_id == instance_id)
12203            {
12204                if current.generation != session.generation {
12205                    let divergence = failure(
12206                        NodeFailureCode::StaleGeneration,
12207                        "spawn generation diverged",
12208                    );
12209                    return match self
12210                        .rollback_spawn(
12211                            &address,
12212                            Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12213                        )
12214                        .await
12215                    {
12216                        Ok(()) => {
12217                            if let Some(record_id) = record_id.as_ref() {
12218                                self.discard_record(record_id)?;
12219                            }
12220                            Err(divergence)
12221                        }
12222                        Err(recovery_error) => {
12223                            if let Some(record_id) = record_id.as_ref() {
12224                                self.mark_record_error(
12225                                    record_id,
12226                                    "spawn generation diverged and recovery failed",
12227                                )?;
12228                            }
12229                            Err(recovery_error)
12230                        }
12231                    };
12232                }
12233                if !matches!(current.status, gate4agent_types::SessionStatus::Registered) {
12234                    if let Some(overlay) = harness_mcp_overlay.take() {
12235                        overlay.retain();
12236                    }
12237                    #[cfg(feature = "dig2-station-probe")]
12238                    if let Some(guard) = browser_station_lease_guard.take() {
12239                        guard.retain();
12240                    }
12241                    return Ok((address, runtime_policy));
12242                }
12243            }
12244            if Instant::now() >= commit_deadline {
12245                self.rollback_spawn(
12246                    &address,
12247                    Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12248                ).await.map_err(|error| {
12249                        failure(
12250                            error.code,
12251                            &format!(
12252                                "start did not commit and registration recovery failed: {}",
12253                                error.message
12254                            ),
12255                        )
12256                    })?;
12257                if let Some(record_id) = record_id.as_ref() {
12258                    self.discard_record(record_id)?;
12259                }
12260                return Err(failure(
12261                    if deadline.is_some() {
12262                        NodeFailureCode::SpawnDeadlineExceeded
12263                    } else {
12264                        NodeFailureCode::BackendBusy
12265                    },
12266                    "start did not commit before the bounded deadline; registration was removed",
12267                ));
12268            }
12269            sleep(Duration::from_millis(2)).await;
12270        }
12271    }
12272
12273    fn effective_spawn_runtime_policy(
12274        &self,
12275        runtime_policy: ProviderRuntimePolicy,
12276    ) -> ProviderRuntimePolicy {
12277        #[cfg(feature = "fixture")]
12278        let runtime_policy = if self.fixture_semantic_hook_policy {
12279            ProviderRuntimePolicy::new(true, true, true, true, false, true)
12280                .expect("monitoring Hook fixture policy is internally valid")
12281        } else {
12282            runtime_policy
12283        };
12284        runtime_policy
12285    }
12286
12287    fn verify_harness_mcp_before_start(
12288        &self,
12289        prepared: Option<&PreparedHarnessMcpSpawn>,
12290    ) -> Result<(), NodeFailure> {
12291        prepared
12292            .map(PreparedHarnessMcpSpawn::verify_helper_program)
12293            .transpose()
12294            .map(|_| ())
12295            .map_err(harness_mcp_failure)
12296    }
12297
12298    async fn rollback_spawn(
12299        &self,
12300        address: &SessionAddress,
12301        commit_timeout: Duration,
12302    ) -> Result<(), NodeFailure> {
12303        self.dispatch_bounded(
12304            ControlCommand::Remove {
12305                instance_id: address.session.instance_id,
12306            },
12307            commit_timeout,
12308        )
12309        .await?;
12310        self.wait_until_removed(address, commit_timeout).await?;
12311        if let Some(binding) = self.remove_binding(address) {
12312            self.cleanup_session_owned_materialization(address, &binding)?;
12313        }
12314        Ok(())
12315    }
12316
12317    async fn remove_session(&self, address: &SessionAddress) -> Result<(), NodeFailure> {
12318        let commit_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
12319        self.dispatch_bounded(
12320            ControlCommand::Remove {
12321                instance_id: address.session.instance_id,
12322            },
12323            commit_timeout,
12324        )
12325            .await?;
12326        self.wait_until_removed(address, commit_timeout).await?;
12327        let removed = self.remove_binding(address);
12328        if let Some(binding) = removed.as_ref() {
12329            self.cleanup_session_owned_materialization(address, binding)?;
12330        }
12331        if let Some(lease_id) = removed.and_then(|binding| binding.managed_worktree_lease_id) {
12332            let snapshot = self.managed_worktrees.lock()
12333                .unwrap_or_else(|poisoned| poisoned.into_inner())
12334                .release_session(
12335                    &lease_id,
12336                    self.incarnation_id,
12337                    address.session.instance_id,
12338                    address.session.generation,
12339                    unix_time_ms(),
12340                );
12341            if self.persist_state().is_err() {
12342                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
12343            }
12344            if let Some(snapshot) = snapshot {
12345                self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
12346            }
12347            let _ = self.cleanup_managed_worktree(&lease_id, false).await;
12348        }
12349        Ok(())
12350    }
12351
12352    /// Polls until `address`'s session leaves the engine's own session map,
12353    /// or the bounded deadline expires.
12354    ///
12355    /// A `Remove` sent while the session is not yet in a terminal status
12356    /// (e.g. still `Stopping`, mid-effect right after a `Stop`) is rejected
12357    /// once by the engine (`SessionStatus::allows_remove`) and
12358    /// then discarded -- nothing re-issues it on its own, so the removal
12359    /// that would satisfy this wait never arrives and the caller was
12360    /// guaranteed to time out even though the session settles microseconds
12361    /// later. So this re-dispatches on every poll tick: a session that
12362    /// settles mid-wait -- the common case right after a `Stop` -- still
12363    /// gets removed inside the same bounded deadline.
12364    ///
12365    /// It re-dispatches only once the status will ACCEPT the command. A
12366    /// rejected re-dispatch is not the harmless no-op this loop first
12367    /// assumed: every rejection publishes a `ControlEventKind::Command
12368    /// Rejected` to every subscriber and writes a WARN line. Ungated, one
12369    /// ordinary teardown produced 241 of them in 1.6 seconds -- the whole
12370    /// `Stopping` window at one known-doomed dispatch per 2ms tick, fanned
12371    /// out to the harness and every attached client. Gated, the same
12372    /// teardown sends exactly one `Remove`, at the first tick the engine
12373    /// can act on it.
12374    ///
12375    /// The status is read from the snapshot this loop already takes, so
12376    /// the gate costs nothing extra. It races -- the status can move
12377    /// between the read and the dispatch -- and that is fine: a rare lost
12378    /// race is one rejection, and the next tick retries.
12379    ///
12380    /// Re-dispatch is separately rate-limited to
12381    /// [`REMOVE_REDISPATCH_INTERVAL_MS`], because the status gate alone
12382    /// leaves a second burst behind it. Once the session settles, every
12383    /// 2ms tick sent another `Remove` while the accepted one was still in
12384    /// flight, and each of those landed on an instance the engine had
12385    /// already dropped -- `agent instance … is not registered`, observed
12386    /// live as a tail of rejections immediately after the `session
12387    /// removed` line. The presence poll stays at 2ms so the wait still
12388    /// exits promptly; only the sending slows down.
12389    ///
12390    /// Rate-limited rather than once-only on purpose: the status gate
12391    /// covers the status precondition, but `Gate4AgentEngine::remove` has
12392    /// others (a pending operation, an in-flight capability probe or
12393    /// history request) that clear on their own. A single shot would turn
12394    /// those into a guaranteed `BackendBusy` timeout; at 10Hz they still
12395    /// get their retries, just twenty of them across the deadline instead
12396    /// of a thousand.
12397    async fn wait_until_removed(
12398        &self,
12399        address: &SessionAddress,
12400        commit_timeout: Duration,
12401    ) -> Result<(), NodeFailure> {
12402        let deadline = Instant::now() + commit_timeout;
12403        let mut next_dispatch = Instant::now();
12404        loop {
12405            let snapshot = self.handle.snapshot();
12406            let status = snapshot
12407                .sessions
12408                .iter()
12409                .find(|session| session.instance_id == address.session.instance_id)
12410                .map(|session| session.status.clone());
12411            let Some(status) = status else {
12412                return Ok(());
12413            };
12414            let now = Instant::now();
12415            if now >= deadline {
12416                return Err(failure(
12417                    NodeFailureCode::BackendBusy,
12418                    "remove did not commit before the bounded deadline; session binding was retained",
12419                ));
12420            }
12421            if status.allows_remove() && now >= next_dispatch {
12422                let _ = self.dispatch(ControlCommand::Remove {
12423                    instance_id: address.session.instance_id,
12424                });
12425                next_dispatch = now + Duration::from_millis(REMOVE_REDISPATCH_INTERVAL_MS);
12426            }
12427            sleep(Duration::from_millis(2)).await;
12428        }
12429    }
12430
12431    fn resync(&self, after_sequence: u64) -> NodeResponse {
12432        let history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
12433        let event_sequence = history.last_sequence;
12434        let oldest_available_sequence = history.replay_floor_sequence;
12435        let events = history
12436            .events
12437            .iter()
12438            .filter(|event| event.sequence > after_sequence)
12439            .cloned()
12440            .collect();
12441        drop(history);
12442        NodeResponse::Resync {
12443            event_sequence,
12444            oldest_available_sequence,
12445            snapshot: self.snapshot(),
12446            events,
12447        }
12448    }
12449}
12450
12451/// Coarse label for one `AgentStreamChunkKindV1`, used only by
12452/// `NodeShared::publish_agent_stream_chunk`'s own debug log -- mirrors
12453/// `hatchery-harness-service`'s `agent_stream_event_kind_label` (same
12454/// "name what crossed this boundary" reasoning, one layer upstream of it).
12455fn agent_stream_chunk_kind_label(kind: &AgentStreamChunkKindV1) -> &'static str {
12456    match kind {
12457        AgentStreamChunkKindV1::Text { .. } => "text",
12458        AgentStreamChunkKindV1::Thinking { .. } => "thinking",
12459        AgentStreamChunkKindV1::InteractionPrompt { .. } => "interaction-prompt",
12460        AgentStreamChunkKindV1::ModeCatalog { .. } => "mode-catalog",
12461        AgentStreamChunkKindV1::ConfigOptions { .. } => "config-options",
12462        AgentStreamChunkKindV1::ModelCatalog { .. } => "model-catalog",
12463        AgentStreamChunkKindV1::Blocked { .. } => "blocked",
12464    }
12465}
12466
12467/// Serves this wire on the local endpoint, and -- when `call_home` names a
12468/// relay -- over an outbound connection to it as well.
12469///
12470/// The two live in one future rather than a fourth arm of `run`'s own
12471/// `select!` because they are one concern: this is where the node's wire
12472/// is served, and the direction a given connection was opened in does not
12473/// change what happens on it. It also means the existing graceful drain
12474/// below covers both without being duplicated.
12475///
12476/// Either side finishing ends the pair, which is correct: the listener
12477/// only returns on shutdown or a bind failure, and `call_home_loop` only
12478/// returns on shutdown -- a failed relay connection is retried inside it,
12479/// never propagated out.
12480async fn accept_connections(
12481    endpoint: &str,
12482    call_home: Option<std::net::SocketAddr>,
12483    shared: Arc<NodeShared>,
12484) -> Result<(), NodeServerError> {
12485    let mut connections = JoinSet::new();
12486    let result = match call_home {
12487        None => accept_connections_inner(endpoint, shared, &mut connections).await,
12488        Some(relay) => {
12489            let listener =
12490                accept_connections_inner(endpoint, Arc::clone(&shared), &mut connections);
12491            let dialler = call_home_loop(relay, Arc::clone(&shared));
12492            tokio::pin!(listener);
12493            tokio::pin!(dialler);
12494            tokio::select! {
12495                result = &mut listener => result,
12496                result = &mut dialler => result,
12497            }
12498        }
12499    };
12500    let graceful_deadline = Instant::now() + Duration::from_millis(CONNECTION_SHUTDOWN_GRACE_MS);
12501    while !connections.is_empty() {
12502        let remaining = graceful_deadline.saturating_duration_since(Instant::now());
12503        if remaining.is_zero()
12504            || timeout(remaining, connections.join_next()).await.is_err()
12505        {
12506            break;
12507        }
12508    }
12509    connections.abort_all();
12510    while connections.join_next().await.is_some() {}
12511    result
12512}
12513
12514/// Backoff ladder for a relay that will not answer, capped rather than
12515/// unbounded: a node whose relay is down is not broken and should keep
12516/// trying forever, but it should not spend a core doing it.
12517const CALL_HOME_BACKOFFS_MS: [u64; 5] = [500, 1_000, 2_000, 4_000, 8_000];
12518
12519/// Dials the relay, serves this wire over the connection, and does it
12520/// again when the connection ends.
12521///
12522/// The node is the SERVER on the socket it just opened. That inversion is
12523/// the whole point: `serve_connection` is the same function the local
12524/// listener feeds, and it neither knows nor cares which end placed the
12525/// call. The one thing this direction adds is the preface -- the relay
12526/// accepted a socket and cannot know whose it is, so the node says its
12527/// name before the handshake it would otherwise begin with.
12528///
12529/// Never returns an error. A relay that is down, refusing, or speaking a
12530/// different protocol is a condition to retry, not a reason to take the
12531/// node's local endpoint down with it; the only way out is shutdown.
12532async fn call_home_loop(
12533    relay: std::net::SocketAddr,
12534    shared: Arc<NodeShared>,
12535) -> Result<(), NodeServerError> {
12536    let mut failures = 0_usize;
12537    loop {
12538        if shared.shutdown.load(Ordering::Acquire) {
12539            return Ok(());
12540        }
12541        let permit = tokio::select! {
12542            permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
12543                permit.map_err(|_| NodeServerError::PreauthClosed)?
12544            }
12545            _ = shared.shutdown_notify.notified() => return Ok(()),
12546        };
12547        match dial_relay_and_serve(relay, Arc::clone(&shared), permit).await {
12548            Ok(()) => {
12549                // A clean end is the relay hanging up, which it does on
12550                // its own restart. Reconnect immediately rather than
12551                // serving the backoff a peer that is plainly there.
12552                failures = 0;
12553                tracing::info!(node_id = %shared.node_id, %relay, "call-home connection closed");
12554            }
12555            Err(error) => {
12556                failures = failures.saturating_add(1);
12557                tracing::warn!(
12558                    node_id = %shared.node_id,
12559                    %relay,
12560                    cause = %error,
12561                    attempt = failures,
12562                    "call-home connection failed",
12563                );
12564                let backoff = CALL_HOME_BACKOFFS_MS
12565                    [failures.saturating_sub(1).min(CALL_HOME_BACKOFFS_MS.len() - 1)];
12566                tokio::select! {
12567                    _ = sleep(Duration::from_millis(backoff)) => {}
12568                    _ = shared.shutdown_notify.notified() => return Ok(()),
12569                }
12570            }
12571        }
12572    }
12573}
12574
12575/// One call-home attempt: connect, name this node, then serve.
12576async fn dial_relay_and_serve(
12577    relay: std::net::SocketAddr,
12578    shared: Arc<NodeShared>,
12579    preauth_permit: OwnedSemaphorePermit,
12580) -> Result<(), NodeServerError> {
12581    let mut stream = tokio::net::TcpStream::connect(relay)
12582        .await
12583        .map_err(NodeServerError::CallHomeTransport)?;
12584    // Nagle off for the same reason the rest of this wire wants it off:
12585    // the traffic is small request/response frames, and coalescing them
12586    // adds latency to every one.
12587    let _ = stream.set_nodelay(true);
12588    gate4agent_node_wire::write_call_home_announce(&mut stream, &shared.node_id)
12589        .await
12590        .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
12591    tracing::info!(node_id = %shared.node_id, %relay, "announced this node to the relay");
12592    serve_connection(stream, shared, preauth_permit).await
12593}
12594
12595async fn accept_connections_inner(
12596    endpoint: &str,
12597    shared: Arc<NodeShared>,
12598    connections: &mut JoinSet<Result<(), NodeServerError>>,
12599) -> Result<(), NodeServerError> {
12600    let mut listener = OwnerOnlyLocalListener::bind(endpoint).await?;
12601    loop {
12602        while connections.try_join_next().is_some() {}
12603        if shared.shutdown.load(Ordering::Acquire) {
12604            return Ok(());
12605        }
12606        let preauth_permit = tokio::select! {
12607            permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
12608                permit.map_err(|_| NodeServerError::PreauthClosed)?
12609            }
12610            _ = shared.shutdown_notify.notified() => return Ok(()),
12611        };
12612        let server = tokio::select! {
12613            result = listener.accept() => result?,
12614            _ = shared.shutdown_notify.notified() => return Ok(()),
12615        };
12616        let connection_shared = Arc::clone(&shared);
12617        connections.spawn(async move {
12618            serve_connection(server, connection_shared, preauth_permit).await
12619        });
12620        if shared.shutdown.load(Ordering::Acquire) {
12621            return Ok(());
12622        }
12623    }
12624}
12625
12626/// Serves one connection, whichever end opened it.
12627///
12628/// Generic over the stream rather than typed to `LocalServerStream`
12629/// because a call-home connection arrives on a `TcpStream` the node itself
12630/// dialled. Nothing in the body ever depended on the concrete type -- it
12631/// reads and writes framed JSON and then `tokio::io::split`s -- so this is
12632/// the whole of what welding "who dials" to "who serves" was costing.
12633async fn serve_connection<S>(
12634    mut pipe: S,
12635    shared: Arc<NodeShared>,
12636    preauth_permit: OwnedSemaphorePermit,
12637) -> Result<(), NodeServerError>
12638where
12639    S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
12640{
12641    let hello_frame = timeout(
12642        Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
12643        read_json_frame_limited_body_timeout(
12644            &mut pipe,
12645            MAX_NODE_HELLO_FRAME_BYTES,
12646            Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
12647        ),
12648    )
12649    .await
12650    .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
12651    let ClientFrame::Hello(hello) = hello_frame else {
12652        return Err(NodeServerError::Handshake("first frame must be hello".to_owned()));
12653    };
12654    if hello.build_stamp != BUILD_STAMP {
12655        return Err(NodeServerError::Handshake(format!(
12656            "build stamp mismatch: local={BUILD_STAMP} remote={}",
12657            hello.build_stamp,
12658        )));
12659    }
12660    let compatibility = match hello.compatibility.as_ref() {
12661        Some(offer) => {
12662            let mut selected = node_compatibility_support(&shared)?
12663                .negotiate(offer)
12664                .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
12665            project_negotiated_provider_ids(&mut selected);
12666            Some(selected)
12667        }
12668        None => None,
12669    };
12670    let server_nonce = random_nonce().map_err(NodeServerError::Authentication)?;
12671    let server_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
12672        (Some(offer), Some(selected)) => negotiated_auth_proof(
12673            shared.access_token.as_bytes(),
12674            AuthDirection::Server,
12675            hello.role,
12676            &hello.client_nonce,
12677            &server_nonce,
12678            offer,
12679            selected,
12680        ),
12681        (None, None) => auth_proof(
12682            shared.access_token.as_bytes(),
12683            AuthDirection::Server,
12684            hello.role,
12685            &hello.client_nonce,
12686            &server_nonce,
12687        ),
12688        _ => Err("node compatibility authentication state is inconsistent".to_owned()),
12689    }
12690    .map_err(NodeServerError::Authentication)?;
12691    write_json_frame_limited(
12692        &mut pipe,
12693        &ServerFrame::Challenge(ServerChallenge {
12694            build_stamp: BUILD_STAMP.to_owned(),
12695            server_nonce,
12696            server_proof,
12697            compatibility: compatibility.clone(),
12698        }),
12699        MAX_NODE_HELLO_FRAME_BYTES,
12700    )
12701    .await?;
12702    let authentication = timeout(
12703        Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
12704        read_json_frame_limited_body_timeout(
12705            &mut pipe,
12706            MAX_NODE_HELLO_FRAME_BYTES,
12707            Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
12708        ),
12709    )
12710    .await
12711    .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
12712    let ClientFrame::Authenticate(authentication) = authentication else {
12713        return Err(NodeServerError::Handshake("second frame must authenticate the challenge".to_owned()));
12714    };
12715    let expected_client_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
12716        (Some(offer), Some(selected)) => negotiated_auth_proof(
12717            shared.access_token.as_bytes(),
12718            AuthDirection::Client,
12719            hello.role,
12720            &hello.client_nonce,
12721            &server_nonce,
12722            offer,
12723            selected,
12724        ),
12725        (None, None) => auth_proof(
12726            shared.access_token.as_bytes(),
12727            AuthDirection::Client,
12728            hello.role,
12729            &hello.client_nonce,
12730            &server_nonce,
12731        ),
12732        _ => Err("node compatibility authentication state is inconsistent".to_owned()),
12733    }
12734    .map_err(NodeServerError::Authentication)?;
12735    if !proofs_match(&authentication.client_proof, &expected_client_proof) {
12736        return Err(NodeServerError::Handshake("access denied".to_owned()));
12737    }
12738    let selected_capabilities = compatibility
12739        .as_ref()
12740        .map(|selected| selected.capabilities.clone())
12741        .unwrap_or_default();
12742    let include_provider_runtime_status = selected_capabilities.iter().any(|capability| {
12743        capability.as_str() == NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY
12744    });
12745    let include_open_provider_ids = selected_capabilities.iter().any(|capability| {
12746        capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
12747    });
12748    let include_terminal_frame_events = selected_capabilities.iter().any(|capability| {
12749        capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
12750    });
12751    let include_agent_stream_events = selected_capabilities.iter().any(|capability| {
12752        capability.as_str() == NODE_AGENT_STREAM_EVENTS_CAPABILITY
12753    });
12754    let include_spawn_profiles = selected_capabilities.iter().any(|capability| {
12755        capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
12756    });
12757    let include_managed_worktrees = selected_capabilities.iter().any(|capability| {
12758        capability.as_str() == NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY
12759    }) && selected_capabilities.iter().any(|capability| {
12760        capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
12761    });
12762    let include_child_environment_profiles = selected_capabilities.iter().any(|capability| {
12763        capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
12764    });
12765    let include_session_bundles = selected_capabilities.iter().any(|capability| {
12766        capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
12767    });
12768    let include_history_context_packs = selected_capabilities.iter().any(|capability| {
12769        capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
12770    });
12771    let include_agent_progress = selected_capabilities.iter().any(|capability| {
12772        capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
12773    });
12774    let include_session_task_correlation = selected_capabilities.iter().any(|capability| {
12775        capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
12776    });
12777    let include_harness_mcp_proxy = selected_capabilities.iter().any(|capability| {
12778        capability.as_str() == NODE_HARNESS_MCP_READ_PROXY_CAPABILITY
12779    });
12780    let authenticated_permit = Arc::clone(&shared.authenticated_slots)
12781        .try_acquire_owned()
12782        .map_err(|_| NodeServerError::AuthenticatedConnectionLimit)?;
12783    let connection_id = shared.next_connection_id.fetch_add(1, Ordering::AcqRel);
12784    let _release_guard = ControllerReleaseGuard {
12785        shared: Arc::clone(&shared),
12786        connection_id,
12787    };
12788    drop(preauth_permit);
12789    let _authenticated_permit = authenticated_permit;
12790    let mut event_rx = shared.event_tx.subscribe();
12791    let mut terminal_event_rx = terminal_event_subscription(
12792        &shared,
12793        include_terminal_frame_events,
12794    );
12795    let mut agent_stream_event_rx = agent_stream_event_subscription(
12796        &shared,
12797        include_agent_stream_events,
12798    );
12799    let mut harness_mcp_proxy_event_rx = harness_mcp_proxy_event_subscription(
12800        &shared,
12801        include_harness_mcp_proxy,
12802    );
12803    write_json_frame(
12804        &mut pipe,
12805        &ServerFrame::Hello(NodeHello {
12806            build_stamp: BUILD_STAMP.to_owned(),
12807            incarnation_id: shared.incarnation_id,
12808            connection_id,
12809            role: hello.role,
12810            event_sequence: shared.current_sequence(),
12811            controller: shared.controller_state(),
12812            snapshot: snapshot_for_wire(
12813                &shared,
12814                include_provider_runtime_status,
12815                include_open_provider_ids,
12816                include_spawn_profiles,
12817                include_managed_worktrees,
12818                include_child_environment_profiles,
12819                include_session_bundles,
12820                include_history_context_packs,
12821                include_agent_progress,
12822                include_session_task_correlation,
12823            ),
12824            compatibility,
12825        }),
12826    )
12827    .await?;
12828    let (mut reader, mut writer) = tokio::io::split(pipe);
12829    let (frame_tx, mut frame_rx) = mpsc::channel(16);
12830    let reader_task = tokio::spawn(async move {
12831        loop {
12832            let frame = read_json_frame_limited_body_timeout::<_, ClientFrame>(
12833                &mut reader,
12834                MAX_NODE_CLIENT_FRAME_BYTES,
12835                Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
12836            )
12837            .await;
12838            let terminal = frame.is_err();
12839            if frame_tx.send(frame).await.is_err() || terminal {
12840                break;
12841            }
12842        }
12843    });
12844    let _reader_abort = AbortTaskOnDrop(reader_task.abort_handle());
12845    let mut pending_events = Vec::new();
12846    let mut resync_required = false;
12847    let mut discard_events_through = 0_u64;
12848    loop {
12849        // One tick of the serve loop, whether or not it had anything to do.
12850        // Against `connection_events_sent` this separates "busy" from
12851        // "spinning": a loop turning over thousands of times a second while
12852        // sending nothing is not serving, it is burning a core.
12853        shared.connection_loop_iterations.fetch_add(1, Ordering::Relaxed);
12854        let durable_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
12855            .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
12856        for _ in 0..durable_drain_budget {
12857            match event_rx.try_recv() {
12858                Ok(event) => queue_connection_event(
12859                    &mut pending_events,
12860                    event,
12861                    discard_events_through,
12862                ),
12863                Err(broadcast::error::TryRecvError::Empty) => break,
12864                Err(broadcast::error::TryRecvError::Lagged(_)) => {
12865                    resync_required = true;
12866                    break;
12867                }
12868                Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
12869            }
12870        }
12871        if let Some(receiver) = terminal_event_rx.as_mut() {
12872            match receiver.try_recv() {
12873                Ok(events) => queue_connection_event_batch(
12874                    &mut pending_events,
12875                    &events,
12876                    discard_events_through,
12877                ),
12878                Err(broadcast::error::TryRecvError::Empty) => {}
12879                Err(broadcast::error::TryRecvError::Lagged(_)) => {
12880                    resync_required = true;
12881                }
12882                Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
12883            }
12884        }
12885        if let Some(receiver) = agent_stream_event_rx.as_mut() {
12886            // Loops up to the same remaining-room budget the durable
12887            // `event_rx` drain above uses, rather than one `try_recv` per
12888            // tick: each successful receive here is itself only ONE
12889            // `NodeEventEnvelope` batch (`publish_agent_stream_chunk` always
12890            // sends `vec![envelope]`, never more), so a single-shot drain
12891            // caps this connection's agent-stream throughput at one chunk
12892            // per outer-loop tick regardless of how many are already queued
12893            // -- exactly the kind of self-inflicted stall this crate's own
12894            // durable-event budget exists to avoid.
12895            let agent_stream_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
12896                .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
12897            for _ in 0..agent_stream_drain_budget {
12898                match receiver.try_recv() {
12899                    Ok(events) => queue_connection_event_batch(
12900                        &mut pending_events,
12901                        &events,
12902                        discard_events_through,
12903                    ),
12904                    Err(broadcast::error::TryRecvError::Empty) => break,
12905                    Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
12906                        tracing::warn!(
12907                            connection_id,
12908                            dropped,
12909                            "agent stream event subscription lagged; forcing a resync",
12910                        );
12911                        resync_required = true;
12912                        break;
12913                    }
12914                    Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
12915                }
12916            }
12917        }
12918        if let Some(receiver) = harness_mcp_proxy_event_rx.as_mut() {
12919            // Mirrors the agent-stream drain immediately above: each
12920            // successful receive is one single-envelope batch
12921            // (`publish_transient` always sends `vec![envelope]`), so this
12922            // loops up to the same remaining-room budget rather than
12923            // stopping after one `try_recv`.
12924            let harness_mcp_proxy_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
12925                .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
12926            for _ in 0..harness_mcp_proxy_drain_budget {
12927                match receiver.try_recv() {
12928                    Ok(events) => queue_connection_event_batch(
12929                        &mut pending_events,
12930                        &events,
12931                        discard_events_through,
12932                    ),
12933                    Err(broadcast::error::TryRecvError::Empty) => break,
12934                    Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
12935                        tracing::warn!(
12936                            connection_id,
12937                            dropped,
12938                            "harness MCP proxy event subscription lagged; forcing a resync",
12939                        );
12940                        resync_required = true;
12941                        break;
12942                    }
12943                    Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
12944                }
12945            }
12946        }
12947        if resync_required {
12948            let event = resync_required_event(&shared);
12949            apply_connection_resync_watermark(
12950                &mut pending_events,
12951                &mut discard_events_through,
12952                event.sequence,
12953            );
12954            write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
12955            resync_required = false;
12956            continue;
12957        }
12958        if !pending_events.is_empty() {
12959            pending_events.sort_unstable_by_key(|event| event.sequence);
12960            let burst_len = connection_event_burst_len(pending_events.len());
12961            shared
12962                .connection_events_sent
12963                .fetch_add(burst_len as u64, Ordering::Relaxed);
12964            for event in pending_events.drain(..burst_len) {
12965                let event = if include_managed_worktrees {
12966                    Some(event)
12967                } else {
12968                    project_event_without_managed_worktrees(event)
12969                };
12970                let event = event.and_then(|event| if include_open_provider_ids {
12971                    Some(event)
12972                } else {
12973                    project_event_legacy_provider_ids(&shared, event)
12974                });
12975                let event = event.map(|event| if include_child_environment_profiles {
12976                    event
12977                } else {
12978                    project_event_without_child_environment_profile(event)
12979                });
12980                let event = event.map(|event| if include_session_bundles {
12981                    event
12982                } else {
12983                    project_event_without_session_bundle(event)
12984                });
12985                let event = event.map(|event| {
12986                    project_event_history_for_wire(event, include_history_context_packs)
12987                });
12988                let event = event.map(|event| if include_history_context_packs {
12989                    event
12990                } else {
12991                    project_event_without_context_pack(event)
12992                });
12993                let event = event.map(|event| if include_session_task_correlation {
12994                    event
12995                } else {
12996                    project_event_without_session_task_binding(event)
12997                });
12998                let exact_harness_controller = hello.role == ClientRole::Operator
12999                    && shared.controller_state().is_some_and(|controller| {
13000                        controller.connection_id == connection_id
13001                    });
13002                let event = event.and_then(|event| {
13003                    if include_harness_mcp_proxy && exact_harness_controller {
13004                        Some(event)
13005                    } else {
13006                        project_event_without_harness_mcp_proxy(event)
13007                    }
13008                });
13009                if let Some(event) = event {
13010                    write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
13011                }
13012            }
13013        }
13014        tokio::select! {
13015            biased;
13016            frame = frame_rx.recv() => {
13017                let frame = match frame {
13018                    Some(frame) => frame,
13019                    None => break,
13020                };
13021                let frame = match frame {
13022                    Ok(frame) => frame,
13023                    Err(FrameError::Io(error)) if matches!(error.kind(), io::ErrorKind::BrokenPipe | io::ErrorKind::UnexpectedEof) => break,
13024                    Err(error) => return Err(error.into()),
13025                };
13026                let ClientFrame::Request(request) = frame else {
13027                    return Err(NodeServerError::Handshake("hello may only be sent once".to_owned()));
13028                };
13029                #[cfg(feature = "fixture")]
13030                let probe_spawn_managed_worktree_v2 = matches!(
13031                    &request.request,
13032                    NodeRequest::SpawnManagedWorktreeV2 { .. }
13033                );
13034                let requires_open_provider_ids =
13035                    request_requires_open_provider_ids(&shared, &request.request);
13036                let requires_child_environment_profile =
13037                    request_requires_child_environment_profile(&shared, &request.request);
13038                let requires_session_bundle =
13039                    request_requires_session_bundle(&shared, &request.request);
13040                let requires_history_context_pack =
13041                    request_requires_history_context_pack(&shared, &request.request);
13042                let mut reply = if requires_open_provider_ids && !include_open_provider_ids {
13043                    ResponseEnvelope {
13044                        request_id: request.request_id,
13045                        result: Err(failure(
13046                            NodeFailureCode::UnsupportedCapability,
13047                            "open provider IDs were not negotiated",
13048                        )),
13049                    }
13050                } else if requires_child_environment_profile
13051                    && !include_child_environment_profiles
13052                {
13053                    ResponseEnvelope {
13054                        request_id: request.request_id,
13055                        result: Err(failure(
13056                            NodeFailureCode::UnsupportedCapability,
13057                            "child environment profiles were not negotiated",
13058                        )),
13059                    }
13060                } else if requires_session_bundle && !include_session_bundles {
13061                    ResponseEnvelope {
13062                        request_id: request.request_id,
13063                        result: Err(failure(
13064                            NodeFailureCode::UnsupportedCapability,
13065                            "session bundles were not negotiated",
13066                        )),
13067                    }
13068                } else if requires_history_context_pack && !include_history_context_packs {
13069                    ResponseEnvelope {
13070                        request_id: request.request_id,
13071                        result: Err(failure(
13072                            NodeFailureCode::UnsupportedCapability,
13073                            "history context packs were not negotiated",
13074                        )),
13075                    }
13076                } else if request_uses_unnegotiated_capability(
13077                    &request.request,
13078                    &selected_capabilities,
13079                )
13080                {
13081                    ResponseEnvelope {
13082                        request_id: request.request_id,
13083                        result: Err(failure(
13084                            NodeFailureCode::UnsupportedCapability,
13085                            "request capability was not negotiated",
13086                        )),
13087                    }
13088                } else {
13089                    process_request(&shared, connection_id, hello.role, request).await
13090                };
13091                #[cfg(feature = "fixture")]
13092                if probe_spawn_managed_worktree_v2 {
13093                    shared
13094                        .fixture_spawn_managed_worktree_v2_failure_probe
13095                        .record(reply.result.as_ref().err().map(|failure| failure.code));
13096                }
13097                if !include_provider_runtime_status {
13098                    clear_response_provider_runtime_status(&mut reply);
13099                }
13100                if !include_managed_worktrees {
13101                    project_response_without_managed_worktrees(&mut reply);
13102                }
13103                if !include_open_provider_ids {
13104                    project_response_legacy_provider_ids(&shared, &mut reply);
13105                }
13106                if !include_child_environment_profiles {
13107                    project_response_without_child_environment_profile(&mut reply);
13108                }
13109                if !include_session_bundles {
13110                    project_response_without_session_bundle(&mut reply);
13111                }
13112                project_response_history_for_wire(
13113                    &mut reply,
13114                    include_history_context_packs,
13115                );
13116                if !include_history_context_packs {
13117                    project_response_without_context_pack(&mut reply);
13118                }
13119                if !include_agent_progress {
13120                    project_response_without_agent_progress(&mut reply);
13121                }
13122                if !include_session_task_correlation {
13123                    project_response_without_session_task_binding(&mut reply);
13124                }
13125                write_json_frame(&mut writer, &ServerFrame::Reply(reply)).await?;
13126            }
13127            _ = tokio::task::yield_now(), if !pending_events.is_empty() => {}
13128            event = event_rx.recv() => {
13129                match event {
13130                    Ok(event) => queue_connection_event(
13131                        &mut pending_events,
13132                        event,
13133                        discard_events_through,
13134                    ),
13135                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13136                    Err(broadcast::error::RecvError::Closed) => break,
13137                }
13138            }
13139            events = receive_terminal_event_batch(&mut terminal_event_rx) => {
13140                match events {
13141                    Ok(events) => queue_connection_event_batch(
13142                        &mut pending_events,
13143                        &events,
13144                        discard_events_through,
13145                    ),
13146                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13147                    Err(broadcast::error::RecvError::Closed) => break,
13148                }
13149            }
13150            events = receive_terminal_event_batch(&mut agent_stream_event_rx) => {
13151                match events {
13152                    Ok(events) => queue_connection_event_batch(
13153                        &mut pending_events,
13154                        &events,
13155                        discard_events_through,
13156                    ),
13157                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13158                    Err(broadcast::error::RecvError::Closed) => break,
13159                }
13160            }
13161            events = receive_terminal_event_batch(&mut harness_mcp_proxy_event_rx) => {
13162                match events {
13163                    Ok(events) => queue_connection_event_batch(
13164                        &mut pending_events,
13165                        &events,
13166                        discard_events_through,
13167                    ),
13168                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13169                    Err(broadcast::error::RecvError::Closed) => break,
13170                }
13171            }
13172            _ = shared.shutdown_notify.notified() => break,
13173        }
13174    }
13175    Ok(())
13176}
13177
13178async fn receive_terminal_event_batch(
13179    receiver: &mut Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>>,
13180) -> Result<Arc<Vec<NodeEventEnvelope>>, broadcast::error::RecvError> {
13181    match receiver {
13182        Some(receiver) => receiver.recv().await,
13183        None => std::future::pending().await,
13184    }
13185}
13186
13187fn terminal_event_subscription(
13188    shared: &NodeShared,
13189    enabled: bool,
13190) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13191    enabled.then(|| shared.terminal_event_tx.subscribe())
13192}
13193
13194/// Mirrors `terminal_event_subscription` exactly, over the agent stream's
13195/// own channel -- see `NodeShared::agent_stream_event_tx`'s own doc for why
13196/// this needs a dedicated channel rather than a flag on the terminal one.
13197fn agent_stream_event_subscription(
13198    shared: &NodeShared,
13199    enabled: bool,
13200) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13201    enabled.then(|| shared.agent_stream_event_tx.subscribe())
13202}
13203
13204/// Mirrors `agent_stream_event_subscription` exactly, over the harness MCP
13205/// proxy's own channel -- see `NodeShared::harness_mcp_proxy_event_tx`'s
13206/// own doc for why this needs a dedicated channel rather than a ride on
13207/// the durable `event_tx`.
13208fn harness_mcp_proxy_event_subscription(
13209    shared: &NodeShared,
13210    enabled: bool,
13211) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13212    enabled.then(|| shared.harness_mcp_proxy_event_tx.subscribe())
13213}
13214
13215fn queue_connection_event(
13216    pending: &mut Vec<NodeEventEnvelope>,
13217    event: NodeEventEnvelope,
13218    discard_through: u64,
13219) {
13220    if event.sequence <= discard_through
13221        && !event.event.requires_harness_mcp_proxy_capability()
13222    {
13223        return;
13224    }
13225    if let NodeEvent::TerminalFrame { address, .. } = &event.event {
13226        if let Some(index) = pending.iter().position(|current| {
13227            matches!(
13228                &current.event,
13229                NodeEvent::TerminalFrame {
13230                    address: current_address,
13231                    ..
13232                } if current_address == address
13233            )
13234        }) {
13235            pending.remove(index);
13236        }
13237    }
13238    pending.push(event);
13239}
13240
13241fn project_event_without_harness_mcp_proxy(
13242    envelope: NodeEventEnvelope,
13243) -> Option<NodeEventEnvelope> {
13244    (!envelope.event.requires_harness_mcp_proxy_capability()).then_some(envelope)
13245}
13246
13247fn queue_connection_event_batch(
13248    pending: &mut Vec<NodeEventEnvelope>,
13249    events: &[NodeEventEnvelope],
13250    discard_through: u64,
13251) {
13252    for event in events {
13253        queue_connection_event(pending, event.clone(), discard_through);
13254    }
13255}
13256
13257fn apply_connection_resync_watermark(
13258    pending: &mut Vec<NodeEventEnvelope>,
13259    discard_through: &mut u64,
13260    marker_sequence: u64,
13261) {
13262    *discard_through = (*discard_through).max(marker_sequence);
13263    pending.retain(|event| event.sequence > *discard_through);
13264}
13265
13266fn connection_event_burst_len(pending_len: usize) -> usize {
13267    pending_len.min(NODE_CONNECTION_EVENT_BURST_MAX)
13268}
13269
13270fn resync_required_event(shared: &NodeShared) -> NodeEventEnvelope {
13271    let history = shared
13272        .history
13273        .lock()
13274        .unwrap_or_else(|poisoned| poisoned.into_inner());
13275    let sequence = history.last_sequence;
13276    let oldest_available_sequence = history.replay_floor_sequence;
13277    NodeEventEnvelope {
13278        sequence,
13279        event: NodeEvent::ResyncRequired {
13280            oldest_available_sequence,
13281        },
13282    }
13283}
13284
13285fn node_compatibility_support(
13286    shared: &NodeShared,
13287) -> Result<NodeCompatibilitySupport, NodeServerError> {
13288    let mut support = node_compatibility_support_for_manifest(
13289        &shared.provider_contracts,
13290        &shared.provider_adapter_contracts,
13291    )?;
13292    if shared
13293        .delivery_store
13294        .lock()
13295        .unwrap_or_else(|poisoned| poisoned.into_inner())
13296        .is_some()
13297    {
13298        support.capabilities.push(
13299            CapabilityId::new(NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY)
13300                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13301        );
13302    }
13303    if shared.harness_mcp_registry.is_some() {
13304        support.capabilities.push(
13305            CapabilityId::new(NODE_HARNESS_MCP_READ_PROXY_CAPABILITY)
13306                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13307        );
13308    }
13309    Ok(support)
13310}
13311
13312fn node_compatibility_support_for_manifest(
13313    provider_contracts: &[ProviderContractSupport],
13314    provider_adapter_contracts: &[ProviderAdapterContractSupport],
13315) -> Result<NodeCompatibilitySupport, NodeServerError> {
13316    Ok(NodeCompatibilitySupport {
13317        build_stamp: BUILD_STAMP.to_owned(),
13318        capabilities: baseline_capabilities()?,
13319        host: platform::host_descriptor().map_err(NodeServerError::Handshake)?,
13320        path_semantics: platform::path_semantics(),
13321        local_transport: platform::local_transport(),
13322        state_schema: StateSchemaSupport {
13323            versions: ProtocolRange::new(NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10)
13324                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13325        },
13326        provider_contracts: provider_contracts.to_vec(),
13327        provider_adapter_contracts: provider_adapter_contracts.to_vec(),
13328    })
13329}
13330
13331fn baseline_capabilities() -> Result<Vec<CapabilityId>, NodeServerError> {
13332    let capabilities = [
13333        NODE_COMPATIBILITY_METADATA_CAPABILITY,
13334        CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
13335        NODE_REPOSITORY_PATH_CAPABILITY,
13336        NODE_WORKSPACE_FILE_READ_CAPABILITY,
13337        NODE_WORKSPACE_FILE_WRITE_CAPABILITY,
13338        NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
13339        NODE_GIT_READ_CAPABILITY,
13340        NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY,
13341        NODE_PROVIDER_ID_OPEN_CAPABILITY,
13342        NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
13343        NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY,
13344        NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
13345        NODE_AGENT_STREAM_EVENTS_CAPABILITY,
13346        NODE_ACP_CONTROL_CAPABILITY,
13347        NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
13348        NODE_SPAWN_PROFILE_REVISION_CAPABILITY,
13349        NODE_WORKTREE_SELECTION_CAPABILITY,
13350        NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
13351        NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
13352        NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
13353        NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
13354        NODE_HISTORY_CONTEXT_PACK_CAPABILITY,
13355        NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
13356        NODE_NATIVE_SESSION_CATALOG_CAPABILITY,
13357        NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
13358        NODE_NATIVE_SESSION_INDEX_CAPABILITY,
13359        NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
13360        NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
13361        NODE_SESSION_TASK_CORRELATION_CAPABILITY,
13362    ]
13363        .into_iter()
13364        .map(|capability| {
13365            CapabilityId::new(capability)
13366                .map_err(|error| NodeServerError::Handshake(error.to_string()))
13367        })
13368        .collect::<Result<Vec<_>, _>>()?;
13369    #[cfg(windows)]
13370    let capabilities = {
13371        let mut capabilities = capabilities;
13372        capabilities.push(
13373            CapabilityId::new(NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY)
13374                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13375        );
13376        capabilities
13377    };
13378    Ok(capabilities)
13379}
13380
13381fn request_uses_unnegotiated_capability(
13382    request: &NodeRequest,
13383    selected_capabilities: &[CapabilityId],
13384) -> bool {
13385    let selected = |required: &str| {
13386        selected_capabilities
13387            .iter()
13388            .any(|capability| capability.as_str() == required)
13389    };
13390    request
13391        .required_capability()
13392        .is_some_and(|required| !selected(required))
13393        || (request.requires_worktree_selection_capability()
13394            && !selected(NODE_WORKTREE_SELECTION_CAPABILITY))
13395        || (request.requires_spawn_spec_defaults_overrides_capability()
13396            && !selected(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY))
13397        || (request.requires_spawn_profile_revision_capability()
13398            && !selected(NODE_SPAWN_PROFILE_REVISION_CAPABILITY))
13399        || (request.requires_child_environment_profile_capability()
13400            && !selected(NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY))
13401        || (request.requires_session_bundle_materialization_capability()
13402            && !selected(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY))
13403        || (request.requires_history_context_pack_capability()
13404            && !selected(NODE_HISTORY_CONTEXT_PACK_CAPABILITY))
13405}
13406
13407fn snapshot_for_wire(
13408    shared: &NodeShared,
13409    include_provider_runtime_status: bool,
13410    include_open_provider_ids: bool,
13411    include_spawn_profiles: bool,
13412    include_managed_worktrees: bool,
13413    include_child_environment_profiles: bool,
13414    include_session_bundles: bool,
13415    include_history_context_packs: bool,
13416    include_agent_progress: bool,
13417    include_session_task_correlation: bool,
13418) -> NodeSnapshot {
13419    let mut snapshot = shared.snapshot();
13420    if !include_provider_runtime_status {
13421        snapshot.provider_runtime_statuses.clear();
13422    }
13423    if !include_open_provider_ids {
13424        project_snapshot_legacy_provider_ids(&mut snapshot);
13425    }
13426    if !include_managed_worktrees {
13427        snapshot.managed_worktrees.clear();
13428        for workspace in &mut snapshot.workspaces {
13429            workspace.managed_worktree_profiles = None;
13430        }
13431    }
13432    if let Some(inventory) = snapshot.launch_inventory.as_mut() {
13433        if !include_spawn_profiles {
13434            inventory.spawn_profiles = None;
13435        }
13436        if !include_session_bundles {
13437            inventory.bundles = None;
13438        }
13439        if inventory.spawn_profiles.is_none()
13440            && inventory.bundles.is_none()
13441            && inventory.network_allowlists.is_none()
13442        {
13443            snapshot.launch_inventory = None;
13444        }
13445    }
13446    if !include_child_environment_profiles {
13447        clear_snapshot_child_environment_profiles(&mut snapshot);
13448    }
13449    if !include_session_bundles {
13450        clear_snapshot_session_bundles(&mut snapshot);
13451    }
13452    project_snapshot_history_for_wire(&mut snapshot, include_history_context_packs);
13453    if !include_history_context_packs {
13454        clear_snapshot_context_packs(&mut snapshot);
13455    }
13456    if !include_agent_progress {
13457        snapshot.agent_progress.clear();
13458    }
13459    if !include_session_task_correlation {
13460        clear_snapshot_session_task_bindings(&mut snapshot);
13461    }
13462    snapshot
13463}
13464
13465fn clear_snapshot_session_task_bindings(snapshot: &mut NodeSnapshot) {
13466    for record in &mut snapshot.session_records {
13467        record.task_binding = None;
13468    }
13469}
13470
13471fn project_event_without_session_task_binding(
13472    mut envelope: NodeEventEnvelope,
13473) -> NodeEventEnvelope {
13474    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
13475        record.task_binding = None;
13476    }
13477    envelope
13478}
13479
13480fn project_response_without_session_task_binding(reply: &mut ResponseEnvelope) {
13481    let Ok(response) = reply.result.as_mut() else { return };
13482    match response {
13483        NodeResponse::Snapshot { snapshot, .. } => {
13484            clear_snapshot_session_task_bindings(snapshot);
13485        }
13486        NodeResponse::Resync { snapshot, events, .. } => {
13487            clear_snapshot_session_task_bindings(snapshot);
13488            for event in events {
13489                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
13490                    record.task_binding = None;
13491                }
13492            }
13493        }
13494        NodeResponse::SessionRecordUpdated { record }
13495        | NodeResponse::ProviderSessionIndexed { record }
13496        | NodeResponse::NativeSessionIndexed { record, .. }
13497        | NodeResponse::SessionRecordResumed { record, .. } => {
13498            record.task_binding = None;
13499        }
13500        _ => {}
13501    }
13502}
13503
13504fn project_response_without_agent_progress(reply: &mut ResponseEnvelope) {
13505    let Ok(response) = reply.result.as_mut() else { return };
13506    match response {
13507        NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
13508            snapshot.agent_progress.clear();
13509        }
13510        _ => {}
13511    }
13512}
13513
13514fn project_event_without_managed_worktrees(
13515    mut envelope: NodeEventEnvelope,
13516) -> Option<NodeEventEnvelope> {
13517    if matches!(
13518        envelope.event,
13519        NodeEvent::ManagedWorktreeUpserted { .. }
13520            | NodeEvent::ManagedWorktreeRemoved { .. }
13521    ) {
13522        return None;
13523    }
13524    if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
13525        workspace.managed_worktree_profiles = None;
13526    }
13527    Some(envelope)
13528}
13529
13530fn project_response_without_managed_worktrees(reply: &mut ResponseEnvelope) {
13531    if matches!(
13532        reply.result,
13533        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { .. })
13534            | Ok(NodeResponse::ManagedWorktreeCleanup { .. })
13535    ) {
13536        reply.result = Err(failure(
13537            NodeFailureCode::UnsupportedCapability,
13538            "managed worktree capability was not negotiated",
13539        ));
13540        return;
13541    }
13542    let Ok(response) = reply.result.as_mut() else { return };
13543    match response {
13544        NodeResponse::Snapshot { snapshot, .. } => {
13545            snapshot.managed_worktrees.clear();
13546            for workspace in &mut snapshot.workspaces {
13547                workspace.managed_worktree_profiles = None;
13548            }
13549        }
13550        NodeResponse::Resync { snapshot, events, .. } => {
13551            snapshot.managed_worktrees.clear();
13552            for workspace in &mut snapshot.workspaces {
13553                workspace.managed_worktree_profiles = None;
13554            }
13555            *events = std::mem::take(events)
13556                .into_iter()
13557                .filter_map(project_event_without_managed_worktrees)
13558                .collect();
13559        }
13560        NodeResponse::WorkspaceRegistered { workspace }
13561        | NodeResponse::StandaloneWorkspaceCreated { workspace }
13562        | NodeResponse::WorktreeCreated { workspace, .. } => {
13563            workspace.managed_worktree_profiles = None;
13564        }
13565        NodeResponse::WorkspaceInspected { inspection } => {
13566            inspection.git.managed_worktree = None;
13567        }
13568        _ => {}
13569    }
13570}
13571
13572fn project_negotiated_provider_ids(compatibility: &mut NegotiatedNodeCompatibility) {
13573    let includes_open_provider_ids = compatibility.capabilities.iter().any(|capability| {
13574        capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
13575    });
13576    if includes_open_provider_ids {
13577        return;
13578    }
13579    compatibility
13580        .provider_contracts
13581        .retain(|contract| provider_id_is_legacy(&contract.provider));
13582    compatibility
13583        .provider_adapter_contracts
13584        .retain(|contract| provider_id_is_legacy(&contract.provider));
13585}
13586
13587fn request_requires_open_provider_ids(shared: &NodeShared, request: &NodeRequest) -> bool {
13588    if let Some(spec) = match request {
13589        NodeRequest::SpawnSpec { spec }
13590        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
13591        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
13592        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
13593        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
13594        _ => None,
13595    } {
13596        return shared
13597            .resolve_spawn_spec(spec)
13598            .is_ok_and(|resolved| !provider_id_is_legacy(&resolved.provider));
13599    }
13600    request_requires_open_provider_ids_with(
13601        request,
13602        |session| shared.validate_address(session).ok(),
13603        |record_id| shared.record(record_id).ok().map(|record| record.provider),
13604    )
13605}
13606
13607fn request_requires_child_environment_profile(
13608    shared: &NodeShared,
13609    request: &NodeRequest,
13610) -> bool {
13611    if let Some(spec) = match request {
13612        NodeRequest::SpawnSpec { spec }
13613        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
13614        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
13615        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
13616        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
13617        _ => None,
13618    } {
13619        return shared
13620            .resolve_spawn_spec(spec)
13621            .is_ok_and(|resolved| resolved.environment_profile_id.is_some());
13622    }
13623    match request {
13624        NodeRequest::Resume { session, .. }
13625        | NodeRequest::Prompt { session, .. }
13626        | NodeRequest::Paste { session, .. }
13627        | NodeRequest::Input { session, .. }
13628        | NodeRequest::TerminalBytes { session, .. }
13629        | NodeRequest::TerminalControl { session, .. }
13630        | NodeRequest::Resize { session, .. }
13631        | NodeRequest::Interrupt { session }
13632        | NodeRequest::Stop { session, .. }
13633        | NodeRequest::Remove { session }
13634        | NodeRequest::ResolveInteraction { session, .. }
13635        | NodeRequest::SetSessionMode { session, .. }
13636        | NodeRequest::SetSessionConfigOption { session, .. }
13637        | NodeRequest::SetSessionModel { session, .. }
13638        | NodeRequest::DiscoverHistory { session, .. }
13639        | NodeRequest::LoadHistory { session, .. }
13640        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
13641        | NodeRequest::ExportContextPack { session } => shared
13642            .session_bindings
13643            .lock()
13644            .unwrap_or_else(|poisoned| poisoned.into_inner())
13645            .get(&session.session.instance_id)
13646            .filter(|binding| {
13647                binding.workspace_id == session.workspace_id
13648                    && binding.generation == session.session.generation
13649            })
13650            .map_or(true, |binding| binding.environment_profile.is_some()),
13651        NodeRequest::RenameSessionRecord { record_id, .. }
13652        | NodeRequest::SetSessionTask { record_id, .. }
13653        | NodeRequest::ResumeSessionRecord { record_id, .. }
13654        | NodeRequest::ForgetSessionRecord { record_id } => shared
13655            .record(record_id)
13656            .map_or(true, |record| record.environment_profile.is_some()),
13657        NodeRequest::ArmHarnessMcpReservation { .. }
13658        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
13659        | NodeRequest::ActivateHarnessMcpReservation { .. }
13660        | NodeRequest::AbortHarnessMcpReservation { .. }
13661        | NodeRequest::PutHarnessMcpReplyChunk { .. }
13662        | NodeRequest::RejectHarnessMcpCall { .. }
13663        | NodeRequest::Snapshot
13664        | NodeRequest::Resync { .. }
13665        | NodeRequest::BeginDeliveryStage { .. }
13666        | NodeRequest::PutDeliveryBlobChunk { .. }
13667        | NodeRequest::CommitDeliveryStage { .. }
13668        | NodeRequest::AbortDeliveryStage { .. }
13669        | NodeRequest::BrowseHostDirectories { .. }
13670        | NodeRequest::InspectWorkspace { .. }
13671        | NodeRequest::ReadWorkspaceFile { .. }
13672        | NodeRequest::WriteWorkspaceFile { .. }
13673        | NodeRequest::CreateWorkspaceFile { .. }
13674        | NodeRequest::CreateWorkspaceDirectory { .. }
13675        | NodeRequest::ReadGitHistory { .. }
13676        | NodeRequest::ReadGitDiff { .. }
13677        | NodeRequest::AcquireController { .. }
13678        | NodeRequest::ReleaseController
13679        | NodeRequest::Spawn { .. }
13680        | NodeRequest::SpawnSpec { .. }
13681        | NodeRequest::RegisterWorkspace { .. }
13682        | NodeRequest::CreateStandaloneWorkspace { .. }
13683        | NodeRequest::UnregisterWorkspace { .. }
13684        | NodeRequest::CreateWorktree { .. }
13685        | NodeRequest::RemoveWorktree { .. }
13686        | NodeRequest::SpawnManagedWorktree { .. }
13687        | NodeRequest::SpawnManagedWorktreeV2 { .. }
13688        | NodeRequest::CleanupManagedWorktree { .. }
13689        | NodeRequest::IndexProviderSession { .. }
13690        | NodeRequest::IndexNativeSession { .. }
13691        | NodeRequest::CatalogNativeSessions { .. }
13692        | NodeRequest::PageNativeSessions { .. }
13693        | NodeRequest::PreviewNativeSession { .. }
13694        | NodeRequest::PreviewSessionRecord { .. }
13695        | NodeRequest::ForgetContextPack { .. }
13696        | NodeRequest::ResolveDurableContextPack { .. }
13697        | NodeRequest::ReadContextPack { .. }
13698        | NodeRequest::Shutdown => false,
13699    }
13700}
13701
13702fn request_requires_session_bundle(shared: &NodeShared, request: &NodeRequest) -> bool {
13703    if let Some(spec) = match request {
13704        NodeRequest::SpawnSpec { spec }
13705        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
13706        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
13707        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
13708        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
13709        _ => None,
13710    } {
13711        return shared
13712            .resolve_spawn_spec(spec)
13713            .is_ok_and(|resolved| resolved.bundle_id.is_some());
13714    }
13715    match request {
13716        NodeRequest::Resume { session, .. }
13717        | NodeRequest::Prompt { session, .. }
13718        | NodeRequest::Paste { session, .. }
13719        | NodeRequest::Input { session, .. }
13720        | NodeRequest::TerminalBytes { session, .. }
13721        | NodeRequest::TerminalControl { session, .. }
13722        | NodeRequest::Resize { session, .. }
13723        | NodeRequest::Interrupt { session }
13724        | NodeRequest::Stop { session, .. }
13725        | NodeRequest::Remove { session }
13726        | NodeRequest::ResolveInteraction { session, .. }
13727        | NodeRequest::SetSessionMode { session, .. }
13728        | NodeRequest::SetSessionConfigOption { session, .. }
13729        | NodeRequest::SetSessionModel { session, .. }
13730        | NodeRequest::DiscoverHistory { session, .. }
13731        | NodeRequest::LoadHistory { session, .. }
13732        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
13733        | NodeRequest::ExportContextPack { session } => shared
13734            .session_bindings
13735            .lock()
13736            .unwrap_or_else(|poisoned| poisoned.into_inner())
13737            .get(&session.session.instance_id)
13738            .filter(|binding| {
13739                binding.workspace_id == session.workspace_id
13740                    && binding.generation == session.session.generation
13741            })
13742            .map_or(true, |binding| binding.bundle.is_some()),
13743        NodeRequest::RenameSessionRecord { record_id, .. }
13744        | NodeRequest::SetSessionTask { record_id, .. }
13745        | NodeRequest::ResumeSessionRecord { record_id, .. }
13746        | NodeRequest::ForgetSessionRecord { record_id } => shared
13747            .record(record_id)
13748            .map_or(true, |record| record.bundle.is_some()),
13749        NodeRequest::ArmHarnessMcpReservation { .. }
13750        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
13751        | NodeRequest::ActivateHarnessMcpReservation { .. }
13752        | NodeRequest::AbortHarnessMcpReservation { .. }
13753        | NodeRequest::PutHarnessMcpReplyChunk { .. }
13754        | NodeRequest::RejectHarnessMcpCall { .. }
13755        | NodeRequest::Snapshot
13756        | NodeRequest::Resync { .. }
13757        | NodeRequest::BeginDeliveryStage { .. }
13758        | NodeRequest::PutDeliveryBlobChunk { .. }
13759        | NodeRequest::CommitDeliveryStage { .. }
13760        | NodeRequest::AbortDeliveryStage { .. }
13761        | NodeRequest::BrowseHostDirectories { .. }
13762        | NodeRequest::InspectWorkspace { .. }
13763        | NodeRequest::ReadWorkspaceFile { .. }
13764        | NodeRequest::WriteWorkspaceFile { .. }
13765        | NodeRequest::CreateWorkspaceFile { .. }
13766        | NodeRequest::CreateWorkspaceDirectory { .. }
13767        | NodeRequest::ReadGitHistory { .. }
13768        | NodeRequest::ReadGitDiff { .. }
13769        | NodeRequest::AcquireController { .. }
13770        | NodeRequest::ReleaseController
13771        | NodeRequest::Spawn { .. }
13772        | NodeRequest::SpawnSpec { .. }
13773        | NodeRequest::RegisterWorkspace { .. }
13774        | NodeRequest::CreateStandaloneWorkspace { .. }
13775        | NodeRequest::UnregisterWorkspace { .. }
13776        | NodeRequest::CreateWorktree { .. }
13777        | NodeRequest::RemoveWorktree { .. }
13778        | NodeRequest::SpawnManagedWorktree { .. }
13779        | NodeRequest::SpawnManagedWorktreeV2 { .. }
13780        | NodeRequest::CleanupManagedWorktree { .. }
13781        | NodeRequest::IndexProviderSession { .. }
13782        | NodeRequest::IndexNativeSession { .. }
13783        | NodeRequest::CatalogNativeSessions { .. }
13784        | NodeRequest::PageNativeSessions { .. }
13785        | NodeRequest::PreviewNativeSession { .. }
13786        | NodeRequest::PreviewSessionRecord { .. }
13787        | NodeRequest::ForgetContextPack { .. }
13788        | NodeRequest::ResolveDurableContextPack { .. }
13789        | NodeRequest::ReadContextPack { .. }
13790        | NodeRequest::Shutdown => false,
13791    }
13792}
13793
13794fn request_requires_history_context_pack(shared: &NodeShared, request: &NodeRequest) -> bool {
13795    if request.requires_history_context_pack_capability() {
13796        return true;
13797    }
13798    if let Some(spec) = match request {
13799        NodeRequest::SpawnSpec { spec }
13800        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
13801        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
13802        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
13803        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
13804        _ => None,
13805    } {
13806        return shared
13807            .resolve_spawn_spec(spec)
13808            .map_or(true, |resolved| resolved.context_id.is_some());
13809    }
13810    match request {
13811        NodeRequest::Resume { session, .. }
13812        | NodeRequest::Prompt { session, .. }
13813        | NodeRequest::Paste { session, .. }
13814        | NodeRequest::Input { session, .. }
13815        | NodeRequest::TerminalBytes { session, .. }
13816        | NodeRequest::TerminalControl { session, .. }
13817        | NodeRequest::Resize { session, .. }
13818        | NodeRequest::Interrupt { session }
13819        | NodeRequest::Stop { session, .. }
13820        | NodeRequest::Remove { session }
13821        | NodeRequest::ResolveInteraction { session, .. }
13822        | NodeRequest::SetSessionMode { session, .. }
13823        | NodeRequest::SetSessionConfigOption { session, .. }
13824        | NodeRequest::SetSessionModel { session, .. } => shared
13825            .session_bindings
13826            .lock()
13827            .unwrap_or_else(|poisoned| poisoned.into_inner())
13828            .get(&session.session.instance_id)
13829            .filter(|binding| {
13830                binding.workspace_id == session.workspace_id
13831                    && binding.generation == session.session.generation
13832            })
13833            .map_or(true, |binding| binding.context.is_some()),
13834        NodeRequest::RenameSessionRecord { record_id, .. }
13835        | NodeRequest::SetSessionTask { record_id, .. }
13836        | NodeRequest::ResumeSessionRecord { record_id, .. }
13837        | NodeRequest::ForgetSessionRecord { record_id } => shared
13838            .record(record_id)
13839            .map_or(true, |record| record.context.is_some()),
13840        NodeRequest::ArmHarnessMcpReservation { .. }
13841        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
13842        | NodeRequest::ActivateHarnessMcpReservation { .. }
13843        | NodeRequest::AbortHarnessMcpReservation { .. }
13844        | NodeRequest::PutHarnessMcpReplyChunk { .. }
13845        | NodeRequest::RejectHarnessMcpCall { .. }
13846        | NodeRequest::Snapshot
13847        | NodeRequest::Resync { .. }
13848        | NodeRequest::BeginDeliveryStage { .. }
13849        | NodeRequest::PutDeliveryBlobChunk { .. }
13850        | NodeRequest::CommitDeliveryStage { .. }
13851        | NodeRequest::AbortDeliveryStage { .. }
13852        | NodeRequest::BrowseHostDirectories { .. }
13853        | NodeRequest::InspectWorkspace { .. }
13854        | NodeRequest::ReadWorkspaceFile { .. }
13855        | NodeRequest::WriteWorkspaceFile { .. }
13856        | NodeRequest::CreateWorkspaceFile { .. }
13857        | NodeRequest::CreateWorkspaceDirectory { .. }
13858        | NodeRequest::ReadGitHistory { .. }
13859        | NodeRequest::ReadGitDiff { .. }
13860        | NodeRequest::AcquireController { .. }
13861        | NodeRequest::ReleaseController
13862        | NodeRequest::Spawn { .. }
13863        | NodeRequest::SpawnSpec { .. }
13864        | NodeRequest::RegisterWorkspace { .. }
13865        | NodeRequest::CreateStandaloneWorkspace { .. }
13866        | NodeRequest::UnregisterWorkspace { .. }
13867        | NodeRequest::CreateWorktree { .. }
13868        | NodeRequest::RemoveWorktree { .. }
13869        | NodeRequest::SpawnManagedWorktree { .. }
13870        | NodeRequest::SpawnManagedWorktreeV2 { .. }
13871        | NodeRequest::CleanupManagedWorktree { .. }
13872        | NodeRequest::IndexProviderSession { .. }
13873        | NodeRequest::IndexNativeSession { .. }
13874        | NodeRequest::CatalogNativeSessions { .. }
13875        | NodeRequest::PageNativeSessions { .. }
13876        | NodeRequest::PreviewNativeSession { .. }
13877        | NodeRequest::PreviewSessionRecord { .. }
13878        | NodeRequest::DiscoverHistory { .. }
13879        | NodeRequest::LoadHistory { .. }
13880        | NodeRequest::ExportContextPackForSessionRecord { .. }
13881        | NodeRequest::ExportContextPack { .. }
13882        | NodeRequest::ForgetContextPack { .. }
13883        | NodeRequest::ResolveDurableContextPack { .. }
13884        | NodeRequest::ReadContextPack { .. }
13885        | NodeRequest::Shutdown => false,
13886    }
13887}
13888
13889fn request_requires_open_provider_ids_with(
13890    request: &NodeRequest,
13891    provider_for_session: impl Fn(&SessionAddress) -> Option<AgentId>,
13892    provider_for_record: impl Fn(&SessionRecordId) -> Option<AgentId>,
13893) -> bool {
13894    match request {
13895        NodeRequest::Spawn { provider, .. } => !provider_id_is_legacy(provider),
13896        NodeRequest::IndexProviderSession { provider, .. } => !provider_id_is_legacy(provider),
13897        NodeRequest::CatalogNativeSessions { route, .. }
13898        | NodeRequest::PageNativeSessions { route, .. } => {
13899            !provider_id_is_legacy(&route.provider)
13900        }
13901        NodeRequest::PreviewNativeSession { selection, .. }
13902        | NodeRequest::IndexNativeSession { selection, .. } => {
13903            !provider_id_is_legacy(&selection.route.provider)
13904        }
13905        NodeRequest::SpawnSpec { .. }
13906        | NodeRequest::SpawnSpecWithHarnessMcp { .. } => false,
13907        NodeRequest::SpawnManagedWorktree { .. }
13908        | NodeRequest::SpawnManagedWorktreeV2 { .. } => false,
13909        NodeRequest::Resume { session, .. }
13910        | NodeRequest::Prompt { session, .. }
13911        | NodeRequest::Paste { session, .. }
13912        | NodeRequest::Input { session, .. }
13913        | NodeRequest::TerminalBytes { session, .. }
13914        | NodeRequest::TerminalControl { session, .. }
13915        | NodeRequest::Resize { session, .. }
13916        | NodeRequest::Interrupt { session }
13917        | NodeRequest::Stop { session, .. }
13918        | NodeRequest::Remove { session }
13919        | NodeRequest::ResolveInteraction { session, .. }
13920        | NodeRequest::SetSessionMode { session, .. }
13921        | NodeRequest::SetSessionConfigOption { session, .. }
13922        | NodeRequest::SetSessionModel { session, .. }
13923        | NodeRequest::DiscoverHistory { session, .. }
13924        | NodeRequest::LoadHistory { session, .. }
13925        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
13926        | NodeRequest::ExportContextPack { session } => provider_for_session(session)
13927            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
13928        NodeRequest::RenameSessionRecord { record_id, .. }
13929        | NodeRequest::SetSessionTask { record_id, .. }
13930        | NodeRequest::ResumeSessionRecord { record_id, .. }
13931        | NodeRequest::ForgetSessionRecord { record_id } => provider_for_record(record_id)
13932            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
13933        NodeRequest::PreviewSessionRecord { record_id, .. } => provider_for_record(record_id)
13934            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
13935        NodeRequest::ArmHarnessMcpReservation { .. }
13936        | NodeRequest::ActivateHarnessMcpReservation { .. }
13937        | NodeRequest::AbortHarnessMcpReservation { .. }
13938        | NodeRequest::PutHarnessMcpReplyChunk { .. }
13939        | NodeRequest::RejectHarnessMcpCall { .. }
13940        | NodeRequest::Snapshot
13941        | NodeRequest::Resync { .. }
13942        | NodeRequest::BeginDeliveryStage { .. }
13943        | NodeRequest::PutDeliveryBlobChunk { .. }
13944        | NodeRequest::CommitDeliveryStage { .. }
13945        | NodeRequest::AbortDeliveryStage { .. }
13946        | NodeRequest::BrowseHostDirectories { .. }
13947        | NodeRequest::InspectWorkspace { .. }
13948        | NodeRequest::ReadWorkspaceFile { .. }
13949        | NodeRequest::WriteWorkspaceFile { .. }
13950        | NodeRequest::CreateWorkspaceFile { .. }
13951        | NodeRequest::CreateWorkspaceDirectory { .. }
13952        | NodeRequest::ReadGitHistory { .. }
13953        | NodeRequest::ReadGitDiff { .. }
13954        | NodeRequest::AcquireController { .. }
13955        | NodeRequest::ReleaseController
13956        | NodeRequest::RegisterWorkspace { .. }
13957        | NodeRequest::CreateStandaloneWorkspace { .. }
13958        | NodeRequest::UnregisterWorkspace { .. }
13959        | NodeRequest::CreateWorktree { .. }
13960        | NodeRequest::RemoveWorktree { .. }
13961        | NodeRequest::CleanupManagedWorktree { .. }
13962        | NodeRequest::Shutdown => false,
13963        NodeRequest::ForgetContextPack { .. } => true,
13964        // No session/record to cheaply resolve a provider from here either;
13965        // the resolved receipt can carry an open-id provider, so require the
13966        // same capability as ForgetContextPack unconditionally. `ReadContextPack`
13967        // is the same digest-keyed lookup one step further into the same
13968        // catalog, so it inherits the same conservative answer.
13969        NodeRequest::ResolveDurableContextPack { .. }
13970        | NodeRequest::ReadContextPack { .. } => true,
13971    }
13972}
13973
13974fn project_snapshot_legacy_provider_ids(snapshot: &mut NodeSnapshot) {
13975    snapshot.enabled_providers.retain(provider_id_is_legacy);
13976    snapshot.provider_runtime_statuses = ProviderRuntimeStatuses::new(
13977        snapshot
13978            .provider_runtime_statuses
13979            .as_slice()
13980            .iter()
13981            .filter(|status| provider_id_is_legacy(status.provider()))
13982            .cloned(),
13983    )
13984    .expect("a filtered provider runtime projection remains valid");
13985    snapshot
13986        .session_records
13987        .retain(|record| provider_id_is_legacy(&record.provider));
13988    for workspace in &mut snapshot.workspaces {
13989        workspace
13990            .sessions
13991            .retain(|session| provider_id_is_legacy(&session.agent_id));
13992    }
13993}
13994
13995fn clear_snapshot_child_environment_profiles(snapshot: &mut NodeSnapshot) {
13996    for record in &mut snapshot.session_records {
13997        record.environment_profile = None;
13998    }
13999    if let Some(profiles) = snapshot
14000        .launch_inventory
14001        .as_mut()
14002        .and_then(|inventory| inventory.spawn_profiles.as_mut())
14003    {
14004        for profile in profiles {
14005            profile.environment_profile = None;
14006        }
14007    }
14008}
14009
14010fn project_event_without_child_environment_profile(
14011    mut envelope: NodeEventEnvelope,
14012) -> NodeEventEnvelope {
14013    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14014        record.environment_profile = None;
14015    }
14016    envelope
14017}
14018
14019fn project_response_without_child_environment_profile(reply: &mut ResponseEnvelope) {
14020    let contains_environment_profile = match reply.result.as_ref() {
14021        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
14022            receipt.environment_profile.is_some()
14023        }
14024        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14025            receipt.spawn.environment_profile.is_some()
14026        }
14027        _ => false,
14028    };
14029    if contains_environment_profile {
14030        reply.result = Err(failure(
14031            NodeFailureCode::UnsupportedCapability,
14032            "child environment profiles were not negotiated",
14033        ));
14034        return;
14035    }
14036    let Ok(response) = reply.result.as_mut() else {
14037        return;
14038    };
14039    match response {
14040        NodeResponse::Snapshot { snapshot, .. } => {
14041            clear_snapshot_child_environment_profiles(snapshot);
14042        }
14043        NodeResponse::Resync { snapshot, events, .. } => {
14044            clear_snapshot_child_environment_profiles(snapshot);
14045            for event in events {
14046                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14047                    record.environment_profile = None;
14048                }
14049            }
14050        }
14051        NodeResponse::SessionRecordUpdated { record }
14052        | NodeResponse::ProviderSessionIndexed { record }
14053        | NodeResponse::NativeSessionIndexed { record, .. }
14054        | NodeResponse::SessionRecordResumed { record, .. } => {
14055            record.environment_profile = None;
14056        }
14057        NodeResponse::Armed { .. }
14058        | NodeResponse::Spawned { .. }
14059        | NodeResponse::Activated { .. }
14060        | NodeResponse::Aborted { .. }
14061        | NodeResponse::ReplyChunkAccepted { .. }
14062        | NodeResponse::CallRejected { .. }
14063        | NodeResponse::DeliveryStageBegun { .. }
14064        | NodeResponse::DeliveryBlobChunkAccepted { .. }
14065        | NodeResponse::DeliveryCommitted { .. }
14066        | NodeResponse::DeliveryStageAborted { .. }
14067        | NodeResponse::WorkspaceInspected { .. }
14068        | NodeResponse::HostDirectoriesBrowsed { .. }
14069        | NodeResponse::WorkspaceFileRead { .. }
14070        | NodeResponse::WorkspaceFileWritten { .. }
14071        | NodeResponse::WorkspaceFileCreated { .. }
14072        | NodeResponse::WorkspaceDirectoryCreated { .. }
14073        | NodeResponse::GitHistoryRead { .. }
14074        | NodeResponse::GitDiffRead { .. }
14075        | NodeResponse::Controller { .. }
14076        | NodeResponse::SpawnAccepted { .. }
14077        | NodeResponse::SpawnSpecAccepted { .. }
14078        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14079        | NodeResponse::ManagedWorktreeCleanup { .. }
14080        | NodeResponse::NativeSessionsCataloged { .. }
14081        | NodeResponse::NativeSessionsPaged { .. }
14082        | NodeResponse::NativeSessionPreviewed { .. }
14083        | NodeResponse::SessionRecordPreviewed { .. }
14084        | NodeResponse::HistoryDiscovered { .. }
14085        | NodeResponse::HistoryLoaded { .. }
14086        | NodeResponse::ContextPackForSessionRecordExported { .. }
14087        | NodeResponse::ContextPackExported { .. }
14088        | NodeResponse::ContextPackForgotten { .. }
14089        | NodeResponse::DurableContextPackResolved { .. }
14090        | NodeResponse::ContextPackBytesRead { .. }
14091        | NodeResponse::SessionRecordForgotten { .. }
14092        | NodeResponse::WorkspaceRegistered { .. }
14093        | NodeResponse::StandaloneWorkspaceCreated { .. }
14094        | NodeResponse::WorkspaceUnregistered { .. }
14095        | NodeResponse::WorktreeCreated { .. }
14096        | NodeResponse::WorktreeRemoved { .. }
14097        | NodeResponse::Accepted
14098        | NodeResponse::ShuttingDown => {}
14099    }
14100}
14101
14102fn clear_snapshot_session_bundles(snapshot: &mut NodeSnapshot) {
14103    for record in &mut snapshot.session_records {
14104        record.bundle = None;
14105    }
14106}
14107
14108fn project_event_without_session_bundle(
14109    mut envelope: NodeEventEnvelope,
14110) -> NodeEventEnvelope {
14111    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14112        record.bundle = None;
14113    }
14114    envelope
14115}
14116
14117fn project_response_without_session_bundle(reply: &mut ResponseEnvelope) {
14118    let contains_bundle = match reply.result.as_ref() {
14119        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.bundle.is_some(),
14120        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14121            receipt.spawn.bundle.is_some()
14122        }
14123        _ => false,
14124    };
14125    if contains_bundle {
14126        reply.result = Err(failure(
14127            NodeFailureCode::UnsupportedCapability,
14128            "session bundles were not negotiated",
14129        ));
14130        return;
14131    }
14132    let Ok(response) = reply.result.as_mut() else {
14133        return;
14134    };
14135    match response {
14136        NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_session_bundles(snapshot),
14137        NodeResponse::Resync { snapshot, events, .. } => {
14138            clear_snapshot_session_bundles(snapshot);
14139            for event in events {
14140                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14141                    record.bundle = None;
14142                }
14143            }
14144        }
14145        NodeResponse::SessionRecordUpdated { record }
14146        | NodeResponse::ProviderSessionIndexed { record }
14147        | NodeResponse::NativeSessionIndexed { record, .. }
14148        | NodeResponse::SessionRecordResumed { record, .. } => record.bundle = None,
14149        NodeResponse::Armed { .. }
14150        | NodeResponse::Spawned { .. }
14151        | NodeResponse::Activated { .. }
14152        | NodeResponse::Aborted { .. }
14153        | NodeResponse::ReplyChunkAccepted { .. }
14154        | NodeResponse::CallRejected { .. }
14155        | NodeResponse::DeliveryStageBegun { .. }
14156        | NodeResponse::DeliveryBlobChunkAccepted { .. }
14157        | NodeResponse::DeliveryCommitted { .. }
14158        | NodeResponse::DeliveryStageAborted { .. }
14159        | NodeResponse::WorkspaceInspected { .. }
14160        | NodeResponse::HostDirectoriesBrowsed { .. }
14161        | NodeResponse::WorkspaceFileRead { .. }
14162        | NodeResponse::WorkspaceFileWritten { .. }
14163        | NodeResponse::WorkspaceFileCreated { .. }
14164        | NodeResponse::WorkspaceDirectoryCreated { .. }
14165        | NodeResponse::GitHistoryRead { .. }
14166        | NodeResponse::GitDiffRead { .. }
14167        | NodeResponse::Controller { .. }
14168        | NodeResponse::SpawnAccepted { .. }
14169        | NodeResponse::SpawnSpecAccepted { .. }
14170        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14171        | NodeResponse::ManagedWorktreeCleanup { .. }
14172        | NodeResponse::NativeSessionsCataloged { .. }
14173        | NodeResponse::NativeSessionsPaged { .. }
14174        | NodeResponse::NativeSessionPreviewed { .. }
14175        | NodeResponse::SessionRecordPreviewed { .. }
14176        | NodeResponse::HistoryDiscovered { .. }
14177        | NodeResponse::HistoryLoaded { .. }
14178        | NodeResponse::ContextPackForSessionRecordExported { .. }
14179        | NodeResponse::ContextPackExported { .. }
14180        | NodeResponse::ContextPackForgotten { .. }
14181        | NodeResponse::DurableContextPackResolved { .. }
14182        | NodeResponse::ContextPackBytesRead { .. }
14183        | NodeResponse::SessionRecordForgotten { .. }
14184        | NodeResponse::WorkspaceRegistered { .. }
14185        | NodeResponse::StandaloneWorkspaceCreated { .. }
14186        | NodeResponse::WorkspaceUnregistered { .. }
14187        | NodeResponse::WorktreeCreated { .. }
14188        | NodeResponse::WorktreeRemoved { .. }
14189        | NodeResponse::Accepted
14190        | NodeResponse::ShuttingDown => {}
14191    }
14192}
14193
14194fn project_snapshot_history_for_wire(
14195    snapshot: &mut NodeSnapshot,
14196    include_history_metadata: bool,
14197) {
14198    for workspace in &mut snapshot.workspaces {
14199        for session in &mut workspace.sessions {
14200            if include_history_metadata {
14201                session.history.loaded = None;
14202            } else {
14203                session.history = Default::default();
14204            }
14205        }
14206    }
14207}
14208
14209fn project_event_history_for_wire(
14210    mut envelope: NodeEventEnvelope,
14211    include_history_metadata: bool,
14212) -> NodeEventEnvelope {
14213    if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
14214        for session in &mut workspace.sessions {
14215            if include_history_metadata {
14216                session.history.loaded = None;
14217            } else {
14218                session.history = Default::default();
14219            }
14220        }
14221    }
14222    envelope
14223}
14224
14225fn project_response_history_for_wire(
14226    reply: &mut ResponseEnvelope,
14227    include_history_metadata: bool,
14228) {
14229    let Ok(response) = reply.result.as_mut() else {
14230        return;
14231    };
14232    match response {
14233        NodeResponse::Snapshot { snapshot, .. } => {
14234            project_snapshot_history_for_wire(snapshot, include_history_metadata);
14235        }
14236        NodeResponse::Resync {
14237            snapshot, events, ..
14238        } => {
14239            project_snapshot_history_for_wire(snapshot, include_history_metadata);
14240            for event in events {
14241                *event = project_event_history_for_wire(
14242                    event.clone(),
14243                    include_history_metadata,
14244                );
14245            }
14246        }
14247        NodeResponse::WorkspaceRegistered { workspace }
14248        | NodeResponse::StandaloneWorkspaceCreated { workspace }
14249        | NodeResponse::WorktreeCreated { workspace, .. } => {
14250            for session in &mut workspace.sessions {
14251                if include_history_metadata {
14252                    session.history.loaded = None;
14253                } else {
14254                    session.history = Default::default();
14255                }
14256            }
14257        }
14258        _ => {}
14259    }
14260}
14261
14262fn clear_snapshot_context_packs(snapshot: &mut NodeSnapshot) {
14263    for record in &mut snapshot.session_records {
14264        record.context_id = None;
14265        record.context = None;
14266        record.exported_context = None;
14267    }
14268}
14269
14270fn project_event_without_context_pack(
14271    mut envelope: NodeEventEnvelope,
14272) -> NodeEventEnvelope {
14273    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14274        record.context_id = None;
14275        record.context = None;
14276        record.exported_context = None;
14277    }
14278    envelope
14279}
14280
14281fn project_response_without_context_pack(reply: &mut ResponseEnvelope) {
14282    let contains_context = match reply.result.as_ref() {
14283        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.context.is_some(),
14284        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14285            receipt.spawn.context.is_some()
14286        }
14287        Ok(NodeResponse::HistoryDiscovered { .. })
14288        | Ok(NodeResponse::HistoryLoaded { .. })
14289        | Ok(NodeResponse::ContextPackForSessionRecordExported { .. })
14290        | Ok(NodeResponse::ContextPackExported { .. })
14291        | Ok(NodeResponse::ContextPackForgotten { .. })
14292        | Ok(NodeResponse::DurableContextPackResolved { .. }) => true,
14293        _ => false,
14294    };
14295    if contains_context {
14296        reply.result = Err(failure(
14297            NodeFailureCode::UnsupportedCapability,
14298            "history context packs were not negotiated",
14299        ));
14300        return;
14301    }
14302    let Ok(response) = reply.result.as_mut() else {
14303        return;
14304    };
14305    match response {
14306        NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_context_packs(snapshot),
14307        NodeResponse::Resync {
14308            snapshot, events, ..
14309        } => {
14310            clear_snapshot_context_packs(snapshot);
14311            for event in events {
14312                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14313                    record.context_id = None;
14314                    record.context = None;
14315                    record.exported_context = None;
14316                }
14317            }
14318        }
14319        NodeResponse::SessionRecordUpdated { record }
14320        | NodeResponse::ProviderSessionIndexed { record }
14321        | NodeResponse::NativeSessionIndexed { record, .. }
14322        | NodeResponse::SessionRecordResumed { record, .. } => {
14323            record.context_id = None;
14324            record.context = None;
14325            record.exported_context = None;
14326        }
14327        _ => {}
14328    }
14329}
14330
14331fn project_response_legacy_provider_ids(shared: &NodeShared, reply: &mut ResponseEnvelope) {
14332    let contains_open_record = match reply.result.as_ref() {
14333        Ok(NodeResponse::SessionRecordUpdated { record })
14334        | Ok(NodeResponse::ProviderSessionIndexed { record })
14335        | Ok(NodeResponse::SessionRecordResumed { record, .. }) => {
14336            !provider_id_is_legacy(&record.provider)
14337        }
14338        Ok(NodeResponse::NativeSessionIndexed { selection, record }) => {
14339            !provider_id_is_legacy(&selection.route.provider)
14340                || !provider_id_is_legacy(&record.provider)
14341        }
14342        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
14343            !provider_id_is_legacy(&receipt.provider)
14344        }
14345        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14346            !provider_id_is_legacy(&receipt.spawn.provider)
14347        }
14348        Ok(NodeResponse::NativeSessionsCataloged { route, .. })
14349        | Ok(NodeResponse::NativeSessionsPaged { route, .. }) => {
14350            !provider_id_is_legacy(&route.provider)
14351        }
14352        Ok(NodeResponse::NativeSessionPreviewed { selection, .. }) => {
14353            !provider_id_is_legacy(&selection.route.provider)
14354        }
14355        _ => false,
14356    };
14357    if contains_open_record {
14358        reply.result = Err(failure(
14359            NodeFailureCode::UnsupportedCapability,
14360            "open provider IDs were not negotiated",
14361        ));
14362        return;
14363    }
14364    let Ok(response) = reply.result.as_mut() else {
14365        return;
14366    };
14367    match response {
14368        NodeResponse::Snapshot { snapshot, .. } => {
14369            project_snapshot_legacy_provider_ids(snapshot);
14370        }
14371        NodeResponse::Resync { snapshot, events, .. } => {
14372            project_snapshot_legacy_provider_ids(snapshot);
14373            *events = std::mem::take(events)
14374                .into_iter()
14375                .filter_map(|event| project_event_legacy_provider_ids(shared, event))
14376                .collect();
14377        }
14378        NodeResponse::WorkspaceRegistered { workspace }
14379        | NodeResponse::StandaloneWorkspaceCreated { workspace }
14380        | NodeResponse::WorktreeCreated { workspace, .. } => {
14381            workspace
14382                .sessions
14383                .retain(|session| provider_id_is_legacy(&session.agent_id));
14384        }
14385        NodeResponse::Armed { .. }
14386        | NodeResponse::Spawned { .. }
14387        | NodeResponse::Activated { .. }
14388        | NodeResponse::Aborted { .. }
14389        | NodeResponse::ReplyChunkAccepted { .. }
14390        | NodeResponse::CallRejected { .. }
14391        | NodeResponse::DeliveryStageBegun { .. }
14392        | NodeResponse::DeliveryBlobChunkAccepted { .. }
14393        | NodeResponse::DeliveryCommitted { .. }
14394        | NodeResponse::DeliveryStageAborted { .. }
14395        | NodeResponse::WorkspaceInspected { .. }
14396        | NodeResponse::HostDirectoriesBrowsed { .. }
14397        | NodeResponse::WorkspaceFileRead { .. }
14398        | NodeResponse::WorkspaceFileWritten { .. }
14399        | NodeResponse::WorkspaceFileCreated { .. }
14400        | NodeResponse::WorkspaceDirectoryCreated { .. }
14401        | NodeResponse::GitHistoryRead { .. }
14402        | NodeResponse::GitDiffRead { .. }
14403        | NodeResponse::Controller { .. }
14404        | NodeResponse::SpawnAccepted { .. }
14405        | NodeResponse::SpawnSpecAccepted { .. }
14406        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14407        | NodeResponse::ManagedWorktreeCleanup { .. }
14408        | NodeResponse::NativeSessionsCataloged { .. }
14409        | NodeResponse::NativeSessionsPaged { .. }
14410        | NodeResponse::NativeSessionPreviewed { .. }
14411        | NodeResponse::SessionRecordPreviewed { .. }
14412        | NodeResponse::HistoryDiscovered { .. }
14413        | NodeResponse::HistoryLoaded { .. }
14414        | NodeResponse::ContextPackForSessionRecordExported { .. }
14415        | NodeResponse::ContextPackExported { .. }
14416        | NodeResponse::ContextPackForgotten { .. }
14417        | NodeResponse::DurableContextPackResolved { .. }
14418        | NodeResponse::ContextPackBytesRead { .. }
14419        | NodeResponse::SessionRecordUpdated { .. }
14420        | NodeResponse::ProviderSessionIndexed { .. }
14421        | NodeResponse::NativeSessionIndexed { .. }
14422        | NodeResponse::SessionRecordResumed { .. }
14423        | NodeResponse::SessionRecordForgotten { .. }
14424        | NodeResponse::WorkspaceUnregistered { .. }
14425        | NodeResponse::WorktreeRemoved { .. }
14426        | NodeResponse::Accepted
14427        | NodeResponse::ShuttingDown => {}
14428    }
14429}
14430
14431fn project_event_legacy_provider_ids(
14432    shared: &NodeShared,
14433    mut envelope: NodeEventEnvelope,
14434) -> Option<NodeEventEnvelope> {
14435    let include = match &mut envelope.event {
14436        NodeEvent::HarnessMcpReadCall { .. } => true,
14437        NodeEvent::Control { address, .. }
14438        | NodeEvent::AgentStream { address, .. }
14439        | NodeEvent::TerminalFrame { address, .. } => shared
14440            .validate_address(address)
14441            .is_ok_and(|provider| provider_id_is_legacy(&provider)),
14442        NodeEvent::WorkspaceAdded { workspace } => {
14443            workspace
14444                .sessions
14445                .retain(|session| provider_id_is_legacy(&session.agent_id));
14446            true
14447        }
14448        NodeEvent::SessionRecordUpserted { record } => {
14449            provider_id_is_legacy(&record.provider)
14450        }
14451        NodeEvent::SessionRecordHistorySummarized { .. } => false,
14452        NodeEvent::SessionRecordRemoved { .. } => shared
14453            .history
14454            .lock()
14455            .unwrap_or_else(|poisoned| poisoned.into_inner())
14456            .removed_record_providers
14457            .get(&envelope.sequence)
14458            .is_some_and(provider_id_is_legacy),
14459        NodeEvent::ControllerChanged { .. }
14460        | NodeEvent::ManagedWorktreeUpserted { .. }
14461        | NodeEvent::ManagedWorktreeRemoved { .. }
14462        | NodeEvent::WorkspaceRemoved { .. }
14463        | NodeEvent::ResyncRequired { .. } => true,
14464    };
14465    include.then_some(envelope)
14466}
14467
14468fn clear_response_provider_runtime_status(reply: &mut ResponseEnvelope) {
14469    let Ok(response) = reply.result.as_mut() else {
14470        return;
14471    };
14472    match response {
14473        NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
14474            snapshot.provider_runtime_statuses.clear();
14475        }
14476        NodeResponse::Armed { .. }
14477        | NodeResponse::Spawned { .. }
14478        | NodeResponse::Activated { .. }
14479        | NodeResponse::Aborted { .. }
14480        | NodeResponse::ReplyChunkAccepted { .. }
14481        | NodeResponse::CallRejected { .. }
14482        | NodeResponse::DeliveryStageBegun { .. }
14483        | NodeResponse::DeliveryBlobChunkAccepted { .. }
14484        | NodeResponse::DeliveryCommitted { .. }
14485        | NodeResponse::DeliveryStageAborted { .. }
14486        | NodeResponse::WorkspaceInspected { .. }
14487        | NodeResponse::HostDirectoriesBrowsed { .. }
14488        | NodeResponse::WorkspaceFileRead { .. }
14489        | NodeResponse::WorkspaceFileWritten { .. }
14490        | NodeResponse::WorkspaceFileCreated { .. }
14491        | NodeResponse::WorkspaceDirectoryCreated { .. }
14492        | NodeResponse::GitHistoryRead { .. }
14493        | NodeResponse::GitDiffRead { .. }
14494        | NodeResponse::Controller { .. }
14495        | NodeResponse::SpawnAccepted { .. }
14496        | NodeResponse::SpawnSpecAccepted { .. }
14497        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14498        | NodeResponse::ManagedWorktreeCleanup { .. }
14499        | NodeResponse::NativeSessionsCataloged { .. }
14500        | NodeResponse::NativeSessionsPaged { .. }
14501        | NodeResponse::NativeSessionPreviewed { .. }
14502        | NodeResponse::SessionRecordPreviewed { .. }
14503        | NodeResponse::HistoryDiscovered { .. }
14504        | NodeResponse::HistoryLoaded { .. }
14505        | NodeResponse::ContextPackForSessionRecordExported { .. }
14506        | NodeResponse::ContextPackExported { .. }
14507        | NodeResponse::ContextPackForgotten { .. }
14508        | NodeResponse::DurableContextPackResolved { .. }
14509        | NodeResponse::ContextPackBytesRead { .. }
14510        | NodeResponse::SessionRecordUpdated { .. }
14511        | NodeResponse::ProviderSessionIndexed { .. }
14512        | NodeResponse::NativeSessionIndexed { .. }
14513        | NodeResponse::SessionRecordResumed { .. }
14514        | NodeResponse::SessionRecordForgotten { .. }
14515        | NodeResponse::WorkspaceRegistered { .. }
14516        | NodeResponse::StandaloneWorkspaceCreated { .. }
14517        | NodeResponse::WorkspaceUnregistered { .. }
14518        | NodeResponse::WorktreeCreated { .. }
14519        | NodeResponse::WorktreeRemoved { .. }
14520        | NodeResponse::Accepted
14521        | NodeResponse::ShuttingDown => {}
14522    }
14523}
14524
14525struct ControllerReleaseGuard {
14526    shared: Arc<NodeShared>,
14527    connection_id: u64,
14528}
14529
14530impl Drop for ControllerReleaseGuard {
14531    fn drop(&mut self) {
14532        self.shared.release_controller(self.connection_id);
14533    }
14534}
14535
14536struct AbortTaskOnDrop(AbortHandle);
14537
14538impl Drop for AbortTaskOnDrop {
14539    fn drop(&mut self) {
14540        self.0.abort();
14541    }
14542}
14543
14544/// Reads an unsigned env-config override, following the pattern of every
14545/// other bounded knob in this module: missing/unparsable/zero falls back to
14546/// `default`, and the resolved value is always clamped to `max` so a bad
14547/// override cannot reopen a bound this budget exists to enforce.
14548fn env_config_bounded(name: &str, default: u64, max: u64) -> u64 {
14549    std::env::var(name)
14550        .ok()
14551        .and_then(|value| value.parse::<u64>().ok())
14552        .filter(|value| *value > 0)
14553        .unwrap_or(default)
14554        .min(max)
14555}
14556
14557fn workspace_inspection_time_budget_ms() -> u64 {
14558    env_config_bounded(
14559        "GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS",
14560        WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT,
14561        WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX,
14562    )
14563}
14564
14565fn workspace_inspection_entry_cap() -> usize {
14566    env_config_bounded(
14567        "GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP",
14568        WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT as u64,
14569        WORKSPACE_INSPECTION_ENTRY_CAP_MAX as u64,
14570    ) as usize
14571}
14572
14573/// Tracks the shared walk-side budget across the whole recursive walk: a
14574/// wall-clock deadline (shared with the git phase that runs after the walk)
14575/// and a cap on directory entries visited, whether or not they were pushed
14576/// to the response. Cheap to check per entry — one `Instant::now()` and one
14577/// integer compare.
14578struct WorkspaceWalkBudget {
14579    deadline: Instant,
14580    entry_cap: usize,
14581    entries_visited: u64,
14582    time_budget_exceeded: bool,
14583    entry_cap_exceeded: bool,
14584}
14585
14586impl WorkspaceWalkBudget {
14587    fn new(deadline: Instant, entry_cap: usize) -> Self {
14588        Self {
14589            deadline,
14590            entry_cap,
14591            entries_visited: 0,
14592            time_budget_exceeded: false,
14593            entry_cap_exceeded: false,
14594        }
14595    }
14596
14597    fn is_exhausted(&self) -> bool {
14598        self.time_budget_exceeded || self.entry_cap_exceeded
14599    }
14600
14601    /// Records one visited directory entry (pushed to the response or not)
14602    /// and reports whether the walk must stop now.
14603    fn record_visit(&mut self) -> bool {
14604        self.entries_visited += 1;
14605        if self.entries_visited as usize >= self.entry_cap {
14606            self.entry_cap_exceeded = true;
14607        }
14608        if Instant::now() >= self.deadline {
14609            self.time_budget_exceeded = true;
14610        }
14611        self.is_exhausted()
14612    }
14613}
14614
14615fn collect_workspace_entries(
14616    root: &Path,
14617    deadline: Instant,
14618    entry_cap: usize,
14619) -> (Vec<WorkspaceEntry>, bool, WorkspaceWalkBudget) {
14620    let mut entries = Vec::new();
14621    let mut truncated = false;
14622    let mut budget = WorkspaceWalkBudget::new(deadline, entry_cap);
14623    walk_workspace_directory(root, None, 0, &mut entries, &mut truncated, &mut budget);
14624    // DFS pre-order emits `x/child` between prefix-named siblings `x` and
14625    // `x-y` ('-' sorts below '/'), while the bounded operator projections
14626    // require strict global byte order over relative paths.
14627    entries.sort_by(|left, right| left.relative_path.as_utf8().cmp(&right.relative_path.as_utf8()));
14628    (entries, truncated, budget)
14629}
14630
14631/// Builds the additive `WorkspaceInspectionTruncationV1` for an
14632/// `inspect_workspace` response — `None` once the walk and git phase both
14633/// completed inside their shared budget.
14634fn workspace_inspection_truncation(
14635    walk_budget: &WorkspaceWalkBudget,
14636    git_time_budget_exceeded: bool,
14637    elapsed: Duration,
14638) -> Option<WorkspaceInspectionTruncationV1> {
14639    if !walk_budget.time_budget_exceeded
14640        && !walk_budget.entry_cap_exceeded
14641        && !git_time_budget_exceeded
14642    {
14643        return None;
14644    }
14645    Some(WorkspaceInspectionTruncationV1 {
14646        walk_time_budget_exceeded: walk_budget.time_budget_exceeded,
14647        walk_entry_cap_exceeded: walk_budget.entry_cap_exceeded,
14648        git_time_budget_exceeded,
14649        entries_visited: walk_budget.entries_visited,
14650        elapsed_ms: u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX),
14651    })
14652}
14653
14654fn walk_workspace_directory(
14655    directory: &Path,
14656    relative_directory: Option<&RepositoryPath>,
14657    depth: usize,
14658    entries: &mut Vec<WorkspaceEntry>,
14659    truncated: &mut bool,
14660    budget: &mut WorkspaceWalkBudget,
14661) {
14662    if budget.is_exhausted() {
14663        *truncated = true;
14664        return;
14665    }
14666    let read_dir = match std::fs::read_dir(directory) {
14667        Ok(read_dir) => read_dir,
14668        Err(_) => {
14669            *truncated = true;
14670            return;
14671        }
14672    };
14673    let mut children = Vec::new();
14674    for child in read_dir {
14675        match child {
14676            Ok(child) => children.push(child),
14677            Err(_) => *truncated = true,
14678        }
14679    }
14680    children.sort_by_key(|child| child.file_name());
14681    for child in children {
14682        if budget.record_visit() {
14683            *truncated = true;
14684            return;
14685        }
14686        if entries.len() >= WORKSPACE_TREE_MAX_ENTRIES {
14687            *truncated = true;
14688            return;
14689        }
14690        let file_type = match child.file_type() {
14691            Ok(file_type) => file_type,
14692            Err(_) => {
14693                *truncated = true;
14694                continue;
14695            }
14696        };
14697        if file_type.is_symlink() {
14698            continue;
14699        }
14700        let name = match child.file_name().into_string() {
14701            Ok(name) => name,
14702            Err(_) => {
14703                *truncated = true;
14704                continue;
14705            }
14706        };
14707        if file_type.is_dir() && is_skipped_workspace_directory(&name, &child.path()) {
14708            continue;
14709        }
14710        let relative_path = match relative_directory {
14711            None => windows_repository_path(name),
14712            Some(parent) => parent
14713                .as_utf8()
14714                .and_then(|parent| windows_repository_path(format!("{parent}/{name}"))),
14715        };
14716        let Some(relative_path) = relative_path else {
14717            *truncated = true;
14718            continue;
14719        };
14720        let kind = if file_type.is_dir() {
14721            WorkspaceEntryKind::Directory
14722        } else if file_type.is_file() {
14723            WorkspaceEntryKind::File
14724        } else {
14725            continue;
14726        };
14727        entries.push(WorkspaceEntry {
14728            relative_path: relative_path.clone(),
14729            kind,
14730        });
14731        if file_type.is_dir() {
14732            if depth + 1 < WORKSPACE_TREE_MAX_DEPTH {
14733                walk_workspace_directory(
14734                    &child.path(),
14735                    Some(&relative_path),
14736                    depth + 1,
14737                    entries,
14738                    truncated,
14739                    budget,
14740                );
14741            } else {
14742                *truncated = true;
14743            }
14744        }
14745    }
14746}
14747
14748fn windows_repository_path(value: String) -> Option<RepositoryPath> {
14749    if value.contains('\\') {
14750        return None;
14751    }
14752    RepositoryPath::utf8(value).ok()
14753}
14754
14755fn windows_repository_path_from_bytes(value: &[u8]) -> Option<RepositoryPath> {
14756    windows_repository_path(std::str::from_utf8(value).ok()?.to_owned())
14757}
14758
14759/// Excludes a directory from the workspace tree walk. The three literal
14760/// names are the common convention-based cases; the `CACHEDIR.TAG` check
14761/// beneath them catches every build-output directory regardless of name
14762/// (cargo writes `CACHEDIR.TAG` into every `--target-dir` it creates, so
14763/// this covers `target-a2`, `target-a2-tui`, and any future renamed build
14764/// directory without maintaining a name heuristic list). The name check
14765/// runs first and returns early so a directory rejected by name never pays
14766/// for the extra `CACHEDIR.TAG` stat.
14767fn is_skipped_workspace_directory(name: &str, path: &Path) -> bool {
14768    if name.eq_ignore_ascii_case(".git")
14769        || name.eq_ignore_ascii_case("target")
14770        || name.eq_ignore_ascii_case("node_modules")
14771    {
14772        return true;
14773    }
14774    path.join("CACHEDIR.TAG").is_file()
14775}
14776
14777fn context_pack_source_status_is_usable(status: &SessionStatus) -> bool {
14778    matches!(
14779        status,
14780        SessionStatus::Running | SessionStatus::Exited { exit_code: Some(0) }
14781    )
14782}
14783
14784#[derive(Clone, Debug, Eq, PartialEq)]
14785enum ContextPackRepositoryHead {
14786    NotRepository,
14787    Unborn,
14788    Commit(String),
14789}
14790
14791#[derive(Clone, Debug, Eq, PartialEq)]
14792struct ContextPackRepositoryObservation {
14793    head: ContextPackRepositoryHead,
14794    git: GitSnapshot,
14795    files: Vec<ContextPackRepositoryFileSource>,
14796}
14797
14798fn stable_context_pack_repository(
14799    first: ContextPackRepositoryObservation,
14800    second: ContextPackRepositoryObservation,
14801) -> Result<ContextPackRepository, NodeFailure> {
14802    if first != second {
14803        return Err(failure(
14804            NodeFailureCode::ContextPackMaterializationFailed,
14805            "context repository changed during bounded capture",
14806        ));
14807    }
14808    Ok(ContextPackRepository::from_sources(
14809        &second.git,
14810        second.files,
14811    ))
14812}
14813
14814/// Appends a "skipped: budget exhausted" diagnostic and returns `true` once
14815/// (and only once) the shared walk+git `deadline` has already passed. Every
14816/// remaining phase in `inspect_git_workspace` checks this immediately
14817/// before issuing its own git call, so a budget spent by the walk (or by an
14818/// earlier git call) skips the rest of the chain instead of paying for it
14819/// anyway.
14820fn git_budget_exhausted(snapshot: &mut GitSnapshot, deadline: Instant, stage: &str) -> bool {
14821    if Instant::now() < deadline {
14822        return false;
14823    }
14824    append_git_diagnostic(
14825        snapshot,
14826        &format!("{stage} skipped: workspace inspection time budget exhausted"),
14827    );
14828    true
14829}
14830
14831/// Deadline-aware sibling of `run_git_bounded`, used only by
14832/// `inspect_git_workspace`'s six-call chain: caps the per-call timeout to
14833/// whatever remains of the shared `deadline` (never more than
14834/// `GIT_COMMAND_TIMEOUT_MS`), so a slow walk phase that already spent most
14835/// of the budget cannot still hand a full per-call timeout to every git
14836/// probe behind it. `inspect_git_workspace` already guards each call site
14837/// with `git_budget_exhausted`, so `deadline` is guaranteed not to have
14838/// passed yet here.
14839async fn run_git_bounded_with_deadline(
14840    root: &str,
14841    arguments: &[&str],
14842    output_limit: usize,
14843    deadline: Instant,
14844) -> io::Result<GitCommandOutput> {
14845    let remaining = deadline.checked_duration_since(Instant::now());
14846    let timeout_ms = remaining
14847        .and_then(|remaining| u64::try_from(remaining.as_millis()).ok())
14848        .unwrap_or(0)
14849        .clamp(1, GIT_COMMAND_TIMEOUT_MS);
14850    run_git_read_bounded(root, arguments, output_limit, timeout_ms).await
14851}
14852
14853/// Runs the bounded six-call git probe chain used by `inspect_workspace`.
14854/// `deadline` is the same absolute instant the preceding directory walk was
14855/// bounded by, so time already spent walking counts against what remains
14856/// for git enrichment. Returns the resulting `GitSnapshot` plus whether the
14857/// shared budget (not an individual per-call timeout) is what cut the git
14858/// phase short.
14859async fn inspect_git_workspace(root: &str, deadline: Instant) -> (GitSnapshot, bool) {
14860    let mut snapshot = GitSnapshot {
14861        is_repository: false,
14862        branch: None,
14863        status: Vec::new(),
14864        recent_commits: Vec::new(),
14865        worktrees: Vec::new(),
14866        managed_worktree: None,
14867        truncated: false,
14868        diagnostic: None,
14869    };
14870    if git_budget_exhausted(&mut snapshot, deadline, "git repository probe") {
14871        return (snapshot, true);
14872    }
14873    let repository = match run_git_bounded_with_deadline(
14874        root,
14875        &["rev-parse", "--is-inside-work-tree"],
14876        4 * 1_024,
14877        deadline,
14878    )
14879    .await
14880    {
14881        Ok(output) => output,
14882        Err(error) => {
14883            append_git_diagnostic(
14884                &mut snapshot,
14885                &format!("git inspection unavailable: {error}"),
14886            );
14887            return (snapshot, false);
14888        }
14889    };
14890    snapshot.truncated |= repository.truncated;
14891    if repository.timed_out {
14892        append_git_diagnostic(&mut snapshot, "git repository probe timed out");
14893        return (snapshot, false);
14894    }
14895    if !repository.success
14896        || String::from_utf8_lossy(&repository.stdout).trim() != "true"
14897    {
14898        let stderr = String::from_utf8_lossy(&repository.stderr);
14899        if !stderr.to_ascii_lowercase().contains("not a git repository") {
14900            append_git_diagnostic(&mut snapshot, stderr.trim());
14901        }
14902        return (snapshot, false);
14903    }
14904    snapshot.is_repository = true;
14905
14906    if git_budget_exhausted(&mut snapshot, deadline, "git branch query") {
14907        return (snapshot, true);
14908    }
14909    match run_git_bounded_with_deadline(root, &["branch", "--show-current"], 4 * 1_024, deadline).await {
14910        Ok(output) => {
14911            snapshot.truncated |= output.truncated;
14912            if output.timed_out {
14913                append_git_diagnostic(&mut snapshot, "git branch query timed out");
14914            } else if output.success {
14915                let branch = String::from_utf8_lossy(&output.stdout).trim().to_owned();
14916                if !branch.is_empty() {
14917                    snapshot.branch = Some(truncate_git_field(
14918                        &branch,
14919                        MAX_REPOSITORY_PATH_BYTES,
14920                        &mut snapshot.truncated,
14921                    ));
14922                }
14923            }
14924        }
14925        Err(error) => append_git_diagnostic(
14926            &mut snapshot,
14927            &format!("git branch query failed: {error}"),
14928        ),
14929    }
14930    if snapshot.branch.is_none() && !git_budget_exhausted(&mut snapshot, deadline, "git detached-HEAD probe") {
14931        if let Ok(output) = run_git_bounded_with_deadline(root, &["rev-parse", "--short", "HEAD"], 4 * 1_024, deadline).await {
14932            snapshot.truncated |= output.truncated;
14933            if output.success && !output.timed_out {
14934                let head = String::from_utf8_lossy(&output.stdout).trim().to_owned();
14935                if !head.is_empty() {
14936                    snapshot.branch = Some(format!("detached:{head}"));
14937                }
14938            }
14939        }
14940    }
14941
14942    if git_budget_exhausted(&mut snapshot, deadline, "git status") {
14943        return (snapshot, true);
14944    }
14945    match run_git_bounded_with_deadline(
14946        root,
14947        &[
14948            "status",
14949            "--porcelain=v1",
14950            "-z",
14951            "--renames",
14952            "--untracked-files=normal",
14953            "--",
14954            ".",
14955        ],
14956        GIT_OUTPUT_MAX_BYTES,
14957        deadline,
14958    )
14959    .await
14960    {
14961        Ok(output) => {
14962            snapshot.truncated |= output.truncated;
14963            if output.timed_out {
14964                append_git_diagnostic(&mut snapshot, "git status timed out");
14965            } else if output.success {
14966                parse_git_status(&output.stdout, &mut snapshot);
14967            } else {
14968                append_git_diagnostic(
14969                    &mut snapshot,
14970                    String::from_utf8_lossy(&output.stderr).trim(),
14971                );
14972            }
14973        }
14974        Err(error) => append_git_diagnostic(
14975            &mut snapshot,
14976            &format!("git status failed: {error}"),
14977        ),
14978    }
14979
14980    if git_budget_exhausted(&mut snapshot, deadline, "git log") {
14981        return (snapshot, true);
14982    }
14983    let log_limit = (GIT_COMMIT_MAX_ENTRIES + 1).to_string();
14984    match run_git_bounded_with_deadline(
14985        root,
14986        &["log", "-n", &log_limit, "--pretty=format:%H%x1f%s", "--", "."],
14987        16 * 1_024,
14988        deadline,
14989    )
14990    .await
14991    {
14992        Ok(output) => {
14993            snapshot.truncated |= output.truncated;
14994            if output.timed_out {
14995                append_git_diagnostic(&mut snapshot, "git log timed out");
14996            } else if output.success {
14997                parse_git_commits(&output.stdout, &mut snapshot);
14998            } else {
14999                let stderr = String::from_utf8_lossy(&output.stderr);
15000                if !stderr.to_ascii_lowercase().contains("does not have any commits") {
15001                    append_git_diagnostic(&mut snapshot, stderr.trim());
15002                }
15003            }
15004        }
15005        Err(error) => append_git_diagnostic(
15006            &mut snapshot,
15007            &format!("git log failed: {error}"),
15008        ),
15009    }
15010
15011    if git_budget_exhausted(&mut snapshot, deadline, "git worktree list") {
15012        return (snapshot, true);
15013    }
15014    match list_git_worktrees_with_deadline(root, deadline).await {
15015        Ok(worktrees) => {
15016            snapshot.worktrees = worktrees.into_iter().map(protocol_worktree).collect()
15017        }
15018        Err(error) => append_git_diagnostic(
15019            &mut snapshot,
15020            &format!("git worktree list failed: {}", error.message),
15021        ),
15022    }
15023    (snapshot, false)
15024}
15025
15026fn parse_git_status(output: &[u8], snapshot: &mut GitSnapshot) {
15027    let mut remaining = output;
15028    while !remaining.is_empty() {
15029        if snapshot.status.len() >= GIT_STATUS_MAX_ENTRIES {
15030            snapshot.truncated = true;
15031            break;
15032        }
15033        let Some(record_end) = remaining.iter().position(|byte| *byte == 0) else {
15034            snapshot.truncated = true;
15035            break;
15036        };
15037        let record = &remaining[..record_end];
15038        remaining = &remaining[record_end + 1..];
15039        let has_previous_path = record
15040            .get(..2)
15041            .is_some_and(|status| status.iter().any(|byte| matches!(*byte, b'R' | b'C')));
15042        let previous_path_bytes = if has_previous_path {
15043            let Some(previous_end) = remaining.iter().position(|byte| *byte == 0) else {
15044                snapshot.truncated = true;
15045                break;
15046            };
15047            let previous = &remaining[..previous_end];
15048            remaining = &remaining[previous_end + 1..];
15049            Some(previous)
15050        } else {
15051            None
15052        };
15053        if record.len() < 4
15054            || record[2] != b' '
15055            || !is_git_status_code(record[0])
15056            || !is_git_status_code(record[1])
15057        {
15058            snapshot.truncated = true;
15059            continue;
15060        }
15061        let Some(path) = windows_repository_path_from_bytes(&record[3..]) else {
15062            snapshot.truncated = true;
15063            continue;
15064        };
15065        let previous_path = match previous_path_bytes {
15066            Some(previous) => {
15067                let Some(previous) = windows_repository_path_from_bytes(previous) else {
15068                    snapshot.truncated = true;
15069                    continue;
15070                };
15071                Some(previous)
15072            }
15073            None => None,
15074        };
15075        snapshot.status.push(GitStatusEntry {
15076            index_status: (record[0] as char).to_string(),
15077            worktree_status: (record[1] as char).to_string(),
15078            path,
15079            previous_path,
15080        });
15081    }
15082}
15083
15084fn is_git_status_code(byte: u8) -> bool {
15085    matches!(byte, b' ' | b'M' | b'T' | b'A' | b'D' | b'R' | b'C' | b'U' | b'?' | b'!')
15086}
15087
15088fn parse_git_commits(output: &[u8], snapshot: &mut GitSnapshot) {
15089    for line in String::from_utf8_lossy(output).lines() {
15090        let Some((id, summary)) = line.split_once('\u{1f}') else {
15091            continue;
15092        };
15093        if snapshot.recent_commits.len() >= GIT_COMMIT_MAX_ENTRIES {
15094            snapshot.truncated = true;
15095            break;
15096        }
15097        let id = truncate_git_field(id, 64, &mut snapshot.truncated);
15098        let summary = truncate_git_field(summary, 512, &mut snapshot.truncated);
15099        snapshot.recent_commits.push(GitCommitSummary { id, summary });
15100    }
15101}
15102
15103fn truncate_git_field(value: &str, max_bytes: usize, truncated: &mut bool) -> String {
15104    if value.len() <= max_bytes {
15105        return value.to_owned();
15106    }
15107    *truncated = true;
15108    let mut boundary = max_bytes;
15109    while boundary > 0 && !value.is_char_boundary(boundary) {
15110        boundary -= 1;
15111    }
15112    value[..boundary].to_owned()
15113}
15114
15115fn append_git_diagnostic(snapshot: &mut GitSnapshot, message: &str) {
15116    if message.is_empty() {
15117        return;
15118    }
15119    let normalized = message
15120        .chars()
15121        .map(|character| if character.is_control() { ' ' } else { character })
15122        .collect::<String>();
15123    let mut diagnostic = snapshot.diagnostic.take().unwrap_or_default();
15124    if !diagnostic.is_empty() {
15125        diagnostic.push_str("; ");
15126    }
15127    diagnostic.push_str(normalized.trim());
15128    snapshot.diagnostic = Some(truncate_git_field(
15129        &diagnostic,
15130        GIT_DIAGNOSTIC_MAX_BYTES,
15131        &mut snapshot.truncated,
15132    ));
15133}
15134
15135async fn run_git_bounded(
15136    root: &str,
15137    arguments: &[&str],
15138    output_limit: usize,
15139) -> io::Result<GitCommandOutput> {
15140    run_git_read_bounded(root, arguments, output_limit, GIT_COMMAND_TIMEOUT_MS).await
15141}
15142
15143async fn read_git_history_bounded(
15144    root: &str,
15145    path: Option<&RepositoryPath>,
15146    before: Option<&GitObjectId>,
15147    limit: u16,
15148) -> Result<GitHistoryPage, NodeFailure> {
15149    let path = path
15150        .map(|path| {
15151            path.as_utf8().ok_or_else(|| {
15152                failure(
15153                    NodeFailureCode::InvalidRepositoryPath,
15154                    "git history path must use UTF-8",
15155                )
15156            })
15157        })
15158        .transpose()?
15159        .unwrap_or(".");
15160    let count = (usize::from(limit) + 1).to_string();
15161    let start = before
15162        .map(|revision| format!("{}^", revision.as_str()))
15163        .unwrap_or_else(|| "HEAD".to_owned());
15164    let arguments = [
15165        "--no-pager",
15166        "log",
15167        "--no-decorate",
15168        "--date=iso-strict",
15169        "--pretty=format:%H%x1f%P%x1f%s%x1f%an%x1f%ae%x1f%aI%x1f%cn%x1f%ce%x1f%cI%x1e",
15170        "-n",
15171        count.as_str(),
15172        start.as_str(),
15173        "--",
15174        path,
15175    ];
15176    let output = run_git_bounded(root, &arguments, 256 * 1_024)
15177        .await
15178        .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git history command failed"))?;
15179    if output.timed_out {
15180        return Err(failure(NodeFailureCode::GitReadTimedOut, "git history exceeded its bounded deadline"));
15181    }
15182    if !output.success {
15183        return Err(failure(NodeFailureCode::GitReadFailed, "git history could not be read"));
15184    }
15185    let mut commits = parse_git_history(&output.stdout);
15186    let has_more = commits.len() > usize::from(limit) || output.truncated;
15187    commits.truncate(usize::from(limit));
15188    let next_before = has_more.then(|| commits.last().map(|commit| commit.id.clone())).flatten();
15189    Ok(GitHistoryPage { commits, next_before, truncated: output.truncated })
15190}
15191
15192fn parse_git_history(output: &[u8]) -> Vec<GitCommitDetails> {
15193    String::from_utf8_lossy(output)
15194        .split('\u{1e}')
15195        .filter_map(|record| {
15196            let fields = record.trim_matches(['\r', '\n']).split('\u{1f}').collect::<Vec<_>>();
15197            if fields.len() != 9 {
15198                return None;
15199            }
15200            let id = GitObjectId::new(fields[0].to_owned()).ok()?;
15201            let parents = if fields[1].is_empty() {
15202                Vec::new()
15203            } else {
15204                fields[1]
15205                    .split(' ')
15206                    .map(|value| GitObjectId::new(value.to_owned()))
15207                    .collect::<Result<Vec<_>, _>>()
15208                    .ok()?
15209            };
15210            Some(GitCommitDetails {
15211                id,
15212                parents,
15213                subject: bounded_git_text(fields[2], 512),
15214                author_name: bounded_git_text(fields[3], 256),
15215                author_email: bounded_git_text(fields[4], 320),
15216                authored_at: bounded_git_text(fields[5], 64),
15217                committer_name: bounded_git_text(fields[6], 256),
15218                committer_email: bounded_git_text(fields[7], 320),
15219                committed_at: bounded_git_text(fields[8], 64),
15220                signature_status: GitSignatureStatus::NoSignature,
15221                signer: None,
15222            })
15223        })
15224        .take(usize::from(MAX_GIT_HISTORY_COMMITS) + 1)
15225        .collect()
15226}
15227
15228fn bounded_git_text(value: &str, max_bytes: usize) -> String {
15229    let normalized = value
15230        .chars()
15231        .map(|character| if character.is_control() { ' ' } else { character })
15232        .collect::<String>();
15233    if normalized.len() <= max_bytes {
15234        return normalized;
15235    }
15236    let mut boundary = max_bytes;
15237    while boundary > 0 && !normalized.is_char_boundary(boundary) {
15238        boundary -= 1;
15239    }
15240    normalized[..boundary].to_owned()
15241}
15242
15243async fn read_git_diff_bounded(root: &str, request: GitDiffRequest) -> Result<GitDiff, NodeFailure> {
15244    let path = request.path.as_ref().map(|path| {
15245        path.as_utf8()
15246            .ok_or_else(|| failure(NodeFailureCode::InvalidRepositoryPath, "git diff path must use UTF-8"))
15247    }).transpose()?;
15248    let mut arguments = match &request.mode {
15249        GitDiffMode::Working => vec!["--no-pager", "-c", "diff.external=", "diff", "--no-ext-diff", "--no-textconv"],
15250        GitDiffMode::Staged => vec!["--no-pager", "-c", "diff.external=", "diff", "--cached", "--no-ext-diff", "--no-textconv"],
15251        GitDiffMode::Commit { .. } => vec!["--no-pager", "-c", "diff.external=", "show", "--format=fuller", "--no-ext-diff", "--no-textconv"],
15252    };
15253    if let GitDiffMode::Commit { revision } = &request.mode {
15254        arguments.push(revision.as_str());
15255    }
15256    arguments.push("--");
15257    if let Some(path) = path {
15258        arguments.push(path);
15259    }
15260    let output = run_git_bounded(root, &arguments, MAX_GIT_DIFF_BYTES)
15261        .await
15262        .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git diff command failed"))?;
15263    if output.timed_out {
15264        return Err(failure(NodeFailureCode::GitReadTimedOut, "git diff exceeded its bounded deadline"));
15265    }
15266    if !output.success {
15267        return Err(failure(NodeFailureCode::GitReadFailed, "git diff could not be read"));
15268    }
15269    Ok(GitDiff {
15270        mode: request.mode,
15271        path: request.path,
15272        text: String::from_utf8_lossy(&output.stdout).into_owned(),
15273        truncated: output.truncated,
15274    })
15275}
15276
15277async fn process_request(shared: &NodeShared, connection_id: u64, role: ClientRole, envelope: RequestEnvelope) -> ResponseEnvelope {
15278    let result = process_request_inner(shared, connection_id, role, envelope.request).await;
15279    ResponseEnvelope { request_id: envelope.request_id, result }
15280}
15281
15282async fn process_request_inner(shared: &NodeShared, connection_id: u64, role: ClientRole, request: NodeRequest) -> Result<NodeResponse, NodeFailure> {
15283    if !request.harness_mcp_contract_is_valid_at(unix_time_ms()) {
15284        return Err(failure(NodeFailureCode::InvalidRequest, "harness MCP request is invalid or stale"));
15285    }
15286    let read_only = matches!(
15287        &request,
15288        NodeRequest::Snapshot
15289            | NodeRequest::Resync { .. }
15290            | NodeRequest::BrowseHostDirectories { .. }
15291            | NodeRequest::InspectWorkspace { .. }
15292            | NodeRequest::ReadWorkspaceFile { .. }
15293            | NodeRequest::ReadGitHistory { .. }
15294            | NodeRequest::ReadGitDiff { .. }
15295            | NodeRequest::CatalogNativeSessions { .. }
15296            | NodeRequest::PageNativeSessions { .. }
15297            | NodeRequest::PreviewNativeSession { .. }
15298            | NodeRequest::PreviewSessionRecord { .. }
15299    );
15300    let _mutation_guard = if read_only {
15301        None
15302    } else {
15303        let guard = shared.mutation_gate.lock().await;
15304        if shared.shutdown.load(Ordering::Acquire) {
15305            return Err(failure(NodeFailureCode::ShuttingDown, "node shutdown has begun"));
15306        }
15307        Some(guard)
15308    };
15309    match request {
15310        NodeRequest::Snapshot => Ok(NodeResponse::Snapshot {
15311            event_sequence: shared.current_sequence(),
15312            controller: shared.controller_state(),
15313            snapshot: shared.snapshot(),
15314        }),
15315        NodeRequest::Resync { after_sequence } => Ok(shared.resync(after_sequence)),
15316        NodeRequest::ArmHarnessMcpReservation {
15317            reservation_id,
15318            activation_digest,
15319            spawn_spec,
15320            launch,
15321            expires_at_unix_ms,
15322        } => {
15323            shared.require_controller(connection_id, role)?;
15324            let registry = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15325                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15326            })?;
15327            let expires_at_unix_ms = registry
15328                .arm(reservation_id.clone(), activation_digest.clone(), spawn_spec, launch, expires_at_unix_ms)
15329                .await
15330                .map_err(harness_mcp_failure)?;
15331            Ok(NodeResponse::Armed { reservation_id, activation_digest, expires_at_unix_ms })
15332        }
15333        NodeRequest::SpawnSpecWithHarnessMcp {
15334            reservation_id,
15335            activation_digest,
15336            spec,
15337            deadline_unix_ms,
15338        } => {
15339            shared.require_controller(connection_id, role)?;
15340            let receipt = shared
15341                .spawn_from_spec_with_harness_mcp(
15342                    reservation_id.clone(), activation_digest.clone(), spec, deadline_unix_ms,
15343                )
15344                .await?;
15345            Ok(NodeResponse::Spawned { reservation_id, activation_digest, receipt })
15346        }
15347        NodeRequest::ActivateHarnessMcpReservation {
15348            reservation_id,
15349            activation_digest,
15350            record_id,
15351            session,
15352        } => {
15353            shared.require_controller(connection_id, role)?;
15354            let provider_root_pid = shared.handle.snapshot().sessions.iter().find(|current| {
15355                current.instance_id == session.session.instance_id
15356                    && current.generation == session.session.generation
15357            }).and_then(|current| current.process_id).ok_or_else(|| {
15358                failure(NodeFailureCode::BindingMismatch, "provider root process is unavailable")
15359            })?;
15360            shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15361                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15362            })?.activate(
15363                &reservation_id, &activation_digest, &record_id, &session, provider_root_pid,
15364            ).map_err(harness_mcp_failure)?;
15365            Ok(NodeResponse::Activated { reservation_id, activation_digest, record_id, session })
15366        }
15367        NodeRequest::AbortHarnessMcpReservation { reservation_id, activation_digest } => {
15368            shared.require_controller(connection_id, role)?;
15369            let instance_id = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15370                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15371            })?.abort(&reservation_id, &activation_digest).map_err(harness_mcp_failure)?;
15372            if let (Some(control), Some(instance_id)) =
15373                (shared.native_launch_profile_control.as_ref(), instance_id)
15374            {
15375                control.clear_native_mcp_server_launch_overlay(instance_id);
15376            }
15377            Ok(NodeResponse::Aborted { reservation_id, activation_digest })
15378        }
15379        NodeRequest::PutHarnessMcpReplyChunk {
15380            reservation_id, activation_digest, record_id, session, call_id,
15381            offset, final_chunk, chunk_hex,
15382        } => {
15383            shared.require_controller(connection_id, role)?;
15384            let (next_offset, completed) = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15385                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15386            })?.put_reply_chunk(
15387                &reservation_id, &activation_digest, &record_id, &session, &call_id,
15388                offset, final_chunk, &chunk_hex,
15389            ).map_err(harness_mcp_failure)?;
15390            Ok(NodeResponse::ReplyChunkAccepted {
15391                reservation_id, activation_digest, record_id, session, call_id,
15392                next_offset, completed,
15393            })
15394        }
15395        NodeRequest::RejectHarnessMcpCall {
15396            reservation_id, activation_digest, record_id, session, call_id, reason,
15397        } => {
15398            shared.require_controller(connection_id, role)?;
15399            shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15400                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15401            })?.reject_call(
15402                &reservation_id, &activation_digest, &record_id, &session, &call_id, reason,
15403            ).map_err(harness_mcp_failure)?;
15404            Ok(NodeResponse::CallRejected {
15405                reservation_id, activation_digest, record_id, session, call_id,
15406            })
15407        }
15408        NodeRequest::BeginDeliveryStage { manifest } => {
15409            shared.require_controller(connection_id, role)?;
15410            shared.begin_delivery_stage(manifest)
15411        }
15412        NodeRequest::PutDeliveryBlobChunk {
15413            stage_id,
15414            blob_digest,
15415            offset,
15416            chunk_hex,
15417        } => {
15418            shared.require_controller(connection_id, role)?;
15419            shared.put_delivery_blob_chunk(stage_id, blob_digest, offset, chunk_hex)
15420        }
15421        NodeRequest::CommitDeliveryStage { stage_id } => {
15422            shared.require_controller(connection_id, role)?;
15423            let receipt = shared.commit_delivery_stage(stage_id)?;
15424            Ok(NodeResponse::DeliveryCommitted { receipt })
15425        }
15426        NodeRequest::AbortDeliveryStage { stage_id } => {
15427            shared.require_controller(connection_id, role)?;
15428            shared.abort_delivery_stage(&stage_id)?;
15429            Ok(NodeResponse::DeliveryStageAborted { stage_id })
15430        }
15431        NodeRequest::BrowseHostDirectories { directory, after } => {
15432            let listing = shared.browse_host_directories(directory, after).await?;
15433            Ok(NodeResponse::HostDirectoriesBrowsed { listing })
15434        }
15435        NodeRequest::InspectWorkspace { workspace_id } => {
15436            let inspection = shared.inspect_workspace(workspace_id).await?;
15437            Ok(NodeResponse::WorkspaceInspected { inspection })
15438        }
15439        NodeRequest::ReadWorkspaceFile { workspace_id, path } => {
15440            let file = shared.read_workspace_file(workspace_id, path).await?;
15441            Ok(NodeResponse::WorkspaceFileRead { file })
15442        }
15443        NodeRequest::WriteWorkspaceFile { workspace_id, path, expected_revision, text } => {
15444            shared.require_controller(connection_id, role)?;
15445            let file = shared
15446                .write_workspace_file(workspace_id, path, expected_revision, text)
15447                .await?;
15448            Ok(NodeResponse::WorkspaceFileWritten { file })
15449        }
15450        NodeRequest::CreateWorkspaceFile { workspace_id, path } => {
15451            shared.require_controller(connection_id, role)?;
15452            let file = shared.create_workspace_file(workspace_id, path).await?;
15453            Ok(NodeResponse::WorkspaceFileCreated { file })
15454        }
15455        NodeRequest::CreateWorkspaceDirectory { workspace_id, path } => {
15456            shared.require_controller(connection_id, role)?;
15457            let entry = shared
15458                .create_workspace_directory(workspace_id.clone(), path)
15459                .await?;
15460            Ok(NodeResponse::WorkspaceDirectoryCreated { workspace_id, entry })
15461        }
15462        NodeRequest::ReadGitHistory { workspace_id, path, before, limit } => {
15463            if role != ClientRole::Operator {
15464                return Err(failure(NodeFailureCode::ObserverReadOnly, "git history requires an operator connection"));
15465            }
15466            if !(1..=MAX_GIT_HISTORY_COMMITS).contains(&limit) {
15467                return Err(failure(NodeFailureCode::InvalidRequest, "git history limit is invalid"));
15468            }
15469            let page = shared
15470                .read_git_history(workspace_id.clone(), path, before, limit)
15471                .await?;
15472            Ok(NodeResponse::GitHistoryRead { workspace_id, page })
15473        }
15474        NodeRequest::ReadGitDiff { workspace_id, request } => {
15475            if role != ClientRole::Operator {
15476                return Err(failure(NodeFailureCode::ObserverReadOnly, "git diff requires an operator connection"));
15477            }
15478            let diff = shared.read_git_diff(workspace_id.clone(), request).await?;
15479            Ok(NodeResponse::GitDiffRead { workspace_id, diff })
15480        }
15481        NodeRequest::CatalogNativeSessions { route, limit } => {
15482            if role != ClientRole::Operator {
15483                return Err(failure(
15484                    NodeFailureCode::ObserverReadOnly,
15485                    "native session catalog requires an operator connection",
15486                ));
15487            }
15488            if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit) {
15489                return Err(failure(
15490                    NodeFailureCode::InvalidRequest,
15491                    "native session catalog limit is invalid",
15492                ));
15493            }
15494            let (entries, summary) = shared
15495                .catalog_native_sessions(route.clone(), limit)
15496                .await?;
15497            Ok(NodeResponse::NativeSessionsCataloged {
15498                route,
15499                entries,
15500                summary: Some(summary),
15501            })
15502        }
15503        NodeRequest::PageNativeSessions {
15504            route,
15505            window,
15506            catalog_revision,
15507            recent_cutoff_unix_ms,
15508            after_selection_id,
15509            limit,
15510        } => {
15511            if role != ClientRole::Operator {
15512                return Err(failure(
15513                    NodeFailureCode::ObserverReadOnly,
15514                    "native session catalog paging requires an operator connection",
15515                ));
15516            }
15517            if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit)
15518                || after_selection_id
15519                    .as_deref()
15520                    .is_some_and(|cursor| validate_candidate_id(cursor).is_err())
15521            {
15522                return Err(failure(
15523                    NodeFailureCode::InvalidRequest,
15524                    "native session catalog page request is invalid",
15525                ));
15526            }
15527            let page = shared
15528                .page_native_sessions(
15529                    route.clone(),
15530                    window,
15531                    catalog_revision,
15532                    recent_cutoff_unix_ms,
15533                    after_selection_id,
15534                    limit,
15535                )
15536                .await?;
15537            Ok(NodeResponse::NativeSessionsPaged {
15538                route,
15539                page,
15540            })
15541        }
15542        NodeRequest::PreviewNativeSession {
15543            selection,
15544            message_limit,
15545        } => {
15546            if role != ClientRole::Operator {
15547                return Err(failure(
15548                    NodeFailureCode::ObserverReadOnly,
15549                    "native session preview requires an operator connection",
15550                ));
15551            }
15552            if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
15553                .contains(&message_limit)
15554                || selection.validate().is_err()
15555            {
15556                return Err(failure(
15557                    NodeFailureCode::InvalidRequest,
15558                    "native session preview request is invalid",
15559                ));
15560            }
15561            let preview = shared
15562                .preview_native_session(selection.clone(), message_limit)
15563                .await?;
15564            Ok(NodeResponse::NativeSessionPreviewed {
15565                selection,
15566                preview,
15567            })
15568        }
15569        NodeRequest::IndexNativeSession {
15570            selection,
15571            display_name,
15572        } => {
15573            shared.require_controller(connection_id, role)?;
15574            let record = shared
15575                .index_native_session(selection.clone(), display_name)
15576                .await?;
15577            Ok(NodeResponse::NativeSessionIndexed { selection, record })
15578        }
15579        NodeRequest::PreviewSessionRecord {
15580            record_id,
15581            message_limit,
15582        } => {
15583            if role != ClientRole::Operator {
15584                return Err(failure(
15585                    NodeFailureCode::ObserverReadOnly,
15586                    "native session preview requires an operator connection",
15587                ));
15588            }
15589            if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
15590                .contains(&message_limit)
15591            {
15592                return Err(failure(
15593                    NodeFailureCode::InvalidRequest,
15594                    "native session preview request is invalid",
15595                ));
15596            }
15597            let record = shared.record(&record_id)?;
15598            let working_directory = shared.workspace_root(&record.workspace_id)?;
15599            if !platform::roots_equal(
15600                &working_directory,
15601                windows_path_text(&record.canonical_root),
15602            ) {
15603                return Err(failure(
15604                    NodeFailureCode::SessionRecordConflict,
15605                    "managed session workspace root changed; refusing to preview another directory",
15606                ));
15607            }
15608            let identity = record.provider_session.clone().ok_or_else(|| {
15609                failure(
15610                    NodeFailureCode::BackendOperationFailed,
15611                    "native session preview is unavailable for this session record",
15612                )
15613            })?;
15614            if identity.key != ProviderSessionKey::SessionId {
15615                return Err(failure(
15616                    NodeFailureCode::BackendOperationFailed,
15617                    "native session preview is unavailable for this session identity",
15618                ));
15619            }
15620            let preview = shared
15621                .preview_native_session_by_session_id(
15622                    record.workspace_id.clone(), record.provider.clone(), identity.id.clone(), message_limit,
15623                )
15624                .await?;
15625            if preview.session_id != identity.id {
15626                return Err(failure(
15627                    NodeFailureCode::SessionRecordConflict,
15628                    "native session preview identity changed while loading",
15629                ));
15630            }
15631            shared.revalidate_session_record_preview(&record, &identity)?;
15632            let preview: gate4agent_types::SessionRecordPreview = preview.into();
15633            shared.publish(NodeEvent::SessionRecordHistorySummarized {
15634                record_id: record_id.clone(),
15635                summary: SessionHistorySummaryV1::from(&preview),
15636            });
15637            Ok(NodeResponse::SessionRecordPreviewed {
15638                record_id,
15639                preview,
15640            })
15641        }
15642        NodeRequest::AcquireController { lease_ms } => {
15643            let controller = shared.acquire_controller(connection_id, role, lease_ms)?;
15644            Ok(NodeResponse::Controller { controller: Some(controller) })
15645        }
15646        NodeRequest::ReleaseController => {
15647            shared.release_controller(connection_id);
15648            Ok(NodeResponse::Controller { controller: shared.controller_state() })
15649        }
15650        NodeRequest::RegisterWorkspace { workspace_id, root } => {
15651            shared.require_controller(connection_id, role)?;
15652            let root = require_windows_path(root)?;
15653            shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
15654            let workspace = shared.register_workspace(workspace_id, root).await?;
15655            Ok(NodeResponse::WorkspaceRegistered { workspace })
15656        }
15657        NodeRequest::CreateStandaloneWorkspace {
15658            workspace_id,
15659            root,
15660            initial_branch,
15661        } => {
15662            shared.require_controller(connection_id, role)?;
15663            let root = require_windows_path(root)?;
15664            shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
15665            let workspace = shared
15666                .create_standalone_workspace(workspace_id, root, initial_branch)
15667                .await?;
15668            Ok(NodeResponse::StandaloneWorkspaceCreated { workspace })
15669        }
15670        NodeRequest::UnregisterWorkspace { workspace_id } => {
15671            shared.require_controller(connection_id, role)?;
15672            shared.reject_managed_reservation(Some(&workspace_id), None)?;
15673            shared.unregister_workspace(&workspace_id)?;
15674            Ok(NodeResponse::WorkspaceUnregistered { workspace_id })
15675        }
15676        NodeRequest::CreateWorktree {
15677            source_workspace_id,
15678            workspace_id,
15679            target_root,
15680            branch,
15681            base,
15682        } => {
15683            shared.require_controller(connection_id, role)?;
15684            let target_root = require_windows_path(target_root)?;
15685            shared.reject_managed_reservation(Some(&workspace_id), Some(&target_root))?;
15686            let (worktree, workspace) = shared
15687                .create_worktree(
15688                    source_workspace_id,
15689                    workspace_id,
15690                    target_root,
15691                    branch,
15692                    base,
15693                )
15694                .await?;
15695            Ok(NodeResponse::WorktreeCreated { worktree, workspace })
15696        }
15697        NodeRequest::RemoveWorktree {
15698            source_workspace_id,
15699            target_root,
15700        } => {
15701            shared.require_controller(connection_id, role)?;
15702            let native_target_root = require_windows_path(target_root.clone())?;
15703            shared.reject_managed_reservation(None, Some(&native_target_root))?;
15704            let workspace_id = shared
15705                .remove_worktree(source_workspace_id, native_target_root)
15706                .await?;
15707            Ok(NodeResponse::WorktreeRemoved {
15708                target_root,
15709                workspace_id,
15710            })
15711        }
15712        NodeRequest::Spawn { workspace_id, provider, mode, terminal_size, initial_prompt } => {
15713            shared.require_controller(connection_id, role)?;
15714            if !terminal_size.is_valid() {
15715                return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
15716            }
15717            if let Some(prompt) = initial_prompt.as_deref() {
15718                validate_node_text("spawn initial prompt", prompt)?;
15719            }
15720            let session = shared
15721                .spawn_session(workspace_id, provider, mode, terminal_size, initial_prompt)
15722                .await?;
15723            Ok(NodeResponse::SpawnAccepted { session })
15724        }
15725        NodeRequest::SpawnSpec { spec } => {
15726            shared.require_controller(connection_id, role)?;
15727            let receipt = shared.spawn_from_spec(spec).await?;
15728            Ok(NodeResponse::SpawnSpecAccepted { receipt })
15729        }
15730        NodeRequest::SpawnManagedWorktree { request } => {
15731            shared.require_controller(connection_id, role)?;
15732            let receipt = shared.spawn_managed_worktree(request).await?;
15733            Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
15734        }
15735        NodeRequest::SpawnManagedWorktreeV2 { request } => {
15736            shared.require_controller(connection_id, role)?;
15737            let receipt = shared.spawn_managed_worktree_v2(request).await?;
15738            Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
15739        }
15740        NodeRequest::CleanupManagedWorktree { lease_id } => {
15741            shared.require_controller(connection_id, role)?;
15742            let lease = shared.cleanup_managed_worktree(&lease_id, true).await?;
15743            Ok(NodeResponse::ManagedWorktreeCleanup { lease })
15744        }
15745        NodeRequest::DiscoverHistory { session, limit } => {
15746            controlled_session(shared, connection_id, role, &session)?;
15747            let candidates = shared.discover_history(&session, limit).await?;
15748            Ok(NodeResponse::HistoryDiscovered {
15749                session,
15750                candidates,
15751            })
15752        }
15753        NodeRequest::LoadHistory {
15754            session,
15755            candidate_id,
15756        } => {
15757            controlled_session(shared, connection_id, role, &session)?;
15758            let loaded = shared
15759                .load_history(&session, candidate_id)
15760                .await?;
15761            Ok(NodeResponse::HistoryLoaded {
15762                session,
15763                session_id: loaded.session_id,
15764                message_count: loaded.message_count,
15765                completed_turn_count: loaded.completed_turn_count,
15766            })
15767        }
15768        NodeRequest::ExportContextPackForSessionRecord { record_id, session } => {
15769            shared.require_controller(connection_id, role)?;
15770            let context = shared
15771                .export_context_pack_for_session_record(&record_id, &session, false)
15772                .await?;
15773            Ok(NodeResponse::ContextPackForSessionRecordExported {
15774                record_id,
15775                session,
15776                context,
15777            })
15778        }
15779        NodeRequest::ExportContextPack { session } => {
15780            controlled_session(shared, connection_id, role, &session)?;
15781            let context = shared.export_context_pack(&session).await?;
15782            Ok(NodeResponse::ContextPackExported { context })
15783        }
15784        NodeRequest::ForgetContextPack { context_id } => {
15785            shared.require_controller(connection_id, role)?;
15786            shared.forget_context_pack(&context_id)?;
15787            Ok(NodeResponse::ContextPackForgotten { context_id })
15788        }
15789        NodeRequest::ResolveDurableContextPack { context_id } => {
15790            // Read-only, idempotent, no controller lease required: this lets
15791            // a caller preflight "is this durably exported pack still here"
15792            // without holding the Node's mutation controller.
15793            let context = shared
15794                .context_catalog
15795                .read()
15796                .unwrap_or_else(|poisoned| poisoned.into_inner())
15797                .get(&context_id)
15798                .map(|pack| pack.receipt().clone())
15799                .ok_or_else(|| {
15800                    failure(
15801                        NodeFailureCode::UnknownContextPack,
15802                        "context pack is not present",
15803                    )
15804                })?;
15805            Ok(NodeResponse::DurableContextPackResolved { context })
15806        }
15807        NodeRequest::ReadContextPack { digest } => {
15808            // Read-only fetch of the pack's own bytes, keyed by content digest
15809            // rather than the caller's `SpawnContextId`: same no-controller-lease
15810            // rationale as `ResolveDurableContextPack` above, one step further
15811            // down the same catalog.
15812            let (id, byte_len, bytes) = shared
15813                .context_catalog
15814                .read()
15815                .unwrap_or_else(|poisoned| poisoned.into_inner())
15816                .get_by_digest(&digest)
15817                .map(|pack| {
15818                    (pack.receipt().id.clone(), pack.receipt().byte_len, pack.bytes().to_vec())
15819                })
15820                .ok_or_else(|| {
15821                    failure(
15822                        NodeFailureCode::UnknownContextPack,
15823                        &format!("context pack {digest} not on this node"),
15824                    )
15825                })?;
15826            tracing::debug!(
15827                digest = digest.as_str(),
15828                byte_len,
15829                "read context pack bytes",
15830            );
15831            Ok(NodeResponse::ContextPackBytesRead {
15832                pack: ContextPackBytesRead {
15833                    digest,
15834                    id,
15835                    byte_len,
15836                    bytes,
15837                },
15838            })
15839        }
15840        NodeRequest::Resume { session, terminal_size, initial_prompt } => {
15841            let provider = controlled_session(shared, connection_id, role, &session)?;
15842            if let Some(prompt) = initial_prompt.as_deref() {
15843                validate_node_text("resume initial prompt", prompt)?;
15844            }
15845            let runtime_requirement = if initial_prompt.is_some() {
15846                ProviderRuntimeRequirement::ResumeWithPrompt
15847            } else {
15848                ProviderRuntimeRequirement::Resume
15849            };
15850            shared.require_session_runtime_policy(&session, runtime_requirement)?;
15851            let runtime_policy = shared
15852                .admit_provider_runtime(&provider, runtime_requirement)
15853                .await?;
15854            let working_directory = shared.bound_workspace_root(&session)?;
15855            let request = ResumeLaunchRequest {
15856                working_directory,
15857                terminal_size,
15858                initial_prompt,
15859            };
15860            request
15861                .validate()
15862                .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
15863            let command = shared.prepare_command(ControlCommand::Resume {
15864                instance_id: session.session.instance_id,
15865                target: ResumeTarget::CurrentProvider,
15866                runtime_policy,
15867                request,
15868            });
15869            shared.arm_resume(&session, command.id, runtime_policy)?;
15870            let dispatch = shared.dispatch_envelope(command);
15871            if let Err(error) = dispatch {
15872                shared.clear_armed_resume(&session);
15873                return Err(error);
15874            }
15875            Ok(NodeResponse::Accepted)
15876        }
15877        NodeRequest::RenameSessionRecord {
15878            record_id,
15879            display_name,
15880        } => {
15881            shared.require_controller(connection_id, role)?;
15882            let record = shared.rename_session_record(&record_id, display_name)?;
15883            Ok(NodeResponse::SessionRecordUpdated { record })
15884        }
15885        NodeRequest::SetSessionTask {
15886            record_id,
15887            expected_revision,
15888            target,
15889        } => {
15890            shared.require_controller(connection_id, role)?;
15891            let record = shared.set_session_task(&record_id, expected_revision, target)?;
15892            Ok(NodeResponse::SessionRecordUpdated { record })
15893        }
15894        NodeRequest::IndexProviderSession {
15895            workspace_id,
15896            provider,
15897            identity,
15898            display_name,
15899        } => {
15900            shared.require_controller(connection_id, role)?;
15901            let record = shared.index_provider_session(
15902                workspace_id,
15903                provider,
15904                identity,
15905                display_name,
15906            )?;
15907            Ok(NodeResponse::ProviderSessionIndexed { record })
15908        }
15909        NodeRequest::ResumeSessionRecord {
15910            record_id,
15911            terminal_size,
15912            initial_prompt,
15913        } => {
15914            shared.require_controller(connection_id, role)?;
15915            if let Some(prompt) = initial_prompt.as_deref() {
15916                validate_node_text("managed resume initial prompt", prompt)?;
15917            }
15918            let (record, session) = shared
15919                .resume_session_record(
15920                    &record_id,
15921                    terminal_size,
15922                    initial_prompt,
15923                )
15924                .await?;
15925            Ok(NodeResponse::SessionRecordResumed { record, session })
15926        }
15927        NodeRequest::ForgetSessionRecord { record_id } => {
15928            shared.require_controller(connection_id, role)?;
15929            shared.forget_session_record(&record_id).await?;
15930            Ok(NodeResponse::SessionRecordForgotten { record_id })
15931        }
15932        NodeRequest::Prompt { session, text } => {
15933            let agent_id = controlled_session(shared, connection_id, role, &session)?;
15934            validate_node_text("prompt", &text)?;
15935            shared.require_session_runtime_policy(
15936                &session,
15937                ProviderRuntimeRequirement::SemanticPrompt,
15938            )?;
15939            let framing = prompt_framing(&agent_id);
15940            shared
15941                .dispatch_input_bounded(
15942                    &session,
15943                    InputAction::SubmitPrompt(PromptPayload { text, framing }),
15944                )
15945                .await?;
15946            Ok(NodeResponse::Accepted)
15947        }
15948        NodeRequest::Paste { session, text } => {
15949            controlled_session(shared, connection_id, role, &session)?;
15950            validate_node_text("paste", &text)?;
15951            shared.require_session_runtime_policy(
15952                &session,
15953                ProviderRuntimeRequirement::SemanticPrompt,
15954            )?;
15955            shared
15956                .dispatch_input_bounded(
15957                    &session,
15958                    InputAction::InsertDraft(PromptPayload {
15959                        text,
15960                        framing: PromptFraming::BracketedPaste,
15961                    }),
15962                )
15963                .await?;
15964            Ok(NodeResponse::Accepted)
15965        }
15966        NodeRequest::Input { session, text } => {
15967            controlled_session(shared, connection_id, role, &session)?;
15968            validate_node_text("terminal input", &text)?;
15969            shared
15970                .dispatch_input_bounded(
15971                    &session,
15972                    InputAction::TerminalText(TerminalText { text }),
15973                )
15974                .await?;
15975            Ok(NodeResponse::Accepted)
15976        }
15977        NodeRequest::TerminalBytes { session, bytes } => {
15978            controlled_session(shared, connection_id, role, &session)?;
15979            let action = terminal_bytes_action(bytes)?;
15980            shared
15981                .dispatch_input_bounded(&session, action)
15982                .await?;
15983            Ok(NodeResponse::Accepted)
15984        }
15985        NodeRequest::TerminalControl { session, control } => {
15986            controlled_session(shared, connection_id, role, &session)?;
15987            shared
15988                .dispatch_input_bounded(&session, InputAction::TerminalControl(control))
15989                .await?;
15990            Ok(NodeResponse::Accepted)
15991        }
15992        NodeRequest::Resize { session, size } => {
15993            controlled_session(shared, connection_id, role, &session)?;
15994            if !size.is_valid() {
15995                return Err(failure(NodeFailureCode::InvalidRequest, "terminal size is invalid"));
15996            }
15997            shared.dispatch_resize_bounded(&session, size).await?;
15998            Ok(NodeResponse::Accepted)
15999        }
16000        NodeRequest::Interrupt { session } => {
16001            controlled_session(shared, connection_id, role, &session)?;
16002            shared
16003                .dispatch_input_bounded(
16004                    &session,
16005                    InputAction::TerminalControl(TerminalControl::Interrupt),
16006                )
16007                .await?;
16008            Ok(NodeResponse::Accepted)
16009        }
16010        NodeRequest::Stop { session, force } => {
16011            controlled_session(shared, connection_id, role, &session)?;
16012            shared.dispatch(ControlCommand::Stop { instance_id: session.session.instance_id, force })?;
16013            Ok(NodeResponse::Accepted)
16014        }
16015        NodeRequest::Remove { session } => {
16016            controlled_session(shared, connection_id, role, &session)?;
16017            shared.remove_session(&session).await?;
16018            Ok(NodeResponse::Accepted)
16019        }
16020        NodeRequest::ResolveInteraction { session, correlation_id, response } => {
16021            shared.require_controller(connection_id, role)?;
16022            let snapshot = shared.require_acp_session(&session)?;
16023            let interaction = snapshot
16024                .provider
16025                .interactions
16026                .iter()
16027                .find(|interaction| {
16028                    interaction_correlation(
16029                        session.session.instance_id,
16030                        session.session.generation,
16031                        interaction.id.0,
16032                    ) == correlation_id
16033                })
16034                .ok_or_else(|| failure(
16035                    NodeFailureCode::InvalidRequest,
16036                    "unknown interaction correlation",
16037                ))?;
16038            if interaction.status != ProviderInteractionStatus::Pending {
16039                return Err(failure(
16040                    NodeFailureCode::BackendOperationFailed,
16041                    "interaction has already been resolved",
16042                ));
16043            }
16044            response
16045                .validate_for(interaction.interaction_kind)
16046                .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
16047            let interaction_id = interaction.id;
16048            let response_kind = response.kind();
16049            let response_outcome = response.outcome();
16050            shared.dispatch(ControlCommand::ResolveInteraction {
16051                instance_id: session.session.instance_id,
16052                generation: session.session.generation,
16053                interaction_id,
16054                response,
16055            })?;
16056            // The one line that lets an operator compare what THIS node
16057            // recorded against what the provider itself reports doing --
16058            // measured live on a kimi run where our side resolved `true`
16059            // while the agent reported "rejected" and neither layer had a
16060            // line to line up against the other.
16061            tracing::info!(
16062                node_id = %shared.node_id,
16063                session = ?session.session,
16064                correlation_id = %correlation_id,
16065                interaction_id = interaction_id.0,
16066                response_kind = ?response_kind,
16067                outcome = ?response_outcome,
16068                "interaction resolved",
16069            );
16070            Ok(NodeResponse::Accepted)
16071        }
16072        NodeRequest::SetSessionMode { session, mode_id } => {
16073            shared.require_controller(connection_id, role)?;
16074            shared.require_acp_session(&session)?;
16075            shared.dispatch(ControlCommand::SetSessionMode {
16076                instance_id: session.session.instance_id,
16077                mode_id,
16078            })?;
16079            Ok(NodeResponse::Accepted)
16080        }
16081        NodeRequest::SetSessionConfigOption {
16082            session,
16083            option_id,
16084            value_json,
16085        } => {
16086            shared.require_controller(connection_id, role)?;
16087            shared.require_acp_session(&session)?;
16088            shared.dispatch(ControlCommand::SetSessionConfigOption {
16089                instance_id: session.session.instance_id,
16090                option_id,
16091                value_json,
16092            })?;
16093            Ok(NodeResponse::Accepted)
16094        }
16095        NodeRequest::SetSessionModel { session, model_id } => {
16096            shared.require_controller(connection_id, role)?;
16097            shared.require_acp_session(&session)?;
16098            shared.dispatch(ControlCommand::SetSessionModel {
16099                instance_id: session.session.instance_id,
16100                model_id,
16101            })?;
16102            Ok(NodeResponse::Accepted)
16103        }
16104        NodeRequest::Shutdown => {
16105            shared.require_controller(connection_id, role)?;
16106            shared.begin_shutdown_locked().await?;
16107            Ok(NodeResponse::ShuttingDown)
16108        }
16109    }
16110}
16111
16112fn controlled_session(shared: &NodeShared, connection_id: u64, role: ClientRole, session: &SessionAddress) -> Result<AgentId, NodeFailure> {
16113    shared.require_controller(connection_id, role)?;
16114    shared.validate_address(session)
16115}
16116
16117/// Selects the sole history candidate matching `provider_session_id` when
16118/// the managed record carries a verified identity, or the sole candidate in
16119/// the discovered set at all when it does not (see
16120/// `export_context_pack_for_session_record_inner`'s identity match). Either
16121/// way, more than one match refuses instead of guessing which one is exact.
16122fn select_history_candidate(
16123    candidates: &[HistoryCandidateSummary],
16124    provider_session_id: Option<&str>,
16125) -> Result<String, NodeFailure> {
16126    let mut matches = candidates.iter().filter(|candidate| match provider_session_id {
16127        Some(id) => candidate.session_id_hint == id,
16128        None => true,
16129    });
16130    let candidate = matches.next().ok_or_else(|| {
16131        failure(
16132            NodeFailureCode::SessionRecordConflict,
16133            if provider_session_id.is_some() {
16134                "no history candidate matches the managed provider session"
16135            } else {
16136                "no history candidate exists for the managed session"
16137            },
16138        )
16139    })?;
16140    if matches.next().is_some() {
16141        return Err(failure(
16142            NodeFailureCode::SessionRecordConflict,
16143            if provider_session_id.is_some() {
16144                "history identity is ambiguous for the managed provider session"
16145            } else {
16146                "history is ambiguous without a verified provider session identity"
16147            },
16148        ));
16149    }
16150    Ok(candidate.id.clone())
16151}
16152
16153fn resolved_spawn_profile_summary(
16154    profile: &SpawnProfileDefaults,
16155    environment_profiles: &BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>,
16156) -> Option<SpawnProfileSummary> {
16157    let environment_profile = match profile.environment_profile_id.as_ref() {
16158        None => None,
16159        Some(profile_id) => {
16160            let binding = environment_profiles.get(profile_id)?;
16161            if binding.provider != profile.provider
16162                || binding.native_profile_id(profile.mode).is_none()
16163            {
16164                return None;
16165            }
16166            Some(ResolvedEnvironmentProfileReceipt {
16167                profile_id: binding.id.clone(),
16168                profile_revision: binding.revision.clone(),
16169                network_allowlist: None,
16170                browser_profile_id: None,
16171            })
16172        }
16173    };
16174    Some(SpawnProfileSummary {
16175        id: profile.profile_id.clone(),
16176        revision: profile.revision.clone(),
16177        environment_profile,
16178    })
16179}
16180
16181/// True if `current` is an acceptable version of `expected` at export
16182/// revalidation/commit time.
16183///
16184/// When `allow_clean_detachment` is `false` (the on-demand,
16185/// `NodeRequest::ExportContextPackForSessionRecord` API path), `current` must
16186/// be byte-identical to `expected` and still actively bound to `session` —
16187/// the original, unchanged invariant.
16188///
16189/// When `true` (the reactive auto-export-at-exit path, `§2.1`), `current` may
16190/// ALSO be the exact Live -> Dormant transition that the same clean exit this
16191/// export reacts to produces on its own: `state` Live -> Dormant,
16192/// `active_session` `Some(session)` -> `None`, `updated_at_unix_ms`
16193/// advancing. Every other field — including `provider_session`, `context`,
16194/// `bundle`, `task_binding` — must still match exactly. A record that changed
16195/// in any other way (rebind, a different active session, an identity
16196/// conflict, capacity eviction) is rejected either way; this widens nothing
16197/// about which sessions are eligible, only how the record's own expected
16198/// detachment is tolerated mid-export.
16199fn session_record_export_target_matches(
16200    expected: &ManagedSessionRecord,
16201    current: &ManagedSessionRecord,
16202    session: &SessionAddress,
16203    allow_clean_detachment: bool,
16204) -> bool {
16205    if current == expected && current.active_session.as_ref() == Some(session) {
16206        return current.state == ManagedSessionState::Live;
16207    }
16208    allow_clean_detachment
16209        && current.state == ManagedSessionState::Dormant
16210        && current.active_session.is_none()
16211        && current.record_id == expected.record_id
16212        && current.display_name == expected.display_name
16213        && current.provider == expected.provider
16214        && current.mode == expected.mode
16215        && current.workspace_id == expected.workspace_id
16216        && current.canonical_root == expected.canonical_root
16217        && current.provider_session == expected.provider_session
16218        && current.environment_profile == expected.environment_profile
16219        && current.bundle == expected.bundle
16220        && current.context_id == expected.context_id
16221        && current.context == expected.context
16222        && current.exported_context == expected.exported_context
16223        && current.task_binding == expected.task_binding
16224        && current.created_at_unix_ms == expected.created_at_unix_ms
16225        && current.last_error == expected.last_error
16226}
16227
16228fn session_record_context_export_binding_is_exact(
16229    expected: &ManagedSessionRecord,
16230    current: &ManagedSessionRecord,
16231    identity: Option<&ProviderSessionIdentity>,
16232    session: &SessionAddress,
16233    binding: &SessionBinding,
16234    provider: &AgentId,
16235    current_root: &str,
16236    allow_clean_detachment: bool,
16237) -> bool {
16238    session_record_export_target_matches(expected, current, session, allow_clean_detachment)
16239        && current.provider_session.as_ref() == identity
16240        && current.workspace_id == session.workspace_id
16241        && provider == &current.provider
16242        && binding.workspace_id == session.workspace_id
16243        && binding.generation == session.session.generation
16244        && binding.record_id.as_ref() == Some(&current.record_id)
16245        && platform::roots_equal(
16246            current_root,
16247            windows_path_text(&current.canonical_root),
16248        )
16249}
16250
16251fn session_record_context_export_source_is_usable(
16252    record: &ManagedSessionRecord,
16253    status: &SessionStatus,
16254    allow_clean_detachment: bool,
16255) -> bool {
16256    (record.state == ManagedSessionState::Live
16257        || (allow_clean_detachment && record.state == ManagedSessionState::Dormant))
16258        && context_pack_source_status_is_usable(status)
16259}
16260
16261fn prompt_framing(_agent_id: &AgentId) -> PromptFraming {
16262    PromptFraming::BracketedPaste
16263}
16264
16265fn validate_node_text(field: &str, text: &str) -> Result<(), NodeFailure> {
16266    if text.len() > MAX_NODE_TEXT_BYTES {
16267        return Err(failure(
16268            NodeFailureCode::InvalidRequest,
16269            &format!(
16270                "{field} length {} exceeds the {MAX_NODE_TEXT_BYTES}-byte node limit",
16271                text.len(),
16272            ),
16273        ));
16274    }
16275    Ok(())
16276}
16277
16278fn validate_terminal_bytes(bytes: &[u8]) -> Result<(), NodeFailure> {
16279    if bytes.is_empty() || bytes.len() > MAX_NODE_TERMINAL_BYTES {
16280        return Err(failure(
16281            NodeFailureCode::InvalidRequest,
16282            &format!(
16283                "terminal byte sequence must contain 1..={MAX_NODE_TERMINAL_BYTES} bytes; received {}",
16284                bytes.len(),
16285            ),
16286        ));
16287    }
16288    Ok(())
16289}
16290
16291fn terminal_bytes_action(bytes: Vec<u8>) -> Result<InputAction, NodeFailure> {
16292    validate_terminal_bytes(&bytes)?;
16293    Ok(InputAction::TerminalBytes(bytes))
16294}
16295
16296fn validate_workspace_request_root(root: &str) -> Result<(), NodeFailure> {
16297    if root.is_empty()
16298        || root.len() > MAX_WORKSPACE_ROOT_BYTES
16299        || root.chars().any(char::is_control)
16300    {
16301        return Err(failure(
16302            NodeFailureCode::InvalidWorkspaceRoot,
16303            &format!(
16304                "workspace root must contain 1..={MAX_WORKSPACE_ROOT_BYTES} bytes and no control characters",
16305            ),
16306        ));
16307    }
16308    Ok(())
16309}
16310
16311fn canonical_standalone_workspace_candidate(
16312    workspace_id: &WorkspaceId,
16313    root: &str,
16314) -> Result<String, NodeServerError> {
16315    let path = Path::new(root);
16316    match std::fs::symlink_metadata(path) {
16317        Ok(_) => WorkspaceConfig::new(workspace_id.clone(), path)
16318            .map(|workspace| workspace.canonical_root),
16319        Err(error) if error.kind() == io::ErrorKind::NotFound => {
16320            if !path.is_absolute() {
16321                return Err(NodeServerError::InvalidWorkspaceRoot {
16322                    workspace_id: workspace_id.clone(),
16323                    path: root.to_owned(),
16324                    message: "path is not absolute".to_owned(),
16325                });
16326            }
16327            let name = path.file_name().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
16328                workspace_id: workspace_id.clone(),
16329                path: root.to_owned(),
16330                message: "path has no standalone directory name".to_owned(),
16331            })?;
16332            let parent = path.parent().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
16333                workspace_id: workspace_id.clone(),
16334                path: root.to_owned(),
16335                message: "path has no parent directory".to_owned(),
16336            })?;
16337            let canonical = std::fs::canonicalize(parent)
16338                .map(|parent| parent.join(name))
16339                .map_err(|error| NodeServerError::InvalidWorkspaceRoot {
16340                    workspace_id: workspace_id.clone(),
16341                    path: root.to_owned(),
16342                    message: error.to_string(),
16343                })?;
16344            let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
16345                NodeServerError::InvalidWorkspaceRoot {
16346                    workspace_id: workspace_id.clone(),
16347                    path: path.to_string_lossy().into_owned(),
16348                    message: "canonical path is not valid Unicode".to_owned(),
16349                }
16350            })?;
16351            let canonical_root = platform::normalize_canonical_root(canonical_root);
16352            validate_workspace_root(workspace_id, &canonical_root)?;
16353            Ok(canonical_root)
16354        }
16355        Err(error) => Err(NodeServerError::InvalidWorkspaceRoot {
16356            workspace_id: workspace_id.clone(),
16357            path: root.to_owned(),
16358            message: error.to_string(),
16359        }),
16360    }
16361}
16362
16363fn validate_git_revision(field: &str, revision: Option<&str>) -> Result<(), NodeFailure> {
16364    let Some(revision) = revision else {
16365        return Ok(());
16366    };
16367    if revision.is_empty()
16368        || revision.len() > MAX_REPOSITORY_PATH_BYTES
16369        || revision.chars().any(char::is_control)
16370    {
16371        return Err(failure(
16372            NodeFailureCode::InvalidRequest,
16373            &format!(
16374                "{field} must contain 1..={MAX_REPOSITORY_PATH_BYTES} bytes and no control characters",
16375            ),
16376        ));
16377    }
16378    Ok(())
16379}
16380
16381fn git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
16382    let code = match error.kind {
16383        GitWorktreeErrorKind::Invalid => NodeFailureCode::InvalidRequest,
16384        GitWorktreeErrorKind::NotRepository => NodeFailureCode::NotGitRepository,
16385        GitWorktreeErrorKind::Conflict => NodeFailureCode::WorktreeConflict,
16386        GitWorktreeErrorKind::Protected => NodeFailureCode::WorktreeProtected,
16387        GitWorktreeErrorKind::Dirty => NodeFailureCode::WorktreeDirty,
16388        GitWorktreeErrorKind::Locked => NodeFailureCode::WorktreeLocked,
16389        GitWorktreeErrorKind::Failed => NodeFailureCode::BackendOperationFailed,
16390    };
16391    failure(code, &error.message)
16392}
16393
16394fn standalone_workspace_failure(error: StandaloneWorkspaceError) -> NodeFailure {
16395    let code = match error.kind {
16396        StandaloneWorkspaceErrorKind::InvalidRoot => NodeFailureCode::InvalidWorkspaceRoot,
16397        StandaloneWorkspaceErrorKind::InvalidInitialBranch => NodeFailureCode::InvalidRequest,
16398        StandaloneWorkspaceErrorKind::GitInitializationFailed => {
16399            NodeFailureCode::BackendOperationFailed
16400        }
16401        StandaloneWorkspaceErrorKind::RecoveryRequired => {
16402            NodeFailureCode::StandaloneWorkspaceRecoveryRequired
16403        }
16404    };
16405    failure(code, error.message)
16406}
16407
16408fn managed_git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
16409    let (code, message) = match error.kind {
16410        GitWorktreeErrorKind::Invalid => (
16411            NodeFailureCode::InvalidRequest,
16412            "managed Git input is invalid",
16413        ),
16414        GitWorktreeErrorKind::NotRepository => (
16415            NodeFailureCode::NotGitRepository,
16416            "managed source is not a Git repository",
16417        ),
16418        GitWorktreeErrorKind::Conflict => (
16419            NodeFailureCode::WorktreeConflict,
16420            "managed Git identity conflicts with existing repository state",
16421        ),
16422        GitWorktreeErrorKind::Protected => (
16423            NodeFailureCode::ManagedWorktreeOwnershipConflict,
16424            "managed worktree ownership validation failed",
16425        ),
16426        GitWorktreeErrorKind::Dirty => (
16427            NodeFailureCode::WorktreeDirty,
16428            "managed worktree contains uncommitted changes",
16429        ),
16430        GitWorktreeErrorKind::Locked => (
16431            NodeFailureCode::WorktreeLocked,
16432            "managed worktree is locked",
16433        ),
16434        GitWorktreeErrorKind::Failed => (
16435            NodeFailureCode::BackendOperationFailed,
16436            "managed Git operation failed",
16437        ),
16438    };
16439    NodeFailure { code, message: message.to_owned() }
16440}
16441
16442fn managed_spawn_receipt(
16443    resolved: &ResolvedSpawnSpec,
16444    incarnation_id: NodeIncarnationId,
16445    session: SessionAddress,
16446    lease: ManagedWorktreeLeaseSnapshot,
16447    environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
16448    bundle: Option<ResolvedBundleReceipt>,
16449    context: Option<ResolvedContextPackReceipt>,
16450) -> ManagedWorktreeSpawnReceipt {
16451    let mut managed = resolved.clone();
16452    managed.target.worktree_id = Some(lease.workspace_id.clone());
16453    ManagedWorktreeSpawnReceipt {
16454        spawn: managed.receipt_with_materialization(
16455            incarnation_id,
16456            session,
16457            environment_profile,
16458            bundle,
16459            context,
16460        ),
16461        lease,
16462    }
16463}
16464
16465fn validate_selected_worktree(
16466    source_root: &str,
16467    selected_root: &str,
16468    worktrees: &[NativeGitWorktreeSnapshot],
16469) -> Result<(), NodeFailure> {
16470    if !worktrees
16471        .iter()
16472        .any(|worktree| worktree_paths_equal(&worktree.path, source_root) && !worktree.is_bare)
16473    {
16474        return Err(failure(
16475            NodeFailureCode::WorktreeProtected,
16476            "source workspace is not an authoritative non-bare Git worktree root",
16477        ));
16478    }
16479    if !worktrees.iter().any(|worktree| {
16480        worktree_paths_equal(&worktree.path, selected_root)
16481            && !worktree.is_main
16482            && !worktree.is_bare
16483    }) {
16484        return Err(failure(
16485            NodeFailureCode::WorktreeProtected,
16486            "selected workspace is not an eligible Git worktree in the source repository",
16487        ));
16488    }
16489    Ok(())
16490}
16491
16492fn workspace_file_failure(error: WorkspaceFileReadError) -> NodeFailure {
16493    let code = match error.kind() {
16494        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
16495        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
16496            NodeFailureCode::RepositoryPathUnsafe
16497        }
16498        WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
16499        WorkspaceFileReadErrorKind::NotRegularFile => {
16500            NodeFailureCode::RepositoryFileNotRegular
16501        }
16502        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
16503            NodeFailureCode::RepositoryFileReadFailed
16504        }
16505        WorkspaceFileReadErrorKind::RevisionConflict => {
16506            NodeFailureCode::RepositoryFileRevisionConflict
16507        }
16508        WorkspaceFileReadErrorKind::AlreadyExists
16509        | WorkspaceFileReadErrorKind::ParentNotFound
16510        | WorkspaceFileReadErrorKind::ParentNotDirectory
16511        | WorkspaceFileReadErrorKind::Canceled => {
16512            NodeFailureCode::RepositoryFileReadFailed
16513        }
16514    };
16515    failure(code, "repository file read failed")
16516}
16517
16518fn workspace_file_write_failure(error: WorkspaceFileReadError) -> NodeFailure {
16519    let code = match error.kind() {
16520        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
16521        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
16522            NodeFailureCode::RepositoryPathUnsafe
16523        }
16524        WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
16525        WorkspaceFileReadErrorKind::NotRegularFile => {
16526            NodeFailureCode::RepositoryFileNotRegular
16527        }
16528        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
16529            NodeFailureCode::RepositoryFileWriteFailed
16530        }
16531        WorkspaceFileReadErrorKind::RevisionConflict => {
16532            NodeFailureCode::RepositoryFileRevisionConflict
16533        }
16534        WorkspaceFileReadErrorKind::AlreadyExists
16535        | WorkspaceFileReadErrorKind::ParentNotFound
16536        | WorkspaceFileReadErrorKind::ParentNotDirectory
16537        | WorkspaceFileReadErrorKind::Canceled => {
16538            NodeFailureCode::RepositoryFileWriteFailed
16539        }
16540    };
16541    failure(code, "repository file write failed")
16542}
16543
16544async fn settle_workspace_entry_create<T>(
16545    mut task: tokio::task::JoinHandle<Result<T, WorkspaceFileReadError>>,
16546    commit_state: Arc<AtomicU8>,
16547    deadline: Duration,
16548    timeout_message: &'static str,
16549    task_failure_message: &'static str,
16550) -> Result<T, NodeFailure> {
16551    let joined = match timeout(deadline, &mut task).await {
16552        Ok(joined) => joined,
16553        Err(_) => {
16554            if commit_state
16555                .compare_exchange(
16556                    WORKSPACE_ENTRY_CREATE_PENDING,
16557                    WORKSPACE_ENTRY_CREATE_CANCELED,
16558                    Ordering::AcqRel,
16559                    Ordering::Acquire,
16560                )
16561                .is_ok()
16562            {
16563                return Err(failure(
16564                    NodeFailureCode::RepositoryEntryCreateTimedOut,
16565                    timeout_message,
16566                ));
16567            }
16568            debug_assert_eq!(
16569                commit_state.load(Ordering::Acquire),
16570                WORKSPACE_ENTRY_CREATE_COMMITTING,
16571            );
16572            task.await
16573        }
16574    };
16575    joined
16576        .map_err(|_| {
16577            failure(
16578                NodeFailureCode::RepositoryEntryCreateFailed,
16579                task_failure_message,
16580            )
16581        })?
16582        .map_err(workspace_entry_create_failure)
16583}
16584
16585fn workspace_entry_create_failure(error: WorkspaceFileReadError) -> NodeFailure {
16586    let code = match error.kind() {
16587        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
16588        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
16589            NodeFailureCode::RepositoryPathUnsafe
16590        }
16591        WorkspaceFileReadErrorKind::AlreadyExists => {
16592            NodeFailureCode::RepositoryEntryAlreadyExists
16593        }
16594        WorkspaceFileReadErrorKind::ParentNotFound | WorkspaceFileReadErrorKind::NotFound => {
16595            NodeFailureCode::RepositoryParentNotFound
16596        }
16597        WorkspaceFileReadErrorKind::ParentNotDirectory
16598        | WorkspaceFileReadErrorKind::NotRegularFile => {
16599            NodeFailureCode::RepositoryParentNotDirectory
16600        }
16601        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
16602            NodeFailureCode::RepositoryEntryCreateFailed
16603        }
16604        WorkspaceFileReadErrorKind::RevisionConflict => {
16605            NodeFailureCode::RepositoryEntryCreateFailed
16606        }
16607        WorkspaceFileReadErrorKind::Canceled => {
16608            NodeFailureCode::RepositoryEntryCreateTimedOut
16609        }
16610    };
16611    failure(code, "repository entry create failed")
16612}
16613
16614fn workspace_file_revision(bytes: &[u8]) -> WorkspaceFileRevision {
16615    let value = digest(&SHA256, bytes)
16616        .as_ref()
16617        .iter()
16618        .map(|byte| format!("{byte:02x}"))
16619        .collect();
16620    WorkspaceFileRevision::new(value).expect("SHA-256 produces a valid workspace revision")
16621}
16622
16623fn managed_spawn_request_digest_v2(
16624    request: &ManagedWorktreeSpawnRequestV2,
16625) -> Result<String, NodeFailure> {
16626    let encoded = serde_json::to_vec(request).map_err(|_| {
16627        failure(
16628            NodeFailureCode::InvalidRequest,
16629            "managed worktree V2 request could not be canonicalized",
16630        )
16631    })?;
16632    let mut value = String::with_capacity(71);
16633    value.push_str("sha256:");
16634    for byte in digest(&SHA256, &encoded).as_ref() {
16635        use std::fmt::Write as _;
16636        write!(&mut value, "{byte:02x}").expect("writing to a String cannot fail");
16637    }
16638    Ok(value)
16639}
16640
16641fn native_session_preview_failure(error: NativeSessionPreviewError) -> NodeFailure {
16642    let code = match error {
16643        NativeSessionPreviewError::InvalidLimit => NodeFailureCode::InvalidRequest,
16644        NativeSessionPreviewError::WorkspaceUnavailable => NodeFailureCode::UnknownWorkspace,
16645        NativeSessionPreviewError::StaleCatalog => NodeFailureCode::StaleNativeSessionCatalog,
16646        NativeSessionPreviewError::UnsupportedProvider
16647        | NativeSessionPreviewError::PreviewUnavailable
16648        | NativeSessionPreviewError::SessionNotFound
16649        | NativeSessionPreviewError::AmbiguousSession => {
16650            NodeFailureCode::BackendOperationFailed
16651        }
16652    };
16653    failure(code, "native session preview failed")
16654}
16655
16656fn host_directory_failure(error: HostDirectoryBrowseError) -> NodeFailure {
16657    let code = match error.kind() {
16658        HostDirectoryBrowseErrorKind::Invalid => NodeFailureCode::HostDirectoryInvalid,
16659        HostDirectoryBrowseErrorKind::ReadFailed => NodeFailureCode::HostDirectoryReadFailed,
16660    };
16661    failure(code, "host directory browse failed")
16662}
16663
16664fn delivery_failure(error: DeliveryStoreError) -> NodeFailure {
16665    let code = match error {
16666        DeliveryStoreError::InvalidManifest => NodeFailureCode::DeliveryManifestInvalid,
16667        DeliveryStoreError::UnknownStage => NodeFailureCode::UnknownDeliveryStage,
16668        DeliveryStoreError::Capacity | DeliveryStoreError::StageConflict => {
16669            NodeFailureCode::DeliveryStageConflict
16670        }
16671        DeliveryStoreError::UnexpectedBlob => NodeFailureCode::DeliveryBlobUnexpected,
16672        DeliveryStoreError::ChunkOutOfOrder | DeliveryStoreError::ChunkOverflow => {
16673            NodeFailureCode::DeliveryChunkOutOfOrder
16674        }
16675        DeliveryStoreError::BlobDigestMismatch => {
16676            NodeFailureCode::DeliveryBlobDigestMismatch
16677        }
16678        DeliveryStoreError::BundleDigestMismatch => {
16679            NodeFailureCode::DeliveryBundleDigestMismatch
16680        }
16681        DeliveryStoreError::StageIncomplete => NodeFailureCode::DeliveryStageIncomplete,
16682        DeliveryStoreError::Unavailable
16683        | DeliveryStoreError::Corrupt
16684        | DeliveryStoreError::Storage(_) => NodeFailureCode::DeliveryStageStorageFailed,
16685    };
16686    failure(code, "delivery operation failed")
16687}
16688
16689fn validate_workspace_root(
16690    workspace_id: &WorkspaceId,
16691    canonical_root: &str,
16692) -> Result<(), NodeServerError> {
16693    #[cfg(windows)]
16694    if canonical_root.starts_with(r"\\") {
16695        return Err(NodeServerError::InvalidWorkspaceRoot {
16696            workspace_id: workspace_id.clone(),
16697            path: canonical_root.to_owned(),
16698            message: "UNC workspace roots are unsupported by Windows PTY providers".to_owned(),
16699        });
16700    }
16701    if !platform::workspace_root_supported(canonical_root)
16702        || canonical_root.len() > WORKING_DIRECTORY_MAX_BYTES
16703    {
16704        return Err(NodeServerError::InvalidWorkspaceRoot {
16705            workspace_id: workspace_id.clone(),
16706            path: canonical_root.to_owned(),
16707            message: "canonical path must be absolute, bounded, and free of control characters"
16708                .to_owned(),
16709        });
16710    }
16711    Ok(())
16712}
16713
16714fn failure(code: NodeFailureCode, _message: &str) -> NodeFailure {
16715    NodeFailure {
16716        code,
16717        message: node_failure_category(code).to_owned(),
16718    }
16719}
16720
16721fn harness_mcp_failure(error: HarnessMcpProxyError) -> NodeFailure {
16722    let code = match error {
16723        HarnessMcpProxyError::Unavailable => NodeFailureCode::HarnessMcpUnavailable,
16724        HarnessMcpProxyError::NotFound => NodeFailureCode::ReservationNotFound,
16725        HarnessMcpProxyError::Conflict => NodeFailureCode::ReservationConflict,
16726        HarnessMcpProxyError::Expired => NodeFailureCode::ReservationExpired,
16727        HarnessMcpProxyError::BindingMismatch => NodeFailureCode::BindingMismatch,
16728        HarnessMcpProxyError::NotActivated => NodeFailureCode::NotActivated,
16729        HarnessMcpProxyError::CallNotFound => NodeFailureCode::CallNotFound,
16730        HarnessMcpProxyError::ChunkOutOfOrder => NodeFailureCode::ChunkOutOfOrder,
16731        HarnessMcpProxyError::ResponseTooLarge => NodeFailureCode::ResponseTooLarge,
16732    };
16733    failure(code, "harness MCP proxy operation failed")
16734}
16735
16736/// Numbers each harness-MCP overlay this process constructs, in
16737/// construction order, so two overlays built back to back in one node process
16738/// still get distinct trace files.
16739static NEXT_HARNESS_MCP_TRACE_SEQUENCE: AtomicU64 = AtomicU64::new(1);
16740
16741/// Builds the optional trace environment entry of one harness-MCP overlay.
16742/// The caller's launch description names both the entry and the node-process
16743/// variable that opts in: when that variable holds a non-empty directory, the
16744/// entry names a trace file under it. Returns `None` when the launch has no
16745/// trace, or the directory is unset, empty, or not valid Unicode. Never creates
16746/// the directory; that is the operator's job.
16747fn harness_mcp_trace_env_entry(launch: &HarnessMcpLaunchV1) -> Option<(OsString, OsString)> {
16748    let trace = launch.trace.as_ref()?;
16749    let trace_dir = std::env::var(&trace.dir_env).ok()?;
16750    if trace_dir.is_empty() {
16751        return None;
16752    }
16753    let sequence = NEXT_HARNESS_MCP_TRACE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
16754    let path = std::path::Path::new(&trace_dir).join(format!("harness-mcp-{sequence}.trace"));
16755    Some((OsString::from(trace.env.as_str()), path.into_os_string()))
16756}
16757
16758fn spawn_deadline_remaining(deadline: Instant) -> Result<Duration, NodeFailure> {
16759    let remaining = deadline.saturating_duration_since(Instant::now());
16760    if remaining.is_zero() {
16761        Err(failure(
16762            NodeFailureCode::SpawnDeadlineExceeded,
16763            "spawn deadline elapsed",
16764        ))
16765    } else {
16766        Ok(remaining)
16767    }
16768}
16769
16770fn spawn_dispatch_timeout(deadline: Option<Instant>) -> Result<Duration, NodeFailure> {
16771    let bounded = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
16772    match deadline {
16773        Some(deadline) => Ok(spawn_deadline_remaining(deadline)?.min(bounded)),
16774        None => Ok(bounded),
16775    }
16776}
16777
16778fn spawn_dispatch_error(error: NodeFailure, deadline: Option<Instant>) -> NodeFailure {
16779    if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
16780        failure(
16781            NodeFailureCode::SpawnDeadlineExceeded,
16782            "spawn dispatch exceeded the spawn deadline",
16783        )
16784    } else {
16785        error
16786    }
16787}
16788
16789fn spawn_runtime_capabilities(
16790    capabilities: &SpawnRequiredCapabilities,
16791) -> Result<Vec<ProviderRuntimeCapability>, NodeFailure> {
16792    capabilities
16793        .iter()
16794        .map(|capability| match capability.as_str() {
16795            SPAWN_RUNTIME_RAW_PTY_LIFECYCLE => Ok(ProviderRuntimeCapability::RawPtyLifecycle),
16796            SPAWN_RUNTIME_SEMANTIC_READINESS => Ok(ProviderRuntimeCapability::SemanticReadiness),
16797            SPAWN_RUNTIME_STRUCTURED_PROMPT => Ok(ProviderRuntimeCapability::StructuredPrompt),
16798            SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY => {
16799                Ok(ProviderRuntimeCapability::ProviderSessionIdentity)
16800            }
16801            SPAWN_RUNTIME_SEMANTIC_RESUME => Ok(ProviderRuntimeCapability::SemanticResume),
16802            _ => Err(failure(
16803                NodeFailureCode::UnsupportedSpawnCapability,
16804                "spawn required capability is unknown",
16805            )),
16806        })
16807        .collect()
16808}
16809
16810fn persistence_failure(_error: io::Error) -> NodeFailure {
16811    NodeFailure {
16812        code: NodeFailureCode::BackendOperationFailed,
16813        message: DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned(),
16814    }
16815}
16816
16817fn node_failure_category(code: NodeFailureCode) -> &'static str {
16818    match code {
16819        NodeFailureCode::InvalidRequest => "invalid-request",
16820        NodeFailureCode::UnsupportedCapability => "unsupported-capability",
16821        NodeFailureCode::HarnessMcpUnavailable => "harness-mcp-unavailable",
16822        NodeFailureCode::ReservationNotFound => "reservation-not-found",
16823        NodeFailureCode::ReservationConflict => "reservation-conflict",
16824        NodeFailureCode::ReservationExpired => "reservation-expired",
16825        NodeFailureCode::BindingMismatch => "binding-mismatch",
16826        NodeFailureCode::NotActivated => "not-activated",
16827        NodeFailureCode::CallNotFound => "call-not-found",
16828        NodeFailureCode::ChunkOutOfOrder => "chunk-out-of-order",
16829        NodeFailureCode::ResponseTooLarge => "response-too-large",
16830        NodeFailureCode::DeliveryManifestInvalid => "delivery-manifest-invalid",
16831        NodeFailureCode::UnknownDeliveryStage => "unknown-delivery-stage",
16832        NodeFailureCode::DeliveryStageConflict => "delivery-stage-conflict",
16833        NodeFailureCode::DeliveryBlobUnexpected => "delivery-blob-unexpected",
16834        NodeFailureCode::DeliveryChunkOutOfOrder => "delivery-chunk-out-of-order",
16835        NodeFailureCode::DeliveryBlobDigestMismatch => "delivery-blob-digest-mismatch",
16836        NodeFailureCode::DeliveryBundleDigestMismatch => "delivery-bundle-digest-mismatch",
16837        NodeFailureCode::DeliveryStageIncomplete => "delivery-stage-incomplete",
16838        NodeFailureCode::DeliveryStageStorageFailed => "delivery-stage-storage-failed",
16839        NodeFailureCode::Unauthorized => "unauthorized",
16840        NodeFailureCode::ObserverReadOnly => "observer-read-only",
16841        NodeFailureCode::ControllerBusy => "controller-busy",
16842        NodeFailureCode::ControllerRequired => "controller-required",
16843        NodeFailureCode::UnknownWorkspace => "unknown-workspace",
16844        NodeFailureCode::HostDirectoryInvalid => "host-directory-invalid",
16845        NodeFailureCode::HostDirectoryReadFailed => "host-directory-read-failed",
16846        NodeFailureCode::HostDirectoryReadTimedOut => "host-directory-read-timed-out",
16847        NodeFailureCode::InvalidRepositoryPath => "invalid-repository-path",
16848        NodeFailureCode::RepositoryFileNotFound => "repository-file-not-found",
16849        NodeFailureCode::RepositoryFileNotRegular => "repository-file-not-regular",
16850        NodeFailureCode::RepositoryPathUnsafe => "repository-path-unsafe",
16851        NodeFailureCode::RepositoryFileReadTimedOut => "repository-file-read-timed-out",
16852        NodeFailureCode::RepositoryFileReadFailed => "repository-file-read-failed",
16853        NodeFailureCode::RepositoryFileWriteTimedOut => "repository-file-write-timed-out",
16854        NodeFailureCode::RepositoryFileWriteFailed => "repository-file-write-failed",
16855        NodeFailureCode::RepositoryFileRevisionConflict => "repository-file-revision-conflict",
16856        NodeFailureCode::RepositoryEntryAlreadyExists => "repository-entry-already-exists",
16857        NodeFailureCode::RepositoryParentNotFound => "repository-parent-not-found",
16858        NodeFailureCode::RepositoryParentNotDirectory => "repository-parent-not-directory",
16859        NodeFailureCode::RepositoryEntryCreateTimedOut => "repository-entry-create-timed-out",
16860        NodeFailureCode::RepositoryEntryCreateFailed => "repository-entry-create-failed",
16861        NodeFailureCode::GitReadTimedOut => "git-read-timed-out",
16862        NodeFailureCode::GitReadFailed => "git-read-failed",
16863        NodeFailureCode::UnknownSpawnProfile => "unknown-spawn-profile",
16864        NodeFailureCode::SpawnProfileRevisionMismatch => "spawn-profile-revision-mismatch",
16865        NodeFailureCode::SpawnTargetMismatch => "spawn-target-mismatch",
16866        NodeFailureCode::SpawnIdempotencyConflict => "spawn-idempotency-conflict",
16867        NodeFailureCode::SpawnIdempotencyCapacity => "spawn-idempotency-capacity",
16868        NodeFailureCode::SpawnDeadlineExceeded => "spawn-deadline-exceeded",
16869        NodeFailureCode::UnsupportedSpawnCapability => "unsupported-spawn-capability",
16870        NodeFailureCode::UnsupportedTransport => "unsupported-transport",
16871        NodeFailureCode::TurnInFlight => "turn-in-flight",
16872        NodeFailureCode::UnknownEnvironmentProfile => "unknown-environment-profile",
16873        NodeFailureCode::UnknownNetworkAllowlist => "unknown-network-allowlist",
16874        NodeFailureCode::UnsupportedNetworkAllowlistMapping => {
16875            "unsupported-network-allowlist-mapping"
16876        }
16877        NodeFailureCode::BrowserStationProbeUnavailable => {
16878            "browser-station-probe-unavailable"
16879        }
16880        NodeFailureCode::BrowserStationUnreachable => "browser-station-unreachable",
16881        NodeFailureCode::BrowserStationProfileBusy => "browser-station-profile-busy",
16882        NodeFailureCode::EnvironmentProfileBindingMismatch => {
16883            "environment-profile-binding-mismatch"
16884        }
16885        NodeFailureCode::UnknownBundle => "unknown-bundle",
16886        NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
16887        NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
16888        NodeFailureCode::UnknownContextPack => "unknown-context-pack",
16889        NodeFailureCode::ContextPackBusy => "context-pack-busy",
16890        NodeFailureCode::ContextPackMaterializationFailed => {
16891            "context-pack-materialization-failed"
16892        }
16893        NodeFailureCode::InvalidWorkspaceRoot => "invalid-workspace-root",
16894        NodeFailureCode::DuplicateWorkspaceId => "duplicate-workspace-id",
16895        NodeFailureCode::DuplicateWorkspaceRoot => "duplicate-workspace-root",
16896        NodeFailureCode::WorkspaceBusy => "workspace-busy",
16897        NodeFailureCode::LastWorkspace => "last-workspace",
16898        NodeFailureCode::NotGitRepository => "not-git-repository",
16899        NodeFailureCode::WorktreeConflict => "worktree-conflict",
16900        NodeFailureCode::WorktreeProtected => "worktree-protected",
16901        NodeFailureCode::WorktreeDirty => "worktree-dirty",
16902        NodeFailureCode::WorktreeLocked => "worktree-locked",
16903        NodeFailureCode::UnknownManagedWorktreeLease => "unknown-managed-worktree-lease",
16904        NodeFailureCode::ManagedWorktreeBusy => "managed-worktree-busy",
16905        NodeFailureCode::ManagedWorktreeOwnershipConflict => "managed-worktree-ownership-conflict",
16906        NodeFailureCode::ManagedWorktreeProfileRevisionMismatch => {
16907            "managed-worktree-profile-revision-mismatch"
16908        }
16909        NodeFailureCode::ManagedWorktreeRecoveryRequired => "managed-worktree-recovery-required",
16910        NodeFailureCode::StandaloneWorkspaceRecoveryRequired => {
16911            "standalone-workspace-recovery-required"
16912        }
16913        NodeFailureCode::UnknownSession => "unknown-session",
16914        NodeFailureCode::UnknownSessionRecord => "unknown-session-record",
16915        NodeFailureCode::SessionRecordNotResumable => "session-record-not-resumable",
16916        NodeFailureCode::SessionRecordBusy => "session-record-busy",
16917        NodeFailureCode::SessionRecordConflict => "session-record-conflict",
16918        NodeFailureCode::SessionWorkspaceMismatch => "session-workspace-mismatch",
16919        NodeFailureCode::WorkspaceRegistrationRequired => {
16920            "workspace-registration-required"
16921        }
16922        NodeFailureCode::StaleNativeSessionCatalog => "stale-native-session-catalog",
16923        NodeFailureCode::StaleGeneration => "stale-generation",
16924        NodeFailureCode::BackendBusy => "backend-busy",
16925        NodeFailureCode::BackendDisconnected => "backend-disconnected",
16926        NodeFailureCode::BackendOperationFailed => "backend-operation-failed",
16927        NodeFailureCode::ShuttingDown => "shutting-down",
16928    }
16929}
16930
16931fn durable_state_server_error(error: io::Error, category: &'static str) -> NodeServerError {
16932    NodeServerError::DurableState(io::Error::new(error.kind(), category))
16933}
16934
16935fn durable_state_load_error(error: io::Error) -> NodeServerError {
16936    let category = match session_registry::state_load_refusal(&error) {
16937        Some(session_registry::StateLoadRefusal::UnsupportedSchema) => {
16938            DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR
16939        }
16940        Some(session_registry::StateLoadRefusal::PathSemanticsUnsupported) => {
16941            DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR
16942        }
16943        Some(session_registry::StateLoadRefusal::NodeIdentityMismatch) => {
16944            DURABLE_STATE_CONFLICT_ERROR
16945        }
16946        _ => DURABLE_STATE_LOAD_FAILED_ERROR,
16947    };
16948    durable_state_server_error(error, category)
16949}
16950
16951fn unix_time_ms() -> u64 {
16952    std::time::SystemTime::now()
16953        .duration_since(std::time::UNIX_EPOCH)
16954        .unwrap_or_default()
16955        .as_millis()
16956        .min(u64::MAX as u128) as u64
16957}
16958
16959fn detached_record_state(record: &ManagedSessionRecord) -> ManagedSessionState {
16960    if record.provider_session.is_some() {
16961        ManagedSessionState::Dormant
16962    } else {
16963        ManagedSessionState::Unavailable
16964    }
16965}
16966
16967fn active_record_state(
16968    identity_admitted: bool,
16969    provider_session_is_known: bool,
16970) -> ManagedSessionState {
16971    if identity_admitted && !provider_session_is_known {
16972        ManagedSessionState::IdentityPending
16973    } else {
16974        ManagedSessionState::Live
16975    }
16976}
16977
16978fn managed_worktree_record_holders(
16979    records: &[ManagedSessionRecord],
16980) -> Vec<(SessionRecordId, WorkspaceId)> {
16981    records
16982        .iter()
16983        .filter(|record| record.provider_session.is_some())
16984        .map(|record| (record.record_id.clone(), record.workspace_id.clone()))
16985        .collect()
16986}
16987
16988fn managed_spawn_record_ownership(
16989    binding: &SessionBinding,
16990    effective_runtime_policy: ProviderRuntimePolicy,
16991) -> (Option<SessionRecordId>, Option<SessionRecordId>) {
16992    debug_assert_eq!(binding.runtime_policy, effective_runtime_policy);
16993    if effective_runtime_policy.provider_session_identity {
16994        (binding.record_id.clone(), None)
16995    } else {
16996        (None, binding.record_id.clone())
16997    }
16998}
16999
17000#[derive(Debug, Error)]
17001pub enum NodeServerError {
17002    #[error("node endpoint must be a bounded absolute local transport path")]
17003    InvalidEndpoint,
17004    #[error("node access token must contain 1..=4096 bytes")]
17005    InvalidAccessToken,
17006    #[error("node HTTP observer must listen on a loopback address: {0}")]
17007    InvalidApiListen(std::net::SocketAddr),
17008    #[error("node browser bridge must listen on a loopback address: {0}")]
17009    InvalidBridgeListen(std::net::SocketAddr),
17010    #[error("node browser bridge token must contain 1..=4096 bytes")]
17011    InvalidBridgeToken,
17012    #[error("node call-home relay must be a loopback address with a nonzero port: {0}")]
17013    InvalidCallHome(std::net::SocketAddr),
17014    #[error("node could not reach its call-home relay: {0}")]
17015    CallHomeTransport(#[source] std::io::Error),
17016    #[error("node durable state path must be an absolute file path: {0}")]
17017    InvalidStatePath(String),
17018    #[error("harness MCP helper must be an exact reviewed absolute regular file")]
17019    InvalidHarnessMcpHelper,
17020    #[error("network allowlist catalog path must be an absolute regular file: {0}")]
17021    InvalidNetworkAllowlistCatalogPath(String),
17022    #[error("network allowlist catalog could not be read: {0}")]
17023    NetworkAllowlistCatalogIo(#[source] std::io::Error),
17024    #[error("network allowlist catalog line {line}: {message}")]
17025    InvalidNetworkAllowlistCatalogEntry { line: usize, message: String },
17026    #[error("network allowlist catalog: {0}")]
17027    InvalidNetworkAllowlistCatalog(String),
17028    #[error("network allowlist catalog exceeds the {max}-entry limit")]
17029    NetworkAllowlistCatalogCapacity { max: usize },
17030    #[error("the local state directory is unavailable; supply an explicit state path")]
17031    LocalStateDirectoryUnavailable,
17032    #[error("the local runtime directory is unavailable; supply an explicit endpoint")]
17033    LocalRuntimeDirectoryUnavailable,
17034    #[error("node durable state failed: {0}")]
17035    DurableState(io::Error),
17036    #[error("node requires at least one configured workspace")]
17037    NoWorkspaces,
17038    #[error("workspace '{workspace_id}' root '{path}' is invalid: {message}")]
17039    InvalidWorkspaceRoot {
17040        workspace_id: WorkspaceId,
17041        path: String,
17042        message: String,
17043    },
17044    #[error("duplicate workspace ID: {0}")]
17045    DuplicateWorkspaceId(WorkspaceId),
17046    #[error("workspaces '{first}' and '{second}' resolve to the same root '{root}'")]
17047    DuplicateWorkspaceRoot {
17048        first: WorkspaceId,
17049        second: WorkspaceId,
17050        root: String,
17051    },
17052    #[error("workspace '{0}' uses managed worktrees but has no local profile")]
17053    ManagedWorktreeProfileRequired(WorkspaceId),
17054    #[error("workspace '{0}' has managed worktree profiles but is not in managed mode")]
17055    ManagedWorktreeProfileModeMismatch(WorkspaceId),
17056    #[error("workspace '{workspace_id}' has duplicate managed worktree profile '{profile_id}'")]
17057    DuplicateManagedWorktreeProfile {
17058        workspace_id: WorkspaceId,
17059        profile_id: WorktreeProfileId,
17060    },
17061    #[error("workspace '{workspace_id}' exceeds the {max}-profile managed worktree limit")]
17062    ManagedWorktreeProfileCapacity {
17063        workspace_id: WorkspaceId,
17064        max: usize,
17065    },
17066    #[error("workspace '{workspace_id}' managed worktree profile '{profile_id}' is invalid: {message}")]
17067    InvalidManagedWorktreeProfile {
17068        workspace_id: WorkspaceId,
17069        profile_id: WorktreeProfileId,
17070        message: String,
17071    },
17072    #[error("duplicate node environment profile: {0}")]
17073    DuplicateEnvironmentProfile(SpawnEnvironmentProfileId),
17074    #[error("node environment profile provider is unavailable: {0}")]
17075    EnvironmentProfileProviderUnavailable(AgentId),
17076    #[error("node environment profile capacity is {max}")]
17077    EnvironmentProfileCapacity { max: usize },
17078    #[error("node environment profiles reuse native profile ID '{0}'")]
17079    DuplicateNativeEnvironmentProfile(String),
17080    #[error("node native environment profile is invalid: {0}")]
17081    NativeEnvironmentProfile(String),
17082    #[error("node session-environment materialization root must be absolute")]
17083    InvalidSessionEnvironmentRoot,
17084    #[error("node session-environment materializer is unavailable")]
17085    SessionEnvironmentMaterializerRequired,
17086    #[error("node session-environment materializer failed to initialize")]
17087    SessionEnvironmentMaterializer,
17088    #[error("node bundle catalog is invalid: {0}")]
17089    BundleCatalog(String),
17090    #[error("node delivery store failed to initialize")]
17091    DeliveryStore,
17092    #[error("node context pack store failed to initialize")]
17093    ContextPackStore,
17094    #[error("active agent registry failed: {0}")]
17095    Registry(String),
17096    #[error("node provider contract manifest is invalid: {0}")]
17097    ProviderContractManifest(String),
17098    #[error("named pipe I/O failed: {0}")]
17099    Io(#[from] io::Error),
17100    #[error("node HTTP observer failed: {0}")]
17101    HttpApi(io::Error),
17102    #[error("node browser bridge failed: {0}")]
17103    BridgeApi(io::Error),
17104    #[error(transparent)]
17105    Frame(#[from] FrameError),
17106    #[error("node handshake failed: {0}")]
17107    Handshake(String),
17108    #[error("node authentication frame was not received before the bounded deadline")]
17109    AuthenticationTimedOut,
17110    #[error("node authentication primitive failed: {0}")]
17111    Authentication(String),
17112    #[error("node incarnation identity generation failed: {0}")]
17113    IncarnationIdentity(String),
17114    #[error("node pre-authentication limiter is closed")]
17115    PreauthClosed,
17116    #[error("node authenticated connection limit was reached")]
17117    AuthenticatedConnectionLimit,
17118    #[error("node Ctrl+C/CtrlBreak signal stream closed")]
17119    SignalStreamClosed,
17120    #[error("node stop-all dispatch failed: {0:?}")]
17121    ShutdownDispatch(NodeFailure),
17122    #[error("node shutdown timed out with {active_native_sessions} physical sessions retained")]
17123    ShutdownTimedOut { active_native_sessions: usize },
17124}
17125
17126impl From<crate::network_allowlist_catalog::NetworkAllowlistCatalogError> for NodeServerError {
17127    fn from(error: crate::network_allowlist_catalog::NetworkAllowlistCatalogError) -> Self {
17128        use crate::network_allowlist_catalog::NetworkAllowlistCatalogError as E;
17129        match error {
17130            E::InvalidPath(path) => Self::InvalidNetworkAllowlistCatalogPath(path),
17131            E::Io(error) => Self::NetworkAllowlistCatalogIo(error),
17132            E::InvalidEntry { line, message } => {
17133                Self::InvalidNetworkAllowlistCatalogEntry { line, message }
17134            }
17135            E::InvalidCatalog(message) => Self::InvalidNetworkAllowlistCatalog(message),
17136            E::Capacity { max } => Self::NetworkAllowlistCatalogCapacity { max },
17137        }
17138    }
17139}
17140
17141#[cfg(test)]
17142mod agent_stream_blocked_tests {
17143    use super::*;
17144    use gate4agent_types::{
17145        AdapterId, AdapterVerification, HostRequestOutcome as ProviderHostRequestOutcome,
17146        ProviderSource,
17147    };
17148
17149    fn provider_source(family: AdapterFamily, adapter: &str) -> ProviderSource {
17150        ProviderSource {
17151            family,
17152            binding: AdapterBinding::new(
17153                AdapterId::new(adapter).unwrap(),
17154                "fixture/v1",
17155                AdapterVerification::SyntheticFixture,
17156            )
17157            .unwrap(),
17158        }
17159    }
17160
17161    fn provider_control_event_at(
17162        family: AdapterFamily,
17163        adapter: &str,
17164        source_sequence: u64,
17165        provider_event: ProviderEvent,
17166    ) -> ControlEvent {
17167        ControlEvent {
17168            sequence: 41,
17169            command_id: None,
17170            instance_id: AgentInstanceId(7),
17171            generation: SessionGeneration(3),
17172            event: ControlEventKind::ProviderEvent {
17173                sequence: 8,
17174                source: provider_source(family, adapter),
17175                source_sequence,
17176                event: provider_event,
17177            },
17178        }
17179    }
17180
17181    fn provider_control_event(family: AdapterFamily, provider_event: ProviderEvent) -> ControlEvent {
17182        provider_control_event_at(family, "fixture", 9, provider_event)
17183    }
17184
17185    fn denied_host_request(
17186        by: ProviderHostDecisionAuthority,
17187        reason: Option<String>,
17188    ) -> ControlEvent {
17189        provider_control_event_at(
17190            AdapterFamily::Acp,
17191            "claude-code",
17192            2,
17193            ProviderEvent::HostRequestObserved {
17194                method: "terminal/create".to_owned(),
17195                params_json: String::new(),
17196                decision: ProviderHostRequestDecision::Denied { by },
17197                outcome: ProviderHostRequestOutcome::Executed,
17198                reason,
17199            },
17200        )
17201    }
17202
17203    fn blocked(
17204        event: &ControlEvent,
17205    ) -> (
17206        Option<String>,
17207        String,
17208        BlockAuthorityV1,
17209        Option<String>,
17210        String,
17211        Option<String>,
17212    ) {
17213        let chunk = agent_stream_chunk(event).expect("expected a Blocked chunk");
17214        chunk.validate().expect("minted chunk must validate");
17215        let AgentStreamChunkKindV1::Blocked {
17216            correlation_id,
17217            tool_class,
17218            authority,
17219            reason_kind,
17220            reason,
17221            help,
17222        } = chunk.kind
17223        else {
17224            panic!("expected AgentStreamChunkKindV1::Blocked");
17225        };
17226        (correlation_id, tool_class, authority, reason_kind, reason, help)
17227    }
17228
17229    /// Pins the fleet and every adapter each member declares: the fleet is
17230    /// exactly what `active_registry` says, and no member declares a hook
17231    /// adapter (lifecycle hooks are retired).
17232    #[test]
17233    fn the_enabled_provider_matrix_is_exact() {
17234        let registry = active_registry().unwrap();
17235        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
17236        assert_eq!(
17237            providers
17238                .iter()
17239                .map(|contract| contract.provider.as_str())
17240                .collect::<Vec<_>>(),
17241            vec!["claude", "codex", "grok", "kimi"],
17242        );
17243        assert!(adapters.iter().all(|contract| {
17244            !matches!(contract.family, AdapterFamily::Hook | AdapterFamily::ManagedHook)
17245        }));
17246    }
17247
17248    /// A gate-denied host request, WITH its reason text carried all the way
17249    /// from the ACP host, mints a `Blocked` chunk naming the gate.
17250    #[test]
17251    fn gate_denied_host_request_mints_a_blocked_chunk() {
17252        let gate_text =
17253            "blocked by dangerous-command gate: rule=filesystem-wipe, argument=rm -rf /";
17254        let event = denied_host_request(
17255            ProviderHostDecisionAuthority::Gate,
17256            Some(gate_text.to_owned()),
17257        );
17258        let chunk = agent_stream_chunk(&event).expect("expected a Blocked chunk");
17259        assert_eq!(chunk.source_sequence, 2);
17260        let (correlation_id, tool_class, authority, reason_kind, reason, help) = blocked(&event);
17261        assert_eq!(authority, BlockAuthorityV1::HostGate);
17262        assert_eq!(reason, gate_text);
17263        assert_eq!(correlation_id, None);
17264        assert_eq!(reason_kind, None);
17265        assert_eq!(help, None);
17266        assert_eq!(tool_class, "Write");
17267    }
17268
17269    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.";
17270
17271    /// The owner's classifier sample, verbatim, arriving as a
17272    /// `tool_call_update`-derived `ProviderEvent::ToolCompleted` with
17273    /// `non_execution_kind: "permission-rule"` -- mints a `Blocked` chunk with
17274    /// `authority: ProviderPermissionRule`, the `Reason:` sentence as `reason`
17275    /// and the guidance tail as `help`.
17276    #[test]
17277    fn classifier_sample_permission_rule_mints_a_blocked_chunk() {
17278        let event = provider_control_event(
17279            AdapterFamily::Acp,
17280            ProviderEvent::ToolCompleted {
17281                id: "toolu_9".to_owned(),
17282                output: CLASSIFIER_SAMPLE.to_owned(),
17283                is_error: true,
17284                duration_ms: None,
17285                agent_id: None,
17286                non_execution_kind: Some("permission-rule".to_owned()),
17287            },
17288        );
17289        let (correlation_id, _, authority, reason_kind, reason, help) = blocked(&event);
17290        assert_eq!(authority, BlockAuthorityV1::ProviderPermissionRule);
17291        assert_eq!(reason, "Blocked by classifier");
17292        assert_eq!(reason_kind, Some("permission-rule".to_owned()));
17293        assert!(help.as_deref().is_some_and(|help| help.starts_with("If you have other tasks")));
17294        assert!(correlation_id.is_some(), "a tool-correlated block must carry a correlation id");
17295    }
17296
17297    /// A `session/prompt` RPC error with `data.codexErrorInfo:
17298    /// "usageLimitExceeded"` (measured live 2026-09-05) mints a `Blocked` chunk
17299    /// with `authority: ProviderQuota`, the message verbatim as `reason` and the
17300    /// vendor code as `reason_kind`.
17301    #[test]
17302    fn codex_quota_rpc_error_mints_a_blocked_chunk() {
17303        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.";
17304        let event = provider_control_event(
17305            AdapterFamily::Acp,
17306            ProviderEvent::SessionEnded {
17307                result: "JSON-RPC error -32603: Internal error".to_owned(),
17308                cost_usd: None,
17309                is_error: true,
17310                stop_reason: Some(ProviderStopReason::ProviderError {
17311                    code: -32603,
17312                    message: message.to_owned(),
17313                    vendor_code: Some("usageLimitExceeded".to_owned()),
17314                }),
17315            },
17316        );
17317        let (_, tool_class, authority, reason_kind, reason, _) = blocked(&event);
17318        assert_eq!(authority, BlockAuthorityV1::ProviderQuota);
17319        assert_eq!(reason, message);
17320        assert_eq!(reason_kind, Some("usageLimitExceeded".to_owned()));
17321        assert_eq!(tool_class, "Session");
17322    }
17323
17324    /// ACP's `stopReason: "refusal"` mints a `Blocked` chunk with
17325    /// `authority: ProviderRefusal`.
17326    #[test]
17327    fn session_end_stop_reason_refusal_mints_a_blocked_chunk() {
17328        let event = provider_control_event(
17329            AdapterFamily::Acp,
17330            ProviderEvent::SessionEnded {
17331                result: "refusal".to_owned(),
17332                cost_usd: None,
17333                is_error: true,
17334                stop_reason: Some(ProviderStopReason::Refusal),
17335            },
17336        );
17337        let (_, _, authority, _, _, _) = blocked(&event);
17338        assert_eq!(authority, BlockAuthorityV1::ProviderRefusal);
17339    }
17340
17341    /// A failed tool completion with no typed `non_execution_kind` -- or one
17342    /// that only says the turn stopped -- is a plain execution failure, never a
17343    /// block; nor is an agent's own narrative claiming one.
17344    #[test]
17345    fn failures_and_narrative_without_a_typed_block_mint_no_blocked_chunk() {
17346        for non_execution_kind in [None, Some("interrupted".to_owned()), Some("cancelled".to_owned())] {
17347            let event = provider_control_event(
17348                AdapterFamily::Acp,
17349                ProviderEvent::ToolCompleted {
17350                    id: "toolu_1".to_owned(),
17351                    output: "command exited with status 1".to_owned(),
17352                    is_error: true,
17353                    duration_ms: Some(12),
17354                    agent_id: None,
17355                    non_execution_kind,
17356                },
17357            );
17358            assert!(agent_stream_chunk(&event).is_none());
17359        }
17360        let narrative = provider_control_event(
17361            AdapterFamily::Acp,
17362            ProviderEvent::ToolCompleted {
17363                id: "toolu_2".to_owned(),
17364                output: "the harness blocked the write, approval needed".to_owned(),
17365                is_error: false,
17366                duration_ms: Some(3),
17367                agent_id: None,
17368                non_execution_kind: None,
17369            },
17370        );
17371        assert!(agent_stream_chunk(&narrative).is_none());
17372    }
17373
17374    /// `Granted`/`Deferred` decisions never mint a `Blocked` chunk, whatever the
17375    /// execution outcome: a granted request that failed while running was
17376    /// still authorized.
17377    #[test]
17378    fn granted_and_deferred_host_requests_never_mint_a_blocked_chunk() {
17379        for (decision, outcome) in [
17380            (
17381                ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
17382                ProviderHostRequestOutcome::Executed,
17383            ),
17384            (ProviderHostRequestDecision::Deferred, ProviderHostRequestOutcome::Executed),
17385            (
17386                ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
17387                ProviderHostRequestOutcome::Failed {
17388                    error: "terminal/create spawn failed: os error 3".to_owned(),
17389                },
17390            ),
17391        ] {
17392            let event = provider_control_event_at(
17393                AdapterFamily::Acp,
17394                "claude-code",
17395                3,
17396                ProviderEvent::HostRequestObserved {
17397                    method: "fs/read_text_file".to_owned(),
17398                    params_json: String::new(),
17399                    decision: decision.clone(),
17400                    outcome,
17401                    reason: None,
17402                },
17403            );
17404            assert!(agent_stream_chunk(&event).is_none(), "{decision:?}");
17405        }
17406    }
17407
17408    /// A deferred `session/request_permission` resolved by an operator or an
17409    /// expired deadline carries no reason text -- the chunk falls back to a
17410    /// categorical sentence naming the authority rather than inventing detail.
17411    #[test]
17412    fn denied_host_request_with_no_reason_falls_back_to_a_categorical_sentence() {
17413        for (by, expected_authority, expected_reason) in [
17414            (
17415                ProviderHostDecisionAuthority::Operator,
17416                BlockAuthorityV1::Operator,
17417                "declined by the operator",
17418            ),
17419            (
17420                ProviderHostDecisionAuthority::DeadlinePolicy,
17421                BlockAuthorityV1::HostDeadline,
17422                "denied by host policy after the operator deadline expired",
17423            ),
17424        ] {
17425            let (_, _, authority, _, reason, _) = blocked(&denied_host_request(by, None));
17426            assert_eq!(authority, expected_authority);
17427            assert_eq!(reason, expected_reason);
17428        }
17429    }
17430
17431    /// An over-long reason is cut to the wire bound BEFORE the chunk is built,
17432    /// at a safe UTF-8 boundary -- `validate` rejects an over-long value
17433    /// outright rather than truncating.
17434    #[test]
17435    fn overlong_blocked_reason_is_truncated_to_the_wire_bound() {
17436        let oversized = "x".repeat(MAX_ACP_BLOCKED_REASON_BYTES + 500);
17437        let (_, _, _, _, reason, _) = blocked(&denied_host_request(
17438            ProviderHostDecisionAuthority::Policy,
17439            Some(oversized),
17440        ));
17441        assert!(reason.len() <= MAX_ACP_BLOCKED_REASON_BYTES);
17442    }
17443}
17444
17445#[cfg(test)]
17446mod standalone_capability_platform_tests {
17447    use super::*;
17448
17449    #[test]
17450    fn standalone_workspace_capability_requires_windows_identity_guards() {
17451        let capabilities = baseline_capabilities().unwrap();
17452        let advertised = capabilities.iter().any(|capability| {
17453            capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
17454        });
17455        assert_eq!(advertised, cfg!(windows));
17456
17457        let request = NodeRequest::CreateStandaloneWorkspace {
17458            workspace_id: WorkspaceId::new("standalone-platform").unwrap(),
17459            root: OpaqueHostPath::utf8("standalone-platform-root".to_owned()).unwrap(),
17460            initial_branch: None,
17461        };
17462        assert_eq!(
17463            request_uses_unnegotiated_capability(&request, &capabilities),
17464            !cfg!(windows),
17465        );
17466    }
17467}
17468
17469
17470#[cfg(test)]
17471mod station_knobs_resolve_tests {
17472    use super::*;
17473    use gate4agent_catalog::EnvMutation;
17474    use gate4agent_runtime_native::{
17475        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
17476        NativeLaunchProfileId,
17477    };
17478    use std::ffi::OsString;
17479    use std::sync::Arc;
17480
17481    struct EmptyEnvironmentResolver;
17482
17483    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
17484        fn resolve_child_environment(
17485            &self,
17486        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
17487            Ok(vec![EnvMutation {
17488                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
17489                value: None,
17490            }])
17491        }
17492    }
17493
17494    fn agent(value: &str) -> AgentId {
17495        AgentId::new(value).unwrap()
17496    }
17497
17498    fn station_knobs_fixture() -> (NodeShared, crate::protocol::SpawnProfileDefaults) {
17499        let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
17500        let profile_revision =
17501            crate::protocol::SpawnEnvironmentProfileRevision::new("local-claude-r1").unwrap();
17502        let spawn_defaults = crate::protocol::SpawnProfileDefaults {
17503            profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
17504            revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
17505            provider: agent("claude"),
17506            mode: SessionMode::Pty,
17507            terminal_size: gate4agent_types::TerminalSize {
17508                rows: 24,
17509                columns: 80,
17510            },
17511            prompt: None,
17512            bundle_id: None,
17513            context_id: None,
17514            environment_profile_id: Some(profile_id.clone()),
17515        };
17516        let spawn_profiles = SpawnProfileRegistry::new([spawn_defaults.clone()]).unwrap();
17517        let catalog = active_registry().unwrap();
17518        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
17519        let control = runtime.native_launch_profile_control();
17520        let node_profile = NodeEnvironmentProfile::new(
17521            profile_id.clone(),
17522            profile_revision,
17523            agent("claude"),
17524            [NativeLaunchProfile::new(
17525                NativeLaunchProfileId::new("local-claude-pty").unwrap(),
17526                agent("claude"),
17527                TransportKind::Pty,
17528                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
17529                Arc::new(EmptyEnvironmentResolver),
17530            )
17531            .unwrap()],
17532        )
17533        .unwrap();
17534        let (binding, native_profiles, _materialization) = node_profile.into_parts();
17535        for native_profile in native_profiles {
17536            runtime.upsert_native_launch_profile(native_profile).unwrap();
17537        }
17538        let workspace = WorkspaceConfig::new(
17539            WorkspaceId::new("primary").unwrap(),
17540            std::env::current_dir().unwrap(),
17541        )
17542        .unwrap();
17543        let shared = NodeShared::new_with_incarnation(
17544            handle,
17545            "fixture-token".to_owned(),
17546            NodeId::new("node-station-knobs-test").unwrap(),
17547            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
17548            vec![workspace],
17549            vec![agent("claude")],
17550            ProviderRuntimeStatuses::default(),
17551            None,
17552            Vec::new(),
17553            Vec::new(),
17554            spawn_profiles,
17555            Some(control),
17556            None,
17557            None,
17558            None,
17559            Vec::new(),
17560            Vec::new(),
17561            Vec::new(),
17562            Vec::new(),
17563            None,
17564            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
17565        );
17566        shared
17567            .environment_profiles
17568            .write()
17569            .unwrap_or_else(|poisoned| poisoned.into_inner())
17570            .insert(binding.id.clone(), binding);
17571        (shared, spawn_defaults)
17572    }
17573
17574    /// Empty-default catalog refuses unknown network_allowlist ids.
17575    /// Feature-off dig2-station-probe keeps browser_profile stub echo.
17576    #[test]
17577    fn resolve_environment_profile_refuses_unknown_network_allowlist() {
17578        let (shared, defaults) = station_knobs_fixture();
17579        let unknown = crate::protocol::SpawnNetworkAllowlistId::new("not-registered").unwrap();
17580        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
17581        let spec = crate::protocol::SpawnSpec {
17582            target: crate::protocol::SpawnTarget {
17583                node_id: shared.node_id.clone(),
17584                workspace_id: WorkspaceId::new("primary").unwrap(),
17585                worktree_id: None,
17586            },
17587            profile_id: defaults.profile_id.clone(),
17588            expected_profile_revision: defaults.revision.clone(),
17589            overrides: crate::protocol::SpawnOverrides {
17590                network_allowlist: Some(unknown),
17591                browser_profile_id: Some(browser),
17592                ..crate::protocol::SpawnOverrides::default()
17593            },
17594            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
17595            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
17596                "station-knobs-unknown-allowlist",
17597            )
17598            .unwrap(),
17599            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
17600        };
17601        let resolved = spec.resolve(&defaults).unwrap();
17602        let failure = shared
17603            .resolve_environment_profile(&resolved)
17604            .expect_err("unknown allowlist must refuse");
17605        assert_eq!(failure.code, NodeFailureCode::UnknownNetworkAllowlist);
17606    }
17607
17608    /// Registered network_allowlist (+ browser_profile_id when feature off)
17609    /// echo onto the env-profile receipt. Empty catalog still allows omit
17610    /// (None). Empty/whitespace refuse is type-level. Feature-off
17611    /// dig2-station-probe keeps stub browser echo; feature-on refuse is
17612    /// covered by `…_refuses_browser_profile_when_station_probe_fails`.
17613    #[test]
17614    fn resolve_environment_profile_echoes_registered_station_network_and_browser_knobs() {
17615        let (shared, defaults) = station_knobs_fixture();
17616        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
17617        #[cfg(not(feature = "dig2-station-probe"))]
17618        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
17619        shared
17620            .network_allowlist_catalog
17621            .write()
17622            .unwrap_or_else(|poisoned| poisoned.into_inner())
17623            .insert_id(allowlist.clone())
17624            .unwrap();
17625        let spec = crate::protocol::SpawnSpec {
17626            target: crate::protocol::SpawnTarget {
17627                node_id: shared.node_id.clone(),
17628                workspace_id: WorkspaceId::new("primary").unwrap(),
17629                worktree_id: None,
17630            },
17631            profile_id: defaults.profile_id.clone(),
17632            expected_profile_revision: defaults.revision.clone(),
17633            overrides: crate::protocol::SpawnOverrides {
17634                network_allowlist: Some(allowlist.clone()),
17635                #[cfg(not(feature = "dig2-station-probe"))]
17636                browser_profile_id: Some(browser.clone()),
17637                ..crate::protocol::SpawnOverrides::default()
17638            },
17639            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
17640            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-echo")
17641                .unwrap(),
17642            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
17643        };
17644        let resolved = spec.resolve(&defaults).unwrap();
17645        assert_eq!(resolved.network_allowlist.as_ref(), Some(&allowlist));
17646        #[cfg(not(feature = "dig2-station-probe"))]
17647        assert_eq!(resolved.browser_profile_id.as_ref(), Some(&browser));
17648        let receipt = shared
17649            .resolve_environment_profile(&resolved)
17650            .expect("resolve ok")
17651            .expect("env profile present");
17652        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
17653        #[cfg(not(feature = "dig2-station-probe"))]
17654        assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
17655        #[cfg(feature = "dig2-station-probe")]
17656        assert_eq!(receipt.browser_profile_id, None);
17657        assert_eq!(receipt.profile_id.as_str(), "local-claude");
17658
17659        let omitted = crate::protocol::SpawnSpec {
17660            overrides: crate::protocol::SpawnOverrides::default(),
17661            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-omit")
17662                .unwrap(),
17663            ..spec
17664        };
17665        let resolved_omit = omitted.resolve(&defaults).unwrap();
17666        let bare = shared
17667            .resolve_environment_profile(&resolved_omit)
17668            .expect("resolve ok")
17669            .expect("env profile present");
17670        assert_eq!(bare.network_allowlist, None);
17671        assert_eq!(bare.browser_profile_id, None);
17672    }
17673
17674    /// Feature `dig2-station-probe`: browser_profile_id set refuses when the
17675    /// cheap station probe cannot succeed. On non-Windows (this CI host) that
17676    /// is `BrowserStationProbeUnavailable`. Never cookies on C2. Lease map is
17677    /// unit-tested separately (probe refuses before lease on Linux).
17678    #[cfg(feature = "dig2-station-probe")]
17679    #[test]
17680    fn resolve_environment_profile_refuses_browser_profile_when_station_probe_fails() {
17681        let (shared, defaults) = station_knobs_fixture();
17682        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
17683        let spec = crate::protocol::SpawnSpec {
17684            target: crate::protocol::SpawnTarget {
17685                node_id: shared.node_id.clone(),
17686                workspace_id: WorkspaceId::new("primary").unwrap(),
17687                worktree_id: None,
17688            },
17689            profile_id: defaults.profile_id.clone(),
17690            expected_profile_revision: defaults.revision.clone(),
17691            overrides: crate::protocol::SpawnOverrides {
17692                browser_profile_id: Some(browser),
17693                ..crate::protocol::SpawnOverrides::default()
17694            },
17695            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
17696            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
17697                "station-knobs-probe-refuse",
17698            )
17699            .unwrap(),
17700            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
17701        };
17702        let resolved = spec.resolve(&defaults).unwrap();
17703        let failure = shared
17704            .resolve_environment_profile(&resolved)
17705            .expect_err("browser_profile with failing probe must refuse");
17706        #[cfg(windows)]
17707        {
17708            assert!(
17709                matches!(
17710                    failure.code,
17711                    NodeFailureCode::BrowserStationUnreachable
17712                        | NodeFailureCode::BrowserStationProbeUnavailable
17713                ),
17714                "unexpected code {:?}",
17715                failure.code
17716            );
17717        }
17718        #[cfg(not(windows))]
17719        {
17720            assert_eq!(
17721                failure.code,
17722                NodeFailureCode::BrowserStationProbeUnavailable
17723            );
17724        }
17725    }
17726
17727    /// Feature on: omitting browser_profile_id still resolves (no probe).
17728    #[cfg(feature = "dig2-station-probe")]
17729    #[test]
17730    fn resolve_environment_profile_skips_station_probe_when_browser_profile_omitted() {
17731        let (shared, defaults) = station_knobs_fixture();
17732        let spec = crate::protocol::SpawnSpec {
17733            target: crate::protocol::SpawnTarget {
17734                node_id: shared.node_id.clone(),
17735                workspace_id: WorkspaceId::new("primary").unwrap(),
17736                worktree_id: None,
17737            },
17738            profile_id: defaults.profile_id.clone(),
17739            expected_profile_revision: defaults.revision.clone(),
17740            overrides: crate::protocol::SpawnOverrides::default(),
17741            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
17742            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
17743                "station-knobs-probe-omit",
17744            )
17745            .unwrap(),
17746            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
17747        };
17748        let resolved = spec.resolve(&defaults).unwrap();
17749        let receipt = shared
17750            .resolve_environment_profile(&resolved)
17751            .expect("omit browser_profile must not probe")
17752            .expect("env profile present");
17753        assert_eq!(receipt.browser_profile_id, None);
17754    }
17755
17756    /// Feature dig2-station-probe: exclusive BrowserStationLease on NodeShared
17757    /// refuses a second holder for the same opaque profile id; release on
17758    /// remove_binding allows reacquire. Never ImportSession/cookies on C2.
17759    #[cfg(feature = "dig2-station-probe")]
17760    #[test]
17761    fn browser_station_lease_exclusive_and_clears_on_remove_binding() {
17762        let (shared, _defaults) = station_knobs_fixture();
17763        let profile = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
17764        let workspace_id = WorkspaceId::new("primary").unwrap();
17765        let address_a = SessionAddress {
17766            workspace_id: workspace_id.clone(),
17767            session: SessionKey {
17768                instance_id: AgentInstanceId(101),
17769                generation: SessionGeneration(1),
17770            },
17771        };
17772        let address_b = SessionAddress {
17773            workspace_id: workspace_id.clone(),
17774            session: SessionKey {
17775                instance_id: AgentInstanceId(102),
17776                generation: SessionGeneration(1),
17777            },
17778        };
17779        let guard_a = crate::dig2_station_lease::acquire_lease(
17780            &shared.browser_station_leases,
17781            profile.clone(),
17782            address_a.session.instance_id,
17783            1,
17784        )
17785        .expect("first lease");
17786        let busy = crate::dig2_station_lease::acquire_lease(
17787            &shared.browser_station_leases,
17788            profile.clone(),
17789            address_b.session.instance_id,
17790            2,
17791        )
17792        .expect_err("second holder must refuse");
17793        assert!(matches!(
17794            busy,
17795            crate::dig2_station_lease::BrowserStationLeaseError::ProfileBusy { .. }
17796        ));
17797        // Simulate a bound session so remove_binding clears the lease.
17798        shared.bind_session_with_policy(
17799            &address_a,
17800            ProviderRuntimePolicy::raw_pty(),
17801            None,
17802        );
17803        guard_a.retain();
17804        assert!(shared.remove_binding(&address_a).is_some());
17805        crate::dig2_station_lease::acquire_lease(
17806            &shared.browser_station_leases,
17807            profile,
17808            address_b.session.instance_id,
17809            3,
17810        )
17811        .expect("reacquire after clear")
17812        .retain();
17813        assert!(
17814            crate::dig2_station_lease::release_lease_for_holder(
17815                &shared.browser_station_leases,
17816                address_b.session.instance_id,
17817            )
17818        );
17819    }
17820
17821    /// Feature-off: no browser_station_leases field; stub echo documented in
17822    /// resolve_environment_profile_echoes_registered_station_network_and_browser_knobs.
17823    #[cfg(not(feature = "dig2-station-probe"))]
17824    #[test]
17825    fn browser_station_lease_feature_off_keeps_stub_without_lease_table() {
17826        let (shared, defaults) = station_knobs_fixture();
17827        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
17828        let spec = crate::protocol::SpawnSpec {
17829            target: crate::protocol::SpawnTarget {
17830                node_id: shared.node_id.clone(),
17831                workspace_id: WorkspaceId::new("primary").unwrap(),
17832                worktree_id: None,
17833            },
17834            profile_id: defaults.profile_id.clone(),
17835            expected_profile_revision: defaults.revision.clone(),
17836            overrides: crate::protocol::SpawnOverrides {
17837                browser_profile_id: Some(browser.clone()),
17838                ..crate::protocol::SpawnOverrides::default()
17839            },
17840            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
17841            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("lease-stub-off")
17842                .unwrap(),
17843            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
17844        };
17845        let resolved = spec.resolve(&defaults).unwrap();
17846        let receipt = shared
17847            .resolve_environment_profile(&resolved)
17848            .expect("stub resolve")
17849            .expect("env profile");
17850        assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
17851    }
17852
17853
17854    /// Startup file loader registers ids into the empty-default catalog.
17855    /// Feature-off dig2 probe stays stub. Never secrets on C2.
17856    #[test]
17857    fn network_allowlist_catalog_loads_from_temp_file() {
17858        let unique = std::time::SystemTime::now()
17859            .duration_since(std::time::UNIX_EPOCH)
17860            .unwrap()
17861            .as_nanos();
17862        let path = std::env::temp_dir().join(format!(
17863            "gate4agent-node-allowlist-config-{}-{unique}.txt",
17864            std::process::id(),
17865        ));
17866        std::fs::write(
17867            &path,
17868            "# opaque station allowlist ids\negress-default\nlab-egress\n",
17869        )
17870        .unwrap();
17871        let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
17872            .expect("temp catalog file must load");
17873        let _ = std::fs::remove_file(&path);
17874        assert_eq!(loaded.len(), 2);
17875        let (shared, defaults) = station_knobs_fixture();
17876        {
17877            let mut catalog = shared
17878                .network_allowlist_catalog
17879                .write()
17880                .unwrap_or_else(|poisoned| poisoned.into_inner());
17881            *catalog = loaded;
17882        }
17883        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
17884        let spec = crate::protocol::SpawnSpec {
17885            target: crate::protocol::SpawnTarget {
17886                node_id: shared.node_id.clone(),
17887                workspace_id: WorkspaceId::new("primary").unwrap(),
17888                worktree_id: None,
17889            },
17890            profile_id: defaults.profile_id.clone(),
17891            expected_profile_revision: defaults.revision.clone(),
17892            overrides: crate::protocol::SpawnOverrides {
17893                network_allowlist: Some(allowlist.clone()),
17894                ..crate::protocol::SpawnOverrides::default()
17895            },
17896            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
17897            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
17898                "station-knobs-catalog-file",
17899            )
17900            .unwrap(),
17901            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
17902        };
17903        let resolved = spec.resolve(&defaults).unwrap();
17904        let receipt = shared
17905            .resolve_environment_profile(&resolved)
17906            .expect("registered via file load")
17907            .expect("env profile present");
17908        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
17909    }
17910
17911    /// Snapshot launch inventory echoes registered catalog ids (opaque).
17912    /// Dig2 station probe is orthogonal (feature `dig2-station-probe`). Never secrets on C2.
17913    #[test]
17914    fn snapshot_launch_inventory_lists_network_allowlist_catalog_ids() {
17915        let (shared, _defaults) = station_knobs_fixture();
17916        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
17917        shared
17918            .network_allowlist_catalog
17919            .write()
17920            .unwrap_or_else(|poisoned| poisoned.into_inner())
17921            .insert_id(allowlist.clone())
17922            .unwrap();
17923        let snapshot = shared.snapshot();
17924        let inventory = snapshot
17925            .launch_inventory
17926            .expect("launch inventory present");
17927        let ids = inventory
17928            .network_allowlists
17929            .expect("non-empty catalog surfaces ids");
17930        assert_eq!(ids, vec![allowlist]);
17931    }
17932
17933    /// Track B: registered id with provider_native codex_network_access refuses
17934    /// clearly for Claude (fixture provider). Unknown id still UnknownNetworkAllowlist.
17935    /// Never secrets on C2. Codex Moderate maps to `-c` overlay; RO/FullAuto refuse.
17936    #[test]
17937    fn resolve_environment_profile_refuses_unsupported_provider_native_mapping() {
17938        let (shared, defaults) = station_knobs_fixture();
17939        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-only").unwrap();
17940        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
17941            id: allowlist.clone(),
17942            permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
17943                protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
17944                peer: "127.0.0.1:443".to_owned(),
17945            }],
17946            provider_native: Some(
17947                crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
17948                    codex_network_access: Some(true),
17949                },
17950            ),
17951        };
17952        shared
17953            .network_allowlist_catalog
17954            .write()
17955            .unwrap_or_else(|poisoned| poisoned.into_inner())
17956            .insert(entry)
17957            .unwrap();
17958        let spec = crate::protocol::SpawnSpec {
17959            target: crate::protocol::SpawnTarget {
17960                node_id: shared.node_id.clone(),
17961                workspace_id: WorkspaceId::new("primary").unwrap(),
17962                worktree_id: None,
17963            },
17964            profile_id: defaults.profile_id.clone(),
17965            expected_profile_revision: defaults.revision.clone(),
17966            overrides: crate::protocol::SpawnOverrides {
17967                network_allowlist: Some(allowlist),
17968                ..crate::protocol::SpawnOverrides::default()
17969            },
17970            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
17971            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
17972                "station-knobs-unsupported-mapping",
17973            )
17974            .unwrap(),
17975            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
17976        };
17977        let resolved = spec.resolve(&defaults).unwrap();
17978        assert_eq!(resolved.provider.as_str(), "claude");
17979        let failure = shared
17980            .resolve_environment_profile(&resolved)
17981            .expect_err("claude + codex_network_access must refuse");
17982        assert_eq!(
17983            failure.code,
17984            NodeFailureCode::UnsupportedNetworkAllowlistMapping
17985        );
17986    }
17987
17988    /// Codex Moderate + codex_network_access yields `-c` overlay argv.
17989    /// ReadOnly / FullAuto refuse UnsupportedNetworkAllowlistMapping.
17990    /// Never secrets on C2.
17991    #[test]
17992    fn resolve_codex_network_access_overlay_moderate_only() {
17993        let (shared, defaults) = station_knobs_fixture();
17994        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-net").unwrap();
17995        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
17996            id: allowlist.clone(),
17997            permits: Vec::new(),
17998            provider_native: Some(
17999                crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
18000                    codex_network_access: Some(true),
18001                },
18002            ),
18003        };
18004        shared
18005            .network_allowlist_catalog
18006            .write()
18007            .unwrap_or_else(|poisoned| poisoned.into_inner())
18008            .insert(entry)
18009            .unwrap();
18010
18011        let overlay = shared
18012            .resolve_codex_network_access_overlay_args(
18013                &agent("codex"),
18014                SessionMode::Pty,
18015                ApprovalLevel::Moderate,
18016                Some(&allowlist),
18017            )
18018            .expect("Moderate Codex maps network_access");
18019        assert_eq!(
18020            overlay,
18021            vec![
18022                std::ffi::OsString::from("-c"),
18023                std::ffi::OsString::from("sandbox_workspace_write.network_access=true"),
18024            ]
18025        );
18026
18027        let acp = shared
18028            .resolve_codex_network_access_overlay_args(
18029                &agent("codex"),
18030                SessionMode::Acp,
18031                ApprovalLevel::Moderate,
18032                Some(&allowlist),
18033            )
18034            .unwrap();
18035        assert_eq!(acp, overlay);
18036
18037        let inline = shared
18038            .resolve_codex_network_access_overlay_args(
18039                &agent("codex"),
18040                SessionMode::Inline,
18041                ApprovalLevel::Moderate,
18042                Some(&allowlist),
18043            )
18044            .unwrap_err();
18045        assert_eq!(
18046            inline.code,
18047            NodeFailureCode::UnsupportedNetworkAllowlistMapping
18048        );
18049
18050        for level in [ApprovalLevel::ReadOnly, ApprovalLevel::FullAuto] {
18051            let spec = crate::protocol::SpawnSpec {
18052                target: crate::protocol::SpawnTarget {
18053                    node_id: shared.node_id.clone(),
18054                    workspace_id: WorkspaceId::new("primary").unwrap(),
18055                    worktree_id: None,
18056                },
18057                profile_id: defaults.profile_id.clone(),
18058                expected_profile_revision: defaults.revision.clone(),
18059                overrides: crate::protocol::SpawnOverrides {
18060                    provider: crate::protocol::SpawnOverride::Set {
18061                        value: agent("codex"),
18062                    },
18063                    approval_level: Some(level),
18064                    network_allowlist: Some(allowlist.clone()),
18065                    ..crate::protocol::SpawnOverrides::default()
18066                },
18067                deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18068                idempotency_key: crate::protocol::SpawnIdempotencyKey::new(format!(
18069                    "codex-net-refuse-{}",
18070                    match level {
18071                        ApprovalLevel::ReadOnly => "ro",
18072                        ApprovalLevel::FullAuto => "fa",
18073                        _ => "other",
18074                    }
18075                ))
18076                .unwrap(),
18077                required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18078            };
18079            let resolved = spec.resolve(&defaults).unwrap();
18080            let failure = shared
18081                .resolve_environment_profile(&resolved)
18082                .expect_err("RO/FullAuto must refuse");
18083            assert_eq!(
18084                failure.code,
18085                NodeFailureCode::UnsupportedNetworkAllowlistMapping
18086            );
18087        }
18088    }
18089
18090    /// Claude PTY + catalog permits → ProviderHome `--settings` sidecar with
18091    /// sandbox.network.allowedDomains. ACP / missing ProviderHome refuse.
18092    /// Never secrets on C2; does not invent provider_native Claude keys.
18093    #[test]
18094    fn resolve_claude_settings_network_overlay_pty_writes_sidecar() {
18095        let (shared, _defaults) = station_knobs_fixture();
18096        let allowlist =
18097            crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-domains").unwrap();
18098        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18099            id: allowlist.clone(),
18100            permits: vec![
18101                crate::network_allowlist_catalog::NetworkPermitSketch {
18102                    protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18103                    peer: "github.com:443".to_owned(),
18104                },
18105                crate::network_allowlist_catalog::NetworkPermitSketch {
18106                    protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18107                    peer: "*.npmjs.org".to_owned(),
18108                },
18109            ],
18110            provider_native: None,
18111        };
18112        shared
18113            .network_allowlist_catalog
18114            .write()
18115            .unwrap_or_else(|poisoned| poisoned.into_inner())
18116            .insert(entry)
18117            .unwrap();
18118
18119        let unique = std::time::SystemTime::now()
18120            .duration_since(std::time::UNIX_EPOCH)
18121            .unwrap()
18122            .as_nanos();
18123        let provider_home = std::env::temp_dir().join(format!(
18124            "gate4agent-claude-provider-home-{}-{unique}",
18125            std::process::id(),
18126        ));
18127        std::fs::create_dir_all(&provider_home).unwrap();
18128
18129        let overlay = shared
18130            .resolve_network_allowlist_overlay_args(
18131                &agent("claude"),
18132                SessionMode::Pty,
18133                ApprovalLevel::Moderate,
18134                Some(&allowlist),
18135                Some(provider_home.as_path()),
18136            )
18137            .expect("Claude PTY settings overlay on unix");
18138        let settings_path = provider_home.join(
18139            crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
18140        );
18141        assert_eq!(
18142            overlay,
18143            vec![
18144                std::ffi::OsString::from("--settings"),
18145                std::ffi::OsString::from(settings_path.as_os_str()),
18146            ]
18147        );
18148        let body = std::fs::read_to_string(&settings_path).unwrap();
18149        let value: serde_json::Value = serde_json::from_str(&body).unwrap();
18150        assert_eq!(value["sandbox"]["enabled"], true);
18151        assert_eq!(
18152            value["sandbox"]["network"]["allowedDomains"],
18153            serde_json::json!(["github.com", "*.npmjs.org"])
18154        );
18155        assert!(settings_path.starts_with(&provider_home));
18156
18157        let acp = shared
18158            .resolve_network_allowlist_overlay_args(
18159                &agent("claude"),
18160                SessionMode::Acp,
18161                ApprovalLevel::Moderate,
18162                Some(&allowlist),
18163                Some(provider_home.as_path()),
18164            )
18165            .expect_err("Claude ACP must refuse settings overlay");
18166        assert_eq!(
18167            acp.code,
18168            NodeFailureCode::UnsupportedNetworkAllowlistMapping
18169        );
18170
18171        let no_home = shared
18172            .resolve_network_allowlist_overlay_args(
18173                &agent("claude"),
18174                SessionMode::Pty,
18175                ApprovalLevel::Moderate,
18176                Some(&allowlist),
18177                None,
18178            )
18179            .expect_err("Claude overlay requires ProviderHome");
18180        assert_eq!(
18181            no_home.code,
18182            NodeFailureCode::UnsupportedNetworkAllowlistMapping
18183        );
18184
18185        let bare = crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-bare").unwrap();
18186        shared
18187            .network_allowlist_catalog
18188            .write()
18189            .unwrap_or_else(|poisoned| poisoned.into_inner())
18190            .insert_id(bare.clone())
18191            .unwrap();
18192        let empty = shared
18193            .resolve_network_allowlist_overlay_args(
18194                &agent("claude"),
18195                SessionMode::Pty,
18196                ApprovalLevel::Moderate,
18197                Some(&bare),
18198                Some(provider_home.as_path()),
18199            )
18200            .unwrap();
18201        assert!(empty.is_empty());
18202
18203        let _ = std::fs::remove_dir_all(&provider_home);
18204    }
18205
18206    /// Kimi / Grok with permits stay without invented argv overlays.
18207    #[test]
18208    fn resolve_kimi_grok_network_overlay_stays_empty_without_native() {
18209        let (shared, _defaults) = station_knobs_fixture();
18210        let allowlist =
18211            crate::protocol::SpawnNetworkAllowlistId::new("egress-kimi-permits").unwrap();
18212        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18213            id: allowlist.clone(),
18214            permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
18215                protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18216                peer: "example.com:443".to_owned(),
18217            }],
18218            provider_native: None,
18219        };
18220        shared
18221            .network_allowlist_catalog
18222            .write()
18223            .unwrap_or_else(|poisoned| poisoned.into_inner())
18224            .insert(entry)
18225            .unwrap();
18226        for provider in ["kimi", "grok"] {
18227            let overlay = shared
18228                .resolve_network_allowlist_overlay_args(
18229                    &agent(provider),
18230                    SessionMode::Pty,
18231                    ApprovalLevel::Moderate,
18232                    Some(&allowlist),
18233                    None,
18234                )
18235                .unwrap();
18236            assert!(
18237                overlay.is_empty(),
18238                "{provider} must not invent network argv from permits"
18239            );
18240        }
18241    }
18242
18243    /// Track B: JSON v2 catalog file loads permits; inventory still ids only.
18244    #[test]
18245    fn network_allowlist_catalog_loads_json_v2_and_lists_ids_only() {
18246        let unique = std::time::SystemTime::now()
18247            .duration_since(std::time::UNIX_EPOCH)
18248            .unwrap()
18249            .as_nanos();
18250        let path = std::env::temp_dir().join(format!(
18251            "gate4agent-node-allowlist-json-{}-{unique}.json",
18252            std::process::id(),
18253        ));
18254        std::fs::write(
18255            &path,
18256            r#"{
18257              "schema_version": 2,
18258              "entries": [
18259                {
18260                  "id": "egress-default",
18261                  "permits": [{ "protocol": "tcp", "peer": "127.0.0.1:443" }]
18262                },
18263                { "id": "lab-egress" }
18264              ]
18265            }"#,
18266        )
18267        .unwrap();
18268        let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
18269            .expect("json v2 catalog must load");
18270        let _ = std::fs::remove_file(&path);
18271        assert_eq!(loaded.len(), 2);
18272        let entry = loaded
18273            .get(&crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap())
18274            .unwrap();
18275        assert_eq!(entry.permits.len(), 1);
18276        let (shared, defaults) = station_knobs_fixture();
18277        {
18278            let mut catalog = shared
18279                .network_allowlist_catalog
18280                .write()
18281                .unwrap_or_else(|poisoned| poisoned.into_inner());
18282            *catalog = loaded;
18283        }
18284        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18285        let spec = crate::protocol::SpawnSpec {
18286            target: crate::protocol::SpawnTarget {
18287                node_id: shared.node_id.clone(),
18288                workspace_id: WorkspaceId::new("primary").unwrap(),
18289                worktree_id: None,
18290            },
18291            profile_id: defaults.profile_id.clone(),
18292            expected_profile_revision: defaults.revision.clone(),
18293            overrides: crate::protocol::SpawnOverrides {
18294                network_allowlist: Some(allowlist.clone()),
18295                ..crate::protocol::SpawnOverrides::default()
18296            },
18297            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18298            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18299                "station-knobs-catalog-json",
18300            )
18301            .unwrap(),
18302            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18303        };
18304        let resolved = spec.resolve(&defaults).unwrap();
18305        let receipt = shared
18306            .resolve_environment_profile(&resolved)
18307            .expect("permits-only entry ok for claude")
18308            .expect("env profile present");
18309        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18310        let inventory = shared
18311            .snapshot()
18312            .launch_inventory
18313            .expect("launch inventory present");
18314        let ids = inventory.network_allowlists.expect("ids surface");
18315        assert_eq!(
18316            ids,
18317            vec![
18318                crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap(),
18319                crate::protocol::SpawnNetworkAllowlistId::new("lab-egress").unwrap(),
18320            ]
18321        );
18322    }
18323
18324
18325}
18326
18327
18328#[cfg(test)]
18329mod workspace_environment_collision_tests {
18330    use super::*;
18331    use crate::session_environment::{
18332        NodeSessionMaterializationProfile, NodeSessionPathBinding, NodeSessionPathClass,
18333    };
18334    use gate4agent_catalog::EnvMutation;
18335    use gate4agent_runtime_native::{
18336        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
18337        NativeLaunchProfileId,
18338    };
18339    use std::ffi::OsString;
18340    use std::sync::Arc;
18341
18342    struct EmptyEnvironmentResolver;
18343
18344    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
18345        fn resolve_child_environment(
18346            &self,
18347        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
18348            Ok(vec![EnvMutation {
18349                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
18350                value: None,
18351            }])
18352        }
18353    }
18354
18355    fn agent(value: &str) -> AgentId {
18356        AgentId::new(value).unwrap()
18357    }
18358
18359    /// Two Claude ProviderHome profiles (`CLAUDE_CONFIG_DIR` A vs B) on one node.
18360    fn claude_config_dir_collision_fixture() -> (
18361        NodeShared,
18362        ResolvedEnvironmentProfileReceipt,
18363        ResolvedEnvironmentProfileReceipt,
18364    ) {
18365        let catalog = active_registry().unwrap();
18366        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18367        let control = runtime.native_launch_profile_control();
18368        let workspace = WorkspaceConfig::new(
18369            WorkspaceId::new("primary").unwrap(),
18370            std::env::current_dir().unwrap(),
18371        )
18372        .unwrap();
18373        let shared = NodeShared::new_with_incarnation(
18374            handle,
18375            "fixture-token".to_owned(),
18376            NodeId::new("node-env-collision-test").unwrap(),
18377            NodeIncarnationId::from_bytes([3; crate::protocol::NODE_INCARNATION_ID_BYTES]),
18378            vec![workspace],
18379            vec![agent("claude")],
18380            ProviderRuntimeStatuses::default(),
18381            None,
18382            Vec::new(),
18383            Vec::new(),
18384            SpawnProfileRegistry::default(),
18385            Some(control),
18386            None,
18387            None,
18388            None,
18389            Vec::new(),
18390            Vec::new(),
18391            Vec::new(),
18392            Vec::new(),
18393            None,
18394            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
18395        );
18396
18397        let mut receipts = Vec::new();
18398        for (suffix, native_suffix) in [("a", "a-pty"), ("b", "b-pty")] {
18399            let profile_id =
18400                SpawnEnvironmentProfileId::new(format!("claude-config-dir-{suffix}")).unwrap();
18401            let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(format!(
18402                "claude-config-dir-{suffix}-r1"
18403            ))
18404            .unwrap();
18405            let materialization = NodeSessionMaterializationProfile::new(
18406                Vec::new(),
18407                vec![NodeSessionPathBinding::new(
18408                    "CLAUDE_CONFIG_DIR",
18409                    NodeSessionPathClass::ProviderHome,
18410                )
18411                .unwrap()],
18412                Vec::new(),
18413            )
18414            .unwrap();
18415            let node_profile = NodeEnvironmentProfile::new_with_materialization(
18416                profile_id.clone(),
18417                profile_revision.clone(),
18418                agent("claude"),
18419                [NativeLaunchProfile::new(
18420                    NativeLaunchProfileId::new(format!("claude-config-dir-{native_suffix}"))
18421                        .unwrap(),
18422                    agent("claude"),
18423                    TransportKind::Pty,
18424                    vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
18425                    Arc::new(EmptyEnvironmentResolver),
18426                )
18427                .unwrap()],
18428                Some(materialization),
18429            )
18430            .unwrap();
18431            let (binding, native_profiles, materialization) = node_profile.into_parts();
18432            for native_profile in native_profiles {
18433                runtime.upsert_native_launch_profile(native_profile).unwrap();
18434            }
18435            shared
18436                .environment_profiles
18437                .write()
18438                .unwrap_or_else(|poisoned| poisoned.into_inner())
18439                .insert(binding.id.clone(), binding);
18440            shared
18441                .environment_materialization_profiles
18442                .write()
18443                .unwrap_or_else(|poisoned| poisoned.into_inner())
18444                .insert(profile_id.clone(), materialization.unwrap());
18445            receipts.push(ResolvedEnvironmentProfileReceipt {
18446                profile_id,
18447                profile_revision,
18448                network_allowlist: None,
18449                browser_profile_id: None,
18450            });
18451        }
18452        let profile_b = receipts.pop().unwrap();
18453        let profile_a = receipts.pop().unwrap();
18454        (shared, profile_a, profile_b)
18455    }
18456
18457    #[test]
18458    fn refuse_distinct_claude_config_dir_profiles_on_same_workspace_cwd() {
18459        let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
18460        let primary = WorkspaceId::new("primary").unwrap();
18461        let worktree = WorkspaceId::new("worktree-b").unwrap();
18462        let address = SessionAddress {
18463            workspace_id: primary.clone(),
18464            session: SessionKey {
18465                instance_id: AgentInstanceId(41),
18466                generation: SessionGeneration(1),
18467            },
18468        };
18469        shared.bind_session_with_policy(
18470            &address,
18471            ProviderRuntimePolicy::raw_pty(),
18472            Some(profile_a.clone()),
18473        );
18474
18475        let refused = shared
18476            .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_b))
18477            .unwrap_err();
18478        assert_eq!(refused.code, NodeFailureCode::WorkspaceBusy);
18479
18480        // Some vs None on the same cwd is also a collision.
18481        let refused_none = shared
18482            .ensure_workspace_environment_profile_compatible(&primary, None)
18483            .unwrap_err();
18484        assert_eq!(refused_none.code, NodeFailureCode::WorkspaceBusy);
18485
18486        // Same profile on the same workspace remains allowed.
18487        shared
18488            .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_a))
18489            .unwrap();
18490
18491        // Distinct workspace id (managed-worktree parallel path) allows profile B.
18492        shared
18493            .ensure_workspace_environment_profile_compatible(&worktree, Some(&profile_b))
18494            .unwrap();
18495    }
18496
18497    #[tokio::test]
18498    async fn spawn_session_refuses_conflicting_environment_profile_before_mutation() {
18499        let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
18500        let primary = WorkspaceId::new("primary").unwrap();
18501        let address = SessionAddress {
18502            workspace_id: primary.clone(),
18503            session: SessionKey {
18504                instance_id: AgentInstanceId(41),
18505                generation: SessionGeneration(1),
18506            },
18507        };
18508        shared.bind_session_with_policy(
18509            &address,
18510            ProviderRuntimePolicy::raw_pty(),
18511            Some(profile_a),
18512        );
18513
18514        let failure = shared
18515            .spawn_session_with_deadline(
18516                primary,
18517                agent("claude"),
18518                SessionMode::Pty,
18519                gate4agent_types::TerminalSize {
18520                    rows: 24,
18521                    columns: 80,
18522                },
18523                None,
18524                Some(profile_b),
18525                None,
18526                None,
18527                None,
18528                None,
18529                SpawnRecordPolicy::ProviderIdentityOnly,
18530                Some(Instant::now() + Duration::from_secs(30)),
18531                &[],
18532                None,
18533                ApprovalLevel::default(),
18534                None,
18535                None,
18536            )
18537            .await
18538            .unwrap_err();
18539        assert_eq!(failure.code, NodeFailureCode::WorkspaceBusy);
18540        // Guard runs before instance id mint / bind.
18541        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
18542        assert!(shared.handle.snapshot().sessions.is_empty());
18543    }
18544}
18545
18546#[cfg(all(test, windows))]
18547mod tests {
18548    use super::*;
18549    use gate4agent_types::ProviderActivity;
18550    use crate::session_environment::{
18551        NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
18552        NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
18553        NodeSessionPathClass,
18554    };
18555    use gate4agent_catalog::EnvMutation;
18556    use gate4agent_runtime_native::{
18557        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
18558        NativeLaunchProfile, NativeLaunchProfileId,
18559    };
18560    use std::ffi::OsString;
18561    use std::sync::Arc;
18562
18563    struct EmptyEnvironmentResolver;
18564
18565    struct FixtureSecretResolver {
18566        deny: Arc<AtomicBool>,
18567    }
18568
18569    impl NodeSecretResolver for FixtureSecretResolver {
18570        fn resolve(
18571            &self,
18572            _reference: &NodeSecretReference,
18573        ) -> Result<NodeSecretValue, NodeSecretResolveError> {
18574            if self.deny.load(Ordering::Acquire) {
18575                Err(NodeSecretResolveError::Denied)
18576            } else {
18577                NodeSecretValue::text("fixture-secret")
18578                    .map_err(|_| NodeSecretResolveError::Unavailable)
18579            }
18580        }
18581    }
18582
18583    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
18584        fn resolve_child_environment(
18585            &self,
18586        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
18587            Ok(vec![EnvMutation {
18588                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
18589                value: None,
18590            }])
18591        }
18592    }
18593
18594    fn agent(value: &str) -> AgentId {
18595        AgentId::new(value).unwrap()
18596    }
18597
18598    fn terminal_test_shared_with_mode(mode: WorktreeServiceMode) -> NodeShared {
18599        let catalog = active_registry().unwrap();
18600        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18601        let workspace = WorkspaceConfig::new(
18602            WorkspaceId::new("primary").unwrap(),
18603            std::env::current_dir().unwrap(),
18604        )
18605        .unwrap()
18606        .with_worktree_service_mode(mode);
18607        NodeShared::new(
18608            handle,
18609            "fixture-token".to_owned(),
18610            NodeId::new("node-terminal-test").unwrap(),
18611            vec![workspace],
18612            vec![agent("claude")],
18613        )
18614    }
18615
18616    fn terminal_test_shared() -> NodeShared {
18617        terminal_test_shared_with_mode(WorktreeServiceMode::Manual)
18618    }
18619
18620    #[cfg(feature = "fixture")]
18621    #[test]
18622    fn managed_worktree_v2_failure_probe_keeps_only_the_latest_code() {
18623        let probe = SpawnManagedWorktreeV2FailureProbe::default();
18624        assert_eq!(probe.latest_code(), None);
18625
18626        probe.record(Some(NodeFailureCode::ManagedWorktreeOwnershipConflict));
18627        assert_eq!(
18628            probe.latest_code(),
18629            Some(NodeFailureCode::ManagedWorktreeOwnershipConflict),
18630        );
18631
18632        probe.record(Some(NodeFailureCode::SpawnDeadlineExceeded));
18633        assert_eq!(
18634            probe.latest_code(),
18635            Some(NodeFailureCode::SpawnDeadlineExceeded),
18636        );
18637
18638        probe.record(None);
18639        assert_eq!(probe.latest_code(), None);
18640    }
18641
18642    #[test]
18643    fn launch_snapshot_is_authoritative_bounded_and_path_free() {
18644        let source = temporary_workspace_root("launch-inventory-source");
18645        let allocation = temporary_workspace_root("launch-inventory-allocation");
18646        std::fs::create_dir_all(&source).unwrap();
18647        std::fs::create_dir_all(&allocation).unwrap();
18648        let workspace_id = WorkspaceId::new("primary").unwrap();
18649        let mut workspace = WorkspaceConfig::new(workspace_id.clone(), &source)
18650            .unwrap()
18651            .with_worktree_service_mode(WorktreeServiceMode::Managed);
18652        for index in 0..MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE {
18653            workspace = workspace
18654                .with_managed_worktree_profile(
18655                    ManagedWorktreeProfile::new(
18656                        WorktreeProfileId::new(format!("profile-{index}")).unwrap(),
18657                        crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
18658                        &allocation,
18659                        "gate4agent",
18660                        "HEAD",
18661                        ManagedWorktreeRetention::RemoveWhenReleased,
18662                    )
18663                    .unwrap(),
18664                )
18665                .unwrap();
18666        }
18667        let overflow = workspace.clone().with_managed_worktree_profile(
18668            ManagedWorktreeProfile::new(
18669                WorktreeProfileId::new("overflow").unwrap(),
18670                crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
18671                &allocation,
18672                "gate4agent",
18673                "HEAD",
18674                ManagedWorktreeRetention::Retain,
18675            )
18676            .unwrap(),
18677        );
18678        assert!(matches!(
18679            overflow,
18680            Err(NodeServerError::ManagedWorktreeProfileCapacity {
18681                max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
18682                ..
18683            }),
18684        ));
18685
18686        let catalog = active_registry().unwrap();
18687        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18688        let shared = NodeShared::new(
18689            handle,
18690            "fixture-token".to_owned(),
18691            NodeId::new("node-launch-inventory").unwrap(),
18692            vec![workspace],
18693            vec![agent("claude")],
18694        );
18695        let snapshot = shared.snapshot();
18696        let launch = snapshot.launch_inventory.as_ref().unwrap();
18697        assert_eq!(launch.spawn_profiles.as_ref().unwrap().len(), 1);
18698        assert!(launch.bundles.as_ref().unwrap().is_empty());
18699        let profiles = snapshot.workspaces[0]
18700            .managed_worktree_profiles
18701            .as_ref()
18702            .unwrap();
18703        assert_eq!(
18704            snapshot.workspaces[0].worktree_service_mode,
18705            Some(WorktreeServiceMode::Managed),
18706        );
18707        assert_eq!(
18708            profiles.profiles.len(),
18709            MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
18710        );
18711        let public_inventory = serde_json::to_string(&(launch, profiles)).unwrap();
18712        for forbidden in ["allocation_root", "branch_prefix", "base", "prompt", "environment", "home"] {
18713            assert!(!public_inventory.contains(forbidden), "launch inventory leaked {forbidden}");
18714        }
18715        let legacy = snapshot_for_wire(
18716            &shared,
18717            true,
18718            true,
18719            false,
18720            false,
18721            true,
18722            false,
18723            true,
18724            false,
18725            true,
18726        );
18727        assert!(legacy.launch_inventory.is_none());
18728        assert!(legacy.workspaces[0].managed_worktree_profiles.is_none());
18729
18730        drop(shared);
18731        std::fs::remove_dir_all(source).unwrap();
18732        std::fs::remove_dir_all(allocation).unwrap();
18733    }
18734
18735    #[test]
18736    fn agent_progress_projection_omits_sensitive_provider_payloads_and_is_capability_gated() {
18737        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
18738            capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
18739        }));
18740        let source = gate4agent_types::ProviderSource {
18741            family: AdapterFamily::PtySemantic,
18742            binding: AdapterBinding::new(
18743                gate4agent_types::AdapterId::new("claude").unwrap(),
18744                "fixture/v1",
18745                gate4agent_types::AdapterVerification::SyntheticFixture,
18746            )
18747            .unwrap(),
18748        };
18749        let provider = ProviderSnapshot {
18750            sequence: 17,
18751            session: Some(ProviderSessionIdentity {
18752                key: ProviderSessionKey::SessionId,
18753                id: "PRIVATE_PROVIDER_SESSION".to_owned(),
18754                transcript_path: Some(r"C:\PRIVATE_PROVIDER_PATH".to_owned()),
18755            }),
18756            model: Some("PRIVATE_MODEL".to_owned()),
18757            tools: vec!["PRIVATE_TOOL_INVENTORY".to_owned()],
18758            completed_turns: 3,
18759            usage: gate4agent_types::TokenUsage {
18760                input_tokens: 10,
18761                output_tokens: 20,
18762                cache_read_tokens: 30,
18763                cache_write_tokens: 40,
18764                reasoning_tokens: 50,
18765                context_window: Some(200_000),
18766            },
18767            lead_activity: ProviderActivity::WaitingForInput,
18768            activity: ProviderActivity::WaitingForInput,
18769            current_prompt: Some("PRIVATE_CURRENT_PROMPT".to_owned()),
18770            active_tools: (0..9)
18771                .map(|index| gate4agent_types::ActiveProviderTool {
18772                    id: format!("private-tool-id-{index}"),
18773                    name: if index == 0 {
18774                        format!("  Read\u{0000}{}", "x".repeat(80))
18775                    } else {
18776                        format!("Tool-{index}")
18777                    },
18778                    input_json: "PRIVATE_TOOL_INPUT_JSON".to_owned(),
18779                })
18780                .collect(),
18781            interactions: vec![gate4agent_types::ProviderInteraction {
18782                id: gate4agent_types::ProviderInteractionId(17),
18783                source: source.clone(),
18784                provider_request_id: Some("PRIVATE_PROVIDER_REQUEST".to_owned()),
18785                interaction_kind: ProviderInteractionKind::Approval,
18786                tool_name: " Approve\u{0007}Tool ".to_owned(),
18787                prompt: "PRIVATE_INTERACTION_PROMPT".to_owned(),
18788                agent_id: Some("PRIVATE_INTERACTION_AGENT".to_owned()),
18789                resume_lead_activity: Some(ProviderActivity::Working),
18790                status: ProviderInteractionStatus::Pending,
18791            }],
18792            subagents: vec![gate4agent_types::ProviderSubagent {
18793                source: source.clone(),
18794                provider_agent_id: "PRIVATE_SUBAGENT_ID".to_owned(),
18795                agent_type: Some("PRIVATE_SUBAGENT_TYPE".to_owned()),
18796                description: Some("PRIVATE_SUBAGENT_DESCRIPTION".to_owned()),
18797            }],
18798            sources: vec![gate4agent_types::ProviderSourceCursor {
18799                source,
18800                sequence: 17,
18801                gap_count: 2,
18802                stale: true,
18803            }],
18804            last_event: Some(ProviderEvent::Error {
18805                message: "PRIVATE_PROVIDER_ERROR".to_owned(),
18806            }),
18807            gap_count: 2,
18808            stale: true,
18809        };
18810        let address = terminal_address(3);
18811        let entry = agent_progress_from_provider_snapshot(address.clone(), &provider).unwrap();
18812        assert_eq!(entry.address, address);
18813        assert_eq!(entry.progress.provider_sequence, 17);
18814        assert_eq!(entry.progress.active_tool_count, 9);
18815        assert_eq!(
18816            entry.progress.active_tool_labels.len(),
18817            MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS,
18818        );
18819        assert_eq!(entry.progress.subagent_count, 1);
18820        assert_eq!(entry.progress.last_event_kind, Some(AgentProgressEventKindV1::Error));
18821        assert!(entry.progress.truncated);
18822        let encoded = serde_json::to_string(&entry).unwrap();
18823        for forbidden in [
18824            "PRIVATE_PROVIDER_SESSION",
18825            "PRIVATE_PROVIDER_PATH",
18826            "PRIVATE_MODEL",
18827            "PRIVATE_TOOL_INVENTORY",
18828            "PRIVATE_CURRENT_PROMPT",
18829            "PRIVATE_TOOL_INPUT_JSON",
18830            "PRIVATE_PROVIDER_REQUEST",
18831            "PRIVATE_INTERACTION_PROMPT",
18832            "PRIVATE_INTERACTION_AGENT",
18833            "PRIVATE_SUBAGENT_ID",
18834            "PRIVATE_SUBAGENT_TYPE",
18835            "PRIVATE_SUBAGENT_DESCRIPTION",
18836            "PRIVATE_PROVIDER_ERROR",
18837            "input_json",
18838            "current_prompt",
18839            "session_id",
18840        ] {
18841            assert!(!encoded.contains(forbidden), "agent progress leaked {forbidden}");
18842        }
18843
18844        let snapshot = NodeSnapshot {
18845            node_id: NodeId::new("node-progress-test").unwrap(),
18846            enabled_providers: vec![agent("claude")],
18847            provider_runtime_statuses: ProviderRuntimeStatuses::default(),
18848            workspaces: Vec::new(),
18849            session_records: Vec::new(),
18850            managed_worktrees: Vec::new(),
18851            launch_inventory: None,
18852            agent_progress: vec![entry],
18853        };
18854        let mut legacy_reply = ResponseEnvelope {
18855            request_id: 1,
18856            result: Ok(NodeResponse::Snapshot {
18857                event_sequence: 0,
18858                controller: None,
18859                snapshot: snapshot.clone(),
18860            }),
18861        };
18862        project_response_without_agent_progress(&mut legacy_reply);
18863        let Ok(NodeResponse::Snapshot { snapshot: legacy, .. }) = legacy_reply.result else {
18864            panic!("agent progress projection changed response kind");
18865        };
18866        assert!(legacy.agent_progress.is_empty());
18867        assert_eq!(snapshot.agent_progress.len(), 1);
18868    }
18869
18870    fn terminal_address(generation: u64) -> SessionAddress {
18871        SessionAddress {
18872            workspace_id: WorkspaceId::new("primary").unwrap(),
18873            session: SessionKey {
18874                instance_id: AgentInstanceId(41),
18875                generation: SessionGeneration(generation),
18876            },
18877        }
18878    }
18879
18880    fn spawn_spec_fixture() -> SpawnSpec {
18881        SpawnSpec {
18882            target: crate::protocol::SpawnTarget {
18883                node_id: NodeId::new("node-terminal-test").unwrap(),
18884                workspace_id: WorkspaceId::new("primary").unwrap(),
18885                worktree_id: None,
18886            },
18887            profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
18888            expected_profile_revision: crate::protocol::SpawnProfileRevision::new("builtin-v1")
18889                .unwrap(),
18890            overrides: crate::protocol::SpawnOverrides::default(),
18891            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18892            idempotency_key: SpawnIdempotencyKey::new("spawn-spec-fixture").unwrap(),
18893            required_capabilities: SpawnRequiredCapabilities::default(),
18894        }
18895    }
18896
18897    fn environment_profile_test_shared(
18898    ) -> (NodeShared, NativeRuntime, ResolvedEnvironmentProfileReceipt) {
18899        let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
18900        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
18901            "local-claude-r1",
18902        )
18903        .unwrap();
18904        let spawn_profiles = SpawnProfileRegistry::new([
18905            crate::protocol::SpawnProfileDefaults {
18906                profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
18907                revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
18908                provider: agent("claude"),
18909                mode: SessionMode::Pty,
18910                terminal_size: gate4agent_types::TerminalSize {
18911                    rows: 24,
18912                    columns: 80,
18913                },
18914                prompt: None,
18915                bundle_id: None,
18916                context_id: None,
18917                environment_profile_id: Some(profile_id.clone()),
18918            },
18919        ])
18920        .unwrap();
18921        let catalog = active_registry().unwrap();
18922        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18923        let control = runtime.native_launch_profile_control();
18924        let node_profile = NodeEnvironmentProfile::new(
18925            profile_id.clone(),
18926            profile_revision.clone(),
18927            agent("claude"),
18928            [NativeLaunchProfile::new(
18929                NativeLaunchProfileId::new("local-claude-pty").unwrap(),
18930                agent("claude"),
18931                TransportKind::Pty,
18932                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
18933                Arc::new(EmptyEnvironmentResolver),
18934            )
18935            .unwrap()],
18936        )
18937        .unwrap();
18938        let (binding, native_profiles, _materialization) = node_profile.into_parts();
18939        for native_profile in native_profiles {
18940            runtime.upsert_native_launch_profile(native_profile).unwrap();
18941        }
18942        let workspace = WorkspaceConfig::new(
18943            WorkspaceId::new("primary").unwrap(),
18944            std::env::current_dir().unwrap(),
18945        )
18946        .unwrap();
18947        let shared = NodeShared::new_with_incarnation(
18948            handle,
18949            "fixture-token".to_owned(),
18950            NodeId::new("node-terminal-test").unwrap(),
18951            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
18952            vec![workspace],
18953            vec![agent("claude")],
18954            ProviderRuntimeStatuses::default(),
18955            None,
18956            Vec::new(),
18957            Vec::new(),
18958            spawn_profiles,
18959            Some(control),
18960            None,
18961            None,
18962            None,
18963            Vec::new(),
18964            Vec::new(),
18965            Vec::new(),
18966            Vec::new(),
18967            None,
18968            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
18969        );
18970        shared
18971            .environment_profiles
18972            .write()
18973            .unwrap_or_else(|poisoned| poisoned.into_inner())
18974            .insert(binding.id.clone(), binding);
18975        (
18976            shared,
18977            runtime,
18978            ResolvedEnvironmentProfileReceipt {
18979                profile_id,
18980                profile_revision,
18981                network_allowlist: None,
18982                browser_profile_id: None,
18983            },
18984        )
18985    }
18986
18987    fn materialization_test_shared(
18988        deny_secret: bool,
18989    ) -> (
18990        NodeShared,
18991        NativeRuntime,
18992        ResolvedEnvironmentProfileReceipt,
18993        PathBuf,
18994        Arc<AtomicBool>,
18995    ) {
18996        let workspace = WorkspaceConfig::new(
18997            WorkspaceId::new("primary").unwrap(),
18998            std::env::current_dir().unwrap(),
18999        )
19000        .unwrap();
19001        materialization_test_shared_with_workspace(deny_secret, workspace)
19002    }
19003
19004    fn materialization_test_shared_with_workspace(
19005        deny_secret: bool,
19006        workspace: WorkspaceConfig,
19007    ) -> (
19008        NodeShared,
19009        NativeRuntime,
19010        ResolvedEnvironmentProfileReceipt,
19011        PathBuf,
19012        Arc<AtomicBool>,
19013    ) {
19014        let root = temporary_workspace_root(if deny_secret {
19015            "materialization-denied"
19016        } else {
19017            "materialization-ready"
19018        });
19019        std::fs::create_dir_all(&root).unwrap();
19020        let materialization_root = root.join("session-environments");
19021        let deny = Arc::new(AtomicBool::new(deny_secret));
19022        let resolver = Arc::new(FixtureSecretResolver {
19023            deny: Arc::clone(&deny),
19024        });
19025        let materializer = SessionEnvironmentMaterializer::new(
19026            materialization_root,
19027            resolver,
19028        )
19029        .unwrap();
19030        let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
19031        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19032            "materialized-claude-r1",
19033        )
19034        .unwrap();
19035        let materialization = NodeSessionMaterializationProfile::new(
19036            vec![NodeSessionEnvironmentMutation::SetSecret {
19037                key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
19038                reference: NodeSecretReference::new("fixture-token").unwrap(),
19039            }],
19040            vec![NodeSessionPathBinding::new(
19041                "GATE4AGENT_PROVIDER_HOME",
19042                NodeSessionPathClass::ProviderHome,
19043            )
19044            .unwrap()],
19045            vec![NodeSessionFile::secret(
19046                NodeSessionPathClass::Config,
19047                "auth/token",
19048                NodeSecretReference::new("fixture-token").unwrap(),
19049            )
19050            .unwrap()],
19051        )
19052        .unwrap();
19053        let catalog = active_registry().unwrap();
19054        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19055        let control = runtime.native_launch_profile_control();
19056        let node_profile = NodeEnvironmentProfile::new_with_materialization(
19057            profile_id.clone(),
19058            profile_revision.clone(),
19059            agent("claude"),
19060            [NativeLaunchProfile::new(
19061                NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
19062                agent("claude"),
19063                TransportKind::Pty,
19064                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19065                Arc::new(EmptyEnvironmentResolver),
19066            )
19067            .unwrap()],
19068            Some(materialization),
19069        )
19070        .unwrap();
19071        let (binding, native_profiles, materialization) = node_profile.into_parts();
19072        for native_profile in native_profiles {
19073            runtime.upsert_native_launch_profile(native_profile).unwrap();
19074        }
19075        let shared = NodeShared::new_with_incarnation(
19076            handle,
19077            "fixture-token".to_owned(),
19078            NodeId::new("node-materialization-test").unwrap(),
19079            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19080            vec![workspace],
19081            vec![agent("claude")],
19082            ProviderRuntimeStatuses::default(),
19083            None,
19084            Vec::new(),
19085            Vec::new(),
19086            SpawnProfileRegistry::default(),
19087            Some(control),
19088            None,
19089            Some(materializer),
19090            None,
19091            Vec::new(),
19092            Vec::new(),
19093            Vec::new(),
19094            Vec::new(),
19095            None,
19096            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19097        );
19098        shared
19099            .environment_profiles
19100            .write()
19101            .unwrap_or_else(|poisoned| poisoned.into_inner())
19102            .insert(binding.id.clone(), binding);
19103        shared
19104            .environment_materialization_profiles
19105            .write()
19106            .unwrap_or_else(|poisoned| poisoned.into_inner())
19107            .insert(profile_id.clone(), materialization.unwrap());
19108        (
19109            shared,
19110            runtime,
19111            ResolvedEnvironmentProfileReceipt {
19112                profile_id,
19113                profile_revision,
19114                network_allowlist: None,
19115                browser_profile_id: None,
19116            },
19117            root,
19118            deny,
19119        )
19120    }
19121
19122    #[cfg(feature = "fixture")]
19123    #[derive(Clone, Copy)]
19124    enum CodexHomeProfile {
19125        Missing,
19126        WrongClass,
19127        Exact,
19128    }
19129
19130    #[cfg(feature = "fixture")]
19131    fn codex_bundle_test_shared(
19132        home_profile: CodexHomeProfile,
19133    ) -> (
19134        NodeShared,
19135        NativeRuntime,
19136        ResolvedEnvironmentProfileReceipt,
19137        NodeBundle,
19138        PathBuf,
19139    ) {
19140        const ROOT_MANIFEST: &[u8] = br#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"review-tools"}"#;
19141        const CLAUDE_MANIFEST: &[u8] =
19142            br#"{"name":"review-tools","version":"1.0.0","description":"Review helpers"}"#;
19143        const SKILL: &[u8] = b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n";
19144        const DIGEST: &str =
19145            "sha256:941f20868a6ef49b4329bf1bb1368515763f5b64d8279b05cd9700966083d707";
19146
19147        let (shared, mut runtime, _claude_receipt, root, _deny) =
19148            materialization_test_shared(false);
19149        let profile_id = SpawnEnvironmentProfileId::new(match home_profile {
19150            CodexHomeProfile::Missing => "codex-home-missing",
19151            CodexHomeProfile::WrongClass => "codex-home-wrong",
19152            CodexHomeProfile::Exact => "codex-home-exact",
19153        })
19154        .unwrap();
19155        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19156            "codex-home-r1",
19157        )
19158        .unwrap();
19159        let materialization = match home_profile {
19160            CodexHomeProfile::Missing => None,
19161            CodexHomeProfile::WrongClass | CodexHomeProfile::Exact => {
19162                Some(NodeSessionMaterializationProfile::new(
19163                    Vec::new(),
19164                    vec![NodeSessionPathBinding::new(
19165                        "CODEX_HOME",
19166                        if matches!(home_profile, CodexHomeProfile::Exact) {
19167                            NodeSessionPathClass::ProviderHome
19168                        } else {
19169                            NodeSessionPathClass::Config
19170                        },
19171                    )
19172                    .unwrap()],
19173                    Vec::new(),
19174                )
19175                .unwrap())
19176            }
19177        };
19178        let node_profile = NodeEnvironmentProfile::new_with_materialization(
19179            profile_id.clone(),
19180            profile_revision.clone(),
19181            agent("codex"),
19182            [NativeLaunchProfile::new(
19183                NativeLaunchProfileId::new(format!("{profile_id}-pty")).unwrap(),
19184                agent("codex"),
19185                TransportKind::Pty,
19186                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19187                Arc::new(EmptyEnvironmentResolver),
19188            )
19189            .unwrap()],
19190            materialization,
19191        )
19192        .unwrap();
19193        let (binding, native_profiles, materialization) = node_profile.into_parts();
19194        for native_profile in native_profiles {
19195            runtime.upsert_native_launch_profile(native_profile).unwrap();
19196        }
19197        shared
19198            .environment_profiles
19199            .write()
19200            .unwrap_or_else(|poisoned| poisoned.into_inner())
19201            .insert(binding.id.clone(), binding);
19202        if let Some(materialization) = materialization {
19203            shared
19204                .environment_materialization_profiles
19205                .write()
19206                .unwrap_or_else(|poisoned| poisoned.into_inner())
19207                .insert(profile_id.clone(), materialization);
19208        }
19209
19210        let bundle_source = root.join(format!("bundle-source-{profile_id}"));
19211        std::fs::create_dir_all(bundle_source.join(".claude-plugin")).unwrap();
19212        std::fs::create_dir_all(bundle_source.join("skills/review-code")).unwrap();
19213        std::fs::write(bundle_source.join("plugin.json"), ROOT_MANIFEST).unwrap();
19214        std::fs::write(
19215            bundle_source.join(".claude-plugin/plugin.json"),
19216            CLAUDE_MANIFEST,
19217        )
19218        .unwrap();
19219        std::fs::write(bundle_source.join("skills/review-code/SKILL.md"), SKILL).unwrap();
19220        crate::bundle_catalog::protect_bundle_source_tree_fixture(&bundle_source).unwrap();
19221        let bundle = NodeBundle::new(
19222            crate::protocol::SpawnBundleId::new("review-tools").unwrap(),
19223            crate::protocol::SpawnBundleRevision::new("review-tools-r1").unwrap(),
19224            crate::protocol::SpawnBundleDigest::new(DIGEST).unwrap(),
19225            &bundle_source,
19226        )
19227        .unwrap();
19228        *shared
19229            .bundle_catalog
19230            .write()
19231            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
19232            BundleCatalog::new([bundle.clone()]).unwrap();
19233        (
19234            shared,
19235            runtime,
19236            ResolvedEnvironmentProfileReceipt {
19237                profile_id,
19238                profile_revision,
19239                network_allowlist: None,
19240                browser_profile_id: None,
19241            },
19242            bundle,
19243            root,
19244        )
19245    }
19246
19247    #[cfg(feature = "fixture")]
19248    #[test]
19249    fn codex_bundle_preflight_rejects_missing_or_wrong_home_before_materialization() {
19250        for home_profile in [CodexHomeProfile::Missing, CodexHomeProfile::WrongClass] {
19251            let (shared, runtime, receipt, bundle, root) =
19252                codex_bundle_test_shared(home_profile);
19253            let error = shared
19254                .bundle_layout(
19255                    &agent("codex"),
19256                    SessionMode::Pty,
19257                    Some(&receipt),
19258                    &bundle,
19259                )
19260                .unwrap_err();
19261            assert_eq!(error.code, NodeFailureCode::BundleBindingMismatch);
19262            assert!(shared
19263                .materializations
19264                .lock()
19265                .unwrap_or_else(|poisoned| poisoned.into_inner())
19266                .is_empty());
19267            let materialization_root = root.join("session-environments");
19268            assert!(!std::fs::read_dir(&materialization_root)
19269                .unwrap()
19270                .filter_map(Result::ok)
19271                .any(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())));
19272            drop(shared);
19273            drop(runtime);
19274            let _ = std::fs::remove_dir_all(root);
19275        }
19276    }
19277
19278    #[cfg(feature = "fixture")]
19279    #[test]
19280    fn codex_bundle_exact_home_profile_materializes_only_the_skill_tree() {
19281        let (shared, runtime, receipt, bundle, root) =
19282            codex_bundle_test_shared(CodexHomeProfile::Exact);
19283        let address = SessionAddress {
19284            workspace_id: WorkspaceId::new("primary").unwrap(),
19285            session: SessionKey {
19286                instance_id: AgentInstanceId(310),
19287                generation: SessionGeneration(1),
19288            },
19289        };
19290        let (overlay, guard) = shared
19291            .prepare_session_materialization(
19292                &address,
19293                &agent("codex"),
19294                SessionMode::Pty,
19295                Some(&receipt),
19296                Some(&bundle.receipt()),
19297                None,
19298                None,
19299            )
19300            .unwrap()
19301            .unwrap();
19302        assert!(matches!(overlay, Some(PreparedNativeLaunchOverlay::Instance(_))));
19303        let ownership = shared
19304            .materializations
19305            .lock()
19306            .unwrap_or_else(|poisoned| poisoned.into_inner())
19307            .get(guard.id().unwrap())
19308            .unwrap()
19309            .clone();
19310        let (profile, _, _) = shared
19311            .materialization_profile(
19312                &agent("codex"),
19313                SessionMode::Pty,
19314                Some(&receipt),
19315                Some(&bundle.receipt()),
19316                None,
19317            )
19318            .unwrap()
19319            .unwrap();
19320        let environment = shared
19321            .session_environment_materializer
19322            .as_ref()
19323            .unwrap()
19324            .resolve_environment(&ownership, &profile)
19325            .unwrap();
19326        assert!(environment.iter().any(|mutation| {
19327            mutation.key == OsString::from("CODEX_HOME")
19328                && mutation.value.as_deref() == Some(ownership.provider_home().as_os_str())
19329        }));
19330        assert_eq!(
19331            std::fs::read(ownership.provider_home().join("skills/review-code/SKILL.md"))
19332                .unwrap(),
19333            b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n",
19334        );
19335        assert!(std::fs::read_dir(ownership.bundle_root())
19336            .unwrap()
19337            .next()
19338            .is_none());
19339        drop(guard);
19340        drop(shared);
19341        drop(runtime);
19342        let _ = std::fs::remove_dir_all(root);
19343    }
19344
19345    #[cfg(feature = "fixture")]
19346    #[test]
19347    fn codex_cold_record_resume_revalidates_the_isolated_skill_tree() {
19348        let (shared, runtime, receipt, bundle, root) =
19349            codex_bundle_test_shared(CodexHomeProfile::Exact);
19350        let bundle_receipt = bundle.receipt();
19351        let address = SessionAddress {
19352            workspace_id: WorkspaceId::new("primary").unwrap(),
19353            session: SessionKey {
19354                instance_id: AgentInstanceId(311),
19355                generation: SessionGeneration(1),
19356            },
19357        };
19358        let (_overlay, guard) = shared
19359            .prepare_session_materialization(
19360                &address,
19361                &agent("codex"),
19362                SessionMode::Pty,
19363                Some(&receipt),
19364                Some(&bundle_receipt),
19365                None,
19366                None,
19367            )
19368            .unwrap()
19369            .unwrap();
19370        let materialization_id = guard.id().unwrap().clone();
19371        let skill_path = shared
19372            .materializations
19373            .lock()
19374            .unwrap_or_else(|poisoned| poisoned.into_inner())
19375            .get(&materialization_id)
19376            .unwrap()
19377            .provider_home()
19378            .join("skills/review-code/SKILL.md");
19379        let record_id = shared
19380            .bind_spawn_session_with_materialization(
19381                &address,
19382                agent("codex"),
19383                SessionMode::Pty,
19384                ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap(),
19385                SpawnRecordPolicy::ProviderIdentityOnly,
19386                Some(receipt.clone()),
19387                Some(bundle_receipt.clone()),
19388                None,
19389                Some(materialization_id.clone()),
19390            )
19391            .unwrap()
19392            .unwrap();
19393        guard.retain();
19394        std::fs::write(skill_path, b"changed-after-record-bind").unwrap();
19395
19396        let error = match shared.resolve_record_materialization(
19397                &record_id,
19398                &agent("codex"),
19399                SessionMode::Pty,
19400                Some(&receipt),
19401                Some(&bundle_receipt),
19402                None,
19403            ) {
19404                Ok(_) => panic!("changed Codex skill tree unexpectedly revalidated"),
19405                Err(error) => error,
19406            };
19407        assert_eq!(error.code, NodeFailureCode::BundleMaterializationFailed);
19408        assert_eq!(
19409            shared
19410                .materializations
19411                .lock()
19412                .unwrap_or_else(|poisoned| poisoned.into_inner())
19413                .get(&materialization_id)
19414                .unwrap()
19415                .state(),
19416            MaterializationState::RecoveryRequired,
19417        );
19418        drop(shared);
19419        drop(runtime);
19420        let _ = std::fs::remove_dir_all(root);
19421    }
19422
19423    fn bind_record_owned_materialization_fixture(
19424        shared: &NodeShared,
19425        receipt: &ResolvedEnvironmentProfileReceipt,
19426        instance_id: u64,
19427    ) -> (SessionAddress, SessionRecordId, MaterializationId, PathBuf) {
19428        let address = SessionAddress {
19429            workspace_id: WorkspaceId::new("primary").unwrap(),
19430            session: SessionKey {
19431                instance_id: AgentInstanceId(instance_id),
19432                generation: SessionGeneration(1),
19433            },
19434        };
19435        let (overlay, materialization_guard) = shared
19436            .prepare_session_materialization(
19437                &address,
19438                &agent("claude"),
19439                SessionMode::Pty,
19440                Some(receipt),
19441                None,
19442                None,
19443                None,
19444            )
19445            .unwrap()
19446            .unwrap();
19447        let materialization_id = materialization_guard.id().unwrap().clone();
19448        let materialization_root = shared
19449            .materializations
19450            .lock()
19451            .unwrap_or_else(|poisoned| poisoned.into_inner())
19452            .get(&materialization_id)
19453            .unwrap()
19454            .root()
19455            .to_path_buf();
19456        let selection = shared
19457            .select_environment_profile(
19458                address.session.instance_id,
19459                &agent("claude"),
19460                SessionMode::Pty,
19461                Some(receipt),
19462            )
19463            .unwrap()
19464            .unwrap();
19465        assert!(shared
19466            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
19467            .unwrap()
19468            .is_none());
19469        let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
19470        let record_id = shared
19471            .bind_spawn_session_with_materialization(
19472                &address,
19473                agent("claude"),
19474                SessionMode::Pty,
19475                policy,
19476                SpawnRecordPolicy::ProviderIdentityOnly,
19477                Some(receipt.clone()),
19478                None,
19479                None,
19480                Some(materialization_id.clone()),
19481            )
19482            .unwrap()
19483            .unwrap();
19484        selection.retain();
19485        materialization_guard.retain();
19486        (address, record_id, materialization_id, materialization_root)
19487    }
19488
19489    #[test]
19490    fn child_environment_profile_capability_is_state_aware_and_legacy_safe() {
19491        let (shared, _runtime, environment_profile) = environment_profile_test_shared();
19492        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
19493            capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
19494        }));
19495        let support = node_compatibility_support_for_manifest(&[], &[]).unwrap();
19496        assert_eq!(support.state_schema.versions.maximum(), NODE_STATE_SCHEMA_V10);
19497        let mut spec = spawn_spec_fixture();
19498        spec.expected_profile_revision =
19499            crate::protocol::SpawnProfileRevision::new("test-r1").unwrap();
19500        assert!(matches!(
19501            &spec.overrides.environment_profile_id,
19502            crate::protocol::SpawnOverride::Inherit,
19503        ));
19504        assert!(!NodeRequest::SpawnSpec { spec: spec.clone() }
19505            .requires_child_environment_profile_capability());
19506        assert!(request_requires_child_environment_profile(
19507            &shared,
19508            &NodeRequest::SpawnSpec { spec: spec.clone() },
19509        ));
19510
19511        let address = terminal_address(1);
19512        shared.bind_session_with_policy(
19513            &address,
19514            ProviderRuntimePolicy::raw_pty(),
19515            Some(environment_profile.clone()),
19516        );
19517        assert!(request_requires_child_environment_profile(
19518            &shared,
19519            &NodeRequest::Input {
19520                session: address.clone(),
19521                text: "x".to_owned(),
19522            },
19523        ));
19524
19525        let mut record = record("claude", "profile-record");
19526        record.active_session = Some(address.clone());
19527        record.environment_profile = Some(environment_profile.clone());
19528        let record_id = record.record_id.clone();
19529        shared.insert_record(record.clone()).unwrap();
19530        assert!(request_requires_child_environment_profile(
19531            &shared,
19532            &NodeRequest::ResumeSessionRecord {
19533                record_id,
19534                terminal_size: gate4agent_types::TerminalSize {
19535                    rows: 24,
19536                    columns: 80,
19537                },
19538                initial_prompt: None,
19539            },
19540        ));
19541
19542        let snapshot = snapshot_for_wire(
19543            &shared, false, true, true, true, false, false, false, false, true,
19544        );
19545        assert_eq!(snapshot.session_records.len(), 1);
19546        assert!(snapshot.session_records[0].environment_profile.is_none());
19547        let event = project_event_without_child_environment_profile(NodeEventEnvelope {
19548            sequence: 1,
19549            event: NodeEvent::SessionRecordUpserted { record },
19550        });
19551        let NodeEvent::SessionRecordUpserted { record } = event.event else {
19552            panic!("record projection changed the event kind");
19553        };
19554        assert!(record.environment_profile.is_none());
19555
19556        let resolved = shared.resolve_spawn_spec(&spec).unwrap();
19557        let mut reply = ResponseEnvelope {
19558            request_id: 1,
19559            result: Ok(NodeResponse::SpawnSpecAccepted {
19560                receipt: resolved.receipt_with_environment(
19561                    shared.incarnation_id,
19562                    address,
19563                    Some(environment_profile),
19564                ),
19565            }),
19566        };
19567        project_response_without_child_environment_profile(&mut reply);
19568        assert_eq!(
19569            reply.result.unwrap_err().code,
19570            NodeFailureCode::UnsupportedCapability,
19571        );
19572    }
19573
19574    #[test]
19575    fn spawn_profile_environment_authority_is_exact_and_legacy_safe() {
19576        let (shared, _runtime, environment_profile) = environment_profile_test_shared();
19577        let snapshot = shared.snapshot();
19578        let profiles = snapshot
19579            .launch_inventory
19580            .as_ref()
19581            .and_then(|inventory| inventory.spawn_profiles.as_ref())
19582            .unwrap();
19583        assert_eq!(profiles.len(), 1);
19584        assert_eq!(profiles[0].environment_profile, Some(environment_profile));
19585        assert!(snapshot.requires_child_environment_profile_capability());
19586
19587        let mut legacy = snapshot.clone();
19588        clear_snapshot_child_environment_profiles(&mut legacy);
19589        assert!(legacy
19590            .launch_inventory
19591            .as_ref()
19592            .unwrap()
19593            .spawn_profiles
19594            .as_ref()
19595            .unwrap()[0]
19596            .environment_profile
19597            .is_none());
19598        assert!(!legacy.requires_child_environment_profile_capability());
19599
19600        let profile = shared.spawn_profiles.iter().next().unwrap().clone();
19601        let bindings = shared
19602            .environment_profiles
19603            .read()
19604            .unwrap_or_else(|poisoned| poisoned.into_inner())
19605            .clone();
19606        let mut provider_mismatch = bindings.clone();
19607        provider_mismatch
19608            .values_mut()
19609            .next()
19610            .unwrap()
19611            .provider = agent("codex");
19612        assert!(resolved_spawn_profile_summary(&profile, &provider_mismatch).is_none());
19613        let mut mode_mismatch = profile;
19614        mode_mismatch.mode = SessionMode::Inline;
19615        assert!(resolved_spawn_profile_summary(&mode_mismatch, &bindings).is_none());
19616
19617        shared
19618            .environment_profiles
19619            .write()
19620            .unwrap_or_else(|poisoned| poisoned.into_inner())
19621            .clear();
19622        assert!(shared
19623            .snapshot()
19624            .launch_inventory
19625            .unwrap()
19626            .spawn_profiles
19627            .unwrap()
19628            .is_empty());
19629    }
19630
19631    #[test]
19632    fn session_bundle_capability_is_state_aware_and_legacy_safe() {
19633        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
19634            capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
19635        }));
19636        let shared = terminal_test_shared();
19637        let bundle = ResolvedBundleReceipt {
19638            id: crate::protocol::SpawnBundleId::new("review-bundle").unwrap(),
19639            revision: crate::protocol::SpawnBundleRevision::new("r1").unwrap(),
19640            digest: crate::protocol::SpawnBundleDigest::new(format!(
19641                "sha256:{}",
19642                "0".repeat(64),
19643            ))
19644            .unwrap(),
19645        };
19646        let mut bundled = record("claude", "bundle-record");
19647        bundled.bundle = Some(bundle.clone());
19648        let record_id = bundled.record_id.clone();
19649        shared.insert_record(bundled.clone()).unwrap();
19650        assert!(request_requires_session_bundle(
19651            &shared,
19652            &NodeRequest::ForgetSessionRecord {
19653                record_id: record_id.clone(),
19654            },
19655        ));
19656
19657        let snapshot = snapshot_for_wire(
19658            &shared, false, true, true, true, true, false, false, false, true,
19659        );
19660        assert!(snapshot.session_records[0].bundle.is_none());
19661        let launch = snapshot.launch_inventory.as_ref().unwrap();
19662        assert!(launch.spawn_profiles.is_some());
19663        assert!(launch.bundles.is_none());
19664        let event = project_event_without_session_bundle(NodeEventEnvelope {
19665            sequence: 1,
19666            event: NodeEvent::SessionRecordUpserted { record: bundled },
19667        });
19668        let NodeEvent::SessionRecordUpserted { record } = event.event else {
19669            panic!("record projection changed the event kind");
19670        };
19671        assert!(record.bundle.is_none());
19672
19673        let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
19674        let mut reply = ResponseEnvelope {
19675            request_id: 1,
19676            result: Ok(NodeResponse::SpawnSpecAccepted {
19677                receipt: resolved.receipt_with_materialization(
19678                    shared.incarnation_id,
19679                    terminal_address(17),
19680                    None,
19681                    Some(bundle),
19682                    None,
19683                ),
19684            }),
19685        };
19686        project_response_without_session_bundle(&mut reply);
19687        assert_eq!(
19688            reply.result.unwrap_err().code,
19689            NodeFailureCode::UnsupportedCapability,
19690        );
19691    }
19692
19693    #[test]
19694    fn history_context_pack_capability_redacts_loaded_messages_and_legacy_metadata() {
19695        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
19696            capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
19697        }));
19698        let context_id = SpawnContextId::new("context-review").unwrap();
19699        let context = ResolvedContextPackReceipt {
19700            id: context_id.clone(),
19701            digest: crate::protocol::SpawnContextDigest::new(format!(
19702                "sha256:{}",
19703                "c".repeat(64),
19704            ))
19705            .unwrap(),
19706            lineage: ContextPackLineageReceipt {
19707                source_node_id: NodeId::new("node-source").unwrap(),
19708                source_session: terminal_address(1),
19709                source_provider: agent("kimi"),
19710            },
19711            source_message_count: 2,
19712            retained_message_count: 2,
19713            byte_len: 256,
19714            truncated: false,
19715        };
19716        assert!(context.is_valid());
19717        let mut bound_record = record("codex", "context-record");
19718        bound_record.context_id = Some(context_id);
19719        bound_record.context = Some(context.clone());
19720        let session = gate4agent_types::SessionSnapshot {
19721            instance_id: AgentInstanceId(91),
19722            agent_id: agent("kimi"),
19723            transport: TransportKind::Pty,
19724            generation: SessionGeneration(3),
19725            status: SessionStatus::Running,
19726            pending_operation: None,
19727            pending_input: None,
19728            process_id: Some(9001),
19729            terminal_size: None,
19730            terminal_frame: None,
19731            terminal_stale: None,
19732            session_options: None,
19733            capabilities: gate4agent_types::CapabilitySnapshot::default(),
19734            history: gate4agent_types::HistorySnapshot {
19735                pending: None,
19736                candidates: vec![HistoryCandidateSummary {
19737                    id: "candidate-opaque".to_owned(),
19738                    session_id_hint: "session-hint".to_owned(),
19739                    modified_at_unix_ms: Some(1),
19740                }],
19741                loaded_candidate_id: Some("candidate-opaque".to_owned()),
19742                loaded: Some(HistorySessionRecord {
19743                    session_id: "private-vendor-session".to_owned(),
19744                    title: Some("private title".to_owned()),
19745                    cwd: Some(r"C:\private\history-root".to_owned()),
19746                    model: Some("private-model".to_owned()),
19747                    message_count: 2,
19748                    completed_turn_count: None,
19749                    total_tokens: 19,
19750                    messages: vec![
19751                        gate4agent_types::HistoryMessageRecord {
19752                            role: gate4agent_types::HistoryMessageRole::User,
19753                            text: "F7_PRIVATE_HISTORY_MESSAGE".to_owned(),
19754                        },
19755                        gate4agent_types::HistoryMessageRecord {
19756                            role: gate4agent_types::HistoryMessageRole::Assistant,
19757                            text: "private answer".to_owned(),
19758                        },
19759                    ],
19760                }),
19761                last_error: None,
19762            },
19763            resume: gate4agent_types::ResumeSnapshot::default(),
19764            foreground: gate4agent_types::ForegroundSnapshot::default(),
19765            provider: gate4agent_types::ProviderSnapshot::default(),
19766            screen_state: gate4agent_types::PtyScreenState::default(),
19767        };
19768        let original = NodeSnapshot {
19769            node_id: NodeId::new("node-terminal-test").unwrap(),
19770            enabled_providers: vec![agent("kimi"), agent("codex")],
19771            provider_runtime_statuses: ProviderRuntimeStatuses::default(),
19772            workspaces: vec![WorkspaceSnapshot {
19773                workspace_id: WorkspaceId::new("primary").unwrap(),
19774                canonical_root: opaque_windows_path(
19775                    std::env::current_dir().unwrap().to_string_lossy().into_owned(),
19776                ),
19777                sessions: vec![session],
19778                worktree_service_mode: None,
19779                managed_worktree_profiles: None,
19780            }],
19781            session_records: vec![bound_record],
19782            managed_worktrees: Vec::new(),
19783            launch_inventory: None,
19784            agent_progress: Vec::new(),
19785        };
19786
19787        let mut negotiated = original.clone();
19788        project_snapshot_history_for_wire(&mut negotiated, true);
19789        assert_eq!(negotiated.workspaces[0].sessions[0].history.candidates.len(), 1);
19790        assert!(negotiated.workspaces[0].sessions[0].history.loaded.is_none());
19791        assert_eq!(negotiated.session_records[0].context.as_ref(), Some(&context));
19792        assert!(!serde_json::to_string(&negotiated)
19793            .unwrap()
19794            .contains("F7_PRIVATE_HISTORY_MESSAGE"));
19795
19796        let mut legacy = original;
19797        project_snapshot_history_for_wire(&mut legacy, false);
19798        clear_snapshot_context_packs(&mut legacy);
19799        assert_eq!(
19800            legacy.workspaces[0].sessions[0].history,
19801            gate4agent_types::HistorySnapshot::default(),
19802        );
19803        assert!(legacy.session_records[0].context_id.is_none());
19804        assert!(legacy.session_records[0].context.is_none());
19805        let encoded = serde_json::to_string(&legacy).unwrap();
19806        assert!(!encoded.contains("F7_PRIVATE_HISTORY_MESSAGE"));
19807        assert!(!encoded.contains("context-review"));
19808
19809        let mut reply = ResponseEnvelope {
19810            request_id: 7,
19811            result: Ok(NodeResponse::ContextPackExported { context }),
19812        };
19813        project_response_without_context_pack(&mut reply);
19814        assert_eq!(
19815            reply.result.unwrap_err().code,
19816            NodeFailureCode::UnsupportedCapability,
19817        );
19818    }
19819
19820    #[test]
19821    fn forgetting_unbound_context_pack_evicts_replay_but_retained_record_is_busy() {
19822        let shared = terminal_test_shared();
19823        let history = HistorySessionRecord {
19824            session_id: "fixture-history".to_owned(),
19825            title: None,
19826            cwd: None,
19827            model: None,
19828            message_count: 1,
19829            completed_turn_count: None,
19830            total_tokens: 0,
19831            messages: vec![gate4agent_types::HistoryMessageRecord {
19832                role: gate4agent_types::HistoryMessageRole::User,
19833                text: "bounded handoff".to_owned(),
19834            }],
19835        };
19836        let pack = NodeContextPack::export(
19837            ContextPackLineageReceipt {
19838                source_node_id: shared.node_id.clone(),
19839                source_session: terminal_address(1),
19840                source_provider: agent("claude"),
19841            },
19842            &history,
19843        )
19844        .unwrap();
19845        let context = shared
19846            .context_catalog
19847            .write()
19848            .unwrap_or_else(|poisoned| poisoned.into_inner())
19849            .insert(pack)
19850            .unwrap();
19851        let mut retained = record("codex", "context-retained-record");
19852        retained.context_id = Some(context.id.clone());
19853        retained.context = Some(context.clone());
19854        let record_id = retained.record_id.clone();
19855        shared.insert_record(retained).unwrap();
19856        assert_eq!(
19857            shared.forget_context_pack(&context.id).unwrap_err().code,
19858            NodeFailureCode::ContextPackBusy,
19859        );
19860        assert!(shared
19861            .context_catalog
19862            .read()
19863            .unwrap_or_else(|poisoned| poisoned.into_inner())
19864            .get(&context.id)
19865            .is_some());
19866        shared
19867            .session_records
19868            .lock()
19869            .unwrap_or_else(|poisoned| poisoned.into_inner())
19870            .records
19871            .remove(&record_id);
19872
19873        let mut spec = spawn_spec_fixture();
19874        spec.overrides.context_id = crate::protocol::SpawnOverride::Set {
19875            value: context.id.clone(),
19876        };
19877        let resolved = shared.resolve_spawn_spec(&spec).unwrap();
19878        let receipt = resolved.receipt_with_materialization(
19879            shared.incarnation_id,
19880            terminal_address(2),
19881            None,
19882            None,
19883            Some(context.clone()),
19884        );
19885        assert!(receipt.context_binding_is_valid());
19886        shared.remember_spawn_spec(spec.clone(), Ok(receipt), Instant::now());
19887
19888        shared.forget_context_pack(&context.id).unwrap();
19889        assert!(shared
19890            .context_catalog
19891            .read()
19892            .unwrap_or_else(|poisoned| poisoned.into_inner())
19893            .get(&context.id)
19894            .is_none());
19895        assert!(shared
19896            .replay_spawn_spec(&spec, Instant::now())
19897            .unwrap()
19898            .is_none());
19899    }
19900
19901    #[test]
19902    fn environment_profile_selection_is_owned_by_the_exact_session_binding() {
19903        let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
19904        let address = terminal_address(1);
19905        let selection = shared
19906            .select_environment_profile(
19907                address.session.instance_id,
19908                &agent("claude"),
19909                SessionMode::Pty,
19910                Some(&environment_profile),
19911            )
19912            .unwrap()
19913            .unwrap();
19914        shared.bind_session_with_policy(
19915            &address,
19916            ProviderRuntimePolicy::raw_pty(),
19917            Some(environment_profile.clone()),
19918        );
19919        selection.retain();
19920        let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
19921        assert!(matches!(
19922            runtime.remove_native_launch_profile(&native_id),
19923            Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
19924        ));
19925        assert!(shared.remove_binding(&address).is_some());
19926        assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
19927
19928        let mismatched = ResolvedEnvironmentProfileReceipt {
19929            profile_id: environment_profile.profile_id,
19930            profile_revision: crate::protocol::SpawnEnvironmentProfileRevision::new(
19931                "local-claude-r2",
19932            )
19933            .unwrap(),
19934            network_allowlist: None,
19935            browser_profile_id: None,
19936        };
19937        let error = match shared.select_environment_profile(
19938                AgentInstanceId(42),
19939                &agent("claude"),
19940                SessionMode::Pty,
19941                Some(&mismatched),
19942            ) {
19943            Ok(_) => panic!("mismatched environment profile revision was selected"),
19944            Err(error) => error,
19945        };
19946        assert_eq!(
19947            error.code,
19948            NodeFailureCode::EnvironmentProfileBindingMismatch,
19949        );
19950        assert!(!shared
19951            .session_bindings
19952            .lock()
19953            .unwrap_or_else(|poisoned| poisoned.into_inner())
19954            .contains_key(&AgentInstanceId(42)));
19955    }
19956
19957    #[test]
19958    fn combined_environment_and_bundle_overlay_clears_with_its_binding() {
19959        let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
19960        let address = terminal_address(1);
19961        let selection = shared
19962            .select_environment_profile(
19963                address.session.instance_id,
19964                &agent("claude"),
19965                SessionMode::Pty,
19966                Some(&environment_profile),
19967            )
19968            .unwrap()
19969            .unwrap();
19970        let overlay = NativeInstanceLaunchOverlay::new(
19971            agent("claude"),
19972            TransportKind::Pty,
19973            vec![EnvMutation {
19974                key: OsString::from("GATE4AGENT_SESSION_TOKEN"),
19975                value: Some(OsString::from("fixture-secret")),
19976            }],
19977            vec![
19978                OsString::from("--plugin-dir"),
19979                std::env::current_dir().unwrap().into_os_string(),
19980            ],
19981        )
19982        .unwrap();
19983        let instance_overlay = shared
19984            .install_prepared_launch_overlay(
19985                address.session.instance_id,
19986                PreparedNativeLaunchOverlay::Instance(overlay),
19987            )
19988            .unwrap()
19989            .unwrap();
19990        let bundle = ResolvedBundleReceipt {
19991            id: crate::protocol::SpawnBundleId::new("combined-bundle").unwrap(),
19992            revision: crate::protocol::SpawnBundleRevision::new("combined-r1").unwrap(),
19993            digest: crate::protocol::SpawnBundleDigest::new(format!(
19994                "sha256:{}",
19995                "0".repeat(64),
19996            ))
19997            .unwrap(),
19998        };
19999        shared.bind_session_with_materialization(
20000            &address,
20001            ProviderRuntimePolicy::raw_pty(),
20002            Some(environment_profile),
20003            Some(bundle),
20004            None,
20005            None,
20006        );
20007        selection.retain();
20008        instance_overlay.retain();
20009
20010        let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
20011        assert!(matches!(
20012            runtime.remove_native_launch_profile(&native_id),
20013            Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
20014        ));
20015        assert!(shared.remove_binding(&address).is_some());
20016        assert!(!shared
20017            .native_launch_profile_control
20018            .as_ref()
20019            .unwrap()
20020            .clear_native_instance_launch_overlay(address.session.instance_id));
20021        assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
20022    }
20023
20024    #[test]
20025    fn session_environment_materialization_failure_is_pre_child_and_durable() {
20026        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
20027        let address = SessionAddress {
20028            workspace_id: WorkspaceId::new("primary").unwrap(),
20029            session: SessionKey {
20030                instance_id: AgentInstanceId(201),
20031                generation: SessionGeneration(1),
20032            },
20033        };
20034        let error = match shared.prepare_session_materialization(
20035            &address,
20036            &agent("claude"),
20037            SessionMode::Pty,
20038            Some(&receipt),
20039            None,
20040            None,
20041            None,
20042        ) {
20043            Ok(_) => panic!("denied local reference unexpectedly materialized"),
20044            Err(error) => error,
20045        };
20046        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
20047        assert!(shared.handle.snapshot().sessions.is_empty());
20048        assert!(shared
20049            .session_bindings
20050            .lock()
20051            .unwrap_or_else(|poisoned| poisoned.into_inner())
20052            .is_empty());
20053        assert!(shared
20054            .materializations
20055            .lock()
20056            .unwrap_or_else(|poisoned| poisoned.into_inner())
20057            .is_empty());
20058        drop(shared);
20059        drop(runtime);
20060        std::fs::remove_dir_all(root).unwrap();
20061    }
20062
20063    #[test]
20064    fn session_environment_binding_retains_on_stop_and_cleans_after_remove_reap() {
20065        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20066        let address = SessionAddress {
20067            workspace_id: WorkspaceId::new("primary").unwrap(),
20068            session: SessionKey {
20069                instance_id: AgentInstanceId(202),
20070                generation: SessionGeneration(1),
20071            },
20072        };
20073        let (overlay, materialization_guard) = shared
20074            .prepare_session_materialization(
20075                &address,
20076                &agent("claude"),
20077                SessionMode::Pty,
20078                Some(&receipt),
20079                None,
20080                None,
20081                None,
20082            )
20083            .unwrap()
20084            .unwrap();
20085        let materialization_id = materialization_guard.id().unwrap().clone();
20086        let materialization_root = shared
20087            .materializations
20088            .lock()
20089            .unwrap_or_else(|poisoned| poisoned.into_inner())
20090            .get(&materialization_id)
20091            .unwrap()
20092            .root()
20093            .to_path_buf();
20094        let selection = shared
20095            .select_environment_profile(
20096                address.session.instance_id,
20097                &agent("claude"),
20098                SessionMode::Pty,
20099                Some(&receipt),
20100            )
20101            .unwrap()
20102            .unwrap();
20103        assert!(shared
20104            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
20105            .unwrap()
20106            .is_none());
20107        shared.bind_session_with_materialization(
20108            &address,
20109            ProviderRuntimePolicy::raw_pty(),
20110            Some(receipt),
20111            None,
20112            None,
20113            Some(materialization_id),
20114        );
20115        selection.retain();
20116        materialization_guard.retain();
20117
20118        assert!(materialization_root.is_dir());
20119        assert!(shared
20120            .session_bindings
20121            .lock()
20122            .unwrap_or_else(|poisoned| poisoned.into_inner())
20123            .contains_key(&address.session.instance_id));
20124
20125        let removed = shared.remove_binding(&address).unwrap();
20126        shared
20127            .cleanup_session_owned_materialization(&address, &removed)
20128            .unwrap();
20129        assert!(!materialization_root.exists());
20130        assert!(shared
20131            .materializations
20132            .lock()
20133            .unwrap_or_else(|poisoned| poisoned.into_inner())
20134            .is_empty());
20135        drop(shared);
20136        drop(runtime);
20137        std::fs::remove_dir_all(root).unwrap();
20138    }
20139
20140    #[tokio::test]
20141    async fn record_owned_session_environment_reuses_on_resume_and_forget_cleans() {
20142        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20143        let address = SessionAddress {
20144            workspace_id: WorkspaceId::new("primary").unwrap(),
20145            session: SessionKey {
20146                instance_id: AgentInstanceId(203),
20147                generation: SessionGeneration(1),
20148            },
20149        };
20150        let (overlay, materialization_guard) = shared
20151            .prepare_session_materialization(
20152                &address,
20153                &agent("claude"),
20154                SessionMode::Pty,
20155                Some(&receipt),
20156                None,
20157                None,
20158                None,
20159            )
20160            .unwrap()
20161            .unwrap();
20162        let materialization_id = materialization_guard.id().unwrap().clone();
20163        let materialization_root = shared
20164            .materializations
20165            .lock()
20166            .unwrap_or_else(|poisoned| poisoned.into_inner())
20167            .get(&materialization_id)
20168            .unwrap()
20169            .root()
20170            .to_path_buf();
20171        let selection = shared
20172            .select_environment_profile(
20173                address.session.instance_id,
20174                &agent("claude"),
20175                SessionMode::Pty,
20176                Some(&receipt),
20177            )
20178            .unwrap()
20179            .unwrap();
20180        assert!(shared
20181            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
20182            .unwrap()
20183            .is_none());
20184        let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
20185        let record_id = shared
20186            .bind_spawn_session_with_materialization(
20187                &address,
20188                agent("claude"),
20189                SessionMode::Pty,
20190                policy,
20191                SpawnRecordPolicy::ProviderIdentityOnly,
20192                Some(receipt.clone()),
20193                None,
20194                None,
20195                Some(materialization_id.clone()),
20196            )
20197            .unwrap()
20198            .unwrap();
20199        selection.retain();
20200        materialization_guard.retain();
20201        assert_eq!(
20202            shared
20203                .materializations
20204                .lock()
20205                .unwrap_or_else(|poisoned| poisoned.into_inner())
20206                .get(&materialization_id)
20207                .unwrap()
20208                .owner(),
20209            &MaterializationOwner::Record {
20210                record_id: record_id.clone(),
20211            },
20212        );
20213
20214        let removed = shared.remove_binding(&address).unwrap();
20215        shared
20216            .cleanup_session_owned_materialization(&address, &removed)
20217            .unwrap();
20218        assert!(materialization_root.is_dir());
20219        assert!(shared
20220            .resolve_record_materialization(
20221                &record_id,
20222                &agent("claude"),
20223                SessionMode::Pty,
20224                Some(&receipt),
20225                None,
20226                None,
20227            )
20228            .unwrap()
20229            .is_some());
20230        {
20231            let mut records = shared
20232                .session_records
20233                .lock()
20234                .unwrap_or_else(|poisoned| poisoned.into_inner());
20235            let record = records.records.get_mut(&record_id).unwrap();
20236            record.active_session = None;
20237            record.state = ManagedSessionState::Unavailable;
20238        }
20239        shared.forget_session_record(&record_id).await.unwrap();
20240        assert!(!materialization_root.exists());
20241        assert!(shared
20242            .materializations
20243            .lock()
20244            .unwrap_or_else(|poisoned| poisoned.into_inner())
20245            .is_empty());
20246        drop(shared);
20247        drop(runtime);
20248        std::fs::remove_dir_all(root).unwrap();
20249    }
20250
20251    #[test]
20252    fn record_materialization_revalidation_failure_persists_recovery_required() {
20253        let (mut shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20254        let state_path = root.join("node-state-v6.json");
20255        shared.state_path = Some(state_path.clone());
20256        let (_address, _record_id, materialization_id, materialization_root) =
20257            bind_record_owned_materialization_fixture(&shared, &receipt, 205);
20258        std::fs::write(
20259            materialization_root.join(".gate4agent-materialization-owner"),
20260            b"tampered",
20261        )
20262        .unwrap();
20263
20264        let error = match shared.resolve_record_materialization(
20265            &_record_id,
20266            &agent("claude"),
20267            SessionMode::Pty,
20268            Some(&receipt),
20269            None,
20270            None,
20271        ) {
20272            Ok(_) => panic!("tampered owner marker unexpectedly revalidated"),
20273            Err(error) => error,
20274        };
20275        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
20276        assert_eq!(
20277            shared
20278                .materializations
20279                .lock()
20280                .unwrap_or_else(|poisoned| poisoned.into_inner())
20281                .get(&materialization_id)
20282                .unwrap()
20283                .state(),
20284            MaterializationState::RecoveryRequired,
20285        );
20286        let loaded = session_registry::load(Some(&state_path), &shared.node_id).unwrap();
20287        assert_eq!(loaded.materializations.len(), 1);
20288        assert_eq!(
20289            loaded.materializations[0].state(),
20290            MaterializationState::RecoveryRequired,
20291        );
20292        drop(shared);
20293        drop(runtime);
20294        std::fs::remove_dir_all(root).unwrap();
20295    }
20296
20297    #[test]
20298    fn transient_record_materialization_resolution_failure_remains_ready() {
20299        let (shared, runtime, receipt, root, deny) = materialization_test_shared(false);
20300        let (_address, record_id, materialization_id, _materialization_root) =
20301            bind_record_owned_materialization_fixture(&shared, &receipt, 206);
20302        deny.store(true, Ordering::Release);
20303
20304        let error = match shared.resolve_record_materialization(
20305            &record_id,
20306            &agent("claude"),
20307            SessionMode::Pty,
20308            Some(&receipt),
20309            None,
20310            None,
20311        ) {
20312            Ok(_) => panic!("denied transient resolver unexpectedly succeeded"),
20313            Err(error) => error,
20314        };
20315        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
20316        assert_eq!(
20317            shared
20318                .materializations
20319                .lock()
20320                .unwrap_or_else(|poisoned| poisoned.into_inner())
20321                .get(&materialization_id)
20322                .unwrap()
20323                .state(),
20324            MaterializationState::Ready,
20325        );
20326        drop(shared);
20327        drop(runtime);
20328        std::fs::remove_dir_all(root).unwrap();
20329    }
20330
20331    #[test]
20332    fn startup_missing_record_materialization_is_not_resumable() {
20333        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20334        let record_id = SessionRecordId::new("sr-missing-materialization").unwrap();
20335        shared
20336            .insert_record(ManagedSessionRecord {
20337                record_id: record_id.clone(),
20338                display_name: "missing materialization".to_owned(),
20339                provider: agent("claude"),
20340                mode: SessionMode::Pty,
20341                state: ManagedSessionState::Dormant,
20342                workspace_id: WorkspaceId::new("primary").unwrap(),
20343                canonical_root: opaque_windows_path(
20344                    std::env::current_dir().unwrap().to_string_lossy().into_owned(),
20345                ),
20346                provider_session: Some(gate4agent_types::ProviderSessionIdentity {
20347                    key: gate4agent_types::ProviderSessionKey::SessionId,
20348                    id: "provider-session-missing-materialization".to_owned(),
20349                    transcript_path: None,
20350                }),
20351                active_session: None,
20352                environment_profile: Some(receipt),
20353                bundle: None,
20354                context_id: None,
20355                context: None,
20356                exported_context: None,
20357                task_binding: None,
20358                created_at_unix_ms: 1,
20359                updated_at_unix_ms: 1,
20360                last_error: None,
20361            })
20362            .unwrap();
20363
20364        shared.reconcile_materializations();
20365        let record = shared.record(&record_id).unwrap();
20366        assert_eq!(record.state, ManagedSessionState::Unavailable);
20367        assert!(record.provider_session.is_none());
20368        assert_eq!(
20369            record.last_error.as_deref(),
20370            Some("environment-profile-unavailable"),
20371        );
20372        drop(shared);
20373        drop(runtime);
20374        std::fs::remove_dir_all(root).unwrap();
20375    }
20376
20377    #[tokio::test]
20378    async fn managed_worktree_cleanup_waits_for_session_environment_cleanup() {
20379        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20380        let lease = install_managed_lease(&shared);
20381        let address = SessionAddress {
20382            workspace_id: WorkspaceId::new("primary").unwrap(),
20383            session: SessionKey {
20384                instance_id: AgentInstanceId(204),
20385                generation: SessionGeneration(1),
20386            },
20387        };
20388        let (overlay, materialization_guard) = shared
20389            .prepare_session_materialization(
20390                &address,
20391                &agent("claude"),
20392                SessionMode::Pty,
20393                Some(&receipt),
20394                None,
20395                None,
20396                Some(lease.lease_id.clone()),
20397            )
20398            .unwrap()
20399            .unwrap();
20400        let materialization_id = materialization_guard.id().unwrap().clone();
20401        let materialization_root = shared
20402            .materializations
20403            .lock()
20404            .unwrap_or_else(|poisoned| poisoned.into_inner())
20405            .get(&materialization_id)
20406            .unwrap()
20407            .root()
20408            .to_path_buf();
20409        let selection = shared
20410            .select_environment_profile(
20411                address.session.instance_id,
20412                &agent("claude"),
20413                SessionMode::Pty,
20414                Some(&receipt),
20415            )
20416            .unwrap()
20417            .unwrap();
20418        assert!(shared
20419            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
20420            .unwrap()
20421            .is_none());
20422        shared.bind_session_with_materialization(
20423            &address,
20424            ProviderRuntimePolicy::raw_pty(),
20425            Some(receipt),
20426            None,
20427            None,
20428            Some(materialization_id),
20429        );
20430        selection.retain();
20431        materialization_guard.retain();
20432
20433        let blocked = shared
20434            .cleanup_managed_worktree(&lease.lease_id, false)
20435            .await
20436            .unwrap_err();
20437        assert_eq!(blocked.code, NodeFailureCode::ManagedWorktreeRecoveryRequired);
20438        assert!(materialization_root.is_dir());
20439        let removed = shared.remove_binding(&address).unwrap();
20440        shared
20441            .cleanup_session_owned_materialization(&address, &removed)
20442            .unwrap();
20443        assert!(!materialization_root.exists());
20444        drop(shared);
20445        drop(runtime);
20446        std::fs::remove_dir_all(root).unwrap();
20447    }
20448
20449    #[tokio::test]
20450    async fn raw_managed_record_is_inventory_only_and_does_not_block_session_cleanup() {
20451        let git_root = temporary_workspace_root("raw-managed-inventory");
20452        let source = git_root.join("source");
20453        let allocation = git_root.join("allocation");
20454        std::fs::create_dir_all(&source).unwrap();
20455        std::fs::create_dir_all(&allocation).unwrap();
20456        run_test_git(&source, &["init"]);
20457        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
20458        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
20459        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
20460        run_test_git(&source, &["add", "seed.txt"]);
20461        run_test_git(&source, &["commit", "-m", "seed"]);
20462        let profile = ManagedWorktreeProfile::new(
20463            WorktreeProfileId::new("default").unwrap(),
20464            crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
20465            &allocation,
20466            "gate4agent",
20467            "HEAD",
20468            ManagedWorktreeRetention::RemoveWhenReleased,
20469        )
20470        .unwrap();
20471        let workspace = WorkspaceConfig::new(
20472            WorkspaceId::new("primary").unwrap(),
20473            &source,
20474        )
20475        .unwrap()
20476        .with_worktree_service_mode(WorktreeServiceMode::Managed)
20477        .with_managed_worktree_profile(profile.clone())
20478        .unwrap();
20479        let (shared, mut runtime, receipt, materialization_fixture_root, _deny) =
20480            materialization_test_shared_with_workspace(false, workspace);
20481        let target = PathBuf::from(profile.allocation_root()).join("mw-raw-inventory");
20482        let source_text = source.to_string_lossy().into_owned();
20483        let target_text = target.to_string_lossy().into_owned();
20484        let created = create_git_worktree(
20485            &source_text,
20486            &target_text,
20487            "gate4agent/mw-raw-inventory",
20488            Some("HEAD"),
20489        )
20490        .await
20491        .unwrap();
20492        let lease = ManagedWorktreeLeaseRecord {
20493            lease_id: ManagedWorktreeLeaseId::new("mw-raw-inventory").unwrap(),
20494            source_workspace_id: WorkspaceId::new("primary").unwrap(),
20495            workspace_id: WorkspaceId::new("managed-raw-inventory").unwrap(),
20496            profile_id: profile.profile_id().clone(),
20497            profile_revision: profile.revision().clone(),
20498            target_root: created.path.clone(),
20499            branch: "gate4agent/mw-raw-inventory".to_owned(),
20500            base_commit: created.head.clone(),
20501            expected_head: Some(created.head.clone()),
20502            retention: ManagedWorktreeRetention::RemoveWhenReleased,
20503            state: ManagedWorktreeLeaseState::Ready,
20504            session_holders: Vec::new(),
20505            record_holders: Vec::new(),
20506            cleanup_failure: None,
20507            created_at_unix_ms: 1,
20508            updated_at_unix_ms: 1,
20509        };
20510        shared
20511            .workspaces
20512            .write()
20513            .unwrap_or_else(|poisoned| poisoned.into_inner())
20514            .insert(lease.workspace_id.clone(), created.path.clone());
20515        *shared
20516            .managed_worktrees
20517            .lock()
20518            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
20519            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
20520        let address = SessionAddress {
20521            workspace_id: lease.workspace_id.clone(),
20522            session: SessionKey {
20523                instance_id: AgentInstanceId(205),
20524                generation: SessionGeneration(1),
20525            },
20526        };
20527        let (overlay, materialization_guard) = shared
20528            .prepare_session_materialization(
20529                &address,
20530                &agent("claude"),
20531                SessionMode::Pty,
20532                Some(&receipt),
20533                None,
20534                None,
20535                Some(lease.lease_id.clone()),
20536            )
20537            .unwrap()
20538            .unwrap();
20539        let materialization_id = materialization_guard.id().unwrap().clone();
20540        let materialization_root = shared
20541            .materializations
20542            .lock()
20543            .unwrap_or_else(|poisoned| poisoned.into_inner())
20544            .get(&materialization_id)
20545            .unwrap()
20546            .root()
20547            .to_path_buf();
20548        let selection = shared
20549            .select_environment_profile(
20550                address.session.instance_id,
20551                &agent("claude"),
20552                SessionMode::Pty,
20553                Some(&receipt),
20554            )
20555            .unwrap()
20556            .unwrap();
20557        assert!(shared
20558            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
20559            .unwrap()
20560            .is_none());
20561        let record_id = shared
20562            .bind_spawn_session_with_materialization(
20563                &address,
20564                agent("claude"),
20565                SessionMode::Pty,
20566                ProviderRuntimePolicy::raw_pty(),
20567                SpawnRecordPolicy::Always,
20568                Some(receipt.clone()),
20569                None,
20570                None,
20571                Some(materialization_id.clone()),
20572            )
20573            .unwrap()
20574            .unwrap();
20575        selection.retain();
20576        materialization_guard.retain();
20577        let record = shared.record(&record_id).unwrap();
20578        assert_eq!(record.state, ManagedSessionState::Live);
20579        assert!(record.provider_session.is_none());
20580        assert!(record.environment_profile.is_none());
20581        assert!(record.bundle.is_none());
20582        assert!(record.context_id.is_none());
20583        assert!(record.context.is_none());
20584        assert_eq!(record.active_session.as_ref(), Some(&address));
20585        assert_eq!(
20586            shared
20587                .materializations
20588                .lock()
20589                .unwrap_or_else(|poisoned| poisoned.into_inner())
20590                .get(&materialization_id)
20591                .unwrap()
20592                .owner(),
20593            &MaterializationOwner::Session {
20594                incarnation_id: shared.incarnation_id,
20595                instance_id: address.session.instance_id,
20596                generation: address.session.generation,
20597            },
20598        );
20599        {
20600            let mut bindings = shared
20601                .session_bindings
20602                .lock()
20603                .unwrap_or_else(|poisoned| poisoned.into_inner());
20604            bindings
20605                .get_mut(&address.session.instance_id)
20606                .unwrap()
20607                .managed_worktree_lease_id = Some(lease.lease_id.clone());
20608        }
20609        let lease_snapshot = shared
20610            .managed_worktrees
20611            .lock()
20612            .unwrap_or_else(|poisoned| poisoned.into_inner())
20613            .bind_session(
20614                &lease.lease_id,
20615                ManagedWorktreeSessionHolder {
20616                    incarnation_id: shared.incarnation_id,
20617                    instance_id: address.session.instance_id,
20618                    generation: address.session.generation,
20619                },
20620                None,
20621                unix_time_ms(),
20622            )
20623            .unwrap();
20624        assert_eq!(lease_snapshot.active_session_count, 1);
20625        assert_eq!(lease_snapshot.managed_record_count, 0);
20626        shared.persist_state().unwrap();
20627        assert!(shared
20628            .managed_worktrees
20629            .lock()
20630            .unwrap_or_else(|poisoned| poisoned.into_inner())
20631            .get(&lease.lease_id)
20632            .unwrap()
20633            .record_holders
20634            .is_empty());
20635        shared
20636            .handle
20637            .dispatch(shared.prepare_command(ControlCommand::Register {
20638                instance_id: address.session.instance_id,
20639                agent_id: agent("claude"),
20640                transport: TransportKind::Pty,
20641            }))
20642            .unwrap();
20643        runtime.tick().await;
20644        shared.reconcile_managed_record(
20645            &record_id,
20646            &address,
20647            &ControlEvent {
20648                sequence: 1,
20649                command_id: None,
20650                instance_id: address.session.instance_id,
20651                generation: address.session.generation,
20652                event: ControlEventKind::Removed,
20653            },
20654            false,
20655            false,
20656        );
20657        let remove = shared.remove_session(&address);
20658        let drive_runtime = async {
20659            runtime.tick().await;
20660        };
20661        let (removed, ()) = tokio::join!(remove, drive_runtime);
20662        removed.unwrap();
20663
20664        assert!(!materialization_root.exists());
20665        assert!(!Path::new(&created.path).exists());
20666        assert_eq!(
20667            shared
20668                .managed_worktrees
20669                .lock()
20670                .unwrap_or_else(|poisoned| poisoned.into_inner())
20671                .get(&lease.lease_id)
20672                .unwrap()
20673                .state,
20674            ManagedWorktreeLeaseState::Removed,
20675        );
20676        let renamed = shared
20677            .rename_session_record(&record_id, "raw managed inventory".to_owned())
20678            .unwrap();
20679        assert_eq!(renamed.record_id, record_id);
20680        assert_eq!(renamed.display_name, "raw managed inventory");
20681        assert_eq!(renamed.state, ManagedSessionState::Unavailable);
20682        assert!(renamed.active_session.is_none());
20683
20684        drop(shared);
20685        drop(runtime);
20686        std::fs::remove_dir_all(materialization_fixture_root).unwrap();
20687        std::fs::remove_dir_all(git_root).unwrap();
20688    }
20689
20690    fn install_managed_lease(shared: &NodeShared) -> ManagedWorktreeLeaseRecord {
20691        let root = std::env::current_dir().unwrap()
20692            .join("managed-test-target")
20693            .to_string_lossy()
20694            .into_owned();
20695        let lease = ManagedWorktreeLeaseRecord {
20696            lease_id: ManagedWorktreeLeaseId::new("mw-test").unwrap(),
20697            source_workspace_id: WorkspaceId::new("primary").unwrap(),
20698            workspace_id: WorkspaceId::new("managed-test").unwrap(),
20699            profile_id: WorktreeProfileId::new("default").unwrap(),
20700            profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
20701            target_root: root.clone(),
20702            branch: "gate4agent/mw-test".to_owned(),
20703            base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
20704            expected_head: Some("0123456789abcdef0123456789abcdef01234567".to_owned()),
20705            retention: ManagedWorktreeRetention::RemoveWhenReleased,
20706            state: ManagedWorktreeLeaseState::Ready,
20707            session_holders: Vec::new(),
20708            record_holders: Vec::new(),
20709            cleanup_failure: None,
20710            created_at_unix_ms: 1,
20711            updated_at_unix_ms: 1,
20712        };
20713        shared.workspaces.write()
20714            .unwrap_or_else(|poisoned| poisoned.into_inner())
20715            .insert(lease.workspace_id.clone(), root);
20716        *shared.managed_worktrees.lock()
20717            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
20718            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
20719        lease
20720    }
20721
20722    #[cfg(feature = "fixture")]
20723    #[test]
20724    fn monitoring_fixture_managed_spawn_retains_effective_record_holder_and_converges_live() {
20725        let mut shared = terminal_test_shared();
20726        shared.fixture_semantic_hook_policy = true;
20727        let lease = install_managed_lease(&shared);
20728        let address = SessionAddress {
20729            workspace_id: lease.workspace_id.clone(),
20730            session: SessionKey {
20731                instance_id: AgentInstanceId(206),
20732                generation: SessionGeneration(1),
20733            },
20734        };
20735        let pre_admission_policy = ProviderRuntimePolicy::raw_pty();
20736        let effective_runtime_policy =
20737            shared.effective_spawn_runtime_policy(pre_admission_policy);
20738        assert_ne!(effective_runtime_policy, pre_admission_policy);
20739        assert!(effective_runtime_policy.provider_session_identity);
20740
20741        let record_id = shared
20742            .bind_spawn_session(
20743                &address,
20744                agent("claude"),
20745                SessionMode::Pty,
20746                effective_runtime_policy,
20747                SpawnRecordPolicy::Always,
20748                None,
20749            )
20750            .unwrap()
20751            .unwrap();
20752        assert_eq!(
20753            shared.record(&record_id).unwrap().state,
20754            ManagedSessionState::IdentityPending,
20755        );
20756        let (record_holder, raw_inventory_record_id) = {
20757            let mut bindings = shared
20758                .session_bindings
20759                .lock()
20760                .unwrap_or_else(|poisoned| poisoned.into_inner());
20761            let binding = bindings.get_mut(&address.session.instance_id).unwrap();
20762            binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
20763            managed_spawn_record_ownership(binding, effective_runtime_policy)
20764        };
20765        assert_eq!(record_holder.as_ref(), Some(&record_id));
20766        assert!(raw_inventory_record_id.is_none());
20767        let snapshot = shared
20768            .managed_worktrees
20769            .lock()
20770            .unwrap_or_else(|poisoned| poisoned.into_inner())
20771            .bind_session(
20772                &lease.lease_id,
20773                ManagedWorktreeSessionHolder {
20774                    incarnation_id: shared.incarnation_id,
20775                    instance_id: address.session.instance_id,
20776                    generation: address.session.generation,
20777                },
20778                record_holder,
20779                unix_time_ms(),
20780            )
20781            .unwrap();
20782        assert_eq!(snapshot.managed_record_count, 1);
20783        assert_eq!(
20784            shared
20785                .managed_worktrees
20786                .lock()
20787                .unwrap_or_else(|poisoned| poisoned.into_inner())
20788                .get(&lease.lease_id)
20789                .unwrap()
20790                .record_holders,
20791            vec![record_id.clone()],
20792        );
20793
20794        let identity = ProviderSessionIdentity {
20795            key: ProviderSessionKey::SessionId,
20796            id: "monitoring-fixture-managed-session".to_owned(),
20797            transcript_path: None,
20798        };
20799        shared.publish_control(ControlEvent {
20800            sequence: 1,
20801            command_id: None,
20802            instance_id: address.session.instance_id,
20803            generation: address.session.generation,
20804            event: ControlEventKind::ProviderEvent {
20805                sequence: 1,
20806                source: gate4agent_types::ProviderSource {
20807                    family: AdapterFamily::Hook,
20808                    binding: AdapterBinding::new(
20809                        gate4agent_types::AdapterId::new("claude-code").unwrap(),
20810                        "fixture/v1",
20811                        gate4agent_types::AdapterVerification::SyntheticFixture,
20812                    )
20813                    .unwrap(),
20814                },
20815                source_sequence: 1,
20816                event: ProviderEvent::SessionIdentityObserved {
20817                    identity: identity.clone(),
20818                },
20819            },
20820        });
20821
20822        let live = shared.record(&record_id).unwrap();
20823        assert_eq!(live.state, ManagedSessionState::Live);
20824        assert_eq!(live.provider_session.as_ref(), Some(&identity));
20825        assert_eq!(live.active_session.as_ref(), Some(&address));
20826        assert_eq!(
20827            shared
20828                .managed_worktrees
20829                .lock()
20830                .unwrap_or_else(|poisoned| poisoned.into_inner())
20831                .get(&lease.lease_id)
20832                .unwrap()
20833                .record_holders,
20834            vec![record_id],
20835        );
20836    }
20837
20838    fn managed_v2_test_shared(
20839        allocation_root: &Path,
20840        state_path: Option<PathBuf>,
20841        profile_revision: &str,
20842    ) -> NodeShared {
20843        let profile = ManagedWorktreeProfile::new(
20844            WorktreeProfileId::new("default").unwrap(),
20845            WorktreeProfileRevision::new(profile_revision).unwrap(),
20846            allocation_root,
20847            "gate4agent",
20848            "HEAD",
20849            ManagedWorktreeRetention::RemoveWhenReleased,
20850        )
20851        .unwrap();
20852        let workspace = WorkspaceConfig::new(
20853            WorkspaceId::new("primary").unwrap(),
20854            std::env::current_dir().unwrap(),
20855        )
20856        .unwrap()
20857        .with_worktree_service_mode(WorktreeServiceMode::Managed)
20858        .with_managed_worktree_profile(profile)
20859        .unwrap();
20860        let catalog = active_registry().unwrap();
20861        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20862        let mut shared = NodeShared::new(
20863            handle,
20864            "fixture-token".to_owned(),
20865            NodeId::new("node-terminal-test").unwrap(),
20866            vec![workspace],
20867            vec![agent("claude")],
20868        );
20869        shared.state_path = state_path;
20870        shared
20871    }
20872
20873    fn managed_v2_request(idempotency_key: &str) -> ManagedWorktreeSpawnRequestV2 {
20874        let mut spawn_spec = spawn_spec_fixture();
20875        spawn_spec.idempotency_key = SpawnIdempotencyKey::new(idempotency_key).unwrap();
20876        ManagedWorktreeSpawnRequestV2 {
20877            spawn_spec,
20878            worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
20879            expected_profile_revision: WorktreeProfileRevision::new("v1").unwrap(),
20880        }
20881    }
20882
20883    #[test]
20884    fn managed_spawn_receipt_rebinds_allocated_worktree_and_roundtrips_wire() {
20885        let shared = terminal_test_shared();
20886        let mut lease = install_managed_lease(&shared);
20887        let session = SessionAddress {
20888            workspace_id: lease.workspace_id.clone(),
20889            session: SessionKey {
20890                instance_id: AgentInstanceId(73),
20891                generation: SessionGeneration(4),
20892            },
20893        };
20894        lease.state = ManagedWorktreeLeaseState::InUse;
20895        lease.session_holders.push(ManagedWorktreeSessionHolder {
20896            incarnation_id: shared.incarnation_id,
20897            instance_id: session.session.instance_id,
20898            generation: session.session.generation,
20899        });
20900        let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
20901        assert!(resolved.target.worktree_id.is_none());
20902
20903        let receipt = managed_spawn_receipt(
20904            &resolved,
20905            shared.incarnation_id,
20906            session.clone(),
20907            lease.snapshot(),
20908            None,
20909            None,
20910            None,
20911        );
20912        assert!(resolved.target.worktree_id.is_none());
20913        assert_eq!(receipt.spawn.target.workspace_id, lease.source_workspace_id);
20914        assert_eq!(receipt.spawn.target.worktree_id.as_ref(), Some(&lease.workspace_id));
20915        assert_eq!(receipt.spawn.session, session);
20916        assert_eq!(receipt.lease.workspace_id, lease.workspace_id);
20917        let encoded = serde_json::to_vec(&receipt).unwrap();
20918        let decoded: ManagedWorktreeSpawnReceipt = serde_json::from_slice(&encoded).unwrap();
20919        assert_eq!(decoded, receipt);
20920    }
20921
20922    #[tokio::test]
20923    async fn managed_spawn_v2_rejects_profile_revision_before_allocation() {
20924        let root = temporary_workspace_root("managed-v2-revision");
20925        let allocation = root.join("allocation");
20926        std::fs::create_dir_all(&allocation).unwrap();
20927        let shared = managed_v2_test_shared(
20928            &allocation,
20929            Some(root.join("node-state.json")),
20930            "v1",
20931        );
20932        let mut request = managed_v2_request("managed-v2-revision");
20933        request.expected_profile_revision = WorktreeProfileRevision::new("v2").unwrap();
20934
20935        let failure = shared.spawn_managed_worktree_v2(request).await.unwrap_err();
20936
20937        assert_eq!(
20938            failure.code,
20939            NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
20940        );
20941        assert!(shared
20942            .managed_worktrees
20943            .lock()
20944            .unwrap_or_else(|poisoned| poisoned.into_inner())
20945            .records()
20946            .is_empty());
20947        assert!(shared
20948            .managed_spawn_replays
20949            .lock()
20950            .unwrap_or_else(|poisoned| poisoned.into_inner())
20951            .is_empty());
20952        std::fs::remove_dir_all(root).unwrap();
20953    }
20954
20955    #[test]
20956    fn managed_spawn_v2_concurrent_same_key_has_one_durable_reservation() {
20957        let root = temporary_workspace_root("managed-v2-concurrent");
20958        let allocation = root.join("allocation");
20959        std::fs::create_dir_all(&allocation).unwrap();
20960        let shared = Arc::new(managed_v2_test_shared(
20961            &allocation,
20962            Some(root.join("node-state.json")),
20963            "v1",
20964        ));
20965        let request = managed_v2_request("managed-v2-concurrent");
20966        let barrier = Arc::new(std::sync::Barrier::new(3));
20967        let mut joins = Vec::new();
20968        for _ in 0..2 {
20969            let shared = Arc::clone(&shared);
20970            let request = request.clone();
20971            let barrier = Arc::clone(&barrier);
20972            joins.push(std::thread::spawn(move || {
20973                let digest = managed_spawn_request_digest_v2(&request).unwrap();
20974                barrier.wait();
20975                shared.acquire_durable_managed_spawn_v2(&request, digest)
20976            }));
20977        }
20978        barrier.wait();
20979        let results = joins
20980            .into_iter()
20981            .map(|join| join.join().unwrap())
20982            .collect::<Vec<_>>();
20983        assert_eq!(
20984            results
20985                .iter()
20986                .filter(|result| {
20987                    matches!(result, Ok(DurableManagedSpawnReplayDecision::Reserved))
20988                })
20989                .count(),
20990            1,
20991        );
20992        assert_eq!(
20993            results
20994                .iter()
20995                .filter(|result| {
20996                    matches!(result, Err(error) if error.code == NodeFailureCode::BackendBusy)
20997                })
20998                .count(),
20999            1,
21000        );
21001        assert_eq!(
21002            shared
21003                .managed_spawn_replays
21004                .lock()
21005                .unwrap_or_else(|poisoned| poisoned.into_inner())
21006                .len(),
21007            1,
21008        );
21009        assert!(shared
21010            .managed_worktrees
21011            .lock()
21012            .unwrap_or_else(|poisoned| poisoned.into_inner())
21013            .records()
21014            .is_empty());
21015        drop(shared);
21016        std::fs::remove_dir_all(root).unwrap();
21017    }
21018
21019    #[tokio::test]
21020    async fn managed_spawn_v2_linked_pending_reopen_fails_closed_without_reallocation() {
21021        let root = temporary_workspace_root("managed-v2-linked-pending");
21022        let allocation = root.join("allocation");
21023        std::fs::create_dir_all(&allocation).unwrap();
21024        let state_path = root.join("node-state.json");
21025        let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
21026        let lease = install_managed_lease(&shared);
21027        let request = managed_v2_request("managed-v2-linked-pending");
21028        assert!(matches!(
21029            shared
21030                .acquire_durable_managed_spawn_v2(
21031                    &request,
21032                    managed_spawn_request_digest_v2(&request).unwrap(),
21033                )
21034                .unwrap(),
21035            DurableManagedSpawnReplayDecision::Reserved,
21036        ));
21037        {
21038            let _transaction = shared
21039                .state_transaction
21040                .lock()
21041                .unwrap_or_else(|poisoned| poisoned.into_inner());
21042            shared
21043                .link_durable_managed_spawn_v2_lease_locked(
21044                    &request.spawn_spec.idempotency_key,
21045                    &lease.lease_id,
21046                )
21047                .unwrap();
21048            shared.persist_state_locked().unwrap();
21049        }
21050        drop(shared);
21051
21052        let mut loaded = session_registry::load(
21053            Some(&state_path),
21054            &NodeId::new("node-terminal-test").unwrap(),
21055        )
21056        .unwrap();
21057        let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
21058        *reopened
21059            .managed_worktrees
21060            .lock()
21061            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21062            ManagedWorktreeRegistry::from_records(
21063                std::mem::take(&mut loaded.managed_worktrees),
21064                std::mem::take(&mut loaded.managed_worktree_tombstones),
21065            )
21066            .unwrap();
21067        *reopened
21068            .managed_spawn_replays
21069            .lock()
21070            .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
21071            .managed_spawn_replays
21072            .into_iter()
21073            .map(|record| (record.idempotency_key.clone(), record))
21074            .collect();
21075        let lease_count = reopened
21076            .managed_worktrees
21077            .lock()
21078            .unwrap_or_else(|poisoned| poisoned.into_inner())
21079            .records()
21080            .len();
21081
21082        let failure = reopened
21083            .spawn_managed_worktree_v2(request)
21084            .await
21085            .unwrap_err();
21086
21087        assert_eq!(
21088            failure.code,
21089            NodeFailureCode::ManagedWorktreeRecoveryRequired,
21090        );
21091        assert_eq!(
21092            reopened
21093                .managed_worktrees
21094                .lock()
21095                .unwrap_or_else(|poisoned| poisoned.into_inner())
21096                .records()
21097                .len(),
21098            lease_count,
21099        );
21100        std::fs::remove_dir_all(root).unwrap();
21101    }
21102
21103    #[tokio::test]
21104    async fn managed_spawn_v2_replays_exact_receipt_after_durable_reopen_and_conflicts() {
21105        let root = temporary_workspace_root("managed-v2-reopen");
21106        let allocation = root.join("allocation");
21107        std::fs::create_dir_all(&allocation).unwrap();
21108        let state_path = root.join("node-state.json");
21109        let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
21110        let lease = install_managed_lease(&shared);
21111        let request = managed_v2_request("managed-v2-reopen");
21112        let session = SessionAddress {
21113            workspace_id: lease.workspace_id.clone(),
21114            session: SessionKey {
21115                instance_id: AgentInstanceId(91),
21116                generation: SessionGeneration(1),
21117            },
21118        };
21119        let snapshot = {
21120            let mut registry = shared
21121                .managed_worktrees
21122                .lock()
21123                .unwrap_or_else(|poisoned| poisoned.into_inner());
21124            let current = registry.get_mut(&lease.lease_id).unwrap();
21125            current.state = ManagedWorktreeLeaseState::InUse;
21126            current.session_holders.push(ManagedWorktreeSessionHolder {
21127                incarnation_id: shared.incarnation_id,
21128                instance_id: session.session.instance_id,
21129                generation: session.session.generation,
21130            });
21131            current.snapshot()
21132        };
21133        let resolved = shared.resolve_spawn_spec(&request.spawn_spec).unwrap();
21134        let receipt = managed_spawn_receipt(
21135            &resolved,
21136            shared.incarnation_id,
21137            session,
21138            snapshot,
21139            None,
21140            None,
21141            None,
21142        );
21143        assert!(matches!(
21144            shared
21145                .acquire_durable_managed_spawn_v2(
21146                    &request,
21147                    managed_spawn_request_digest_v2(&request).unwrap(),
21148                )
21149                .unwrap(),
21150            DurableManagedSpawnReplayDecision::Reserved,
21151        ));
21152        {
21153            let _transaction = shared
21154                .state_transaction
21155                .lock()
21156                .unwrap_or_else(|poisoned| poisoned.into_inner());
21157            shared
21158                .link_durable_managed_spawn_v2_lease_locked(
21159                    &request.spawn_spec.idempotency_key,
21160                    &lease.lease_id,
21161                )
21162                .unwrap();
21163            shared.persist_state_locked().unwrap();
21164        }
21165        shared
21166            .commit_durable_managed_spawn_v2(&request, receipt.clone())
21167            .unwrap();
21168        let legacy_same_key = ManagedWorktreeSpawnRequest {
21169            spawn_spec: request.spawn_spec.clone(),
21170            worktree_profile_id: request.worktree_profile_id.clone(),
21171        };
21172        shared.remember_managed_spawn_attempt(
21173            Some(&request.spawn_spec.idempotency_key),
21174            legacy_same_key.clone(),
21175            Ok(receipt.clone()),
21176            Instant::now(),
21177        );
21178        assert!(shared
21179            .replay_managed_spawn(&legacy_same_key, Instant::now())
21180            .unwrap()
21181            .is_none());
21182        drop(shared);
21183
21184        let mut loaded = session_registry::load(
21185            Some(&state_path),
21186            &NodeId::new("node-terminal-test").unwrap(),
21187        )
21188        .unwrap();
21189        assert_eq!(loaded.managed_spawn_replays.len(), 1);
21190        let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
21191        *reopened
21192            .managed_worktrees
21193            .lock()
21194            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21195            ManagedWorktreeRegistry::from_records(
21196                std::mem::take(&mut loaded.managed_worktrees),
21197                std::mem::take(&mut loaded.managed_worktree_tombstones),
21198            )
21199            .unwrap();
21200        *reopened
21201            .managed_spawn_replays
21202            .lock()
21203            .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
21204            .managed_spawn_replays
21205            .into_iter()
21206            .map(|record| (record.idempotency_key.clone(), record))
21207            .collect();
21208        let lease_count_before = reopened
21209            .managed_worktrees
21210            .lock()
21211            .unwrap_or_else(|poisoned| poisoned.into_inner())
21212            .records()
21213            .len();
21214
21215        assert_eq!(
21216            reopened
21217                .spawn_managed_worktree_v2(request.clone())
21218                .await
21219                .unwrap(),
21220            receipt,
21221        );
21222        assert_eq!(
21223            reopened
21224                .managed_worktrees
21225                .lock()
21226                .unwrap_or_else(|poisoned| poisoned.into_inner())
21227                .records()
21228                .len(),
21229            lease_count_before,
21230        );
21231        let mut changed = request;
21232        changed.spawn_spec.deadline_ms = crate::protocol::SpawnDeadlineMs::new(29_000).unwrap();
21233        let failure = reopened
21234            .spawn_managed_worktree_v2(changed)
21235            .await
21236            .unwrap_err();
21237        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
21238        assert_eq!(
21239            reopened
21240                .managed_worktrees
21241                .lock()
21242                .unwrap_or_else(|poisoned| poisoned.into_inner())
21243                .records()
21244                .len(),
21245            lease_count_before,
21246        );
21247        let mut new_key = managed_v2_request("managed-v2-new-after-profile-change");
21248        new_key.expected_profile_revision = WorktreeProfileRevision::new("v1").unwrap();
21249        let failure = reopened
21250            .spawn_managed_worktree_v2(new_key)
21251            .await
21252            .unwrap_err();
21253        assert_eq!(
21254            failure.code,
21255            NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
21256        );
21257        assert_eq!(
21258            reopened
21259                .managed_worktrees
21260                .lock()
21261                .unwrap_or_else(|poisoned| poisoned.into_inner())
21262                .records()
21263                .len(),
21264            lease_count_before,
21265        );
21266        assert_eq!(
21267            reopened
21268                .managed_spawn_replays
21269                .lock()
21270                .unwrap_or_else(|poisoned| poisoned.into_inner())
21271                .len(),
21272            1,
21273        );
21274        std::fs::remove_dir_all(root).unwrap();
21275    }
21276
21277    #[test]
21278    fn legacy_managed_spawn_idempotency_remains_transient_and_exact() {
21279        let shared = terminal_test_shared();
21280        let request = ManagedWorktreeSpawnRequest {
21281            spawn_spec: spawn_spec_fixture(),
21282            worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
21283        };
21284        let expected = failure(NodeFailureCode::BackendBusy, "legacy fixture failure");
21285        shared.remember_managed_spawn(
21286            request.clone(),
21287            Err(expected.clone()),
21288            Instant::now(),
21289        );
21290
21291        assert_eq!(
21292            shared
21293                .replay_managed_spawn(&request, Instant::now())
21294                .unwrap()
21295                .unwrap(),
21296            Err(expected),
21297        );
21298        assert!(shared
21299            .managed_spawn_replays
21300            .lock()
21301            .unwrap_or_else(|poisoned| poisoned.into_inner())
21302            .is_empty());
21303    }
21304
21305    #[tokio::test]
21306    async fn ordinary_spawn_and_spawn_spec_cannot_bypass_managed_lease_holder_fence() {
21307        let shared = terminal_test_shared();
21308        let lease = install_managed_lease(&shared);
21309        let raw = shared.spawn_session(
21310            lease.workspace_id.clone(),
21311            agent("claude"),
21312            SessionMode::Pty,
21313            gate4agent_types::TerminalSize { rows: 24, columns: 80 },
21314            None,
21315        ).await.unwrap_err();
21316        assert_eq!(raw.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
21317        let mut spec = spawn_spec_fixture();
21318        spec.target.workspace_id = lease.workspace_id.clone();
21319        let specified = shared.spawn_from_spec(spec).await.unwrap_err();
21320        assert_eq!(specified.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
21321        let mut selected = spawn_spec_fixture();
21322        selected.idempotency_key = SpawnIdempotencyKey::new("managed-selected-bypass").unwrap();
21323        selected.target.worktree_id = Some(lease.workspace_id);
21324        let selected = shared.spawn_from_spec(selected).await.unwrap_err();
21325        assert_eq!(selected.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
21326        assert!(shared.handle.snapshot().sessions.is_empty());
21327    }
21328
21329    #[test]
21330    fn legacy_projection_omits_managed_snapshot_events_and_path_bearing_diagnostics() {
21331        let shared = terminal_test_shared();
21332        let lease = install_managed_lease(&shared);
21333        let snapshot = snapshot_for_wire(
21334            &shared, true, true, true, false, true, true, false, false, true,
21335        );
21336        assert!(snapshot.managed_worktrees.is_empty());
21337        assert!(snapshot.workspaces.iter().all(|workspace| {
21338            workspace.managed_worktree_profiles.is_none()
21339        }));
21340        let mut reply = ResponseEnvelope {
21341            request_id: 1,
21342            result: Ok(NodeResponse::Resync {
21343                event_sequence: 1,
21344                oldest_available_sequence: 1,
21345                snapshot: shared.snapshot(),
21346                events: vec![NodeEventEnvelope {
21347                    sequence: 1,
21348                    event: NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() },
21349                }],
21350            }),
21351        };
21352        project_response_without_managed_worktrees(&mut reply);
21353        let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
21354            panic!("projection changed the response kind");
21355        };
21356        assert!(snapshot.managed_worktrees.is_empty());
21357        assert!(events.is_empty());
21358        let mut git = git_snapshot_for_parser();
21359        git.branch = Some(lease.branch.clone());
21360        git.managed_worktree = Some(crate::protocol::ManagedWorktreeGitScope {
21361            lease_id: lease.lease_id.clone(),
21362            source_workspace_id: lease.source_workspace_id.clone(),
21363            branch: lease.branch.clone(),
21364            base_commit: GitObjectId::new(lease.base_commit.clone()).unwrap(),
21365            active_session_count: 1,
21366            managed_record_count: 0,
21367        });
21368        let mut inspection_reply = ResponseEnvelope {
21369            request_id: 2,
21370            result: Ok(NodeResponse::WorkspaceInspected {
21371                inspection: WorkspaceInspection {
21372                    workspace_id: lease.workspace_id.clone(),
21373                    entries: Vec::new(),
21374                    tree_truncated: false,
21375                    git,
21376                    truncation: None,
21377                },
21378            }),
21379        };
21380        project_response_without_managed_worktrees(&mut inspection_reply);
21381        let Ok(NodeResponse::WorkspaceInspected { inspection }) = inspection_reply.result else {
21382            panic!("projection changed the inspection response kind");
21383        };
21384        assert!(inspection.git.managed_worktree.is_none());
21385        let failure = managed_git_worktree_failure(GitWorktreeError {
21386            kind: GitWorktreeErrorKind::Failed,
21387            message: r"git failed at C:\private\managed-target".to_owned(),
21388        });
21389        assert!(!failure.message.contains("private"));
21390        assert!(!failure.message.contains("managed-target"));
21391    }
21392
21393    #[tokio::test]
21394    async fn restart_reconciliation_retains_exact_owned_worktree_and_explicit_cleanup_removes_it() {
21395        let root = temporary_workspace_root("managed-restart");
21396        let source = root.join("source");
21397        let allocation = root.join("allocation");
21398        std::fs::create_dir_all(&source).unwrap();
21399        std::fs::create_dir_all(&allocation).unwrap();
21400        run_test_git(&source, &["init"]);
21401        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
21402        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
21403        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
21404        run_test_git(&source, &["add", "seed.txt"]);
21405        run_test_git(&source, &["commit", "-m", "seed"]);
21406        let profile = ManagedWorktreeProfile::new(
21407            WorktreeProfileId::new("default").unwrap(),
21408            crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
21409            &allocation,
21410            "gate4agent",
21411            "HEAD",
21412            ManagedWorktreeRetention::Retain,
21413        ).unwrap();
21414        let workspace = WorkspaceConfig::new(
21415            WorkspaceId::new("primary").unwrap(),
21416            &source,
21417        ).unwrap()
21418            .with_worktree_service_mode(WorktreeServiceMode::Managed)
21419            .with_managed_worktree_profile(profile.clone()).unwrap();
21420        let target = PathBuf::from(profile.allocation_root()).join("mw-restart");
21421        let source_text = workspace.canonical_root().to_owned();
21422        let target_text = target.to_string_lossy().into_owned();
21423        let created = create_git_worktree(
21424            &source_text,
21425            &target_text,
21426            "gate4agent/mw-restart",
21427            Some("HEAD"),
21428        ).await.unwrap();
21429        let lease = ManagedWorktreeLeaseRecord {
21430            lease_id: ManagedWorktreeLeaseId::new("mw-restart").unwrap(),
21431            source_workspace_id: WorkspaceId::new("primary").unwrap(),
21432            workspace_id: WorkspaceId::new("managed-restart").unwrap(),
21433            profile_id: profile.profile_id().clone(),
21434            profile_revision: profile.revision().clone(),
21435            target_root: created.path.clone(),
21436            branch: "gate4agent/mw-restart".to_owned(),
21437            base_commit: created.head.clone(),
21438            expected_head: None,
21439            retention: ManagedWorktreeRetention::Retain,
21440            state: ManagedWorktreeLeaseState::Allocating,
21441            session_holders: Vec::new(),
21442            record_holders: Vec::new(),
21443            cleanup_failure: None,
21444            created_at_unix_ms: 1,
21445            updated_at_unix_ms: 1,
21446        };
21447        let catalog = active_registry().unwrap();
21448        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
21449        let shared = NodeShared::new_with_incarnation(
21450            handle,
21451            "fixture-token".to_owned(),
21452            NodeId::new("node-managed-restart").unwrap(),
21453            NodeIncarnationId::from_bytes([7; crate::protocol::NODE_INCARNATION_ID_BYTES]),
21454            vec![workspace],
21455            vec![agent("claude")],
21456            ProviderRuntimeStatuses::default(),
21457            None,
21458            Vec::new(),
21459            Vec::new(),
21460            SpawnProfileRegistry::default(),
21461            None,
21462            None,
21463            None,
21464            None,
21465            Vec::new(),
21466            vec![lease.clone()],
21467            Vec::new(),
21468            Vec::new(),
21469            None,
21470            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
21471        );
21472        shared.reconcile_managed_worktrees().await;
21473        let retained = shared.managed_worktrees.lock()
21474            .unwrap_or_else(|poisoned| poisoned.into_inner())
21475            .get(&lease.lease_id).unwrap().clone();
21476        assert_eq!(retained.state, ManagedWorktreeLeaseState::Retained);
21477        assert!(shared.workspaces.read()
21478            .unwrap_or_else(|poisoned| poisoned.into_inner())
21479            .contains_key(&lease.workspace_id));
21480        let removed = shared.cleanup_managed_worktree(&lease.lease_id, true).await.unwrap();
21481        assert_eq!(removed.state, ManagedWorktreeLeaseState::Removed);
21482        assert!(!Path::new(&created.path).exists());
21483        std::fs::remove_dir_all(root).unwrap();
21484    }
21485
21486    fn run_test_git(root: &Path, arguments: &[&str]) {
21487        let output = std::process::Command::new("git")
21488            .arg("-C").arg(root).args(arguments).output().unwrap();
21489        assert!(
21490            output.status.success(),
21491            "git {:?} failed: {}",
21492            arguments,
21493            String::from_utf8_lossy(&output.stderr),
21494        );
21495    }
21496
21497    #[tokio::test]
21498    async fn spawn_spec_validation_fails_before_session_mutation() {
21499        let shared = terminal_test_shared();
21500        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21501            capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
21502        }));
21503        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21504            capability.as_str() == NODE_SPAWN_PROFILE_REVISION_CAPABILITY
21505        }));
21506        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21507            capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
21508        }));
21509
21510        let mut target_mismatch = spawn_spec_fixture();
21511        target_mismatch.target.node_id = NodeId::new("other-node").unwrap();
21512        let failure = shared.spawn_from_spec(target_mismatch).await.unwrap_err();
21513        assert_eq!(failure.code, NodeFailureCode::SpawnTargetMismatch);
21514
21515        let mut unknown_profile = spawn_spec_fixture();
21516        unknown_profile.idempotency_key = SpawnIdempotencyKey::new("unknown-profile").unwrap();
21517        unknown_profile.profile_id = crate::protocol::SpawnProfileId::new("missing").unwrap();
21518        let failure = shared.spawn_from_spec(unknown_profile).await.unwrap_err();
21519        assert_eq!(failure.code, NodeFailureCode::UnknownSpawnProfile);
21520
21521        let mut revision_mismatch = spawn_spec_fixture();
21522        revision_mismatch.idempotency_key =
21523            SpawnIdempotencyKey::new("profile-revision-mismatch").unwrap();
21524        revision_mismatch.expected_profile_revision =
21525            crate::protocol::SpawnProfileRevision::new("test-r2").unwrap();
21526        let failure = shared.spawn_from_spec(revision_mismatch).await.unwrap_err();
21527        assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
21528
21529        let mut unsupported_materializer = spawn_spec_fixture();
21530        unsupported_materializer.idempotency_key =
21531            SpawnIdempotencyKey::new("unsupported-materializer").unwrap();
21532        unsupported_materializer.overrides.bundle_id = crate::protocol::SpawnOverride::Set {
21533            value: crate::protocol::SpawnBundleId::new("bundle-v1").unwrap(),
21534        };
21535        let failure = shared
21536            .spawn_from_spec(unsupported_materializer)
21537            .await
21538            .unwrap_err();
21539        assert_eq!(failure.code, NodeFailureCode::UnknownBundle);
21540
21541        let failure = shared
21542            .spawn_session_with_deadline(
21543                WorkspaceId::new("primary").unwrap(),
21544                agent("claude"),
21545                SessionMode::Pty,
21546                gate4agent_types::TerminalSize {
21547                    rows: 24,
21548                    columns: 80,
21549                },
21550                None,
21551                None,
21552                None,
21553                None,
21554                None,
21555                None,
21556                SpawnRecordPolicy::ProviderIdentityOnly,
21557                Some(Instant::now()),
21558                &[],
21559                None,
21560                ApprovalLevel::default(),
21561                None,
21562                None,
21563            )
21564            .await
21565            .unwrap_err();
21566        assert_eq!(failure.code, NodeFailureCode::SpawnDeadlineExceeded);
21567        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
21568        assert!(shared.handle.snapshot().sessions.is_empty());
21569    }
21570
21571    #[test]
21572    fn worktree_selection_requires_its_separate_negotiated_capability() {
21573        let mut spec = spawn_spec_fixture();
21574        spec.target.worktree_id = Some(WorkspaceId::new("selected-worktree").unwrap());
21575        let request = NodeRequest::SpawnSpec { spec };
21576        let spawn_spec_capability =
21577            CapabilityId::new(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY).unwrap();
21578        assert!(request_uses_unnegotiated_capability(
21579            &request,
21580            &[spawn_spec_capability.clone()],
21581        ));
21582        let worktree_capability =
21583            CapabilityId::new(NODE_WORKTREE_SELECTION_CAPABILITY).unwrap();
21584        assert!(request_uses_unnegotiated_capability(
21585            &request,
21586            &[spawn_spec_capability.clone(), worktree_capability.clone()],
21587        ));
21588        let profile_revision_capability =
21589            CapabilityId::new(NODE_SPAWN_PROFILE_REVISION_CAPABILITY).unwrap();
21590        assert!(request_uses_unnegotiated_capability(
21591            &request,
21592            &[
21593                spawn_spec_capability.clone(),
21594                worktree_capability.clone(),
21595                profile_revision_capability.clone(),
21596            ],
21597        ));
21598        // Default `SpawnOverrides` leaves `bundle_id`/`context_id` at
21599        // `Inherit`, not `Clear` — the profile's own defaults might still
21600        // materialize a bundle or a context pack, so both capabilities stay
21601        // conservatively required until negotiated (see
21602        // `requires_session_bundle_materialization_capability` /
21603        // `requires_history_context_pack_capability`).
21604        let bundle_materialization_capability =
21605            CapabilityId::new(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY).unwrap();
21606        assert!(request_uses_unnegotiated_capability(
21607            &request,
21608            &[
21609                spawn_spec_capability.clone(),
21610                worktree_capability.clone(),
21611                profile_revision_capability.clone(),
21612                bundle_materialization_capability.clone(),
21613            ],
21614        ));
21615        let history_context_pack_capability =
21616            CapabilityId::new(NODE_HISTORY_CONTEXT_PACK_CAPABILITY).unwrap();
21617        assert!(!request_uses_unnegotiated_capability(
21618            &request,
21619            &[
21620                spawn_spec_capability,
21621                worktree_capability,
21622                profile_revision_capability,
21623                bundle_materialization_capability,
21624                history_context_pack_capability,
21625            ],
21626        ));
21627    }
21628
21629    #[tokio::test]
21630    async fn spawn_spec_idempotency_replays_receipt_and_conflicts_before_session_mutation() {
21631        let mut shared = terminal_test_shared();
21632        let spec = spawn_spec_fixture();
21633        let receipt = shared
21634            .resolve_spawn_spec(&spec)
21635            .unwrap()
21636            .receipt(shared.incarnation_id, terminal_address(1));
21637        shared.remember_spawn_spec(spec.clone(), Ok(receipt.clone()), Instant::now());
21638
21639        let mut revised = shared.spawn_profiles.get(&spec.profile_id).unwrap().clone();
21640        revised.revision = crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
21641        shared.spawn_profiles = SpawnProfileRegistry::new([revised]).unwrap();
21642
21643        assert_eq!(shared.spawn_from_spec(spec.clone()).await.unwrap(), receipt);
21644
21645        let mut revision_conflicting = spec.clone();
21646        revision_conflicting.expected_profile_revision =
21647            crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
21648        let failure = shared.spawn_from_spec(revision_conflicting).await.unwrap_err();
21649        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
21650
21651        let mut stale_cache_miss = spec.clone();
21652        stale_cache_miss.idempotency_key =
21653            SpawnIdempotencyKey::new("stale-profile-cache-miss").unwrap();
21654        let failure = shared.spawn_from_spec(stale_cache_miss).await.unwrap_err();
21655        assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
21656
21657        let mut conflicting = spec;
21658        conflicting.deadline_ms = crate::protocol::SpawnDeadlineMs::new(31_000).unwrap();
21659        let failure = shared.spawn_from_spec(conflicting).await.unwrap_err();
21660        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
21661        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
21662        assert!(shared.handle.snapshot().sessions.is_empty());
21663    }
21664
21665    #[tokio::test]
21666    async fn off_worktree_service_rejects_selection_and_mutation_before_git() {
21667        let shared = terminal_test_shared_with_mode(WorktreeServiceMode::Off);
21668        let mut spec = spawn_spec_fixture();
21669        spec.target.worktree_id = Some(WorkspaceId::new("unregistered-worktree").unwrap());
21670        let failure = shared.spawn_from_spec(spec).await.unwrap_err();
21671        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
21672
21673        let failure = shared
21674            .create_worktree(
21675                WorkspaceId::new("primary").unwrap(),
21676                WorkspaceId::new("new-worktree").unwrap(),
21677                "not-an-absolute-path".to_owned(),
21678                "invalid branch".to_owned(),
21679                None,
21680            )
21681            .await
21682            .unwrap_err();
21683        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
21684
21685        let failure = shared
21686            .remove_worktree(
21687                WorkspaceId::new("primary").unwrap(),
21688                "not-an-absolute-path".to_owned(),
21689            )
21690            .await
21691            .unwrap_err();
21692        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
21693        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
21694        assert!(shared.handle.snapshot().sessions.is_empty());
21695    }
21696
21697    #[tokio::test]
21698    async fn manual_worktree_registration_failure_removes_the_created_worktree() {
21699        let root = temporary_workspace_root("manual-worktree-compensation");
21700        let source = root.join("source");
21701        let target = root.join("target");
21702        let invalid_state_parent = root.join("state-parent-is-a-file");
21703        let invalid_state_path = invalid_state_parent.join("state.json");
21704        std::fs::create_dir_all(&source).unwrap();
21705        std::fs::write(&invalid_state_parent, "not a directory").unwrap();
21706        run_test_git(&source, &["init"]);
21707        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
21708        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
21709        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
21710        run_test_git(&source, &["add", "seed.txt"]);
21711        run_test_git(&source, &["commit", "-m", "seed"]);
21712
21713        let catalog = active_registry().unwrap();
21714        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
21715        let workspace = WorkspaceConfig::new(
21716            WorkspaceId::new("primary").unwrap(),
21717            &source,
21718        ).unwrap();
21719        let mut shared = NodeShared::new(
21720            handle,
21721            "fixture-token".to_owned(),
21722            NodeId::new("node-worktree-compensation").unwrap(),
21723            vec![workspace],
21724            vec![agent("claude")],
21725        );
21726        shared.state_path = Some(invalid_state_path);
21727
21728        let error = shared.create_worktree(
21729            WorkspaceId::new("primary").unwrap(),
21730            WorkspaceId::new("compensated").unwrap(),
21731            target.to_string_lossy().into_owned(),
21732            "gate4agent/compensated".to_owned(),
21733            Some("HEAD".to_owned()),
21734        ).await.unwrap_err();
21735
21736        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21737        assert!(!target.exists());
21738        assert!(list_git_worktrees(source.to_str().unwrap()).await.unwrap()
21739            .iter()
21740            .all(|worktree| !worktree_paths_equal(&worktree.path, &target.to_string_lossy())));
21741        std::fs::remove_dir_all(root).unwrap();
21742    }
21743
21744    #[test]
21745    fn selected_worktree_requires_authoritative_non_main_non_bare_entry() {
21746        fn worktree(path: &str, is_main: bool, is_bare: bool) -> NativeGitWorktreeSnapshot {
21747            NativeGitWorktreeSnapshot {
21748                path: path.to_owned(),
21749                head: "0123456789abcdef".to_owned(),
21750                branch: Some("refs/heads/test".to_owned()),
21751                is_bare,
21752                is_main,
21753                locked: false,
21754                lock_reason: None,
21755                prunable: false,
21756                prunable_reason: None,
21757                workspace_id: None,
21758            }
21759        }
21760
21761        let source = r"C:\repo";
21762        let selected = r"C:\repo-worktree";
21763        let listed = vec![
21764            worktree(source, true, false),
21765            worktree(selected, false, false),
21766        ];
21767        assert!(validate_selected_worktree(source, selected, &listed).is_ok());
21768
21769        let main_failure = validate_selected_worktree(source, source, &listed).unwrap_err();
21770        assert_eq!(main_failure.code, NodeFailureCode::WorktreeProtected);
21771
21772        let bare = vec![
21773            worktree(source, true, false),
21774            worktree(selected, false, true),
21775        ];
21776        let bare_failure = validate_selected_worktree(source, selected, &bare).unwrap_err();
21777        assert_eq!(bare_failure.code, NodeFailureCode::WorktreeProtected);
21778
21779        let foreign_failure =
21780            validate_selected_worktree(source, r"C:\foreign", &listed).unwrap_err();
21781        assert_eq!(foreign_failure.code, NodeFailureCode::WorktreeProtected);
21782    }
21783
21784    fn terminal_frame(sequence: u64) -> TerminalFrame {
21785        TerminalFrame {
21786            sequence,
21787            size: gate4agent_types::TerminalSize {
21788                rows: 24,
21789                columns: 80,
21790            },
21791            cursor_row: 2,
21792            cursor_column: 3,
21793            contents: format!("frame-{sequence}"),
21794            formatted: vec![1, 2, 3],
21795            scrollback_formatted: Vec::new(),
21796            alternate_screen: false,
21797            mouse_protocol_enabled: false,
21798            mouse_protocol_encoding: Default::default(),
21799            produced_at_unix_ms: 0,
21800            screen_state: gate4agent_types::PtyScreenState::default(),
21801            bracketed_paste: None,
21802        }
21803    }
21804
21805    #[test]
21806    fn terminal_frame_watermark_coalesces_and_resets_for_exact_session_address() {
21807        let shared = terminal_test_shared();
21808        let mut receiver = shared.terminal_event_tx.subscribe();
21809        let original = terminal_address(1);
21810
21811        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
21812        let first = receiver.try_recv().unwrap();
21813        assert_eq!(first.len(), 1);
21814        assert!(matches!(
21815            &first[0].event,
21816            NodeEvent::TerminalFrame { address, frame }
21817                if address == &original && frame.sequence == 7
21818        ));
21819
21820        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
21821        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(6))]);
21822        assert!(matches!(
21823            receiver.try_recv(),
21824            Err(broadcast::error::TryRecvError::Empty)
21825        ));
21826
21827        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(8))]);
21828        let advanced = receiver.try_recv().unwrap();
21829        assert_eq!(advanced[0].sequence, 2);
21830
21831        let resumed = terminal_address(2);
21832        shared.publish_terminal_frame_candidates(vec![(resumed.clone(), terminal_frame(1))]);
21833        let reset = receiver.try_recv().unwrap();
21834        assert_eq!(reset[0].sequence, 3);
21835        assert!(matches!(
21836            &reset[0].event,
21837            NodeEvent::TerminalFrame { address, frame }
21838                if address == &resumed && frame.sequence == 1
21839        ));
21840        shared.clear_terminal_frame_watermark(&resumed);
21841        assert!(!shared
21842            .terminal_frame_watermarks
21843            .lock()
21844            .unwrap_or_else(|poisoned| poisoned.into_inner())
21845            .contains_key(&resumed.session.instance_id));
21846    }
21847
21848    #[test]
21849    fn terminal_frame_batches_replace_without_entering_node_event_history() {
21850        let shared = terminal_test_shared();
21851        let mut receiver = shared.terminal_event_tx.subscribe();
21852        let address = terminal_address(1);
21853        let count = NODE_EVENT_HISTORY_MAX as u64 + 17;
21854
21855        for sequence in 1..=count {
21856            shared.publish_terminal_frame_candidates(vec![(
21857                address.clone(),
21858                terminal_frame(sequence),
21859            )]);
21860        }
21861
21862        let history = shared
21863            .history
21864            .lock()
21865            .unwrap_or_else(|poisoned| poisoned.into_inner());
21866        assert!(history.events.is_empty());
21867        assert_eq!(history.last_sequence, count);
21868        assert_eq!(history.replay_floor_sequence, 1);
21869        drop(history);
21870        let NodeResponse::Resync {
21871            event_sequence,
21872            oldest_available_sequence,
21873            events,
21874            ..
21875        } = shared.resync(0) else {
21876            unreachable!("resync helper returned another response")
21877        };
21878        assert_eq!(event_sequence, count);
21879        assert_eq!(oldest_available_sequence, 1);
21880        assert!(events.is_empty());
21881        assert!(matches!(
21882            resync_required_event(&shared).event,
21883            NodeEvent::ResyncRequired {
21884                oldest_available_sequence: 1,
21885            }
21886        ));
21887        assert!(matches!(
21888            receiver.try_recv(),
21889            Err(broadcast::error::TryRecvError::Lagged(skipped)) if skipped == count - 1
21890        ));
21891        let latest = receiver.try_recv().unwrap();
21892        assert_eq!(latest.len(), 1);
21893        assert_eq!(latest[0].sequence, count);
21894    }
21895
21896    #[test]
21897    fn real_durable_eviction_advances_authoritative_replay_floor() {
21898        let shared = terminal_test_shared();
21899        for _ in 0..=NODE_EVENT_HISTORY_MAX {
21900            shared.publish(NodeEvent::ControllerChanged { controller: None });
21901        }
21902
21903        let NodeResponse::Resync {
21904            event_sequence,
21905            oldest_available_sequence,
21906            events,
21907            ..
21908        } = shared.resync(0) else {
21909            unreachable!("resync helper returned another response")
21910        };
21911        assert_eq!(event_sequence, NODE_EVENT_HISTORY_MAX as u64 + 1);
21912        assert_eq!(oldest_available_sequence, 2);
21913        assert_eq!(events.first().map(|event| event.sequence), Some(2));
21914        assert_eq!(events.len(), NODE_EVENT_HISTORY_MAX);
21915        assert!(matches!(
21916            resync_required_event(&shared).event,
21917            NodeEvent::ResyncRequired {
21918                oldest_available_sequence: 2,
21919            }
21920        ));
21921    }
21922
21923    #[test]
21924    fn legacy_terminal_frame_connection_has_no_live_subscription() {
21925        let shared = terminal_test_shared();
21926        let without_capability = terminal_event_subscription(&shared, false);
21927        let with_capability = terminal_event_subscription(&shared, true);
21928        assert!(without_capability.is_none());
21929        assert!(with_capability.is_some());
21930        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21931            capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
21932        }));
21933    }
21934
21935    #[test]
21936    fn continuous_terminal_output_leaves_request_service_after_bounded_event_burst() {
21937        let mut pending = Vec::new();
21938        let mut event_sequence = 0_u64;
21939        for frame_sequence in 1..=4 {
21940            for instance in 1..=(NODE_CONNECTION_EVENT_BURST_MAX as u64 * 2) {
21941                event_sequence += 1;
21942                let mut address = terminal_address(1);
21943                address.session.instance_id = AgentInstanceId(instance);
21944                queue_connection_event(
21945                    &mut pending,
21946                    NodeEventEnvelope {
21947                        sequence: event_sequence,
21948                        event: NodeEvent::TerminalFrame {
21949                            address,
21950                            frame: terminal_frame(frame_sequence),
21951                        },
21952                    },
21953                    0,
21954                );
21955            }
21956        }
21957        assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX * 2);
21958        pending.sort_unstable_by_key(|event| event.sequence);
21959
21960        let mut uninterrupted = pending.clone();
21961        let first_burst = connection_event_burst_len(uninterrupted.len());
21962        uninterrupted.drain(..first_burst);
21963        let second_burst = connection_event_burst_len(uninterrupted.len());
21964        uninterrupted.drain(..second_burst);
21965        assert!(uninterrupted.is_empty());
21966
21967        let (frame_tx, mut frame_rx) =
21968            mpsc::channel::<Result<ClientFrame, FrameError>>(1);
21969        frame_tx
21970            .try_send(Ok(ClientFrame::Request(RequestEnvelope {
21971                request_id: 77,
21972                request: NodeRequest::Snapshot,
21973            })))
21974            .unwrap();
21975        let burst_len = connection_event_burst_len(pending.len());
21976        assert_eq!(burst_len, NODE_CONNECTION_EVENT_BURST_MAX);
21977        pending.drain(..burst_len);
21978        assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX);
21979        assert!(matches!(
21980            frame_rx.try_recv(),
21981            Ok(Ok(ClientFrame::Request(RequestEnvelope {
21982                request_id: 77,
21983                request: NodeRequest::Snapshot,
21984            })))
21985        ));
21986    }
21987
21988    #[test]
21989    fn terminal_lag_watermark_discards_retained_and_replayed_batches() {
21990        let mut pending = Vec::new();
21991        for sequence in [7_u64, 8] {
21992            let mut address = terminal_address(1);
21993            address.session.instance_id = AgentInstanceId(sequence);
21994            queue_connection_event(
21995                &mut pending,
21996                NodeEventEnvelope {
21997                    sequence,
21998                    event: NodeEvent::TerminalFrame {
21999                        address,
22000                        frame: terminal_frame(sequence),
22001                    },
22002                },
22003                0,
22004            );
22005        }
22006        let mut discard_through = 0;
22007        apply_connection_resync_watermark(&mut pending, &mut discard_through, 8);
22008        assert!(pending.is_empty());
22009
22010        let replayed = NodeEventEnvelope {
22011            sequence: 8,
22012            event: NodeEvent::TerminalFrame {
22013                address: terminal_address(1),
22014                frame: terminal_frame(8),
22015            },
22016        };
22017        let advanced = NodeEventEnvelope {
22018            sequence: 9,
22019            event: NodeEvent::TerminalFrame {
22020                address: terminal_address(2),
22021                frame: terminal_frame(1),
22022            },
22023        };
22024        queue_connection_event_batch(
22025            &mut pending,
22026            &[replayed, advanced.clone()],
22027            discard_through,
22028        );
22029        assert_eq!(pending, vec![advanced]);
22030    }
22031
22032    fn record(provider: &str, record_id: &str) -> ManagedSessionRecord {
22033        ManagedSessionRecord {
22034            record_id: SessionRecordId::new(record_id).unwrap(),
22035            display_name: format!("{provider} record"),
22036            provider: agent(provider),
22037            mode: SessionMode::Pty,
22038            state: ManagedSessionState::Unavailable,
22039            workspace_id: WorkspaceId::new("primary").unwrap(),
22040            canonical_root: opaque_windows_path(
22041                std::env::current_dir().unwrap().to_string_lossy().into_owned(),
22042            ),
22043            provider_session: None,
22044            active_session: None,
22045            environment_profile: None,
22046            bundle: None,
22047            context_id: None,
22048            context: None,
22049            exported_context: None,
22050            task_binding: None,
22051            created_at_unix_ms: 1,
22052            updated_at_unix_ms: 1,
22053            last_error: None,
22054        }
22055    }
22056
22057    fn retention_test_record(
22058        workspace_id: &str,
22059        record_id: &str,
22060        state: ManagedSessionState,
22061        updated_at_unix_ms: u64,
22062    ) -> ManagedSessionRecord {
22063        ManagedSessionRecord {
22064            record_id: SessionRecordId::new(record_id).unwrap(),
22065            display_name: format!("{record_id} display"),
22066            provider: agent("claude"),
22067            mode: SessionMode::Pty,
22068            state,
22069            workspace_id: WorkspaceId::new(workspace_id).unwrap(),
22070            canonical_root: opaque_windows_path(
22071                std::env::current_dir().unwrap().to_string_lossy().into_owned(),
22072            ),
22073            provider_session: None,
22074            active_session: None,
22075            environment_profile: None,
22076            bundle: None,
22077            context_id: None,
22078            context: None,
22079            exported_context: None,
22080            task_binding: None,
22081            created_at_unix_ms: updated_at_unix_ms,
22082            updated_at_unix_ms,
22083            last_error: None,
22084        }
22085    }
22086
22087    /// Two workspaces of three `Unavailable` records each, staggered by
22088    /// age, plus one never-touchable decoy per non-`Unavailable` state in
22089    /// `workspace-a` (all as old as that workspace's oldest real record --
22090    /// if the sweep's state gate ever leaked, these would be the first
22091    /// thing it wrongly retired). Ages, oldest to newest:
22092    /// `workspace-a`: a1 (500s), a2 (300s), a3 (100s).
22093    /// `workspace-b`: b1 (400s), b2 (200s), b3 (50s).
22094    fn retention_fixture_records(now: u64) -> Vec<ManagedSessionRecord> {
22095        vec![
22096            retention_test_record("workspace-a", "a1", ManagedSessionState::Unavailable, now - 500_000),
22097            retention_test_record("workspace-a", "a2", ManagedSessionState::Unavailable, now - 300_000),
22098            retention_test_record("workspace-a", "a3", ManagedSessionState::Unavailable, now - 100_000),
22099            retention_test_record("workspace-b", "b1", ManagedSessionState::Unavailable, now - 400_000),
22100            retention_test_record("workspace-b", "b2", ManagedSessionState::Unavailable, now - 200_000),
22101            retention_test_record("workspace-b", "b3", ManagedSessionState::Unavailable, now - 50_000),
22102            retention_test_record("workspace-a", "decoy-live", ManagedSessionState::Live, now - 500_000),
22103            retention_test_record(
22104                "workspace-a",
22105                "decoy-pending",
22106                ManagedSessionState::IdentityPending,
22107                now - 500_000,
22108            ),
22109            retention_test_record(
22110                "workspace-a",
22111                "decoy-dormant",
22112                ManagedSessionState::Dormant,
22113                now - 500_000,
22114            ),
22115        ]
22116    }
22117
22118    fn retention_fixture_shared() -> (NodeShared, NativeRuntime, PathBuf, u64) {
22119        let root = temporary_workspace_root("session-record-retention");
22120        std::fs::create_dir_all(&root).unwrap();
22121        let catalog = active_registry().unwrap();
22122        let (handle, runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22123        let workspace_a =
22124            WorkspaceConfig::new(WorkspaceId::new("workspace-a").unwrap(), &root).unwrap();
22125        let workspace_b =
22126            WorkspaceConfig::new(WorkspaceId::new("workspace-b").unwrap(), &root).unwrap();
22127        let mut shared = NodeShared::new(
22128            handle,
22129            "fixture-token".to_owned(),
22130            NodeId::new("node-retention-test").unwrap(),
22131            vec![workspace_a, workspace_b],
22132            vec![agent("claude")],
22133        );
22134        shared.state_path = Some(root.join("node-state-v10.json"));
22135        let now = unix_time_ms();
22136        for record in retention_fixture_records(now) {
22137            shared.insert_record(record).unwrap();
22138        }
22139        shared
22140            .persist_state_locked()
22141            .expect("fixture-only, empty-history persist against a fresh temp state path");
22142        (shared, runtime, root, now)
22143    }
22144
22145    fn retention_removed_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
22146        shared
22147            .history
22148            .lock()
22149            .unwrap_or_else(|poisoned| poisoned.into_inner())
22150            .events
22151            .iter()
22152            .filter_map(|envelope| match &envelope.event {
22153                NodeEvent::SessionRecordRemoved { record_id } => {
22154                    Some(record_id.as_str().to_owned())
22155                }
22156                _ => None,
22157            })
22158            .collect()
22159    }
22160
22161    fn retention_remaining_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
22162        shared
22163            .session_records
22164            .lock()
22165            .unwrap_or_else(|poisoned| poisoned.into_inner())
22166            .records
22167            .keys()
22168            .map(|record_id| record_id.as_str().to_owned())
22169            .collect()
22170    }
22171
22172    fn owned(values: &[&str]) -> std::collections::BTreeSet<String> {
22173        values.iter().map(|value| (*value).to_owned()).collect()
22174    }
22175
22176    #[tokio::test]
22177    async fn session_record_retention_keep_n_only_keeps_the_newest_per_workspace() {
22178        let (mut shared, runtime, root, _now) = retention_fixture_shared();
22179        shared.session_record_retention = SessionRecordRetentionConfig {
22180            age_ms: 0,
22181            keep_per_workspace: 1,
22182        };
22183        shared.retire_unavailable_session_records().await;
22184
22185        assert_eq!(
22186            retention_remaining_record_ids(&shared),
22187            owned(&["a3", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
22188        );
22189        assert_eq!(
22190            retention_removed_record_ids(&shared),
22191            owned(&["a1", "a2", "b1", "b2"]),
22192        );
22193        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 4);
22194
22195        drop(shared);
22196        drop(runtime);
22197        std::fs::remove_dir_all(root).unwrap();
22198    }
22199
22200    /// Age-only would retire {a1, a2, b1}; keep-N-only would retire
22201    /// {a1, a2, b1, b2}. With both flags set the sweep must retire exactly
22202    /// the intersection {a1, a2, b1} -- `b2` fails the age test (it is only
22203    /// 200s old against a 250s threshold) even though it fails the keep-N
22204    /// test too, proving retirement needs BOTH, not either.
22205    #[tokio::test]
22206    async fn session_record_retention_both_set_retires_only_the_intersection() {
22207        let (mut shared, runtime, root, _now) = retention_fixture_shared();
22208        shared.session_record_retention = SessionRecordRetentionConfig {
22209            age_ms: 250_000,
22210            keep_per_workspace: 1,
22211        };
22212        shared.retire_unavailable_session_records().await;
22213
22214        assert_eq!(
22215            retention_remaining_record_ids(&shared),
22216            owned(&["a3", "b2", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
22217        );
22218        assert_eq!(retention_removed_record_ids(&shared), owned(&["a1", "a2", "b1"]));
22219        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 3);
22220
22221        drop(shared);
22222        drop(runtime);
22223        std::fs::remove_dir_all(root).unwrap();
22224    }
22225
22226    #[tokio::test]
22227    async fn session_record_retention_both_disabled_is_a_no_op() {
22228        let (mut shared, runtime, root, _now) = retention_fixture_shared();
22229        shared.session_record_retention = SessionRecordRetentionConfig::default();
22230        shared.retire_unavailable_session_records().await;
22231
22232        assert_eq!(
22233            retention_remaining_record_ids(&shared),
22234            owned(&[
22235                "a1", "a2", "a3", "b1", "b2", "b3",
22236                "decoy-live", "decoy-pending", "decoy-dormant",
22237            ]),
22238        );
22239        assert!(retention_removed_record_ids(&shared).is_empty());
22240        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 0);
22241
22242        drop(shared);
22243        drop(runtime);
22244        std::fs::remove_dir_all(root).unwrap();
22245    }
22246
22247    #[test]
22248    fn session_record_context_export_selects_one_exact_history_identity() {
22249        let candidates = vec![
22250            HistoryCandidateSummary {
22251                id: "candidate-other".to_owned(),
22252                session_id_hint: "provider-other".to_owned(),
22253                modified_at_unix_ms: Some(1),
22254            },
22255            HistoryCandidateSummary {
22256                id: "candidate-exact".to_owned(),
22257                session_id_hint: "provider-exact".to_owned(),
22258                modified_at_unix_ms: Some(2),
22259            },
22260        ];
22261        assert_eq!(
22262            select_history_candidate(&candidates, Some("provider-exact")).unwrap(),
22263            "candidate-exact",
22264        );
22265    }
22266
22267    #[test]
22268    fn session_record_context_export_rejects_missing_and_ambiguous_history_identity() {
22269        let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
22270            id: id.to_owned(),
22271            session_id_hint: hint.to_owned(),
22272            modified_at_unix_ms: None,
22273        };
22274        let missing = select_history_candidate(
22275            &[candidate("candidate-other", "provider-other")],
22276            Some("provider-exact"),
22277        )
22278        .unwrap_err();
22279        assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
22280        let ambiguous = select_history_candidate(
22281            &[
22282                candidate("candidate-a", "provider-exact"),
22283                candidate("candidate-b", "provider-exact"),
22284            ],
22285            Some("provider-exact"),
22286        )
22287        .unwrap_err();
22288        assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
22289    }
22290
22291    /// The PTY-sidecar-observation shape (no fleet provider today, but
22292    /// capability-keyed rather than vendor-locked) never carries a verified
22293    /// `provider_session`; its record-bound export instead falls back to
22294    /// "exactly one discovered candidate" (see
22295    /// `export_context_pack_for_session_record_inner`). This is the same
22296    /// ambiguity refusal as the identity-filtered path, just without an
22297    /// identity to filter by.
22298    #[test]
22299    fn session_record_context_export_selects_sole_history_candidate_without_identity() {
22300        let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
22301            id: id.to_owned(),
22302            session_id_hint: hint.to_owned(),
22303            modified_at_unix_ms: None,
22304        };
22305        assert_eq!(
22306            select_history_candidate(&[candidate("candidate-sole", "sidecar-session")], None)
22307                .unwrap(),
22308            "candidate-sole",
22309        );
22310        let missing = select_history_candidate(&[], None).unwrap_err();
22311        assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
22312        let ambiguous = select_history_candidate(
22313            &[
22314                candidate("candidate-a", "sidecar-session-a"),
22315                candidate("candidate-b", "sidecar-session-b"),
22316            ],
22317            None,
22318        )
22319        .unwrap_err();
22320        assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
22321    }
22322
22323    #[test]
22324    fn session_record_context_export_revalidation_rejects_record_change() {
22325        let session = terminal_address(1);
22326        let identity = ProviderSessionIdentity {
22327            key: ProviderSessionKey::SessionId,
22328            id: "provider-exact".to_owned(),
22329            transcript_path: Some(r"C:\private\provider.jsonl".to_owned()),
22330        };
22331        let mut expected = record("claude", "record-context-export");
22332        expected.state = ManagedSessionState::Live;
22333        expected.provider_session = Some(identity.clone());
22334        expected.active_session = Some(session.clone());
22335        let binding = SessionBinding {
22336            workspace_id: session.workspace_id.clone(),
22337            generation: session.session.generation,
22338            runtime_policy: ProviderRuntimePolicy::raw_pty(),
22339            pending_resume: None,
22340            record_id: Some(expected.record_id.clone()),
22341            managed_worktree_lease_id: None,
22342            environment_profile: None,
22343            bundle: None,
22344            context: None,
22345            materialization_id: None,
22346        };
22347        let current_root = windows_path_text(&expected.canonical_root).to_owned();
22348        assert!(session_record_context_export_binding_is_exact(
22349            &expected,
22350            &expected,
22351            Some(&identity),
22352            &session,
22353            &binding,
22354            &expected.provider,
22355            &current_root,
22356            false,
22357        ));
22358        let mut changed = expected.clone();
22359        changed.updated_at_unix_ms += 1;
22360        assert!(!session_record_context_export_binding_is_exact(
22361            &expected,
22362            &changed,
22363            Some(&identity),
22364            &session,
22365            &binding,
22366            &expected.provider,
22367            &current_root,
22368            false,
22369        ));
22370        // A same-instant timestamp bump alone (state and active_session
22371        // otherwise unchanged) is not the clean-detachment transition
22372        // either, even with the reactive flag on: only the exact Live ->
22373        // Dormant + active_session cleared shape qualifies.
22374        assert!(!session_record_context_export_binding_is_exact(
22375            &expected,
22376            &changed,
22377            Some(&identity),
22378            &session,
22379            &binding,
22380            &expected.provider,
22381            &current_root,
22382            true,
22383        ));
22384        let mut detached = expected.clone();
22385        detached.state = ManagedSessionState::Dormant;
22386        detached.active_session = None;
22387        detached.updated_at_unix_ms += 1;
22388        assert!(!session_record_context_export_binding_is_exact(
22389            &expected,
22390            &detached,
22391            Some(&identity),
22392            &session,
22393            &binding,
22394            &expected.provider,
22395            &current_root,
22396            false,
22397        ));
22398        assert!(session_record_context_export_binding_is_exact(
22399            &expected,
22400            &detached,
22401            Some(&identity),
22402            &session,
22403            &binding,
22404            &expected.provider,
22405            &current_root,
22406            true,
22407        ));
22408    }
22409
22410    /// Mirrors `session_record_context_export_revalidation_rejects_record_change`
22411    /// for a record whose provider never carries a verified identity (the
22412    /// PTY-sidecar-observation shape -- capability-keyed, no fleet provider
22413    /// declares one today): `identity` is `None` throughout, and the
22414    /// exactness check falls back to comparing `provider_session` itself,
22415    /// which must still be `None` on both sides.
22416    #[test]
22417    fn session_record_context_export_binding_is_exact_without_identity() {
22418        let session = terminal_address(1);
22419        let mut expected = record("pty-sidecar-fixture", "record-context-export-no-identity");
22420        expected.state = ManagedSessionState::Live;
22421        expected.active_session = Some(session.clone());
22422        let binding = SessionBinding {
22423            workspace_id: session.workspace_id.clone(),
22424            generation: session.session.generation,
22425            runtime_policy: ProviderRuntimePolicy::raw_pty(),
22426            pending_resume: None,
22427            record_id: Some(expected.record_id.clone()),
22428            managed_worktree_lease_id: None,
22429            environment_profile: None,
22430            bundle: None,
22431            context: None,
22432            materialization_id: None,
22433        };
22434        let current_root = windows_path_text(&expected.canonical_root).to_owned();
22435        assert!(session_record_context_export_binding_is_exact(
22436            &expected,
22437            &expected,
22438            None,
22439            &session,
22440            &binding,
22441            &expected.provider,
22442            &current_root,
22443            false,
22444        ));
22445        // A record that gained an identity between the initial lookup and
22446        // this recheck no longer matches the `None` this shape expects to
22447        // keep seeing -- treated the same as any other mid-export drift.
22448        let mut drifted = expected.clone();
22449        drifted.provider_session = Some(ProviderSessionIdentity {
22450            key: ProviderSessionKey::SessionId,
22451            id: "unexpected-identity".to_owned(),
22452            transcript_path: None,
22453        });
22454        assert!(!session_record_context_export_binding_is_exact(
22455            &expected,
22456            &drifted,
22457            None,
22458            &session,
22459            &binding,
22460            &expected.provider,
22461            &current_root,
22462            false,
22463        ));
22464    }
22465
22466    #[test]
22467    fn session_record_context_export_preflight_requires_live_usable_source() {
22468        let mut source = record("claude", "record-context-export-preflight");
22469        source.state = ManagedSessionState::Live;
22470        assert!(session_record_context_export_source_is_usable(
22471            &source,
22472            &SessionStatus::Running,
22473            false,
22474        ));
22475        assert!(session_record_context_export_source_is_usable(
22476            &source,
22477            &SessionStatus::Exited { exit_code: Some(0) },
22478            false,
22479        ));
22480
22481        source.state = ManagedSessionState::Dormant;
22482        assert!(!session_record_context_export_source_is_usable(
22483            &source,
22484            &SessionStatus::Running,
22485            false,
22486        ));
22487        // The reactive auto-export-at-exit path (allow_clean_detachment) is
22488        // the only caller that may treat a Dormant, cleanly-exited record as
22489        // still usable — the on-demand path above never does.
22490        assert!(session_record_context_export_source_is_usable(
22491            &source,
22492            &SessionStatus::Exited { exit_code: Some(0) },
22493            true,
22494        ));
22495        assert!(!session_record_context_export_source_is_usable(
22496            &source,
22497            &SessionStatus::Exited { exit_code: Some(0) },
22498            false,
22499        ));
22500        source.state = ManagedSessionState::Live;
22501        assert!(!session_record_context_export_source_is_usable(
22502            &source,
22503            &SessionStatus::Starting,
22504            false,
22505        ));
22506    }
22507
22508    #[test]
22509    fn session_record_context_export_final_conflict_leaves_catalog_empty() {
22510        let catalog = active_registry().unwrap();
22511        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22512        let workspace = WorkspaceConfig::new(
22513            WorkspaceId::new("primary").unwrap(),
22514            std::env::current_dir().unwrap(),
22515        )
22516        .unwrap();
22517        let shared = NodeShared::new(
22518            handle,
22519            "fixture-token".to_owned(),
22520            NodeId::new("node-context-export-conflict").unwrap(),
22521            vec![workspace],
22522            vec![agent("claude")],
22523        );
22524        let history = HistorySessionRecord {
22525            session_id: "provider-exact".to_owned(),
22526            title: None,
22527            cwd: None,
22528            model: None,
22529            message_count: 1,
22530            completed_turn_count: None,
22531            total_tokens: 0,
22532            messages: vec![gate4agent_types::HistoryMessageRecord {
22533                role: gate4agent_types::HistoryMessageRole::User,
22534                text: "bounded context".to_owned(),
22535            }],
22536        };
22537        let pack = NodeContextPack::export(
22538            ContextPackLineageReceipt {
22539                source_node_id: shared.node_id.clone(),
22540                source_session: terminal_address(1),
22541                source_provider: agent("claude"),
22542            },
22543            &history,
22544        )
22545        .unwrap();
22546        let context_id = pack.receipt().id.clone();
22547        let session = terminal_address(1);
22548        let identity = ProviderSessionIdentity {
22549            key: ProviderSessionKey::SessionId,
22550            id: history.session_id.clone(),
22551            transcript_path: None,
22552        };
22553        let mut expected = record("claude", "record-context-export-final-conflict");
22554        expected.state = ManagedSessionState::Live;
22555        expected.provider_session = Some(identity.clone());
22556        expected.active_session = Some(session.clone());
22557        let mut changed = expected.clone();
22558        changed.updated_at_unix_ms += 1;
22559        shared.insert_record(changed).unwrap();
22560
22561        let error = shared
22562            .commit_context_pack_for_session_record(
22563                &expected,
22564                Some(&identity),
22565                &session,
22566                "candidate-exact",
22567                &history,
22568                pack,
22569                false,
22570            )
22571            .unwrap_err();
22572
22573        assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
22574        assert!(shared
22575            .context_catalog
22576            .read()
22577            .unwrap_or_else(|poisoned| poisoned.into_inner())
22578            .get(&context_id)
22579            .is_none());
22580    }
22581
22582    #[tokio::test]
22583    async fn session_task_binding_mints_assigns_clears_with_cas_and_persists_v9() {
22584        let root = temporary_workspace_root("session-task-binding");
22585        std::fs::create_dir_all(&root).unwrap();
22586        let state_path = root.join("node-state.json");
22587        let catalog = active_registry().unwrap();
22588        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22589        let workspace = WorkspaceConfig::new(WorkspaceId::new("primary").unwrap(), &root).unwrap();
22590        let node_id = NodeId::new("node-1").unwrap();
22591        let mut shared = NodeShared::new(
22592            handle,
22593            "fixture-token".to_owned(),
22594            node_id.clone(),
22595            vec![workspace],
22596            vec![agent("claude")],
22597        );
22598        shared.state_path = Some(state_path.clone());
22599        let record = record("claude", "sr-task");
22600        let record_id = record.record_id.clone();
22601        shared.insert_record(record).unwrap();
22602        shared.persist_state().unwrap();
22603        shared
22604            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
22605            .unwrap();
22606
22607        let minted = process_request_inner(
22608            &shared,
22609            77,
22610            ClientRole::Operator,
22611            NodeRequest::SetSessionTask {
22612                record_id: record_id.clone(),
22613                expected_revision: 0,
22614                target: SessionTaskTargetV1::New,
22615            },
22616        )
22617        .await
22618        .unwrap();
22619        let NodeResponse::SessionRecordUpdated { record: minted } = minted else {
22620            panic!("session task mutation returned another response");
22621        };
22622        let minted_binding = minted.task_binding.clone().unwrap();
22623        let minted_task_id = minted_binding.task_id.clone().unwrap();
22624        assert_eq!(minted_binding.revision, 1);
22625        assert!(minted_task_id.as_str().starts_with("task-"));
22626        assert_eq!(minted_task_id.as_str().len(), 29);
22627
22628        let event_count = shared.current_sequence();
22629        let repeated = process_request_inner(
22630            &shared,
22631            77,
22632            ClientRole::Operator,
22633            NodeRequest::SetSessionTask {
22634                record_id: record_id.clone(),
22635                expected_revision: 1,
22636                target: SessionTaskTargetV1::Existing {
22637                    task_id: minted_task_id.clone(),
22638                },
22639            },
22640        )
22641        .await
22642        .unwrap();
22643        assert_eq!(repeated, NodeResponse::SessionRecordUpdated { record: minted.clone() });
22644        assert_eq!(shared.current_sequence(), event_count);
22645
22646        let stale = process_request_inner(
22647            &shared,
22648            77,
22649            ClientRole::Operator,
22650            NodeRequest::SetSessionTask {
22651                record_id: record_id.clone(),
22652                expected_revision: 0,
22653                target: SessionTaskTargetV1::Existing {
22654                    task_id: minted_task_id,
22655                },
22656            },
22657        )
22658        .await
22659        .unwrap_err();
22660        assert_eq!(stale.code, NodeFailureCode::SessionRecordConflict);
22661
22662        let assigned_id: TaskId = "task-ffeeddccbbaa998877665544".parse().unwrap();
22663        let assigned = process_request_inner(
22664            &shared,
22665            77,
22666            ClientRole::Operator,
22667            NodeRequest::SetSessionTask {
22668                record_id: record_id.clone(),
22669                expected_revision: 1,
22670                target: SessionTaskTargetV1::Existing {
22671                    task_id: assigned_id.clone(),
22672                },
22673            },
22674        )
22675        .await
22676        .unwrap();
22677        let NodeResponse::SessionRecordUpdated { record: assigned } = assigned else {
22678            panic!("session task assignment returned another response");
22679        };
22680        assert_eq!(assigned.task_binding.as_ref().unwrap().revision, 2);
22681        assert_eq!(assigned.task_binding.as_ref().unwrap().task_id.as_ref(), Some(&assigned_id));
22682
22683        let cleared = process_request_inner(
22684            &shared,
22685            77,
22686            ClientRole::Operator,
22687            NodeRequest::SetSessionTask {
22688                record_id: record_id.clone(),
22689                expected_revision: 2,
22690                target: SessionTaskTargetV1::Clear,
22691            },
22692        )
22693        .await
22694        .unwrap();
22695        let NodeResponse::SessionRecordUpdated { record: cleared } = cleared else {
22696            panic!("session task clear returned another response");
22697        };
22698        assert_eq!(cleared.task_binding.as_ref().unwrap().revision, 3);
22699        assert!(cleared.task_binding.as_ref().unwrap().task_id.is_none());
22700        assert!(cleared.task_binding.as_ref().unwrap().changed_at_unix_ms > 0);
22701        assert_eq!(
22702            cleared.task_binding.as_ref().unwrap().changed_at_unix_ms,
22703            cleared.updated_at_unix_ms,
22704        );
22705
22706        let persisted = std::fs::read(&state_path).unwrap();
22707        assert_eq!(serde_json::from_slice::<serde_json::Value>(&persisted).unwrap()["version"], NODE_STATE_SCHEMA_V10);
22708        let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
22709        assert_eq!(loaded.records[0].task_binding, cleared.task_binding);
22710        std::fs::remove_dir_all(root).unwrap();
22711    }
22712
22713    #[test]
22714    fn session_task_capability_strips_snapshots_events_and_replies() {
22715        let mut bound = record("claude", "sr-task-projection");
22716        bound.updated_at_unix_ms = 2;
22717        bound.task_binding = Some(SessionTaskBindingV1 {
22718            revision: 1,
22719            task_id: Some("task-00112233445566778899aaff".parse().unwrap()),
22720            changed_at_unix_ms: 2,
22721        });
22722        let request = NodeRequest::SetSessionTask {
22723            record_id: bound.record_id.clone(),
22724            expected_revision: 1,
22725            target: SessionTaskTargetV1::Clear,
22726        };
22727        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22728            capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
22729        }));
22730        assert!(!request_uses_unnegotiated_capability(
22731            &request,
22732            &baseline_capabilities().unwrap(),
22733        ));
22734        assert!(request_uses_unnegotiated_capability(&request, &[]));
22735
22736        let mut snapshot = terminal_test_shared().snapshot();
22737        snapshot.session_records.push(bound.clone());
22738        let mut reply = ResponseEnvelope {
22739            request_id: 1,
22740            result: Ok(NodeResponse::Resync {
22741                event_sequence: 1,
22742                oldest_available_sequence: 1,
22743                snapshot,
22744                events: vec![NodeEventEnvelope {
22745                    sequence: 1,
22746                    event: NodeEvent::SessionRecordUpserted {
22747                        record: bound.clone(),
22748                    },
22749                }],
22750            }),
22751        };
22752        project_response_without_session_task_binding(&mut reply);
22753        let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
22754            panic!("task projection changed the response kind");
22755        };
22756        assert!(snapshot.session_records[0].task_binding.is_none());
22757        let NodeEvent::SessionRecordUpserted { record } = &events[0].event else {
22758            panic!("task projection changed the event kind");
22759        };
22760        assert!(record.task_binding.is_none());
22761
22762        let projected = project_event_without_session_task_binding(NodeEventEnvelope {
22763            sequence: 2,
22764            event: NodeEvent::SessionRecordUpserted { record: bound.clone() },
22765        });
22766        let NodeEvent::SessionRecordUpserted { record } = projected.event else {
22767            panic!("task event projection changed the event kind");
22768        };
22769        assert!(record.task_binding.is_none());
22770
22771        let mut direct = ResponseEnvelope {
22772            request_id: 2,
22773            result: Ok(NodeResponse::SessionRecordUpdated { record: bound }),
22774        };
22775        project_response_without_session_task_binding(&mut direct);
22776        let Ok(NodeResponse::SessionRecordUpdated { record }) = direct.result else {
22777            panic!("task reply projection changed the response kind");
22778        };
22779        assert!(record.task_binding.is_none());
22780    }
22781
22782    #[test]
22783    fn windows_runtime_default_node_endpoint_is_exact_and_valid() {
22784        assert_eq!(DEFAULT_NODE_ENDPOINT, r"\\.\pipe\gate4agent-node");
22785        let workspace = WorkspaceConfig::new(
22786            WorkspaceId::new("primary").unwrap(),
22787            std::env::current_dir().unwrap(),
22788        )
22789        .unwrap();
22790        let config = NodeServerConfig::new(
22791            DEFAULT_NODE_ENDPOINT,
22792            "fixture-token",
22793            NodeId::new("node-1").unwrap(),
22794            [workspace],
22795        )
22796        .unwrap();
22797        assert_eq!(config.endpoint, DEFAULT_NODE_ENDPOINT);
22798    }
22799
22800    #[cfg(all(feature = "fixture", windows))]
22801    #[test]
22802    fn provider_bundle_argv_hold_fixture_is_parent_bounded_and_waits_after_proof() {
22803        let root = temporary_workspace_root("provider-bundle-argv-hold");
22804        let outside = temporary_workspace_root("provider-bundle-argv-hold-outside");
22805        std::fs::create_dir_all(&root).unwrap();
22806        std::fs::create_dir_all(&outside).unwrap();
22807        let proof_path = root.join("provider.proof");
22808        let release_signal = root.join("release.signal");
22809        let config = NodeServerConfig::new(
22810            format!(
22811                r"\\.\pipe\gate4agent-provider-bundle-hold-{}",
22812                std::process::id(),
22813            ),
22814            "fixture-token",
22815            NodeId::new("provider-bundle-hold-node").unwrap(),
22816            [WorkspaceConfig::new(
22817                WorkspaceId::new("primary").unwrap(),
22818                std::env::current_dir().unwrap(),
22819            )
22820            .unwrap()],
22821        )
22822        .unwrap()
22823        .with_spawn_profiles(SpawnProfileRegistry::new([
22824            crate::protocol::SpawnProfileDefaults {
22825                profile_id: crate::protocol::SpawnProfileId::new("kimi-bundle-hold").unwrap(),
22826                revision: crate::protocol::SpawnProfileRevision::new("kimi-bundle-hold-r1")
22827                    .unwrap(),
22828                provider: agent("kimi"),
22829                mode: SessionMode::Pty,
22830                terminal_size: gate4agent_types::TerminalSize {
22831                    rows: 24,
22832                    columns: 80,
22833                },
22834                prompt: None,
22835                bundle_id: None,
22836                context_id: None,
22837                environment_profile_id: None,
22838            },
22839        ])
22840        .unwrap());
22841        let server = NodeServer::new_provider_bundle_argv_hold_fixture(
22842            config,
22843            agent("kimi"),
22844            proof_path.clone(),
22845            release_signal.clone(),
22846        )
22847        .unwrap();
22848        assert_eq!(server.shared.enabled_providers, [agent("kimi")]);
22849
22850        let spec = NodeServer::provider_bundle_argv_fixture_spec(
22851            agent("kimi"),
22852            proof_path.clone(),
22853            Some(release_signal.clone()),
22854        )
22855        .unwrap();
22856        let wrapper = &spec.launch.fixed_args[spec.launch.fixed_args.len() - 3];
22857        let proof_write = wrapper.find("WriteAllLines($proofPath").unwrap();
22858        let release_wait = wrapper
22859            .find("while (-not (Test-Path -LiteralPath $releaseSignal -PathType Leaf))")
22860            .unwrap();
22861        let fixture_ready = wrapper.find("fixture-ready>").unwrap();
22862        assert!(proof_write < release_wait);
22863        assert!(release_wait < fixture_ready);
22864        assert!(wrapper.contains("[DateTime]::UtcNow.AddSeconds(45)"));
22865        assert!(wrapper.contains("Start-Sleep -Milliseconds 20"));
22866        assert_eq!(
22867            &spec.launch.fixed_args[spec.launch.fixed_args.len() - 2..],
22868            &[
22869                proof_path.to_string_lossy().into_owned(),
22870                release_signal.to_string_lossy().into_owned(),
22871            ],
22872        );
22873
22874        let error = NodeServer::provider_bundle_argv_fixture_spec(
22875            agent("kimi"),
22876            proof_path,
22877            Some(outside.join("release.signal")),
22878        )
22879        .unwrap_err();
22880        assert!(matches!(
22881            error,
22882            NodeServerError::Registry(message)
22883                if message == "provider bundle argv release path must share the proof parent"
22884        ));
22885        drop(server);
22886        std::fs::remove_dir_all(root).unwrap();
22887        std::fs::remove_dir_all(outside).unwrap();
22888    }
22889
22890    #[cfg(feature = "fixture")]
22891    #[test]
22892    fn four_provider_context_fixture_registry_is_unambiguous_and_history_bound() {
22893        let root = temporary_workspace_root("context-fixture-registry");
22894        std::fs::create_dir_all(&root).unwrap();
22895        let proof_path = root.join("context.proof");
22896        let config = NodeServerConfig::new(
22897            format!(
22898                r"\\.\pipe\gate4agent-context-fixture-registry-{}",
22899                std::process::id(),
22900            ),
22901            "fixture-token",
22902            NodeId::new("context-fixture-node").unwrap(),
22903            [WorkspaceConfig::new(
22904                WorkspaceId::new("primary").unwrap(),
22905                std::env::current_dir().unwrap(),
22906            )
22907            .unwrap()],
22908        )
22909        .unwrap();
22910        let server = NodeServer::new_context_pack_fixture(config, proof_path).unwrap();
22911        let mut enabled = server
22912            .shared
22913            .enabled_providers
22914            .iter()
22915            .map(AgentId::as_str)
22916            .collect::<Vec<_>>();
22917        enabled.sort_unstable();
22918        assert_eq!(enabled, ["claude", "codex", "grok", "kimi"]);
22919        for provider in &server.shared.enabled_providers {
22920            assert!(server
22921                .shared
22922                .provider_adapter_contracts
22923                .iter()
22924                .any(|contract| {
22925                    &contract.provider == provider
22926                        && contract.family == AdapterFamily::History
22927                }));
22928        }
22929        drop(server);
22930        std::fs::remove_dir_all(root).unwrap();
22931    }
22932
22933    #[cfg(feature = "fixture")]
22934    #[test]
22935    fn monitoring_context_fixture_combines_claude_and_codex_hooks_with_history() {
22936        let root = temporary_workspace_root("monitoring-context-fixture");
22937        std::fs::create_dir_all(&root).unwrap();
22938        let proof_path = root.join("context.proof");
22939        let standard = NodeServer::context_pack_fixture_catalog(
22940            proof_path.clone(),
22941            None,
22942            false,
22943        ).unwrap();
22944        let monitoring = NodeServer::context_pack_fixture_catalog(
22945            proof_path.clone(),
22946            None,
22947            true,
22948        ).unwrap();
22949        let claude = AgentId::new("claude").unwrap();
22950        let standard_claude = standard.get(&claude).unwrap();
22951        let monitoring_claude = monitoring.get(&claude).unwrap();
22952        // Lifecycle hooks are retired (owner ruling 2026-09-25): neither the
22953        // standard nor the monitoring fixture declares a hook adapter
22954        // anymore.
22955        assert!(monitoring_claude.capabilities.adapters.hook.is_none());
22956        assert_eq!(
22957            monitoring_claude.capabilities.adapters.history,
22958            standard_claude.capabilities.adapters.history,
22959        );
22960        assert_ne!(monitoring_claude.launch, standard_claude.launch);
22961        let codex = AgentId::new("codex").unwrap();
22962        let standard_codex = standard.get(&codex).unwrap();
22963        let monitoring_codex = monitoring.get(&codex).unwrap();
22964        assert!(monitoring_codex.capabilities.adapters.hook.is_none());
22965        assert_eq!(
22966            monitoring_codex.capabilities.adapters.history,
22967            standard_codex.capabilities.adapters.history,
22968        );
22969        assert_ne!(monitoring_codex.launch, standard_codex.launch);
22970        let codex_validation = monitoring_codex.launch.fixed_args.iter()
22971            .find(|argument| argument.contains("F7_CODEX_BUNDLE_CONTEXT_VALIDATED"))
22972            .unwrap();
22973        for required in [
22974            "$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')",
22975            "$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')",
22976            "@('role', 'text')",
22977            "g4a-private-provider-session-canary",
22978            "private-provider-login-identity",
22979            "$rawProof",
22980            "$env:GATE4AGENT_HOOK_URL",
22981            "g4a-private-provider-session-canary",
22982        ] {
22983            assert!(codex_validation.contains(required), "missing privacy validation: {required}");
22984        }
22985        for provider in ["grok", "kimi"] {
22986            let provider = AgentId::new(provider).unwrap();
22987            assert_eq!(monitoring.get(&provider), standard.get(&provider));
22988        }
22989
22990        let config = NodeServerConfig::new(
22991            format!(
22992                r"\\.\pipe\gate4agent-monitoring-context-fixture-{}",
22993                std::process::id(),
22994            ),
22995            "fixture-token",
22996            NodeId::new("monitoring-context-fixture-node").unwrap(),
22997            [WorkspaceConfig::new(
22998                WorkspaceId::new("primary").unwrap(),
22999                std::env::current_dir().unwrap(),
23000            )
23001            .unwrap()],
23002        )
23003        .unwrap();
23004        let server = NodeServer::new_monitoring_context_pack_fixture(config, proof_path).unwrap();
23005        assert!(server.shared.fixture_semantic_hook_policy);
23006        drop(server);
23007        std::fs::remove_dir_all(root).unwrap();
23008    }
23009
23010    #[cfg(feature = "fixture")]
23011    #[test]
23012    fn kimi_context_only_fixture_is_bounded_and_preserves_codex_validation() {
23013        let root = temporary_workspace_root("kimi-context-only-fixture");
23014        std::fs::create_dir_all(&root).unwrap();
23015        let proof_path = root.join("context.proof");
23016        let standard = NodeServer::context_pack_fixture_catalog(
23017            proof_path.clone(),
23018            None,
23019            false,
23020        ).unwrap();
23021        let kimi = AgentId::new("kimi").unwrap();
23022        let context_only = NodeServer::context_pack_fixture_catalog(
23023            proof_path,
23024            Some(&kimi),
23025            false,
23026        )
23027        .unwrap();
23028
23029        let codex = AgentId::new("codex").unwrap();
23030        assert_eq!(
23031            standard.get(&codex).unwrap().launch.fixed_args,
23032            context_only.get(&codex).unwrap().launch.fixed_args,
23033        );
23034        let script = context_only
23035            .get(&kimi)
23036            .unwrap()
23037            .launch
23038            .fixed_args
23039            .iter()
23040            .find(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED"))
23041            .expect("Kimi context-only validation script is missing");
23042        for required in [
23043            "$bundleArgs.Count -ne 0",
23044            "GATE4AGENT_CONTEXT_ROOT",
23045            "context-pack.json",
23046            "$entries.Count -ne 1",
23047            "g4a-context-pack-v1",
23048            "$document.PSObject.Properties['cwd']",
23049            "'claude', 'codex', 'grok', 'kimi'",
23050            "$hasUser",
23051            "$hasAssistant",
23052            "SHA256",
23053            "context-only",
23054        ] {
23055            assert!(script.contains(required), "missing validation: {required}");
23056        }
23057        for forbidden in ["CODEX_HOME", "USERPROFILE", "auth", "token", "global config"] {
23058            assert!(!script.contains(forbidden), "forbidden home/auth access: {forbidden}");
23059        }
23060        assert!(!standard
23061            .get(&kimi)
23062            .unwrap()
23063            .launch
23064            .fixed_args
23065            .iter()
23066            .any(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED")));
23067
23068        std::fs::remove_dir_all(root).unwrap();
23069    }
23070
23071    #[cfg(all(feature = "fixture", windows))]
23072    #[tokio::test]
23073    async fn kimi_context_only_fixture_spawns_catalogued_context_pack_through_spawn_spec() {
23074        let root = temporary_workspace_root("kimi-context-only-spawn");
23075        let workspace_root = root.join("workspace");
23076        std::fs::create_dir_all(&workspace_root).unwrap();
23077        let materialization_root = root.join("private-materializations");
23078        let proof_path = root.join("context.proof");
23079        let node_id = NodeId::new("node-kimi-context-only-spawn").unwrap();
23080        let workspace_id = WorkspaceId::new("primary").unwrap();
23081        let profile_id = crate::protocol::SpawnProfileId::new("target-kimi").unwrap();
23082        let endpoint = format!(
23083            r"\\.\pipe\gate4agent-kimi-context-only-spawn-{}-{}",
23084            std::process::id(),
23085            unix_time_ms(),
23086        );
23087        let spawn_profiles = SpawnProfileRegistry::new([
23088            crate::protocol::SpawnProfileDefaults {
23089                profile_id: profile_id.clone(),
23090                revision: crate::protocol::SpawnProfileRevision::new("target-kimi-r1").unwrap(),
23091                provider: agent("kimi"),
23092                mode: SessionMode::Pty,
23093                terminal_size: gate4agent_types::TerminalSize {
23094                    rows: 30,
23095                    columns: 120,
23096                },
23097                prompt: None,
23098                bundle_id: None,
23099                context_id: None,
23100                environment_profile_id: None,
23101            },
23102        ])
23103        .unwrap();
23104        let mut config = NodeServerConfig::new(
23105            &endpoint,
23106            "fixture-token",
23107            node_id.clone(),
23108            [WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap()],
23109        )
23110        .unwrap()
23111        .with_spawn_profiles(spawn_profiles)
23112        .with_session_environment_materialization(
23113            materialization_root,
23114            Arc::new(FixtureSecretResolver {
23115                deny: Arc::new(AtomicBool::new(false)),
23116            }),
23117        )
23118        .unwrap();
23119        config.fixture_raw_pty_runtime = true;
23120        let server = NodeServer::new_context_only_proof_fixture(
23121            config,
23122            agent("kimi"),
23123            proof_path.clone(),
23124        )
23125        .unwrap();
23126        let shared = Arc::clone(&server.shared);
23127        let shutdown = server.shutdown_handle();
23128
23129        let history = HistorySessionRecord {
23130            session_id: "context-only-source-history".to_owned(),
23131            title: None,
23132            cwd: None,
23133            model: None,
23134            message_count: 2,
23135            completed_turn_count: None,
23136            total_tokens: 0,
23137            messages: vec![
23138                gate4agent_types::HistoryMessageRecord {
23139                    role: gate4agent_types::HistoryMessageRole::User,
23140                    text: "continue the bounded parent run through native C2".to_owned(),
23141                },
23142                gate4agent_types::HistoryMessageRecord {
23143                    role: gate4agent_types::HistoryMessageRole::Assistant,
23144                    text: "the exact parent context is ready for the child run".to_owned(),
23145                },
23146            ],
23147        };
23148        let pack = NodeContextPack::export(
23149            ContextPackLineageReceipt {
23150                source_node_id: node_id.clone(),
23151                source_session: SessionAddress {
23152                    workspace_id: workspace_id.clone(),
23153                    session: SessionKey {
23154                        instance_id: AgentInstanceId(71),
23155                        generation: SessionGeneration(1),
23156                    },
23157                },
23158                source_provider: agent("codex"),
23159            },
23160            &history,
23161        )
23162        .unwrap();
23163        let expected_context_hash = digest(&SHA256, pack.bytes())
23164            .as_ref()
23165            .iter()
23166            .map(|byte| format!("{byte:02x}"))
23167            .collect::<String>();
23168        let context = shared
23169            .context_catalog
23170            .write()
23171            .unwrap_or_else(|poisoned| poisoned.into_inner())
23172            .insert(pack)
23173            .unwrap();
23174        let spec = SpawnSpec {
23175            target: crate::protocol::SpawnTarget {
23176                node_id,
23177                workspace_id: workspace_id.clone(),
23178                worktree_id: None,
23179            },
23180            profile_id,
23181            expected_profile_revision: crate::protocol::SpawnProfileRevision::new(
23182                "target-kimi-r1",
23183            ).unwrap(),
23184            overrides: crate::protocol::SpawnOverrides {
23185                context_id: crate::protocol::SpawnOverride::Set {
23186                    value: context.id.clone(),
23187                },
23188                ..crate::protocol::SpawnOverrides::default()
23189            },
23190            deadline_ms: crate::protocol::SpawnDeadlineMs::new(10_000).unwrap(),
23191            idempotency_key: SpawnIdempotencyKey::new("kimi-context-only-spawn").unwrap(),
23192            required_capabilities: SpawnRequiredCapabilities::default(),
23193        };
23194        let resolved = match shared.resolve_spawn_spec(&spec) {
23195            Ok(resolved) => resolved,
23196            Err(error) => panic!(
23197                "Kimi context-only resolve rejected: exact_code={:?} message={}",
23198                error.code,
23199                error.message,
23200            ),
23201        };
23202        let resolved_context = match shared.resolve_context(&resolved) {
23203            Ok(Some(resolved_context)) => resolved_context,
23204            Ok(None) => panic!("Kimi context-only resolve omitted the exact ContextPack"),
23205            Err(error) => panic!(
23206                "Kimi context catalog resolve rejected: exact_code={:?} message={}",
23207                error.code,
23208                error.message,
23209            ),
23210        };
23211        assert_eq!(resolved_context, context);
23212        let runtime_policy = match shared
23213            .admit_provider_runtime(&agent("kimi"), ProviderRuntimeRequirement::RawPty)
23214            .await
23215        {
23216            Ok(runtime_policy) => runtime_policy,
23217            Err(error) => panic!(
23218                "Kimi RawPty admission rejected: exact_code={:?} message={}",
23219                error.code,
23220                error.message,
23221            ),
23222        };
23223        assert!(runtime_policy.raw_pty_lifecycle);
23224
23225        let launch_control = shared.native_launch_profile_control.as_ref().unwrap();
23226        let generic_overlay = NativeLaunchEnvironmentOverlay::new(
23227            agent("kimi"),
23228            TransportKind::Pty,
23229            vec![EnvMutation {
23230                key: OsString::from("GATE4AGENT_UNPROFILED_GENERIC"),
23231                value: Some(OsString::from("must-remain-rejected")),
23232            }],
23233        )
23234        .unwrap();
23235        assert_eq!(
23236            launch_control
23237                .install_native_launch_environment_overlay(
23238                    AgentInstanceId(72),
23239                    generic_overlay,
23240                )
23241                .unwrap_err(),
23242            gate4agent_runtime_native::NativeLaunchProfileError::EnvironmentOverlaySelectionMissing,
23243        );
23244
23245        let diagnostic_address = SessionAddress {
23246            workspace_id: workspace_id.clone(),
23247            session: SessionKey {
23248                instance_id: AgentInstanceId(73),
23249                generation: SessionGeneration(1),
23250            },
23251        };
23252        let (diagnostic_overlay, diagnostic_materialization) = match shared
23253            .prepare_session_materialization(
23254                &diagnostic_address,
23255                &agent("kimi"),
23256                SessionMode::Pty,
23257                None,
23258                None,
23259                Some(&resolved_context),
23260                None,
23261            )
23262        {
23263            Ok(Some(prepared)) => prepared,
23264            Ok(None) => panic!("Kimi context-only materialization produced no launch overlay"),
23265            Err(error) => panic!(
23266                "Kimi context-only materialization rejected: exact_code={:?} message={}",
23267                error.code,
23268                error.message,
23269            ),
23270        };
23271        let diagnostic_overlay = diagnostic_overlay
23272            .expect("Kimi context-only materialization omitted its environment overlay");
23273        assert!(matches!(
23274            &diagnostic_overlay,
23275            PreparedNativeLaunchOverlay::Environment(_),
23276        ));
23277        match shared.install_prepared_launch_overlay(
23278            diagnostic_address.session.instance_id,
23279            diagnostic_overlay,
23280        ) {
23281            Ok(None) => {}
23282            Ok(Some(_)) => panic!("Kimi context-only overlay unexpectedly retained argv authority"),
23283            Err(error) => panic!(
23284                "Kimi context-only overlay installation rejected: exact_code={:?} message={}",
23285                error.code,
23286                error.message,
23287            ),
23288        }
23289        assert!(launch_control.clear_native_instance_launch_overlay(
23290            diagnostic_address.session.instance_id,
23291        ));
23292        drop(diagnostic_materialization);
23293        assert!(shared
23294            .materializations
23295            .lock()
23296            .unwrap_or_else(|poisoned| poisoned.into_inner())
23297            .is_empty());
23298
23299        let node_task = tokio::spawn(server.run());
23300        let ready_client = timeout(Duration::from_secs(5), async {
23301            loop {
23302                match gate4agent_node_wire::LocalNodeClient::connect(
23303                    &endpoint,
23304                    &shared.node_id,
23305                    ClientRole::Observer,
23306                    "fixture-token",
23307                )
23308                .await
23309                {
23310                    Ok(client) => break client,
23311                    Err(_) if !node_task.is_finished() => sleep(Duration::from_millis(10)).await,
23312                    Err(error) => panic!("Kimi context-only Node stopped before readiness: {error}"),
23313                }
23314            }
23315        })
23316        .await
23317        .expect("Kimi context-only Node did not become ready");
23318        assert_eq!(
23319            ready_client
23320                .hello()
23321                .snapshot
23322                .provider_runtime_statuses
23323                .iter()
23324                .find(|status| status.provider().as_str() == "kimi")
23325                .expect("Kimi runtime status is missing")
23326                .mode(),
23327            crate::protocol::ProviderRuntimeMode::RawPassthrough,
23328        );
23329        drop(ready_client);
23330
23331        let receipt = match shared.spawn_from_spec(spec).await {
23332            Ok(receipt) => receipt,
23333            Err(error) => panic!(
23334                "Kimi context-only SpawnSpec rejected: exact_code={:?} message={}",
23335                error.code,
23336                error.message,
23337            ),
23338        };
23339        assert_eq!(receipt.provider, agent("kimi"));
23340        assert_eq!(receipt.context.as_ref(), Some(&context));
23341        assert!(receipt.context_binding_is_valid());
23342        timeout(Duration::from_secs(5), async {
23343            while !proof_path.is_file() {
23344                sleep(Duration::from_millis(10)).await;
23345            }
23346        })
23347        .await
23348        .expect("Kimi context-only fixture did not write its proof");
23349
23350        let proof = std::fs::read_to_string(&proof_path).unwrap();
23351        let lines = proof.lines().collect::<Vec<_>>();
23352        assert_eq!(lines.len(), 7);
23353        assert_eq!(lines[0], "context-only");
23354        assert!(Path::new(lines[1]).is_absolute());
23355        assert_ne!(
23356            std::fs::canonicalize(Path::new(lines[1])).unwrap(),
23357            std::fs::canonicalize(&workspace_root).unwrap(),
23358        );
23359        assert_eq!(
23360            std::fs::canonicalize(Path::new(lines[2])).unwrap(),
23361            std::fs::canonicalize(&workspace_root).unwrap(),
23362        );
23363        assert_eq!(lines[3], expected_context_hash);
23364        assert_eq!(lines[4], "g4a-context-pack-v1");
23365        assert_eq!(lines[5], "codex");
23366        assert_eq!(lines[6], "2");
23367        assert!(shared.handle.snapshot().sessions.iter().any(|session| {
23368            session.instance_id == receipt.session.session.instance_id
23369                && session.agent_id.as_str() == "kimi"
23370        }));
23371
23372        shutdown.request_shutdown().await.unwrap();
23373        timeout(Duration::from_secs(5), node_task)
23374            .await
23375            .unwrap()
23376            .unwrap()
23377            .unwrap();
23378        drop(shutdown);
23379        drop(shared);
23380        std::fs::remove_dir_all(root).unwrap();
23381    }
23382
23383    #[cfg(feature = "fixture")]
23384    #[test]
23385    fn clean_exit_fixture_constructor_is_fixture_scoped_and_path_bounded() {
23386        let root = temporary_workspace_root("clean-exit-constructor");
23387        std::fs::create_dir_all(&root).unwrap();
23388        let config = construction_test_config("clean-exit-constructor")
23389            .with_state_path(root.join("node-state.json"))
23390            .unwrap();
23391        let server = NodeServer::new_clean_exit_fixture(
23392            config,
23393            root.clone(),
23394            root.join("started.marker"),
23395            root.join("release.signal"),
23396        )
23397        .unwrap();
23398        assert_eq!(
23399            server
23400                .shared
23401                .enabled_providers
23402                .iter()
23403                .map(AgentId::as_str)
23404                .collect::<Vec<_>>(),
23405            ["claude"],
23406        );
23407        drop(server);
23408        std::fs::remove_dir_all(root).unwrap();
23409    }
23410
23411    #[test]
23412    fn production_registry_provider_contract_manifest_is_exact() {
23413        let registry = active_registry().unwrap();
23414        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
23415        assert_eq!(
23416            providers
23417                .iter()
23418                .map(|contract| (contract.provider.as_str(), contract.revision.as_str()))
23419                .collect::<Vec<_>>(),
23420            vec![
23421                ("claude", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
23422                ("codex", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
23423                ("grok", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
23424                ("kimi", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
23425            ]
23426        );
23427        assert_eq!(
23428            adapters
23429                .iter()
23430                .map(|contract| (
23431                    contract.provider.as_str(),
23432                    contract.family,
23433                    contract.adapter_id.as_str(),
23434                    contract.revision.as_str(),
23435                ))
23436                .collect::<Vec<_>>(),
23437            vec![
23438                ("claude", AdapterFamily::PtySemantic, "claude-code", "gate4agent-adapter/v1"),
23439                ("claude", AdapterFamily::Pipe, "claude-code", "gate4agent-adapter/v1"),
23440                ("claude", AdapterFamily::Acp, "claude-code", "gate4agent-adapter/v1"),
23441                ("claude", AdapterFamily::OneShot, "claude", "gate4agent-inline/claude-code-2.1/v1"),
23442                ("claude", AdapterFamily::History, "claude-code", "gate4agent-adapter/v1"),
23443                ("claude", AdapterFamily::Resume, "claude-code", "gate4agent-adapter/v1"),
23444                ("claude", AdapterFamily::SessionOptions, "claude-code", "gate4agent-session-options/orca-d8629c4/v1"),
23445                ("codex", AdapterFamily::PtySemantic, "codex", "gate4agent-adapter/v1"),
23446                ("codex", AdapterFamily::Pipe, "codex", "gate4agent-adapter/v1"),
23447                ("codex", AdapterFamily::Acp, "codex", "gate4agent-adapter/v1"),
23448                ("codex", AdapterFamily::OneShot, "codex", "gate4agent-inline/codex-cli-0.144/v1"),
23449                ("codex", AdapterFamily::History, "codex", "gate4agent-adapter/v1"),
23450                ("codex", AdapterFamily::Resume, "codex", "gate4agent-adapter/v1"),
23451                ("codex", AdapterFamily::SessionOptions, "codex", "gate4agent-session-options/orca-d8629c4/v1"),
23452                ("grok", AdapterFamily::Acp, "grok", "gate4agent-adapter/v1"),
23453                ("grok", AdapterFamily::History, "grok", "gate4agent-adapter/v1"),
23454                ("grok", AdapterFamily::Resume, "grok", "gate4agent-adapter/v1"),
23455                ("kimi", AdapterFamily::PtySemantic, "kimi", "gate4agent-adapter/v1"),
23456                ("kimi", AdapterFamily::Pipe, "kimi", "gate4agent-adapter/v1"),
23457                ("kimi", AdapterFamily::Acp, "kimi", "gate4agent-adapter/v1"),
23458                ("kimi", AdapterFamily::OneShot, "kimi", "gate4agent-inline/kimi-code-0.31/v1"),
23459                ("kimi", AdapterFamily::History, "kimi", "gate4agent-adapter/v1"),
23460                ("kimi", AdapterFamily::Resume, "kimi", "gate4agent-adapter/v1"),
23461            ]
23462        );
23463    }
23464
23465    fn construction_test_config(name: &str) -> NodeServerConfig {
23466        let workspace = WorkspaceConfig::new(
23467            WorkspaceId::new("primary").unwrap(),
23468            std::env::current_dir().unwrap(),
23469        )
23470        .unwrap();
23471        NodeServerConfig::new(
23472            format!(r"\\.\pipe\gate4agent-{name}"),
23473            "fixture-token",
23474            NodeId::new(name).unwrap(),
23475            [workspace],
23476        )
23477        .unwrap()
23478    }
23479
23480    fn maximum_size_provider_manifest_registry() -> AgentRegistry {
23481        let adapter = AdapterBinding::new(
23482            gate4agent_types::AdapterId::new("a".repeat(96)).unwrap(),
23483            "r".repeat(crate::protocol::MAX_ADAPTER_CONTRACT_REVISION_BYTES),
23484            gate4agent_types::AdapterVerification::SyntheticFixture,
23485        )
23486        .unwrap();
23487        let agent_ids = ["claude", "codex", "kimi"]
23488            .into_iter()
23489            .map(|agent_id| AgentId::new(agent_id).unwrap())
23490            .collect::<Vec<_>>();
23491        let adapters = gate4agent_catalog::AdapterRegistry::new(
23492            [
23493                AdapterFamily::PtySemantic,
23494                AdapterFamily::Pipe,
23495                AdapterFamily::OneShot,
23496                AdapterFamily::Acp,
23497                AdapterFamily::Hook,
23498                AdapterFamily::ManagedHook,
23499                AdapterFamily::History,
23500                AdapterFamily::Resume,
23501                AdapterFamily::SessionOptions,
23502                AdapterFamily::CapabilityProbe,
23503            ]
23504            .into_iter()
23505            .map(|family| gate4agent_catalog::AdapterDescriptor {
23506                family,
23507                binding: adapter.clone(),
23508                agents: agent_ids.clone(),
23509            }),
23510        )
23511        .unwrap();
23512        let specs = agent_ids
23513            .iter()
23514            .map(|agent_id| {
23515                let agent_id = agent_id.as_str();
23516                let mut spec = builtin_registry().get_by_id(agent_id).unwrap().clone();
23517                spec.revision =
23518                    "r".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES);
23519                spec.capabilities.transports.pty_adapter = Some(adapter.clone());
23520                spec.capabilities.transports.pipe = Some(gate4agent_types::PipeTransportSpec {
23521                    adapter: adapter.clone(),
23522                    protocol: gate4agent_types::PipeProtocol::SemanticNdjson,
23523                    launch_override: None,
23524                    prompt_delivery: gate4agent_types::PipePromptDelivery::None,
23525                });
23526                spec.capabilities.transports.acp = Some(gate4agent_types::AcpTransportSpec {
23527                    adapter: adapter.clone(),
23528                    launch_override: None,
23529                });
23530                spec.capabilities.adapters.hook = Some(adapter.clone());
23531                spec.capabilities.adapters.managed_hook = Some(adapter.clone());
23532                spec.capabilities.adapters.one_shot = Some(adapter.clone());
23533                spec.capabilities.adapters.history = Some(adapter.clone());
23534                spec.capabilities.adapters.resume = Some(adapter.clone());
23535                spec.capabilities.adapters.session_options = Some(adapter.clone());
23536                spec.capabilities.adapters.capability_probe = Some(adapter.clone());
23537                spec
23538            })
23539            .collect::<Vec<_>>();
23540        AgentRegistry::new_with_adapters(specs, &adapters).unwrap()
23541    }
23542
23543    /// The real fleet's manifest is accepted by a real server construction.
23544    ///
23545    /// The count is asserted against `active_registry` itself rather than a
23546    /// literal, so this stays a test that construction succeeds for whatever
23547    /// the fleet is -- which is the property worth guarding -- instead of a
23548    /// second place the fleet size is written down and has to be kept in
23549    /// step with the first.
23550    #[test]
23551    fn node_server_construction_accepts_the_production_manifest() {
23552        let registry = active_registry().unwrap();
23553        let (providers, _) = provider_contract_manifest(&registry).unwrap();
23554        assert_eq!(providers.len(), registry.iter().count());
23555        assert!(!providers.is_empty());
23556        let server = NodeServer::new_with_registry(
23557            construction_test_config("manifest-production-bound"),
23558            registry,
23559        )
23560        .unwrap();
23561        drop(server);
23562    }
23563
23564    #[test]
23565    fn node_server_construction_rejects_count_valid_manifest_over_handshake_capacity() {
23566        let registry = maximum_size_provider_manifest_registry();
23567        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
23568        assert_eq!(providers.len(), 3);
23569        assert_eq!(adapters.len(), 30);
23570        assert!(adapters.len() <= crate::protocol::MAX_PROVIDER_ADAPTER_CONTRACTS);
23571        let error = match NodeServer::new_with_registry(
23572            construction_test_config("manifest-handshake-overflow"),
23573            registry,
23574        ) {
23575            Ok(_) => panic!("oversized negotiated manifest was accepted"),
23576            Err(error) => error,
23577        };
23578        assert!(matches!(
23579            error,
23580            NodeServerError::ProviderContractManifest(message)
23581                if message.contains("authentication binding length")
23582                    && message.contains("8192-byte limit")
23583        ));
23584    }
23585
23586    #[test]
23587    fn provider_contract_manifest_rejects_wire_revision_overflow_before_startup() {
23588        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
23589        spec.revision = "x".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES + 1);
23590        let registry = AgentRegistry::new([spec]).unwrap();
23591        assert!(matches!(
23592            provider_contract_manifest(&registry),
23593            Err(NodeServerError::ProviderContractManifest(message))
23594                if message.contains("exceeds the 128-byte limit")
23595        ));
23596    }
23597
23598    #[test]
23599    fn provider_contract_manifest_rejects_invalid_wire_revision_before_startup() {
23600        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
23601        spec.revision = "invalid revision".to_owned();
23602        let registry = AgentRegistry::new([spec]).unwrap();
23603        assert!(matches!(
23604            provider_contract_manifest(&registry),
23605            Err(NodeServerError::ProviderContractManifest(message))
23606                if message.contains("bounded lowercase ASCII")
23607        ));
23608    }
23609
23610    #[test]
23611    fn provider_contract_manifest_rejects_conflicting_duplicate_family_before_startup() {
23612        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
23613        let transport_binding = spec.capabilities.adapters.one_shot.clone().unwrap();
23614        let conflicting_binding = builtin_registry()
23615            .get_by_id("codex")
23616            .unwrap()
23617            .capabilities
23618            .adapters
23619            .one_shot
23620            .clone()
23621            .unwrap();
23622        let pipe = spec.capabilities.transports.pipe.as_mut().unwrap();
23623        pipe.protocol = gate4agent_types::PipeProtocol::OneShotText;
23624        pipe.adapter = transport_binding;
23625        spec.capabilities.adapters.one_shot = Some(conflicting_binding);
23626        let registry = AgentRegistry::new([spec]).unwrap();
23627        assert!(matches!(
23628            provider_contract_manifest(&registry),
23629            Err(NodeServerError::ProviderContractManifest(message))
23630                if message.contains("conflicting OneShot adapter bindings")
23631        ));
23632    }
23633
23634    #[test]
23635    fn public_node_failures_expose_only_fixed_categories() {
23636        let provider = failure(
23637            NodeFailureCode::BackendOperationFailed,
23638            r"provider bearer-secret failed at C:\private\session.jsonl",
23639        );
23640        assert_eq!(provider.message, "backend-operation-failed");
23641        assert!(!provider.message.contains("bearer-secret"));
23642        assert!(!provider.message.contains(r"C:\private"));
23643        let persistence = persistence_failure(io::Error::new(
23644            io::ErrorKind::Other,
23645            r"state write failed at C:\private\state-v1.json",
23646        ));
23647        assert_eq!(persistence.message, DURABLE_STATE_COMMIT_FAILED_ERROR);
23648        assert!(!persistence.message.contains(r"C:\private"));
23649
23650        for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::Unsupported] {
23651            let generic = durable_state_load_error(io::Error::new(kind, "host I/O failure"));
23652            assert_eq!(
23653                generic.to_string(),
23654                "node durable state failed: durable-state-load-failed",
23655            );
23656        }
23657    }
23658
23659    #[test]
23660    fn terminal_byte_sequences_are_bounded_before_dispatch() {
23661        let sequence = b"\x1b[1;5D".to_vec();
23662        assert_eq!(
23663            terminal_bytes_action(sequence.clone()).unwrap(),
23664            InputAction::TerminalBytes(sequence),
23665        );
23666        assert_eq!(
23667            terminal_bytes_action(Vec::new()).unwrap_err().code,
23668            NodeFailureCode::InvalidRequest,
23669        );
23670        assert_eq!(
23671            terminal_bytes_action(vec![0; MAX_NODE_TERMINAL_BYTES + 1])
23672                .unwrap_err()
23673                .code,
23674            NodeFailureCode::InvalidRequest,
23675        );
23676    }
23677
23678    #[test]
23679    fn node_server_rejects_a_second_owner_of_the_same_durable_state_path() {
23680        let root = temporary_workspace_root("exclusive-state-owner");
23681        std::fs::create_dir_all(&root).unwrap();
23682        let workspace = WorkspaceConfig::new(
23683            WorkspaceId::new("primary").unwrap(),
23684            &root,
23685        )
23686        .unwrap();
23687        let state_path = root.join("state-v1.json");
23688        let endpoint = format!(
23689            r"\\.\pipe\gate4agent-node-state-lock-{}-{}",
23690            std::process::id(),
23691            std::time::SystemTime::now()
23692                .duration_since(std::time::UNIX_EPOCH)
23693                .unwrap()
23694                .as_nanos(),
23695        );
23696        let config = NodeServerConfig::new(
23697            endpoint,
23698            "fixture-token",
23699            NodeId::new("node-1").unwrap(),
23700            [workspace],
23701        )
23702        .unwrap()
23703        .with_state_path(&state_path)
23704        .unwrap();
23705
23706        let first = NodeServer::new(config.clone()).unwrap();
23707        let error = match NodeServer::new(config.clone()) {
23708            Ok(_) => panic!("a second node server acquired the same durable state path"),
23709            Err(error) => error,
23710        };
23711        assert!(matches!(
23712            &error,
23713            NodeServerError::DurableState(error)
23714                if error.kind() == io::ErrorKind::WouldBlock
23715        ));
23716        assert_eq!(
23717            error.to_string(),
23718            "node durable state failed: durable-state-lock-failed",
23719        );
23720        assert!(!error.to_string().contains(&state_path.to_string_lossy().into_owned()));
23721        drop(first);
23722        let second = NodeServer::new(config).unwrap();
23723        drop(second);
23724        std::fs::remove_dir_all(root).unwrap();
23725    }
23726
23727    #[test]
23728    fn session_binding_is_workspace_scoped_and_removed_fail_closed() {
23729        let catalog = active_registry().unwrap();
23730        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23731        let workspace_id = WorkspaceId::new("primary").unwrap();
23732        let workspace = WorkspaceConfig::new(
23733            workspace_id.clone(),
23734            std::env::current_dir().unwrap(),
23735        )
23736        .unwrap();
23737        let shared = NodeShared::new(
23738            handle,
23739            "fixture-token".to_owned(),
23740            NodeId::new("node-1").unwrap(),
23741            vec![workspace.clone()],
23742            vec![agent("claude"), agent("codex"), agent("kimi")],
23743        );
23744        let instance_id = AgentInstanceId(17);
23745        let generation = SessionGeneration(3);
23746        assert!(shared.address_for(instance_id, generation).is_none());
23747        shared.bind_session(instance_id, workspace_id.clone(), generation);
23748        let address = SessionAddress {
23749            workspace_id,
23750            session: SessionKey { instance_id, generation },
23751        };
23752        assert_eq!(
23753            shared.workspace_root(&address.workspace_id).unwrap(),
23754            workspace.canonical_root,
23755        );
23756        assert_eq!(shared.address_for(instance_id, generation), Some(address.clone()));
23757        shared.remove_binding(&address);
23758        assert!(shared.address_for(instance_id, generation).is_none());
23759    }
23760
23761    #[test]
23762    fn open_provider_request_gate_is_state_aware_and_fail_closed() {
23763        let address = SessionAddress {
23764            workspace_id: WorkspaceId::new("primary").unwrap(),
23765            session: SessionKey {
23766                instance_id: AgentInstanceId(7),
23767                generation: SessionGeneration(2),
23768            },
23769        };
23770        let record_id = SessionRecordId::new("record-a").unwrap();
23771        let size = gate4agent_types::TerminalSize {
23772            rows: 24,
23773            columns: 80,
23774        };
23775        let address_requests = [
23776            NodeRequest::Resume {
23777                session: address.clone(),
23778                terminal_size: size,
23779                initial_prompt: None,
23780            },
23781            NodeRequest::Prompt { session: address.clone(), text: "p".to_owned() },
23782            NodeRequest::Paste { session: address.clone(), text: "p".to_owned() },
23783            NodeRequest::Input { session: address.clone(), text: "p".to_owned() },
23784            NodeRequest::TerminalBytes { session: address.clone(), bytes: vec![1] },
23785            NodeRequest::TerminalControl {
23786                session: address.clone(),
23787                control: TerminalControl::Enter,
23788            },
23789            NodeRequest::Resize { session: address.clone(), size },
23790            NodeRequest::Interrupt { session: address.clone() },
23791            NodeRequest::Stop { session: address.clone(), force: false },
23792            NodeRequest::Remove { session: address },
23793        ];
23794        for request in &address_requests {
23795            assert!(request_requires_open_provider_ids_with(
23796                request,
23797                |_| Some(agent("third-party-agent")),
23798                |_| Some(agent("claude")),
23799            ));
23800            assert!(!request_requires_open_provider_ids_with(
23801                request,
23802                |_| Some(agent("claude")),
23803                |_| Some(agent("claude")),
23804            ));
23805            assert!(request_requires_open_provider_ids_with(
23806                request,
23807                |_| None,
23808                |_| Some(agent("claude")),
23809            ));
23810        }
23811
23812        let record_requests = [
23813            NodeRequest::RenameSessionRecord {
23814                record_id: record_id.clone(),
23815                display_name: "renamed".to_owned(),
23816            },
23817            NodeRequest::ResumeSessionRecord {
23818                record_id: record_id.clone(),
23819                terminal_size: size,
23820                initial_prompt: None,
23821            },
23822            NodeRequest::ForgetSessionRecord { record_id },
23823        ];
23824        for request in &record_requests {
23825            assert!(request_requires_open_provider_ids_with(
23826                request,
23827                |_| Some(agent("claude")),
23828                |_| Some(agent("grok")),
23829            ));
23830            assert!(!request_requires_open_provider_ids_with(
23831                request,
23832                |_| Some(agent("claude")),
23833                |_| Some(agent("kimi")),
23834            ));
23835            assert!(request_requires_open_provider_ids_with(
23836                request,
23837                |_| Some(agent("claude")),
23838                |_| None,
23839            ));
23840        }
23841
23842        assert!(request_requires_open_provider_ids_with(
23843            &NodeRequest::Spawn {
23844                workspace_id: WorkspaceId::new("primary").unwrap(),
23845                provider: agent("third-party-agent"),
23846                mode: SessionMode::Pty,
23847                terminal_size: size,
23848                initial_prompt: None,
23849            },
23850            |_| None,
23851            |_| None,
23852        ));
23853        assert!(!request_requires_open_provider_ids_with(
23854            &NodeRequest::Snapshot,
23855            |_| None,
23856            |_| None,
23857        ));
23858        assert!(request_requires_open_provider_ids_with(
23859            &NodeRequest::ForgetContextPack {
23860                context_id: SpawnContextId::new("ctx-forget").unwrap(),
23861            },
23862            |_| None,
23863            |_| None,
23864        ));
23865    }
23866
23867    #[test]
23868    fn legacy_projection_filters_open_record_replies_and_event_history() {
23869        let catalog = active_registry().unwrap();
23870        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23871        let workspace = WorkspaceConfig::new(
23872            WorkspaceId::new("primary").unwrap(),
23873            std::env::current_dir().unwrap(),
23874        )
23875        .unwrap();
23876        let shared = NodeShared::new(
23877            handle,
23878            "fixture-token".to_owned(),
23879            NodeId::new("node-1").unwrap(),
23880            vec![workspace],
23881            vec![agent("claude"), agent("grok")],
23882        );
23883        let legacy = record("claude", "legacy-record");
23884        let open = record("grok", "open-record");
23885        let legacy_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
23886            record: legacy.clone(),
23887        });
23888        let open_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
23889            record: open.clone(),
23890        });
23891        let legacy_removed = shared.publish(NodeEvent::SessionRecordRemoved {
23892            record_id: legacy.record_id.clone(),
23893        });
23894        let open_removed = shared.publish(NodeEvent::SessionRecordRemoved {
23895            record_id: open.record_id.clone(),
23896        });
23897        assert!(project_event_legacy_provider_ids(&shared, legacy_upsert.clone()).is_some());
23898        assert!(project_event_legacy_provider_ids(&shared, open_upsert.clone()).is_none());
23899        assert!(project_event_legacy_provider_ids(&shared, legacy_removed.clone()).is_some());
23900        assert!(project_event_legacy_provider_ids(&shared, open_removed.clone()).is_none());
23901
23902        let mut record_reply = ResponseEnvelope {
23903            request_id: 1,
23904            result: Ok(NodeResponse::SessionRecordUpdated { record: open }),
23905        };
23906        project_response_legacy_provider_ids(&shared, &mut record_reply);
23907        assert!(matches!(
23908            record_reply.result,
23909            Err(NodeFailure { code: NodeFailureCode::UnsupportedCapability, .. })
23910        ));
23911
23912        let mut resync = ResponseEnvelope {
23913            request_id: 2,
23914            result: Ok(NodeResponse::Resync {
23915                event_sequence: open_removed.sequence,
23916                oldest_available_sequence: 1,
23917                snapshot: shared.snapshot(),
23918                events: vec![legacy_upsert, open_upsert, legacy_removed, open_removed],
23919            }),
23920        };
23921        project_response_legacy_provider_ids(&shared, &mut resync);
23922        let Ok(NodeResponse::Resync { snapshot, events, .. }) = resync.result else {
23923            panic!("legacy projection changed the resync response kind");
23924        };
23925        assert!(snapshot.session_records.iter().all(|item| {
23926            provider_id_is_legacy(&item.provider)
23927        }));
23928        assert_eq!(events.len(), 2);
23929        assert!(events.iter().all(|event| matches!(
23930            &event.event,
23931            NodeEvent::SessionRecordUpserted { record }
23932                if provider_id_is_legacy(&record.provider)
23933        ) || matches!(&event.event, NodeEvent::SessionRecordRemoved { .. })));
23934    }
23935
23936    #[test]
23937    fn session_binding_capacity_is_bounded_by_the_control_session_limit() {
23938        let catalog = active_registry().unwrap();
23939        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23940        let workspace_id = WorkspaceId::new("primary").unwrap();
23941        let workspace = WorkspaceConfig::new(
23942            workspace_id.clone(),
23943            std::env::current_dir().unwrap(),
23944        )
23945        .unwrap();
23946        let shared = NodeShared::new(
23947            handle,
23948            "fixture-token".to_owned(),
23949            NodeId::new("node-1").unwrap(),
23950            vec![workspace],
23951            vec![agent("claude")],
23952        );
23953        for index in 0..CONTROL_SESSIONS_MAX {
23954            shared.bind_session(
23955                AgentInstanceId((index + 1) as u64),
23956                workspace_id.clone(),
23957                SessionGeneration(1),
23958            );
23959        }
23960        let error = shared.ensure_binding_capacity().unwrap_err();
23961        assert_eq!(error.code, NodeFailureCode::BackendBusy);
23962    }
23963
23964    #[tokio::test]
23965    async fn provider_spawn_admission_rejects_before_session_mutation() {
23966        let catalog = active_registry().unwrap();
23967        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23968        let workspace = WorkspaceConfig::new(
23969            WorkspaceId::new("primary").unwrap(),
23970            std::env::current_dir().unwrap(),
23971        )
23972        .unwrap();
23973        let mut shared = NodeShared::new(
23974            handle,
23975            "fixture-token".to_owned(),
23976            NodeId::new("node-provider-admission").unwrap(),
23977            vec![workspace],
23978            vec![agent("claude")],
23979        );
23980        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
23981            crate::protocol::ProviderRuntimeStatus::unavailable(agent("claude")),
23982        ])
23983        .unwrap();
23984        let terminal_size = gate4agent_types::TerminalSize {
23985            rows: 24,
23986            columns: 80,
23987        };
23988
23989        let unavailable = shared
23990            .spawn_session(
23991                WorkspaceId::new("primary").unwrap(),
23992                agent("claude"),
23993                SessionMode::Pty,
23994                terminal_size,
23995                None,
23996            )
23997            .await
23998            .unwrap_err();
23999        assert_eq!(unavailable.code, NodeFailureCode::BackendOperationFailed);
24000        assert_eq!(unavailable.message, "backend-operation-failed");
24001
24002        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
24003            crate::protocol::ProviderRuntimeStatus::raw_passthrough(
24004                agent("claude"),
24005                Some(crate::protocol::ProviderRuntimeVersion::new("999.0.0").unwrap()),
24006            ),
24007        ])
24008        .unwrap();
24009        let semantic = shared
24010            .spawn_session(
24011                WorkspaceId::new("primary").unwrap(),
24012                agent("claude"),
24013                SessionMode::Inline,
24014                terminal_size,
24015                None,
24016            )
24017            .await
24018            .unwrap_err();
24019        assert_eq!(semantic.code, NodeFailureCode::UnsupportedCapability);
24020        assert_eq!(semantic.message, "unsupported-capability");
24021
24022        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
24023        assert!(shared
24024            .session_bindings
24025            .lock()
24026            .unwrap_or_else(|poisoned| poisoned.into_inner())
24027            .is_empty());
24028        assert!(shared
24029            .session_records
24030            .lock()
24031            .unwrap_or_else(|poisoned| poisoned.into_inner())
24032            .records
24033            .is_empty());
24034        assert!(shared.handle.snapshot().sessions.is_empty());
24035    }
24036
24037    #[tokio::test]
24038    async fn raw_spawn_binding_is_visible_with_a_durable_live_record() {
24039        let catalog = active_registry().unwrap();
24040        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24041        let workspace_id = WorkspaceId::new("primary").unwrap();
24042        let workspace = WorkspaceConfig::new(
24043            workspace_id.clone(),
24044            std::env::current_dir().unwrap(),
24045        )
24046        .unwrap();
24047        let shared = NodeShared::new(
24048            handle,
24049            "fixture-token".to_owned(),
24050            NodeId::new("node-raw-spawn-record").unwrap(),
24051            vec![workspace],
24052            vec![agent("claude")],
24053        );
24054        let address = SessionAddress {
24055            workspace_id,
24056            session: SessionKey {
24057                instance_id: AgentInstanceId(71),
24058                generation: SessionGeneration::default(),
24059            },
24060        };
24061
24062        let record_id = shared
24063            .bind_spawn_session(
24064                &address,
24065                agent("claude"),
24066                SessionMode::Pty,
24067                ProviderRuntimePolicy::raw_pty(),
24068                SpawnRecordPolicy::Always,
24069                None,
24070            )
24071            .unwrap()
24072            .unwrap();
24073        shared
24074            .handle
24075            .dispatch(shared.prepare_command(ControlCommand::Register {
24076                instance_id: address.session.instance_id,
24077                agent_id: agent("claude"),
24078                transport: TransportKind::Pty,
24079            }))
24080            .unwrap();
24081        runtime.tick().await;
24082
24083        assert!(shared.handle.snapshot().sessions.iter().any(|session| {
24084            session.instance_id == address.session.instance_id
24085                && session.generation == address.session.generation
24086        }));
24087        let record = shared.record(&record_id).unwrap();
24088        assert_eq!(record.display_name, "claude #1");
24089        assert_eq!(record.state, ManagedSessionState::Live);
24090        assert_eq!(record.active_session.as_ref(), Some(&address));
24091        assert!(record.provider_session.is_none());
24092        let binding_record_id = shared
24093            .session_bindings
24094            .lock()
24095            .unwrap_or_else(|poisoned| poisoned.into_inner())
24096            .get(&address.session.instance_id)
24097            .and_then(|binding| binding.record_id.clone());
24098        assert_eq!(binding_record_id.as_ref(), Some(&record_id));
24099
24100        let renamed = shared
24101            .rename_session_record(&record_id, "raw review".to_owned())
24102            .unwrap();
24103        assert_eq!(renamed.record_id, record_id);
24104        assert_eq!(renamed.display_name, "raw review");
24105        shared.reconcile_managed_record(
24106            &record_id,
24107            &address,
24108            &ControlEvent {
24109                sequence: 1,
24110                command_id: None,
24111                instance_id: address.session.instance_id,
24112                generation: address.session.generation,
24113                event: ControlEventKind::Running { process_id: None },
24114            },
24115            false,
24116            false,
24117        );
24118        let running = shared.record(&record_id).unwrap();
24119        assert_eq!(running.state, ManagedSessionState::Live);
24120        assert_eq!(running.active_session.as_ref(), Some(&address));
24121        assert_eq!(running.display_name, "raw review");
24122
24123        shared.reconcile_managed_record(
24124            &record_id,
24125            &address,
24126            &ControlEvent {
24127                sequence: 2,
24128                command_id: None,
24129                instance_id: address.session.instance_id,
24130                generation: address.session.generation,
24131                event: ControlEventKind::Exited {
24132                    exit_code: Some(0),
24133                    forced: false,
24134                },
24135            },
24136            false,
24137            false,
24138        );
24139        let detached = shared.record(&record_id).unwrap();
24140        assert_eq!(detached.state, ManagedSessionState::Unavailable);
24141        assert!(detached.active_session.is_none());
24142        assert_eq!(detached.display_name, "raw review");
24143        let renamed_detached = shared
24144            .rename_session_record(&record_id, "raw review detached".to_owned())
24145            .unwrap();
24146        assert_eq!(renamed_detached.record_id, record_id);
24147        assert_eq!(renamed_detached.state, ManagedSessionState::Unavailable);
24148        assert!(renamed_detached.active_session.is_none());
24149        assert_eq!(renamed_detached.display_name, "raw review detached");
24150        assert_eq!(shared.snapshot().session_records, vec![renamed_detached]);
24151    }
24152
24153    #[test]
24154    fn legacy_raw_spawn_binding_remains_untracked() {
24155        let catalog = active_registry().unwrap();
24156        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24157        let workspace_id = WorkspaceId::new("primary").unwrap();
24158        let workspace = WorkspaceConfig::new(
24159            workspace_id.clone(),
24160            std::env::current_dir().unwrap(),
24161        )
24162        .unwrap();
24163        let shared = NodeShared::new(
24164            handle,
24165            "fixture-token".to_owned(),
24166            NodeId::new("node-legacy-raw-spawn").unwrap(),
24167            vec![workspace],
24168            vec![agent("claude")],
24169        );
24170        let address = SessionAddress {
24171            workspace_id,
24172            session: SessionKey {
24173                instance_id: AgentInstanceId(72),
24174                generation: SessionGeneration::default(),
24175            },
24176        };
24177
24178        let record_id = shared
24179            .bind_spawn_session(
24180                &address,
24181                agent("claude"),
24182                SessionMode::Pty,
24183                ProviderRuntimePolicy::raw_pty(),
24184                SpawnRecordPolicy::ProviderIdentityOnly,
24185                None,
24186            )
24187            .unwrap();
24188
24189        assert!(record_id.is_none());
24190        let binding_record_id = shared
24191            .session_bindings
24192            .lock()
24193            .unwrap_or_else(|poisoned| poisoned.into_inner())
24194            .get(&address.session.instance_id)
24195            .and_then(|binding| binding.record_id.clone());
24196        assert!(binding_record_id.is_none());
24197        assert!(shared.snapshot().session_records.is_empty());
24198    }
24199
24200    #[tokio::test]
24201    async fn raw_runtime_policy_rejects_prompt_paste_and_resume_before_mutation() {
24202        let catalog = active_registry().unwrap();
24203        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24204        let workspace_id = WorkspaceId::new("primary").unwrap();
24205        let workspace = WorkspaceConfig::new(
24206            workspace_id.clone(),
24207            std::env::current_dir().unwrap(),
24208        )
24209        .unwrap();
24210        let mut shared = NodeShared::new(
24211            handle,
24212            "fixture-token".to_owned(),
24213            NodeId::new("node-raw-semantic-gate").unwrap(),
24214            vec![workspace],
24215            vec![agent("claude")],
24216        );
24217        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
24218            crate::protocol::ProviderRuntimeStatus::raw_passthrough(agent("claude"), None),
24219        ])
24220        .unwrap();
24221        shared
24222            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
24223            .unwrap();
24224        let address = SessionAddress {
24225            workspace_id,
24226            session: SessionKey {
24227                instance_id: AgentInstanceId(72),
24228                generation: SessionGeneration::default(),
24229            },
24230        };
24231        shared.bind_session_with_policy(
24232            &address,
24233            ProviderRuntimePolicy::raw_pty(),
24234            None,
24235        );
24236        shared
24237            .handle
24238            .dispatch(shared.prepare_command(ControlCommand::Register {
24239                instance_id: address.session.instance_id,
24240                agent_id: agent("claude"),
24241                transport: TransportKind::Pty,
24242            }))
24243            .unwrap();
24244        runtime.tick().await;
24245        let command_id_before = shared.next_command_id.load(Ordering::Acquire);
24246        let revision_before = shared.handle.snapshot().revision;
24247        let history_sequence_before = shared.current_sequence();
24248        let terminal_size = gate4agent_types::TerminalSize {
24249            rows: 24,
24250            columns: 80,
24251        };
24252
24253        for request in [
24254            NodeRequest::Prompt {
24255                session: address.clone(),
24256                text: "prompt".to_owned(),
24257            },
24258            NodeRequest::Paste {
24259                session: address.clone(),
24260                text: "paste".to_owned(),
24261            },
24262            // `initial_prompt: Some(_)` selects `ResumeWithPrompt`, which needs
24263            // the same semantic capabilities as `Prompt`/`Paste`. A bare
24264            // (promptless) resume is a raw PTY relaunch and IS admitted under
24265            // `raw_pty()` — see `raw_pty_admits_provider_native_resume_without_prompt_only`
24266            // in `provider_runtime.rs`; it is deliberately not exercised here.
24267            NodeRequest::Resume {
24268                session: address.clone(),
24269                terminal_size,
24270                initial_prompt: Some("resume".to_owned()),
24271            },
24272        ] {
24273            let error = process_request_inner(
24274                &shared,
24275                77,
24276                ClientRole::Operator,
24277                request,
24278            )
24279            .await
24280            .unwrap_err();
24281            assert_eq!(error.code, NodeFailureCode::UnsupportedCapability);
24282        }
24283
24284        assert_eq!(shared.next_command_id.load(Ordering::Acquire), command_id_before);
24285        assert_eq!(shared.handle.snapshot().revision, revision_before);
24286        assert_eq!(shared.current_sequence(), history_sequence_before);
24287        let bindings = shared
24288            .session_bindings
24289            .lock()
24290            .unwrap_or_else(|poisoned| poisoned.into_inner());
24291        let binding = bindings.get(&address.session.instance_id).unwrap();
24292        assert_eq!(binding.generation, address.session.generation);
24293        assert!(binding.pending_resume.is_none());
24294        assert!(binding.record_id.is_none());
24295    }
24296
24297    #[tokio::test]
24298    async fn windows_rejects_unix_bytes_workspace_path_before_filesystem_access() {
24299        let catalog = active_registry().unwrap();
24300        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24301        let primary = WorkspaceConfig::new(
24302            WorkspaceId::new("primary").unwrap(),
24303            std::env::current_dir().unwrap(),
24304        )
24305        .unwrap();
24306        let shared = NodeShared::new(
24307            handle,
24308            "fixture-token".to_owned(),
24309            NodeId::new("node-1").unwrap(),
24310            vec![primary],
24311            vec![agent("claude")],
24312        );
24313        shared
24314            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
24315            .unwrap();
24316        let before = shared.snapshot();
24317        let error = process_request_inner(
24318            &shared,
24319            77,
24320            ClientRole::Operator,
24321            NodeRequest::RegisterWorkspace {
24322                workspace_id: WorkspaceId::new("foreign").unwrap(),
24323                root: OpaqueHostPath::unix_bytes(b"/srv/repo".to_vec()).unwrap(),
24324            },
24325        )
24326        .await
24327        .unwrap_err();
24328        assert_eq!(error.code, NodeFailureCode::InvalidRequest);
24329        assert_eq!(error.message, "invalid-request");
24330        assert_eq!(shared.snapshot().workspaces, before.workspaces);
24331    }
24332
24333    #[tokio::test]
24334    async fn dynamic_workspaces_reject_duplicates_busy_and_last_then_publish_ordered_events() {
24335        let catalog = active_registry().unwrap();
24336        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24337        let primary_id = WorkspaceId::new("primary").unwrap();
24338        let primary = WorkspaceConfig::new(
24339            primary_id.clone(),
24340            std::env::current_dir().unwrap(),
24341        )
24342        .unwrap();
24343        let shared = NodeShared::new(
24344            handle,
24345            "fixture-token".to_owned(),
24346            NodeId::new("node-1").unwrap(),
24347            vec![primary.clone()],
24348            vec![agent("claude")],
24349        );
24350        let secondary_id = WorkspaceId::new("secondary").unwrap();
24351        let secondary = shared
24352            .register_workspace(
24353                secondary_id.clone(),
24354                std::env::temp_dir().to_string_lossy().into_owned(),
24355            )
24356            .await
24357            .unwrap();
24358        assert_eq!(secondary.workspace_id, secondary_id);
24359        assert!(secondary.sessions.is_empty());
24360        assert_eq!(shared.snapshot().workspaces.len(), 2);
24361
24362        let duplicate_id = shared
24363            .register_workspace(
24364                secondary_id.clone(),
24365                primary.canonical_root().to_owned(),
24366            )
24367            .await
24368            .unwrap_err();
24369        assert_eq!(duplicate_id.code, NodeFailureCode::DuplicateWorkspaceId);
24370        let duplicate_root = shared
24371            .register_workspace(
24372                WorkspaceId::new("duplicate-root").unwrap(),
24373                windows_path_text(&secondary.canonical_root).to_owned(),
24374            )
24375            .await
24376            .unwrap_err();
24377        assert_eq!(duplicate_root.code, NodeFailureCode::DuplicateWorkspaceRoot);
24378
24379        let address = SessionAddress {
24380            workspace_id: secondary_id.clone(),
24381            session: SessionKey {
24382                instance_id: AgentInstanceId(41),
24383                generation: SessionGeneration(1),
24384            },
24385        };
24386        shared.bind_session(
24387            address.session.instance_id,
24388            secondary_id.clone(),
24389            address.session.generation,
24390        );
24391        let busy = shared.unregister_workspace(&secondary_id).unwrap_err();
24392        assert_eq!(busy.code, NodeFailureCode::WorkspaceBusy);
24393        shared.remove_binding(&address);
24394        shared.unregister_workspace(&secondary_id).unwrap();
24395        assert_eq!(shared.snapshot().workspaces.len(), 1);
24396
24397        let last = shared.unregister_workspace(&primary_id).unwrap_err();
24398        assert_eq!(last.code, NodeFailureCode::LastWorkspace);
24399        let history = shared
24400            .history
24401            .lock()
24402            .unwrap_or_else(|poisoned| poisoned.into_inner());
24403        assert!(matches!(
24404            &history.events[0],
24405            NodeEventEnvelope {
24406                sequence: 1,
24407                event: NodeEvent::WorkspaceAdded { workspace },
24408            } if workspace.workspace_id == secondary_id
24409        ));
24410        assert!(matches!(
24411            &history.events[1],
24412            NodeEventEnvelope {
24413                sequence: 2,
24414                event: NodeEvent::WorkspaceRemoved { workspace_id },
24415            } if workspace_id == &secondary_id
24416        ));
24417    }
24418
24419    #[tokio::test]
24420    async fn provider_session_reference_index_is_controller_gated_and_idempotent() {
24421        let secondary_root = temporary_workspace_root("provider-session-reference-index");
24422        std::fs::create_dir_all(&secondary_root).unwrap();
24423        let catalog = active_registry().unwrap();
24424        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24425        let primary_id = WorkspaceId::new("primary").unwrap();
24426        let secondary_id = WorkspaceId::new("secondary").unwrap();
24427        let shared = NodeShared::new(
24428            handle,
24429            "fixture-token".to_owned(),
24430            NodeId::new("node-session-reference-index").unwrap(),
24431            vec![
24432                WorkspaceConfig::new(primary_id.clone(), std::env::current_dir().unwrap())
24433                    .unwrap(),
24434                WorkspaceConfig::new(secondary_id.clone(), &secondary_root).unwrap(),
24435            ],
24436            vec![agent("claude"), agent("codex")],
24437        );
24438        let identity = ProviderSessionIdentity {
24439            key: gate4agent_types::ProviderSessionKey::SessionId,
24440            id: "provider-session-42".to_owned(),
24441            transcript_path: None,
24442        };
24443        let index_request = |workspace_id: WorkspaceId,
24444                             provider: AgentId,
24445                             identity: ProviderSessionIdentity,
24446                             display_name: &str| {
24447            NodeRequest::IndexProviderSession {
24448                workspace_id,
24449                provider,
24450                identity,
24451                display_name: display_name.to_owned(),
24452            }
24453        };
24454        let request = index_request(
24455            primary_id.clone(),
24456            agent("claude"),
24457            identity.clone(),
24458            "release shepherd",
24459        );
24460        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
24461            capability.as_str() == NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY
24462        }));
24463        let observer = process_request_inner(
24464            &shared,
24465            77,
24466            ClientRole::Observer,
24467            request.clone(),
24468        )
24469        .await
24470        .unwrap_err();
24471        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
24472        let uncontrolled = process_request_inner(
24473            &shared,
24474            77,
24475            ClientRole::Operator,
24476            request.clone(),
24477        )
24478        .await
24479        .unwrap_err();
24480        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
24481        shared
24482            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
24483            .unwrap();
24484
24485        let indexed = process_request_inner(
24486            &shared,
24487            77,
24488            ClientRole::Operator,
24489            request.clone(),
24490        )
24491        .await
24492        .unwrap();
24493        let NodeResponse::ProviderSessionIndexed { record } = indexed else {
24494            panic!("provider session reference index returned another response");
24495        };
24496        assert_eq!(record.state, ManagedSessionState::Dormant);
24497        assert_eq!(record.mode, SessionMode::Pty);
24498        assert_eq!(record.workspace_id, primary_id);
24499        assert_eq!(record.provider_session.as_ref(), Some(&identity));
24500        assert!(record.active_session.is_none());
24501        assert!(record.environment_profile.is_none());
24502        assert!(record.bundle.is_none());
24503        assert!(record.context.is_none());
24504
24505        let repeated = process_request_inner(
24506            &shared,
24507            77,
24508            ClientRole::Operator,
24509            index_request(
24510                primary_id.clone(),
24511                agent("claude"),
24512                identity.clone(),
24513                "ignored replacement",
24514            ),
24515        )
24516        .await
24517        .unwrap();
24518        assert!(matches!(
24519            repeated,
24520            NodeResponse::ProviderSessionIndexed { record: ref existing }
24521                if existing.record_id == record.record_id
24522                    && existing.display_name == "release shepherd"
24523        ));
24524        let conflicting_workspace = process_request_inner(
24525            &shared,
24526            77,
24527            ClientRole::Operator,
24528            index_request(
24529                secondary_id,
24530                agent("claude"),
24531                identity.clone(),
24532                "release shepherd",
24533            ),
24534        )
24535        .await
24536        .unwrap_err();
24537        assert_eq!(conflicting_workspace.code, NodeFailureCode::SessionRecordConflict);
24538        let conflicting_provider = process_request_inner(
24539            &shared,
24540            77,
24541            ClientRole::Operator,
24542            index_request(
24543                primary_id.clone(),
24544                agent("codex"),
24545                identity.clone(),
24546                "release shepherd",
24547            ),
24548        )
24549        .await
24550        .unwrap_err();
24551        assert_eq!(conflicting_provider.code, NodeFailureCode::SessionRecordConflict);
24552        let transcript_rejected = process_request_inner(
24553            &shared,
24554            77,
24555            ClientRole::Operator,
24556            index_request(
24557                primary_id,
24558                agent("claude"),
24559                ProviderSessionIdentity {
24560                    transcript_path: Some("C:/provider/transcript.jsonl".to_owned()),
24561                    ..identity
24562                },
24563                "release shepherd",
24564            ),
24565        )
24566        .await
24567        .unwrap_err();
24568        assert_eq!(transcript_rejected.code, NodeFailureCode::InvalidRequest);
24569        let upserts = shared
24570            .history
24571            .lock()
24572            .unwrap_or_else(|poisoned| poisoned.into_inner())
24573            .events
24574            .iter()
24575            .filter(|event| matches!(
24576                &event.event,
24577                NodeEvent::SessionRecordUpserted { record: published }
24578                    if published.record_id == record.record_id
24579            ))
24580            .count();
24581        assert_eq!(upserts, 1);
24582        std::fs::remove_dir_all(secondary_root).unwrap();
24583    }
24584
24585    #[tokio::test]
24586    async fn standalone_workspace_is_independent_registered_and_controller_gated() {
24587        let fixture_root = temporary_workspace_root("standalone-independent");
24588        let target = fixture_root.join("independent");
24589        std::fs::create_dir_all(&fixture_root).unwrap();
24590        let catalog = active_registry().unwrap();
24591        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24592        let selected = WorkspaceConfig::new(
24593            WorkspaceId::new("primary").unwrap(),
24594            std::env::current_dir().unwrap(),
24595        )
24596        .unwrap();
24597        let selected_root = selected.canonical_root().to_owned();
24598        let shared = NodeShared::new(
24599            handle,
24600            "fixture-token".to_owned(),
24601            NodeId::new("node-standalone").unwrap(),
24602            vec![selected],
24603            vec![agent("claude")],
24604        );
24605        let workspace_id = WorkspaceId::new("independent").unwrap();
24606        let request = NodeRequest::CreateStandaloneWorkspace {
24607            workspace_id: workspace_id.clone(),
24608            root: opaque_windows_path(target.to_string_lossy().into_owned()),
24609            initial_branch: Some("main".to_owned()),
24610        };
24611        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
24612            capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
24613        }));
24614        assert!(!request_uses_unnegotiated_capability(
24615            &request,
24616            &baseline_capabilities().unwrap(),
24617        ));
24618        assert!(request_uses_unnegotiated_capability(&request, &[]));
24619
24620        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
24621            .await
24622            .unwrap_err();
24623        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
24624        assert!(!target.exists());
24625        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
24626            .await
24627            .unwrap_err();
24628        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
24629        assert!(!target.exists());
24630        shared
24631            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
24632            .unwrap();
24633
24634        let response = process_request_inner(&shared, 77, ClientRole::Operator, request)
24635            .await
24636            .unwrap();
24637        let NodeResponse::StandaloneWorkspaceCreated { workspace } = response else {
24638            panic!("standalone workspace creation returned another response");
24639        };
24640        assert_eq!(workspace.workspace_id, workspace_id);
24641        assert!(target.join(".git").is_dir());
24642        let branch = run_git_bounded(
24643            target.to_str().unwrap(),
24644            &["branch", "--show-current"],
24645            GIT_OUTPUT_MAX_BYTES,
24646        )
24647        .await
24648        .unwrap();
24649        assert!(branch.success);
24650        assert_eq!(String::from_utf8(branch.stdout).unwrap().trim(), "main");
24651        let worktrees = list_git_worktrees(target.to_str().unwrap()).await.unwrap();
24652        assert_eq!(worktrees.len(), 1);
24653        let listed_root = WorkspaceConfig::new(
24654            WorkspaceId::new("listed-independent").unwrap(),
24655            &worktrees[0].path,
24656        )
24657        .unwrap();
24658        assert!(platform::roots_equal(
24659            listed_root.canonical_root(),
24660            windows_path_text(&workspace.canonical_root),
24661        ));
24662        assert!(!platform::roots_equal(listed_root.canonical_root(), &selected_root));
24663        assert!(shared.snapshot().workspaces.iter().any(|snapshot| {
24664            snapshot.workspace_id == workspace_id
24665                && snapshot.canonical_root == workspace.canonical_root
24666        }));
24667        assert!(shared
24668            .history
24669            .lock()
24670            .unwrap_or_else(|poisoned| poisoned.into_inner())
24671            .events
24672            .iter()
24673            .any(|event| matches!(
24674                &event.event,
24675                NodeEvent::WorkspaceAdded { workspace }
24676                    if workspace.workspace_id == workspace_id
24677            )));
24678        std::fs::remove_dir_all(fixture_root).unwrap();
24679    }
24680
24681    #[tokio::test]
24682    async fn standalone_workspace_registration_failure_preserves_guarded_repository_for_recovery() {
24683        let fixture_root = temporary_workspace_root("standalone-compensation");
24684        let created_target = fixture_root.join("created-target");
24685        let existing_target = fixture_root.join("existing-target");
24686        let invalid_state_parent = fixture_root.join("state-parent-is-a-file");
24687        let invalid_state_path = invalid_state_parent.join("state.json");
24688        std::fs::create_dir_all(&existing_target).unwrap();
24689        std::fs::write(&invalid_state_parent, "not a directory").unwrap();
24690        let catalog = active_registry().unwrap();
24691        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24692        let primary = WorkspaceConfig::new(
24693            WorkspaceId::new("primary").unwrap(),
24694            std::env::current_dir().unwrap(),
24695        )
24696        .unwrap();
24697        let mut shared = NodeShared::new(
24698            handle,
24699            "fixture-token".to_owned(),
24700            NodeId::new("node-standalone-compensation").unwrap(),
24701            vec![primary],
24702            vec![agent("claude")],
24703        );
24704        shared.state_path = Some(invalid_state_path);
24705
24706        let created_recovery = shared
24707            .create_standalone_workspace(
24708                WorkspaceId::new("compensated").unwrap(),
24709                created_target.to_string_lossy().into_owned(),
24710                Some("main".to_owned()),
24711            )
24712            .await
24713            .unwrap_err();
24714        assert_eq!(
24715            created_recovery.code,
24716            NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
24717        );
24718        assert!(created_target.join(".git").is_dir());
24719        assert!(shared.snapshot().workspaces.iter().all(|workspace| {
24720            workspace.workspace_id.as_str() != "compensated"
24721        }));
24722
24723        let existing_recovery = shared
24724            .create_standalone_workspace(
24725                WorkspaceId::new("restored").unwrap(),
24726                existing_target.to_string_lossy().into_owned(),
24727                None,
24728            )
24729            .await
24730            .unwrap_err();
24731        assert_eq!(
24732            existing_recovery.code,
24733            NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
24734        );
24735        assert!(existing_target.is_dir());
24736        assert!(existing_target.join(".git").is_dir());
24737        assert!(shared.snapshot().workspaces.iter().all(|workspace| {
24738            workspace.workspace_id.as_str() != "restored"
24739        }));
24740
24741        let registered_empty = fixture_root.join("registered-empty");
24742        std::fs::create_dir(&registered_empty).unwrap();
24743        let catalog = active_registry().unwrap();
24744        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24745        let registered = WorkspaceConfig::new(
24746            WorkspaceId::new("registered-empty").unwrap(),
24747            &registered_empty,
24748        )
24749        .unwrap();
24750        let duplicate_shared = NodeShared::new(
24751            handle,
24752            "fixture-token".to_owned(),
24753            NodeId::new("node-standalone-duplicate-root").unwrap(),
24754            vec![registered],
24755            vec![agent("claude")],
24756        );
24757        let duplicate = duplicate_shared
24758            .create_standalone_workspace(
24759                WorkspaceId::new("duplicate-root").unwrap(),
24760                registered_empty.to_string_lossy().into_owned(),
24761                None,
24762            )
24763            .await
24764            .unwrap_err();
24765        assert_eq!(duplicate.code, NodeFailureCode::DuplicateWorkspaceRoot);
24766        assert!(std::fs::read_dir(&registered_empty).unwrap().next().is_none());
24767        std::fs::remove_dir_all(fixture_root).unwrap();
24768    }
24769
24770    #[test]
24771    fn managed_record_id_collision_preserves_the_existing_record() {
24772        let catalog = active_registry().unwrap();
24773        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24774        let workspace = WorkspaceConfig::new(
24775            WorkspaceId::new("primary").unwrap(),
24776            std::env::current_dir().unwrap(),
24777        )
24778        .unwrap();
24779        let shared = NodeShared::new(
24780            handle,
24781            "fixture-token".to_owned(),
24782            NodeId::new("node-1").unwrap(),
24783            vec![workspace.clone()],
24784            vec![agent("claude")],
24785        );
24786        let record_id = SessionRecordId::new("sr-collision").unwrap();
24787        let original = ManagedSessionRecord {
24788            record_id: record_id.clone(),
24789            display_name: "original".to_owned(),
24790            provider: agent("claude"),
24791            mode: SessionMode::Pty,
24792            state: ManagedSessionState::Dormant,
24793            workspace_id: workspace.workspace_id,
24794            canonical_root: opaque_windows_path(workspace.canonical_root),
24795            provider_session: None,
24796            active_session: None,
24797            environment_profile: None,
24798            bundle: None,
24799            context_id: None,
24800            context: None,
24801            exported_context: None,
24802            task_binding: None,
24803            created_at_unix_ms: 1,
24804            updated_at_unix_ms: 1,
24805            last_error: None,
24806        };
24807        shared.insert_record(original.clone()).unwrap();
24808        let mut collision = original.clone();
24809        collision.display_name = "replacement".to_owned();
24810        let error = shared.insert_record(collision).unwrap_err();
24811        assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
24812        assert_eq!(shared.record(&record_id).unwrap(), original);
24813    }
24814
24815    #[test]
24816    fn managed_record_runtime_errors_are_exposed_only_as_allowlisted_categories() {
24817        let catalog = active_registry().unwrap();
24818        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24819        let workspace = WorkspaceConfig::new(
24820            WorkspaceId::new("primary").unwrap(),
24821            std::env::current_dir().unwrap(),
24822        )
24823        .unwrap();
24824        let shared = NodeShared::new(
24825            handle,
24826            "fixture-token".to_owned(),
24827            NodeId::new("node-1").unwrap(),
24828            vec![workspace.clone()],
24829            vec![agent("claude")],
24830        );
24831        let record_id = SessionRecordId::new("sr-runtime-error").unwrap();
24832        shared
24833            .insert_record(ManagedSessionRecord {
24834                record_id: record_id.clone(),
24835                display_name: "runtime error".to_owned(),
24836                provider: agent("claude"),
24837                mode: SessionMode::Pty,
24838                state: ManagedSessionState::IdentityPending,
24839                workspace_id: workspace.workspace_id,
24840                canonical_root: opaque_windows_path(workspace.canonical_root),
24841                provider_session: None,
24842                active_session: None,
24843                environment_profile: None,
24844                bundle: None,
24845                context_id: None,
24846                context: None,
24847                exported_context: None,
24848                task_binding: None,
24849                created_at_unix_ms: 1,
24850                updated_at_unix_ms: 1,
24851                last_error: None,
24852            })
24853            .unwrap();
24854
24855        shared
24856            .mark_record_error(
24857                &record_id,
24858                r"provider failed with bearer-secret-123 at C:\private\session.jsonl",
24859            )
24860            .unwrap();
24861        let record = shared.record(&record_id).unwrap();
24862        assert_eq!(record.state, ManagedSessionState::Unavailable);
24863        assert_eq!(
24864            record.last_error.as_deref(),
24865            Some("provider-runtime-failed"),
24866        );
24867        let snapshot = shared.snapshot();
24868        assert_eq!(snapshot.session_records, vec![record]);
24869    }
24870
24871    /// Regression coverage for the "operator sees `deadline` instead of the
24872    /// kernel's own named rejection" defect: a spawn-dispatch waiter must be
24873    /// able to learn a command's asynchronous kernel rejection (here,
24874    /// specifically classified as `UnsupportedTransport`) exactly once, and
24875    /// must not still find it after that -- proving the fast path this
24876    /// mechanism exists for (`spawn_session_with_deadline`'s commit-wait
24877    /// loop) drains the reason instead of leaving it to be rediscovered (or
24878    /// missed) on a later poll.
24879    #[test]
24880    fn rejected_command_reason_is_classified_and_consumed_exactly_once() {
24881        let catalog = active_registry().unwrap();
24882        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24883        let workspace = WorkspaceConfig::new(
24884            WorkspaceId::new("primary").unwrap(),
24885            std::env::current_dir().unwrap(),
24886        )
24887        .unwrap();
24888        let shared = NodeShared::new(
24889            handle,
24890            "fixture-token".to_owned(),
24891            NodeId::new("node-1").unwrap(),
24892            vec![workspace],
24893            vec![agent("claude")],
24894        );
24895
24896        assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
24897
24898        shared.record_rejected_command(
24899            CommandId(1),
24900            "agent 'claude' does not support transport Acp".to_owned(),
24901            true,
24902        );
24903        // An unrelated command_id must not see another command's reason.
24904        assert!(shared.take_rejected_command_reason(CommandId(2)).is_none());
24905
24906        let reason = shared.take_rejected_command_reason(CommandId(1)).unwrap();
24907        assert_eq!(reason.message, "agent 'claude' does not support transport Acp");
24908        assert!(reason.unsupported_transport);
24909
24910        // Consumed: a second read for the same command_id finds nothing.
24911        assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
24912
24913        // A rejection unrelated to transport support keeps its own
24914        // classification distinct, so the reader can choose the generic
24915        // `BackendOperationFailed` code instead of misreporting
24916        // `UnsupportedTransport`.
24917        shared.record_rejected_command(
24918            CommandId(3),
24919            "backend busy".to_owned(),
24920            false,
24921        );
24922        let unclassified = shared.take_rejected_command_reason(CommandId(3)).unwrap();
24923        assert!(!unclassified.unsupported_transport);
24924    }
24925
24926    #[test]
24927    fn provider_identity_update_is_ignored_without_exact_session_policy() {
24928        let catalog = active_registry().unwrap();
24929        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24930        let workspace = WorkspaceConfig::new(
24931            WorkspaceId::new("primary").unwrap(),
24932            std::env::current_dir().unwrap(),
24933        )
24934        .unwrap();
24935        let shared = NodeShared::new(
24936            handle,
24937            "fixture-token".to_owned(),
24938            NodeId::new("node-identity-policy").unwrap(),
24939            vec![workspace.clone()],
24940            vec![agent("claude")],
24941        );
24942        let address = SessionAddress {
24943            workspace_id: workspace.workspace_id.clone(),
24944            session: SessionKey {
24945                instance_id: AgentInstanceId(90),
24946                generation: SessionGeneration(1),
24947            },
24948        };
24949        let record_id = SessionRecordId::new("sr-policy-denied").unwrap();
24950        shared
24951            .insert_record(ManagedSessionRecord {
24952                record_id: record_id.clone(),
24953                display_name: "policy denied".to_owned(),
24954                provider: agent("claude"),
24955                mode: SessionMode::Pty,
24956                state: ManagedSessionState::IdentityPending,
24957                workspace_id: workspace.workspace_id,
24958                canonical_root: opaque_windows_path(workspace.canonical_root),
24959                provider_session: None,
24960                active_session: Some(address.clone()),
24961                environment_profile: None,
24962                bundle: None,
24963                context_id: None,
24964                context: None,
24965                exported_context: None,
24966                task_binding: None,
24967                created_at_unix_ms: 1,
24968                updated_at_unix_ms: 1,
24969                last_error: None,
24970            })
24971            .unwrap();
24972        let event = ControlEvent {
24973            sequence: 1,
24974            command_id: None,
24975            instance_id: address.session.instance_id,
24976            generation: address.session.generation,
24977            event: ControlEventKind::ProviderEvent {
24978                sequence: 1,
24979                source: gate4agent_types::ProviderSource {
24980                    family: AdapterFamily::PtySemantic,
24981                    binding: AdapterBinding::new(
24982                        gate4agent_types::AdapterId::new("claude").unwrap(),
24983                        "fixture/v1",
24984                        gate4agent_types::AdapterVerification::SyntheticFixture,
24985                    )
24986                    .unwrap(),
24987                },
24988                source_sequence: 1,
24989                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
24990                    identity: gate4agent_types::ProviderSessionIdentity {
24991                        key: gate4agent_types::ProviderSessionKey::SessionId,
24992                        id: "provider-session".to_owned(),
24993                        transcript_path: None,
24994                    },
24995                },
24996            },
24997        };
24998
24999        shared.reconcile_managed_record(&record_id, &address, &event, false, false);
25000
25001        let record = shared.record(&record_id).unwrap();
25002        assert!(record.provider_session.is_none());
25003        assert_eq!(record.state, ManagedSessionState::IdentityPending);
25004        assert_eq!(record.active_session.as_ref(), Some(&address));
25005    }
25006
25007    #[test]
25008    fn provider_identity_cannot_rebind_a_record_across_workspaces() {
25009        let secondary_root = temporary_workspace_root("identity-scope");
25010        std::fs::create_dir_all(&secondary_root).unwrap();
25011        let catalog = active_registry().unwrap();
25012        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25013        let primary = WorkspaceConfig::new(
25014            WorkspaceId::new("primary").unwrap(),
25015            std::env::current_dir().unwrap(),
25016        )
25017        .unwrap();
25018        let secondary = WorkspaceConfig::new(
25019            WorkspaceId::new("secondary").unwrap(),
25020            &secondary_root,
25021        )
25022        .unwrap();
25023        let shared = NodeShared::new(
25024            handle,
25025            "fixture-token".to_owned(),
25026            NodeId::new("node-1").unwrap(),
25027            vec![primary.clone(), secondary.clone()],
25028            vec![agent("claude")],
25029        );
25030        let identity = gate4agent_types::ProviderSessionIdentity {
25031            key: gate4agent_types::ProviderSessionKey::SessionId,
25032            id: "provider-session".to_owned(),
25033            transcript_path: None,
25034        };
25035        let dormant_id = SessionRecordId::new("sr-dormant").unwrap();
25036        shared
25037            .insert_record(ManagedSessionRecord {
25038                record_id: dormant_id.clone(),
25039                display_name: "dormant".to_owned(),
25040                provider: agent("claude"),
25041                mode: SessionMode::Pty,
25042                state: ManagedSessionState::Dormant,
25043                workspace_id: primary.workspace_id,
25044                canonical_root: opaque_windows_path(primary.canonical_root),
25045                provider_session: Some(identity.clone()),
25046                active_session: None,
25047                environment_profile: None,
25048                bundle: None,
25049                context_id: None,
25050                context: None,
25051                exported_context: None,
25052                task_binding: None,
25053                created_at_unix_ms: 1,
25054                updated_at_unix_ms: 1,
25055                last_error: None,
25056            })
25057            .unwrap();
25058        let address = SessionAddress {
25059            workspace_id: secondary.workspace_id.clone(),
25060            session: SessionKey {
25061                instance_id: AgentInstanceId(91),
25062                generation: SessionGeneration(1),
25063            },
25064        };
25065        let current_id = SessionRecordId::new("sr-current").unwrap();
25066        shared
25067            .insert_record(ManagedSessionRecord {
25068                record_id: current_id.clone(),
25069                display_name: "current".to_owned(),
25070                provider: agent("claude"),
25071                mode: SessionMode::Pty,
25072                state: ManagedSessionState::IdentityPending,
25073                workspace_id: secondary.workspace_id,
25074                canonical_root: opaque_windows_path(secondary.canonical_root),
25075                provider_session: None,
25076                active_session: Some(address.clone()),
25077                environment_profile: None,
25078                bundle: None,
25079                context_id: None,
25080                context: None,
25081                exported_context: None,
25082                task_binding: None,
25083                created_at_unix_ms: 2,
25084                updated_at_unix_ms: 2,
25085                last_error: None,
25086            })
25087            .unwrap();
25088        shared.bind_managed_session(
25089            &address,
25090            current_id.clone(),
25091            ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
25092            None,
25093        );
25094        let event = ControlEvent {
25095            sequence: 1,
25096            command_id: None,
25097            instance_id: address.session.instance_id,
25098            generation: address.session.generation,
25099            event: ControlEventKind::ProviderEvent {
25100                sequence: 1,
25101                source: gate4agent_types::ProviderSource {
25102                    family: gate4agent_types::AdapterFamily::PtySemantic,
25103                    binding: gate4agent_types::AdapterBinding::new(
25104                        gate4agent_types::AdapterId::new("claude").unwrap(),
25105                        "fixture/v1",
25106                        gate4agent_types::AdapterVerification::SyntheticFixture,
25107                    )
25108                    .unwrap(),
25109                },
25110                source_sequence: 1,
25111                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
25112                    identity,
25113                },
25114            },
25115        };
25116        shared.reconcile_managed_record(&current_id, &address, &event, true, false);
25117
25118        let binding = shared
25119            .session_bindings
25120            .lock()
25121            .unwrap_or_else(|poisoned| poisoned.into_inner())
25122            .get(&address.session.instance_id)
25123            .cloned()
25124            .unwrap();
25125        assert_eq!(binding.record_id.as_ref(), Some(&current_id));
25126        assert_eq!(shared.record(&dormant_id).unwrap().state, ManagedSessionState::Dormant);
25127        let current = shared.record(&current_id).unwrap();
25128        assert_eq!(current.state, ManagedSessionState::Unavailable);
25129        assert_eq!(current.active_session.as_ref(), Some(&address));
25130        assert_eq!(
25131            current.last_error.as_deref(),
25132            Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR),
25133        );
25134        std::fs::remove_dir_all(secondary_root).unwrap();
25135    }
25136
25137    #[tokio::test]
25138    async fn semantic_identity_with_transcript_path_reuses_record_and_survives_reload() {
25139        let root = temporary_workspace_root("semantic-provider-session");
25140        std::fs::create_dir_all(&root).unwrap();
25141        let state_path = root.join("state-v1.json");
25142        let catalog = active_registry().unwrap();
25143        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25144        let workspace = WorkspaceConfig::new(
25145            WorkspaceId::new("primary").unwrap(),
25146            &root,
25147        )
25148        .unwrap();
25149        let node_id = NodeId::new("node-semantic-identity").unwrap();
25150        let shared = NodeShared::new_with_incarnation(
25151            handle,
25152            "fixture-token".to_owned(),
25153            node_id.clone(),
25154            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
25155            vec![workspace.clone()],
25156            vec![agent("claude")],
25157            ProviderRuntimeStatuses::default(),
25158            None,
25159            Vec::new(),
25160            Vec::new(),
25161            SpawnProfileRegistry::default(),
25162            None,
25163            None,
25164            None,
25165            Some(state_path.clone()),
25166            Vec::new(),
25167            Vec::new(),
25168            Vec::new(),
25169            Vec::new(),
25170            None,
25171            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
25172        );
25173        let persisted_identity = gate4agent_types::ProviderSessionIdentity {
25174            key: gate4agent_types::ProviderSessionKey::SessionId,
25175            id: "provider-session".to_owned(),
25176            transcript_path: None,
25177        };
25178        let durable_id = SessionRecordId::new("sr-durable").unwrap();
25179        shared
25180            .insert_record(ManagedSessionRecord {
25181                record_id: durable_id.clone(),
25182                display_name: "durable".to_owned(),
25183                provider: agent("claude"),
25184                mode: SessionMode::Pty,
25185                state: ManagedSessionState::Dormant,
25186                workspace_id: workspace.workspace_id.clone(),
25187                canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
25188                provider_session: Some(persisted_identity.clone()),
25189                active_session: None,
25190                environment_profile: None,
25191                bundle: None,
25192                context_id: None,
25193                context: None,
25194                exported_context: None,
25195                task_binding: None,
25196                created_at_unix_ms: 1,
25197                updated_at_unix_ms: 1,
25198                last_error: None,
25199            })
25200            .unwrap();
25201        let address = SessionAddress {
25202            workspace_id: workspace.workspace_id.clone(),
25203            session: SessionKey {
25204                instance_id: AgentInstanceId(92),
25205                generation: SessionGeneration(2),
25206            },
25207        };
25208        let pending_id = SessionRecordId::new("sr-pending").unwrap();
25209        shared
25210            .insert_record(ManagedSessionRecord {
25211                record_id: pending_id.clone(),
25212                display_name: "pending".to_owned(),
25213                provider: agent("claude"),
25214                mode: SessionMode::Pty,
25215                state: ManagedSessionState::IdentityPending,
25216                workspace_id: workspace.workspace_id.clone(),
25217                canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
25218                provider_session: None,
25219                active_session: Some(address.clone()),
25220                environment_profile: None,
25221                bundle: None,
25222                context_id: None,
25223                context: None,
25224                exported_context: None,
25225                task_binding: None,
25226                created_at_unix_ms: 2,
25227                updated_at_unix_ms: 2,
25228                last_error: None,
25229            })
25230            .unwrap();
25231        shared.bind_managed_session(
25232            &address,
25233            pending_id.clone(),
25234            ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
25235            None,
25236        );
25237        let observed_identity = gate4agent_types::ProviderSessionIdentity {
25238            transcript_path: Some(r"C:\private\provider-transcript.jsonl".to_owned()),
25239            ..persisted_identity
25240        };
25241        let event = ControlEvent {
25242            sequence: 1,
25243            command_id: None,
25244            instance_id: address.session.instance_id,
25245            generation: address.session.generation,
25246            event: ControlEventKind::ProviderEvent {
25247                sequence: 1,
25248                source: gate4agent_types::ProviderSource {
25249                    family: gate4agent_types::AdapterFamily::PtySemantic,
25250                    binding: gate4agent_types::AdapterBinding::new(
25251                        gate4agent_types::AdapterId::new("claude").unwrap(),
25252                        "fixture/v1",
25253                        gate4agent_types::AdapterVerification::SyntheticFixture,
25254                    )
25255                    .unwrap(),
25256                },
25257                source_sequence: 1,
25258                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
25259                    identity: observed_identity.clone(),
25260                },
25261            },
25262        };
25263        shared.reconcile_managed_record(&pending_id, &address, &event, true, false);
25264
25265        let snapshot = shared.snapshot();
25266        assert_eq!(snapshot.session_records.len(), 1);
25267        let live = &snapshot.session_records[0];
25268        assert_eq!(live.record_id, durable_id);
25269        assert_eq!(live.state, ManagedSessionState::Live);
25270        assert_eq!(live.active_session.as_ref(), Some(&address));
25271        assert_eq!(live.provider_session.as_ref(), Some(&observed_identity));
25272        let binding = shared
25273            .session_bindings
25274            .lock()
25275            .unwrap_or_else(|poisoned| poisoned.into_inner())
25276            .get(&address.session.instance_id)
25277            .cloned()
25278            .unwrap();
25279        assert_eq!(binding.record_id.as_ref(), Some(&durable_id));
25280
25281        let mut duplicate = live.clone();
25282        duplicate.record_id = SessionRecordId::new("sr-duplicate").unwrap();
25283        duplicate.display_name = "duplicate".to_owned();
25284        duplicate.state = ManagedSessionState::Dormant;
25285        duplicate.active_session = None;
25286        duplicate.provider_session.as_mut().unwrap().transcript_path = None;
25287        let duplicate_error = shared.insert_record(duplicate).unwrap_err();
25288        assert_eq!(duplicate_error.code, NodeFailureCode::SessionRecordConflict);
25289        assert_eq!(shared.snapshot().session_records.len(), 1);
25290
25291        let terminal_size = gate4agent_types::TerminalSize {
25292            rows: 24,
25293            columns: 80,
25294        };
25295        let (first_busy, second_busy) = tokio::join!(
25296            shared.resume_session_record(&durable_id, terminal_size, None),
25297            shared.resume_session_record(&durable_id, terminal_size, None),
25298        );
25299        for busy in [first_busy.unwrap_err(), second_busy.unwrap_err()] {
25300            assert_eq!(busy.code, NodeFailureCode::SessionRecordBusy);
25301            assert_eq!(busy.message, "session-record-busy");
25302        }
25303
25304        let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
25305        assert_eq!(loaded.records.len(), 1);
25306        assert_eq!(loaded.records[0].record_id, durable_id);
25307        assert_eq!(loaded.records[0].state, ManagedSessionState::Dormant);
25308        assert_eq!(
25309            loaded.records[0]
25310                .provider_session
25311                .as_ref()
25312                .unwrap()
25313                .transcript_path,
25314            None,
25315        );
25316        let catalog = active_registry().unwrap();
25317        let (reloaded_handle, _reloaded_runtime) =
25318            NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25319        let reloaded = NodeShared::new_with_incarnation(
25320            reloaded_handle,
25321            "fixture-token".to_owned(),
25322            node_id,
25323            NodeIncarnationId::from_bytes([1; crate::protocol::NODE_INCARNATION_ID_BYTES]),
25324            vec![workspace],
25325            vec![agent("claude")],
25326            ProviderRuntimeStatuses::default(),
25327            None,
25328            Vec::new(),
25329            Vec::new(),
25330            SpawnProfileRegistry::default(),
25331            None,
25332            None,
25333            None,
25334            None,
25335            loaded.records,
25336            loaded.managed_worktrees,
25337            loaded.managed_worktree_tombstones,
25338            loaded.materializations,
25339            loaded.warning,
25340            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
25341        );
25342        assert_eq!(reloaded.snapshot().session_records.len(), 1);
25343        assert_eq!(reloaded.snapshot().session_records[0].record_id, durable_id);
25344        std::fs::remove_dir_all(root).unwrap();
25345    }
25346
25347    #[tokio::test]
25348    async fn workspace_reregistration_requires_the_recorded_root_and_restores_records() {
25349        let secondary_root = temporary_workspace_root("workspace-restore");
25350        let other_root = temporary_workspace_root("workspace-conflict");
25351        std::fs::create_dir_all(&secondary_root).unwrap();
25352        std::fs::create_dir_all(&other_root).unwrap();
25353        let catalog = active_registry().unwrap();
25354        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25355        let primary = WorkspaceConfig::new(
25356            WorkspaceId::new("primary").unwrap(),
25357            std::env::current_dir().unwrap(),
25358        )
25359        .unwrap();
25360        let secondary = WorkspaceConfig::new(
25361            WorkspaceId::new("secondary").unwrap(),
25362            &secondary_root,
25363        )
25364        .unwrap();
25365        let shared = NodeShared::new(
25366            handle,
25367            "fixture-token".to_owned(),
25368            NodeId::new("node-1").unwrap(),
25369            vec![primary, secondary.clone()],
25370            vec![agent("claude")],
25371        );
25372        let record_id = SessionRecordId::new("sr-workspace").unwrap();
25373        shared
25374            .insert_record(ManagedSessionRecord {
25375                record_id: record_id.clone(),
25376                display_name: "workspace record".to_owned(),
25377                provider: agent("claude"),
25378                mode: SessionMode::Pty,
25379                state: ManagedSessionState::Dormant,
25380                workspace_id: secondary.workspace_id.clone(),
25381                canonical_root: opaque_windows_path(secondary.canonical_root.clone()),
25382                provider_session: Some(gate4agent_types::ProviderSessionIdentity {
25383                    key: gate4agent_types::ProviderSessionKey::SessionId,
25384                    id: "provider-session".to_owned(),
25385                    transcript_path: None,
25386                }),
25387                active_session: None,
25388                environment_profile: None,
25389                bundle: None,
25390                context_id: None,
25391                context: None,
25392                exported_context: None,
25393                task_binding: None,
25394                created_at_unix_ms: 1,
25395                updated_at_unix_ms: 1,
25396                last_error: None,
25397            })
25398            .unwrap();
25399        shared.unregister_workspace(&secondary.workspace_id).unwrap();
25400        let unavailable = shared.record(&record_id).unwrap();
25401        assert_eq!(unavailable.state, ManagedSessionState::Unavailable);
25402        assert_eq!(windows_path_text(&unavailable.canonical_root), secondary.canonical_root);
25403
25404        let conflict = shared
25405            .register_workspace(
25406                secondary.workspace_id.clone(),
25407                other_root.to_string_lossy().into_owned(),
25408            )
25409            .await
25410            .unwrap_err();
25411        assert_eq!(conflict.code, NodeFailureCode::SessionRecordConflict);
25412        shared
25413            .register_workspace(
25414                secondary.workspace_id,
25415                secondary_root.to_string_lossy().into_owned(),
25416            )
25417            .await
25418            .unwrap();
25419        let restored = shared.record(&record_id).unwrap();
25420        assert_eq!(restored.state, ManagedSessionState::Dormant);
25421        assert!(restored.last_error.is_none());
25422        std::fs::remove_dir_all(secondary_root).unwrap();
25423        std::fs::remove_dir_all(other_root).unwrap();
25424    }
25425
25426    #[test]
25427    fn dynamic_workspace_root_is_rejected_before_filesystem_canonicalization() {
25428        let error = validate_workspace_request_root(
25429            &"x".repeat(MAX_WORKSPACE_ROOT_BYTES + 1),
25430        )
25431        .unwrap_err();
25432        assert_eq!(error.code, NodeFailureCode::InvalidWorkspaceRoot);
25433        let control = validate_workspace_request_root("C:\\repo\nchild").unwrap_err();
25434        assert_eq!(control.code, NodeFailureCode::InvalidWorkspaceRoot);
25435    }
25436
25437    #[test]
25438    fn concurrent_publish_and_resync_never_acknowledge_an_unappended_event() {
25439        let catalog = active_registry().unwrap();
25440        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25441        let workspace = WorkspaceConfig::new(
25442            WorkspaceId::new("primary").unwrap(),
25443            std::env::current_dir().unwrap(),
25444        )
25445        .unwrap();
25446        let shared = NodeShared::new(
25447            handle,
25448            "fixture-token".to_owned(),
25449            NodeId::new("node-1").unwrap(),
25450            vec![workspace],
25451            vec![agent("claude")],
25452        );
25453        let publishers = 4_u64;
25454        let events_per_publisher = 256_u64;
25455        let start = std::sync::Barrier::new(publishers as usize + 1);
25456        let finished = AtomicU64::new(0);
25457
25458        std::thread::scope(|scope| {
25459            for _ in 0..publishers {
25460                scope.spawn(|| {
25461                    start.wait();
25462                    for _ in 0..events_per_publisher {
25463                        shared.publish(NodeEvent::ControllerChanged { controller: None });
25464                    }
25465                    finished.fetch_add(1, Ordering::Release);
25466                });
25467            }
25468            start.wait();
25469            while finished.load(Ordering::Acquire) != publishers {
25470                assert_resync_history_is_linearized(shared.resync(0));
25471                std::thread::yield_now();
25472            }
25473        });
25474        let response = shared.resync(0);
25475        assert_resync_history_is_linearized(response.clone());
25476        let NodeResponse::Resync { event_sequence, events, .. } = response else {
25477            unreachable!("resync helper returned another response");
25478        };
25479        assert_eq!(event_sequence, publishers * events_per_publisher);
25480        assert_eq!(events.len() as u64, event_sequence);
25481    }
25482
25483    fn assert_resync_history_is_linearized(response: NodeResponse) {
25484        let NodeResponse::Resync { event_sequence, events, .. } = response else {
25485            unreachable!("resync helper returned another response");
25486        };
25487        assert!(events.windows(2).all(|pair| {
25488            pair[0].sequence.checked_add(1) == Some(pair[1].sequence)
25489        }));
25490        assert_eq!(events.last().map_or(0, |event| event.sequence), event_sequence);
25491    }
25492
25493    #[test]
25494    fn canonical_workspace_roots_are_safe_for_cmd_exe() {
25495        assert_eq!(
25496            platform::normalize_canonical_root(r"\\?\C:\repo\worktree".to_owned()),
25497            r"C:\repo\worktree",
25498        );
25499        assert_eq!(
25500            platform::normalize_canonical_root(r"\\?\UNC\server\share\repo".to_owned()),
25501            r"\\server\share\repo",
25502        );
25503        assert!(validate_workspace_root(
25504            &WorkspaceId::new("network").unwrap(),
25505            r"\\server\share\repo",
25506        )
25507        .is_err());
25508        let workspace = WorkspaceConfig::new(
25509            WorkspaceId::new("primary").unwrap(),
25510            std::env::current_dir().unwrap(),
25511        )
25512        .unwrap();
25513        assert!(!workspace.canonical_root.starts_with(r"\\?\"));
25514    }
25515
25516    #[test]
25517    fn resume_rebinds_only_on_the_expected_authorized_generation() {
25518        let catalog = active_registry().unwrap();
25519        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25520        let workspace_id = WorkspaceId::new("primary").unwrap();
25521        let workspace = WorkspaceConfig::new(
25522            workspace_id.clone(),
25523            std::env::current_dir().unwrap(),
25524        )
25525        .unwrap();
25526        let shared = NodeShared::new(
25527            handle,
25528            "fixture-token".to_owned(),
25529            NodeId::new("node-1").unwrap(),
25530            vec![workspace],
25531            vec![agent("claude")],
25532        );
25533        let instance_id = AgentInstanceId(17);
25534        let original = SessionAddress {
25535            workspace_id,
25536            session: SessionKey {
25537                instance_id,
25538                generation: SessionGeneration(3),
25539            },
25540        };
25541        shared.bind_session(
25542            instance_id,
25543            original.workspace_id.clone(),
25544            original.session.generation,
25545        );
25546        shared
25547            .arm_resume(
25548                &original,
25549                CommandId(41),
25550                ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
25551            )
25552            .unwrap();
25553
25554        shared.publish_control(ControlEvent {
25555            sequence: 1,
25556            command_id: None,
25557            instance_id,
25558            generation: SessionGeneration(4),
25559            event: ControlEventKind::Running { process_id: None },
25560        });
25561        assert_eq!(
25562            shared.address_for(instance_id, SessionGeneration(3)),
25563            Some(original.clone()),
25564        );
25565        assert!(shared.address_for(instance_id, SessionGeneration(4)).is_none());
25566
25567        shared.publish_control(ControlEvent {
25568            sequence: 2,
25569            command_id: Some(CommandId(42)),
25570            instance_id,
25571            generation: SessionGeneration(3),
25572            event: ControlEventKind::CommandRejected {
25573                message: "unrelated command".to_owned(),
25574            },
25575        });
25576
25577        shared.publish_control(ControlEvent {
25578            sequence: 3,
25579            command_id: None,
25580            instance_id,
25581            generation: SessionGeneration(4),
25582            event: ControlEventKind::ResumeAuthorized {
25583                session: gate4agent_types::ResumeSessionSummary {
25584                    key: gate4agent_types::ProviderSessionKey::SessionId,
25585                    id: "fixture-session".to_owned(),
25586                },
25587            },
25588        });
25589        assert!(shared.address_for(instance_id, SessionGeneration(3)).is_none());
25590        let rebound = shared.address_for(instance_id, SessionGeneration(4)).unwrap();
25591        assert_eq!(rebound.workspace_id, original.workspace_id);
25592        let history = shared
25593            .history
25594            .lock()
25595            .unwrap_or_else(|poisoned| poisoned.into_inner());
25596        assert!(history.events.iter().any(|envelope| matches!(
25597            &envelope.event,
25598            NodeEvent::Control { address, event }
25599                if address == &rebound
25600                    && matches!(&event.event, ControlEventKind::ResumeAuthorized { .. })
25601        )));
25602    }
25603
25604    #[test]
25605    fn all_node_pty_prompts_use_bracketed_paste_framing() {
25606        for agent in ["claude", "codex", "kimi"] {
25607            assert_eq!(
25608                prompt_framing(&AgentId::new(agent).unwrap()),
25609                PromptFraming::BracketedPaste
25610            );
25611        }
25612    }
25613
25614    fn generous_walk_deadline() -> Instant {
25615        Instant::now() + Duration::from_secs(30)
25616    }
25617
25618    #[test]
25619    fn workspace_tree_is_relative_and_skips_heavy_directories() {
25620        let root = temporary_workspace_root("tree");
25621        std::fs::create_dir_all(root.join("src")).unwrap();
25622        std::fs::create_dir_all(root.join(".git/objects")).unwrap();
25623        std::fs::create_dir_all(root.join("target/debug")).unwrap();
25624        std::fs::create_dir_all(root.join("node_modules/package")).unwrap();
25625        std::fs::write(root.join("src/lib.rs"), b"pub fn fixture() {}\n").unwrap();
25626        std::fs::write(root.join("README.md"), b"fixture\n").unwrap();
25627        // A renamed/agent-scoped build directory (`target-a2`-style, per the
25628        // real repository's own build layout) that carries the Cache
25629        // Directory Tagging Specification marker cargo writes into every
25630        // `--target-dir` it creates: must be skipped even though its name
25631        // does not exactly match `target`.
25632        std::fs::create_dir_all(root.join("target-a2/debug")).unwrap();
25633        std::fs::write(root.join("target-a2/CACHEDIR.TAG"), b"Signature: 8a477f597d28d172789f06886806bc55\n").unwrap();
25634        // A directory that merely *starts with* `target` but carries no
25635        // CACHEDIR.TAG: the new prefix-adjacent check must not false-match
25636        // on the name alone, so this stays in the response.
25637        std::fs::create_dir_all(root.join("target-notes")).unwrap();
25638        std::fs::write(root.join("target-notes/plan.md"), b"not a build directory\n").unwrap();
25639
25640        let (entries, truncated, _budget) =
25641            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
25642        assert!(!truncated);
25643        assert!(entries.iter().any(|entry| {
25644            entry.relative_path.as_utf8() == Some("src")
25645                && entry.kind == WorkspaceEntryKind::Directory
25646        }));
25647        assert!(entries.iter().any(|entry| {
25648            entry.relative_path.as_utf8() == Some("src/lib.rs")
25649                && entry.kind == WorkspaceEntryKind::File
25650        }));
25651        assert!(entries.iter().any(|entry| {
25652            entry.relative_path.as_utf8() == Some("target-notes")
25653                && entry.kind == WorkspaceEntryKind::Directory
25654        }));
25655        assert!(entries.iter().any(|entry| {
25656            entry.relative_path.as_utf8() == Some("target-notes/plan.md")
25657                && entry.kind == WorkspaceEntryKind::File
25658        }));
25659        assert!(entries.iter().all(|entry| {
25660            let path = entry.relative_path.as_utf8().unwrap();
25661            !path.starts_with(".git")
25662                && !(path == "target" || path.starts_with("target/"))
25663                && !(path == "target-a2" || path.starts_with("target-a2/"))
25664                && !path.starts_with("node_modules")
25665        }));
25666
25667        std::fs::remove_dir_all(&root).unwrap();
25668    }
25669
25670    #[test]
25671    fn workspace_walk_stops_at_its_entry_cap_and_marks_a_partial_result() {
25672        let root = temporary_workspace_root("tree-entry-cap");
25673        std::fs::create_dir_all(&root).unwrap();
25674        for index in 0..20 {
25675            std::fs::write(root.join(format!("file-{index:02}.txt")), b"fixture\n").unwrap();
25676        }
25677
25678        let (entries, truncated, budget) =
25679            collect_workspace_entries(&root, generous_walk_deadline(), 5);
25680        assert!(truncated);
25681        assert!(budget.entry_cap_exceeded);
25682        assert!(!budget.time_budget_exceeded);
25683        assert!(entries.len() <= 5);
25684
25685        let inspection_truncation = workspace_inspection_truncation(&budget, false, Duration::from_millis(1))
25686            .expect("an entry-cap-truncated walk must report a truncation marker");
25687        assert!(inspection_truncation.walk_entry_cap_exceeded);
25688        assert!(!inspection_truncation.walk_time_budget_exceeded);
25689        assert!(!inspection_truncation.git_time_budget_exceeded);
25690        assert!(inspection_truncation.entries_visited >= 5);
25691
25692        std::fs::remove_dir_all(&root).unwrap();
25693    }
25694
25695    #[test]
25696    fn workspace_walk_within_budget_reports_no_truncation() {
25697        let root = temporary_workspace_root("tree-no-truncation");
25698        std::fs::create_dir_all(&root).unwrap();
25699        std::fs::write(root.join("only-file.txt"), b"fixture\n").unwrap();
25700
25701        let (_entries, truncated, budget) =
25702            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
25703        assert!(!truncated);
25704        assert!(workspace_inspection_truncation(&budget, false, Duration::from_millis(1)).is_none());
25705
25706        std::fs::remove_dir_all(&root).unwrap();
25707    }
25708
25709    #[test]
25710    fn workspace_walk_emits_strict_global_byte_order_for_prefix_named_siblings() {
25711        let root = temporary_workspace_root("tree-prefix-siblings");
25712        std::fs::create_dir_all(root.join("gate4agent-c2/src")).unwrap();
25713        std::fs::write(root.join("gate4agent-c2/src/lib.rs"), b"fixture\n").unwrap();
25714        std::fs::create_dir_all(root.join("gate4agent-c2-client")).unwrap();
25715        std::fs::write(root.join("gate4agent-c2-client/lib.rs"), b"fixture\n").unwrap();
25716
25717        let (entries, truncated, _budget) =
25718            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
25719        assert!(!truncated);
25720        let paths = entries
25721            .iter()
25722            .map(|entry| entry.relative_path.as_utf8().unwrap().to_owned())
25723            .collect::<Vec<_>>();
25724        assert!(paths.windows(2).all(|pair| pair[0] < pair[1]));
25725        assert_eq!(
25726            paths,
25727            vec![
25728                "gate4agent-c2".to_owned(),
25729                "gate4agent-c2-client".to_owned(),
25730                "gate4agent-c2-client/lib.rs".to_owned(),
25731                "gate4agent-c2/src".to_owned(),
25732                "gate4agent-c2/src/lib.rs".to_owned(),
25733            ],
25734        );
25735
25736        std::fs::remove_dir_all(&root).unwrap();
25737    }
25738
25739    #[test]
25740    fn git_parsers_cap_status_and_commit_results() {
25741        let mut status_output = Vec::new();
25742        for index in 0..=GIT_STATUS_MAX_ENTRIES {
25743            status_output.extend_from_slice(format!(" M src/file-{index}.rs\0").as_bytes());
25744        }
25745        let mut snapshot = git_snapshot_for_parser();
25746        parse_git_status(&status_output, &mut snapshot);
25747        assert_eq!(snapshot.status.len(), GIT_STATUS_MAX_ENTRIES);
25748        assert!(snapshot.truncated);
25749        assert_eq!(snapshot.status[0].index_status, " ");
25750        assert_eq!(snapshot.status[0].worktree_status, "M");
25751
25752        snapshot.truncated = false;
25753        let commit_output = (0..=GIT_COMMIT_MAX_ENTRIES)
25754            .map(|index| format!("abc{index}\u{1f}commit {index}\n"))
25755            .collect::<String>();
25756        parse_git_commits(commit_output.as_bytes(), &mut snapshot);
25757        assert_eq!(snapshot.recent_commits.len(), GIT_COMMIT_MAX_ENTRIES);
25758        assert!(snapshot.truncated);
25759    }
25760
25761    #[test]
25762    fn porcelain_v1_z_parser_preserves_current_then_previous_rename_and_copy_order() {
25763        let mut snapshot = git_snapshot_for_parser();
25764        parse_git_status(
25765            b"R  src/current name.rs\0src/previous name.rs\0 C src/copy.rs\0src/original.rs\0?? src/line\nname.rs\0",
25766            &mut snapshot,
25767        );
25768
25769        assert!(!snapshot.truncated);
25770        assert_eq!(snapshot.status.len(), 3);
25771        assert_eq!(snapshot.status[0].index_status, "R");
25772        assert_eq!(snapshot.status[0].worktree_status, " ");
25773        assert_eq!(snapshot.status[0].path.as_utf8(), Some("src/current name.rs"));
25774        assert_eq!(
25775            snapshot.status[0]
25776                .previous_path
25777                .as_ref()
25778                .and_then(RepositoryPath::as_utf8),
25779            Some("src/previous name.rs"),
25780        );
25781        assert_eq!(snapshot.status[1].index_status, " ");
25782        assert_eq!(snapshot.status[1].worktree_status, "C");
25783        assert_eq!(snapshot.status[1].path.as_utf8(), Some("src/copy.rs"));
25784        assert_eq!(
25785            snapshot.status[1]
25786                .previous_path
25787                .as_ref()
25788                .and_then(RepositoryPath::as_utf8),
25789            Some("src/original.rs"),
25790        );
25791        assert_eq!(snapshot.status[2].path.as_utf8(), Some("src/line\nname.rs"));
25792        assert!(snapshot.status[2].previous_path.is_none());
25793    }
25794
25795    #[test]
25796    fn porcelain_v1_z_parser_never_invents_invalid_or_incomplete_path_identity() {
25797        let mut snapshot = git_snapshot_for_parser();
25798        parse_git_status(
25799            b"?? valid.rs\0?? invalid-\xff.rs\0?? invalid\\windows.rs\0R  current.rs\0",
25800            &mut snapshot,
25801        );
25802
25803        assert!(snapshot.truncated);
25804        assert_eq!(snapshot.status.len(), 1);
25805        assert_eq!(snapshot.status[0].path.as_utf8(), Some("valid.rs"));
25806        assert!(snapshot.status[0].previous_path.is_none());
25807    }
25808
25809    #[tokio::test]
25810    async fn observer_can_browse_host_directories_without_controller() {
25811        let root = temporary_workspace_root("host-directory-observer");
25812        std::fs::create_dir_all(root.join("directory-b")).unwrap();
25813        std::fs::create_dir_all(root.join("directory-a")).unwrap();
25814        std::fs::write(root.join("ordinary-file"), b"not a directory").unwrap();
25815        let workspace_id = WorkspaceId::new("primary").unwrap();
25816        let workspace = WorkspaceConfig::new(workspace_id, &root).unwrap();
25817        let catalog = active_registry().unwrap();
25818        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25819        let shared = NodeShared::new(
25820            handle,
25821            "fixture-token".to_owned(),
25822            NodeId::new("node-1").unwrap(),
25823            vec![workspace],
25824            vec![agent("claude")],
25825        );
25826        let request = NodeRequest::BrowseHostDirectories {
25827            directory: Some(OpaqueHostPath::utf8(root.to_string_lossy().into_owned()).unwrap()),
25828            after: None,
25829        };
25830        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
25831            capability.as_str() == CAPABILITY_HOST_DIRECTORY_BROWSE_V1
25832        }));
25833        assert!(!request_uses_unnegotiated_capability(
25834            &request,
25835            &baseline_capabilities().unwrap(),
25836        ));
25837        assert!(request_uses_unnegotiated_capability(&request, &[]));
25838
25839        let response = process_request_inner(&shared, 77, ClientRole::Observer, request)
25840            .await
25841            .unwrap();
25842        let NodeResponse::HostDirectoriesBrowsed { listing } = response else {
25843            panic!("host directory browse returned another response");
25844        };
25845        assert_eq!(
25846            listing
25847                .entries
25848                .iter()
25849                .map(|entry| entry.display_name.as_str())
25850                .collect::<Vec<_>>(),
25851            vec!["directory-a", "directory-b"],
25852        );
25853        assert!(listing.entries.iter().all(|entry| !entry.is_link));
25854        assert!(!listing.incomplete);
25855        std::fs::remove_dir_all(root).unwrap();
25856    }
25857
25858    #[tokio::test]
25859    async fn observer_can_inspect_only_a_registered_workspace() {
25860        let root = temporary_workspace_root("observer");
25861        std::fs::create_dir_all(root.join("src")).unwrap();
25862        std::fs::write(root.join("src/main.rs"), b"fn main() {}\n").unwrap();
25863        let workspace_id = WorkspaceId::new("primary").unwrap();
25864        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
25865        let catalog = active_registry().unwrap();
25866        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25867        let shared = NodeShared::new(
25868            handle,
25869            "fixture-token".to_owned(),
25870            NodeId::new("node-1").unwrap(),
25871            vec![workspace],
25872            vec![agent("claude")],
25873        );
25874
25875        let response = process_request_inner(
25876            &shared,
25877            77,
25878            ClientRole::Observer,
25879            NodeRequest::InspectWorkspace {
25880                workspace_id: workspace_id.clone(),
25881            },
25882        )
25883        .await
25884        .unwrap();
25885        let NodeResponse::WorkspaceInspected { inspection } = response else {
25886            panic!("workspace inspection returned another response");
25887        };
25888        assert_eq!(inspection.workspace_id, workspace_id);
25889        assert!(inspection.entries.iter().any(|entry| {
25890            entry.relative_path.as_utf8() == Some("src/main.rs")
25891        }));
25892
25893        let unknown = process_request_inner(
25894            &shared,
25895            77,
25896            ClientRole::Observer,
25897            NodeRequest::InspectWorkspace {
25898                workspace_id: WorkspaceId::new("unknown").unwrap(),
25899            },
25900        )
25901        .await
25902        .unwrap_err();
25903        assert_eq!(unknown.code, NodeFailureCode::UnknownWorkspace);
25904        std::fs::remove_dir_all(&root).unwrap();
25905    }
25906
25907    #[test]
25908    fn context_pack_source_accepts_only_running_or_successful_exit() {
25909        assert!(context_pack_source_status_is_usable(&SessionStatus::Running));
25910        assert!(context_pack_source_status_is_usable(&SessionStatus::Exited {
25911            exit_code: Some(0),
25912        }));
25913        assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
25914            exit_code: Some(1),
25915        }));
25916        assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
25917            exit_code: None,
25918        }));
25919        assert!(!context_pack_source_status_is_usable(&SessionStatus::Failed {
25920            message: "provider failed".to_owned(),
25921        }));
25922        assert!(!context_pack_source_status_is_usable(&SessionStatus::Registered));
25923        assert!(!context_pack_source_status_is_usable(&SessionStatus::Starting));
25924        assert!(!context_pack_source_status_is_usable(&SessionStatus::Stopping));
25925    }
25926
25927    #[test]
25928    fn context_pack_repository_rejects_changed_head_with_same_git_snapshot() {
25929        let git = git_snapshot_for_parser();
25930        let first = ContextPackRepositoryObservation {
25931            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
25932            git: git.clone(),
25933            files: Vec::new(),
25934        };
25935        let second = ContextPackRepositoryObservation {
25936            head: ContextPackRepositoryHead::Commit("b".repeat(40)),
25937            git,
25938            files: Vec::new(),
25939        };
25940
25941        let error = stable_context_pack_repository(first, second).unwrap_err();
25942
25943        assert_eq!(
25944            error.code,
25945            NodeFailureCode::ContextPackMaterializationFailed,
25946        );
25947    }
25948
25949    #[test]
25950    fn context_pack_repository_rejects_changed_file_with_same_git_snapshot() {
25951        let git = git_snapshot_for_parser();
25952        let first = ContextPackRepositoryObservation {
25953            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
25954            git: git.clone(),
25955            files: vec![ContextPackRepositoryFileSource::utf8(
25956                "README.md",
25957                "first\n".to_owned(),
25958                6,
25959            )],
25960        };
25961        let second = ContextPackRepositoryObservation {
25962            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
25963            git,
25964            files: vec![ContextPackRepositoryFileSource::utf8(
25965                "README.md",
25966                "other\n".to_owned(),
25967                6,
25968            )],
25969        };
25970
25971        let error = stable_context_pack_repository(first, second).unwrap_err();
25972
25973        assert_eq!(
25974            error.code,
25975            NodeFailureCode::ContextPackMaterializationFailed,
25976        );
25977    }
25978
25979    #[tokio::test]
25980    async fn native_session_catalog_zero_active_session_fixture() {
25981        let workspace_root = temporary_workspace_root("native-catalog-workspace");
25982        let history_root = temporary_workspace_root("native-catalog-history");
25983        let outside_root = temporary_workspace_root("native-catalog-outside");
25984        std::fs::create_dir_all(&workspace_root).unwrap();
25985        std::fs::create_dir_all(&history_root).unwrap();
25986        std::fs::create_dir_all(&outside_root).unwrap();
25987        let transcript = |session_id: &str, cwd: &Path, title: &str| {
25988            [
25989                serde_json::json!({
25990                    "type": "user",
25991                    "sessionId": session_id,
25992                    "cwd": cwd.to_str().unwrap(),
25993                    "message": { "content": "raw user text must not cross the catalog wire" }
25994                }),
25995                serde_json::json!({
25996                    "type": "assistant",
25997                    "sessionId": session_id,
25998                    "cwd": cwd.to_str().unwrap(),
25999                    "message": { "model": "claude-sonnet", "content": "raw answer" }
26000                }),
26001                serde_json::json!({
26002                    "type": "ai-title",
26003                    "sessionId": session_id,
26004                    "aiTitle": title
26005                }),
26006            ]
26007            .into_iter()
26008            .map(|record| record.to_string())
26009            .collect::<Vec<_>>()
26010            .join("\n")
26011        };
26012        std::fs::write(
26013            history_root.join("stable-session.jsonl"),
26014            transcript("stable-session", &workspace_root, "Workspace review"),
26015        )
26016        .unwrap();
26017        std::fs::write(
26018            history_root.join("outside-session.jsonl"),
26019            transcript("outside-session", &outside_root, "Outside review"),
26020        )
26021        .unwrap();
26022        let history = NativeHistoryConfig::new(vec![
26023            NativeHistoryRoot::new(
26024                gate4agent_types::AdapterId::new("claude-code").unwrap(),
26025                HistorySourceLayout::SingleNdjson,
26026                history_root.clone(),
26027            )
26028            .unwrap(),
26029        ])
26030        .unwrap();
26031        let catalog = active_registry().unwrap();
26032        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26033        assert!(handle.snapshot().sessions.is_empty());
26034        let workspace_id = WorkspaceId::new("primary").unwrap();
26035        let workspace = WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap();
26036        let mut shared = NodeShared::new(
26037            handle,
26038            "fixture-token".to_owned(),
26039            NodeId::new("node-native-catalog").unwrap(),
26040            vec![workspace],
26041            vec![agent("claude")],
26042        );
26043        shared.native_session_catalog = Some(Arc::new(Mutex::new(
26044            NativeSessionCatalogAuthority::new(history),
26045        )));
26046        let route = NativeSessionCatalogRoute::workspace(
26047            workspace_id.clone(),
26048            agent("claude"),
26049        );
26050        let request = NodeRequest::CatalogNativeSessions {
26051            route: route.clone(),
26052            limit: 2,
26053        };
26054        let observer = process_request_inner(&shared, 7, ClientRole::Observer, request.clone())
26055            .await
26056            .unwrap_err();
26057        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26058        let response = process_request_inner(&shared, 7, ClientRole::Operator, request)
26059            .await
26060            .unwrap();
26061        let NodeResponse::NativeSessionsCataloged { entries, summary, .. } = response else {
26062            panic!("native catalog returned a different response");
26063        };
26064        let summary = summary.expect("node must emit native session catalog summary metadata");
26065        assert_eq!(entries.len(), 1);
26066        assert!(gate4agent_types::validate_candidate_id(&entries[0].selection_id).is_ok());
26067        assert_eq!(entries[0].external_group, None);
26068        assert_eq!(entries[0].record_id, None);
26069        assert_eq!(entries[0].title, None);
26070        assert_eq!(entries[0].model, None);
26071        assert_eq!(entries[0].message_count, 0);
26072        let encoded_entries = serde_json::to_string(&entries).unwrap();
26073        assert!(!encoded_entries.contains("stable-session"));
26074        assert!(!encoded_entries.contains(history_root.to_string_lossy().as_ref()));
26075        let page_request = NodeRequest::PageNativeSessions {
26076            route: route.clone(),
26077            window: crate::protocol::NativeSessionCatalogWindow::Recent,
26078            catalog_revision: summary.catalog_revision,
26079            recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
26080            after_selection_id: None,
26081            limit: 2,
26082        };
26083        let observer = process_request_inner(
26084            &shared,
26085            7,
26086            ClientRole::Observer,
26087            page_request.clone(),
26088        )
26089        .await
26090        .unwrap_err();
26091        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26092        let page_response = process_request_inner(
26093            &shared,
26094            7,
26095            ClientRole::Operator,
26096            page_request.clone(),
26097        )
26098        .await
26099        .unwrap();
26100        assert!(matches!(
26101            page_response,
26102            NodeResponse::NativeSessionsPaged { page, .. }
26103                if page.entries.len() == 1 && !page.has_more && page.remaining_count == 0
26104        ));
26105        let stale = process_request_inner(
26106            &shared,
26107            7,
26108            ClientRole::Operator,
26109            NodeRequest::PageNativeSessions {
26110                route: route.clone(),
26111                window: crate::protocol::NativeSessionCatalogWindow::Recent,
26112                catalog_revision: summary.catalog_revision.wrapping_add(1),
26113                recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
26114                after_selection_id: None,
26115                limit: 2,
26116            },
26117        )
26118        .await
26119        .unwrap_err();
26120        assert_eq!(stale.code, NodeFailureCode::StaleNativeSessionCatalog);
26121        let selection = NativeSessionSelection {
26122            route: route.clone(),
26123            catalog_revision: summary.catalog_revision,
26124            recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
26125            selection_id: entries[0].selection_id.clone(),
26126        };
26127        let preview_response = process_request_inner(
26128            &shared,
26129            7,
26130            ClientRole::Operator,
26131            NodeRequest::PreviewNativeSession {
26132                selection: selection.clone(),
26133                message_limit: 1,
26134            },
26135        )
26136        .await
26137        .unwrap();
26138        let NodeResponse::NativeSessionPreviewed { preview, .. } = preview_response else {
26139            panic!("native preview returned a different response");
26140        };
26141        assert_eq!(preview.messages.len(), 1);
26142        assert!(preview.message_count_exact);
26143        assert_eq!(preview.messages[0].text, "raw answer");
26144        assert!(preview.truncated);
26145
26146        let index_request = NodeRequest::IndexNativeSession {
26147            selection: selection.clone(),
26148            display_name: "Saved Claude".to_owned(),
26149        };
26150        let observer = process_request_inner(
26151            &shared,
26152            7,
26153            ClientRole::Observer,
26154            index_request.clone(),
26155        )
26156        .await
26157        .unwrap_err();
26158        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26159        let without_controller = process_request_inner(
26160            &shared,
26161            7,
26162            ClientRole::Operator,
26163            index_request.clone(),
26164        )
26165        .await
26166        .unwrap_err();
26167        assert_eq!(without_controller.code, NodeFailureCode::ControllerRequired);
26168        let acquired = process_request_inner(
26169            &shared,
26170            7,
26171            ClientRole::Operator,
26172            NodeRequest::AcquireController { lease_ms: 5_000 },
26173        )
26174        .await
26175        .unwrap();
26176        assert!(matches!(acquired, NodeResponse::Controller { controller: Some(_) }));
26177        let indexed = process_request_inner(
26178            &shared,
26179            7,
26180            ClientRole::Operator,
26181            index_request,
26182        )
26183        .await
26184        .unwrap();
26185        assert!(indexed.native_session_index_contract_is_valid());
26186        let NodeResponse::NativeSessionIndexed {
26187            selection: echoed_selection,
26188            record,
26189        } = indexed else {
26190            panic!("native session index returned a different response");
26191        };
26192        assert_eq!(echoed_selection, selection);
26193        let recataloged = process_request_inner(
26194            &shared,
26195            7,
26196            ClientRole::Operator,
26197            NodeRequest::CatalogNativeSessions {
26198                route,
26199                limit: 2,
26200            },
26201        )
26202        .await
26203        .unwrap();
26204        let NodeResponse::NativeSessionsCataloged { entries, .. } = recataloged else {
26205            panic!("native catalog returned a different response after indexing");
26206        };
26207        assert_eq!(entries[0].record_id.as_ref(), Some(&record.record_id));
26208
26209        let unregistered_route = NativeSessionCatalogRoute::unregistered(agent("claude"));
26210        let external_catalog = process_request_inner(
26211            &shared,
26212            7,
26213            ClientRole::Operator,
26214            NodeRequest::CatalogNativeSessions {
26215                route: unregistered_route.clone(),
26216                limit: 2,
26217            },
26218        )
26219        .await
26220        .unwrap();
26221        let external_json = serde_json::to_string(&external_catalog).unwrap();
26222        assert!(!external_json.contains("outside-session"));
26223        assert!(!external_json.contains(outside_root.to_string_lossy().as_ref()));
26224        assert!(!external_json.contains(history_root.to_string_lossy().as_ref()));
26225        assert!(!external_json.contains("\"session_id\""));
26226        assert!(!external_json.contains("\"cwd\""));
26227        assert!(!external_json.contains("\"path\""));
26228        let NodeResponse::NativeSessionsCataloged {
26229            route: echoed_route,
26230            entries: external_entries,
26231            summary: Some(external_summary),
26232        } = external_catalog
26233        else {
26234            panic!("unregistered native catalog returned a different response");
26235        };
26236        assert_eq!(echoed_route, unregistered_route);
26237        assert_eq!(external_entries.len(), 1);
26238        assert_eq!(external_entries[0].record_id, None);
26239        let external_group = external_entries[0]
26240            .external_group
26241            .as_ref()
26242            .expect("unregistered session must have an external group");
26243        assert_eq!(
26244            external_group.kind,
26245            gate4agent_types::NativeSessionExternalGroupKind::Project,
26246        );
26247        assert!(external_group.group_id.starts_with("external-"));
26248        assert_eq!(
26249            external_group.display_name,
26250            outside_root.file_name().unwrap().to_string_lossy(),
26251        );
26252        let external_selection = NativeSessionSelection {
26253            route: unregistered_route,
26254            catalog_revision: external_summary.catalog_revision,
26255            recent_cutoff_unix_ms: external_summary.recent_cutoff_unix_ms,
26256            selection_id: external_entries[0].selection_id.clone(),
26257        };
26258        let external_preview = process_request_inner(
26259            &shared,
26260            7,
26261            ClientRole::Operator,
26262            NodeRequest::PreviewNativeSession {
26263                selection: external_selection.clone(),
26264                message_limit: 1,
26265            },
26266        )
26267        .await
26268        .unwrap();
26269        let external_preview_json = serde_json::to_string(&external_preview).unwrap();
26270        assert!(!external_preview_json.contains("outside-session"));
26271        assert!(!external_preview_json.contains(outside_root.to_string_lossy().as_ref()));
26272        assert!(!external_preview_json.contains("\"session_id\""));
26273        let NodeResponse::NativeSessionPreviewed { preview, .. } = external_preview else {
26274            panic!("unregistered native preview returned a different response");
26275        };
26276        assert_eq!(preview.messages.len(), 1);
26277        let external_index = process_request_inner(
26278            &shared,
26279            7,
26280            ClientRole::Operator,
26281            NodeRequest::IndexNativeSession {
26282                selection: external_selection,
26283                display_name: "External Claude".to_owned(),
26284            },
26285        )
26286        .await
26287        .unwrap_err();
26288        assert_eq!(
26289            external_index.code,
26290            NodeFailureCode::WorkspaceRegistrationRequired,
26291        );
26292        let record_response = process_request_inner(
26293            &shared,
26294            7,
26295            ClientRole::Operator,
26296            NodeRequest::PreviewSessionRecord {
26297                record_id: record.record_id.clone(),
26298                message_limit: 2,
26299            },
26300        )
26301        .await
26302        .unwrap();
26303        let NodeResponse::SessionRecordPreviewed { record_id, preview } = record_response else {
26304            panic!("record preview returned a different response");
26305        };
26306        assert_eq!(record_id, record.record_id);
26307        assert_eq!(preview.messages.len(), 2);
26308        let encoded = serde_json::to_string(&preview).unwrap();
26309        assert!(!encoded.contains("stable-session"));
26310        assert!(!encoded.contains(history_root.to_string_lossy().as_ref()));
26311        let NodeResponse::Resync { events, .. } = shared.resync(0) else {
26312            panic!("record preview history summary returned a different response");
26313        };
26314        let summaries = events
26315            .iter()
26316            .filter_map(|envelope| match &envelope.event {
26317                NodeEvent::SessionRecordHistorySummarized { record_id, summary }
26318                    if record_id == &record.record_id =>
26319                {
26320                    Some(*summary)
26321                }
26322                _ => None,
26323            })
26324            .collect::<Vec<_>>();
26325        assert_eq!(summaries, vec![SessionHistorySummaryV1::from(&preview)]);
26326        let summary_json = serde_json::to_string(&summaries).unwrap();
26327        for private in [
26328            "stable-session",
26329            "raw user text must not cross the catalog wire",
26330            "raw answer",
26331            "Workspace review",
26332            "claude-sonnet",
26333        ] {
26334            assert!(!summary_json.contains(private), "{private}");
26335        }
26336        let identity = record.provider_session.clone().unwrap();
26337        assert_eq!(
26338            shared.revalidate_session_record_preview(&record, &identity),
26339            Ok(())
26340        );
26341        shared.remove_record_memory(&record.record_id);
26342        assert_eq!(
26343            shared
26344                .revalidate_session_record_preview(&record, &identity)
26345                .unwrap_err()
26346                .code,
26347            NodeFailureCode::UnknownSessionRecord
26348        );
26349        let released = process_request_inner(
26350            &shared,
26351            7,
26352            ClientRole::Operator,
26353            NodeRequest::ReleaseController,
26354        )
26355        .await
26356        .unwrap();
26357        assert!(matches!(released, NodeResponse::Controller { controller: None }));
26358        assert!(shared.controller_state().is_none());
26359        std::fs::remove_dir_all(workspace_root).unwrap();
26360        std::fs::remove_dir_all(history_root).unwrap();
26361        std::fs::remove_dir_all(outside_root).unwrap();
26362    }
26363
26364    #[tokio::test]
26365    async fn native_session_catalog_concurrent_operation_fails_fast_without_waiting_on_mutex() {
26366        let history_root = temporary_workspace_root("native-catalog-concurrent-operation");
26367        std::fs::create_dir_all(&history_root).unwrap();
26368        let history = NativeHistoryConfig::new(vec![
26369            NativeHistoryRoot::new(
26370                gate4agent_types::AdapterId::new("claude-code").unwrap(),
26371                HistorySourceLayout::SingleNdjson,
26372                history_root.clone(),
26373            )
26374            .unwrap(),
26375        ])
26376        .unwrap();
26377        let catalog = Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)));
26378        let held = catalog.lock().unwrap();
26379        let result = timeout(
26380            Duration::from_secs(1),
26381            run_native_session_catalog_operation(
26382                Arc::clone(&catalog),
26383                "native session catalog",
26384                |_| Ok::<(), std::convert::Infallible>(()),
26385            ),
26386        )
26387        .await
26388        .expect("concurrent native catalog request must not wait for the operation deadline")
26389        .unwrap_err();
26390        assert_eq!(result.code, NodeFailureCode::BackendBusy);
26391        drop(held);
26392        std::fs::remove_dir_all(history_root).unwrap();
26393    }
26394
26395    fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
26396        let unique = std::time::SystemTime::now()
26397            .duration_since(std::time::UNIX_EPOCH)
26398            .unwrap()
26399            .as_nanos();
26400        std::env::temp_dir().join(format!(
26401            "gate4agent-node-{label}-{}-{unique}",
26402            std::process::id(),
26403        ))
26404    }
26405
26406    fn git_snapshot_for_parser() -> GitSnapshot {
26407        GitSnapshot {
26408            is_repository: true,
26409            branch: Some("main".to_owned()),
26410            status: Vec::new(),
26411            recent_commits: Vec::new(),
26412            worktrees: Vec::new(),
26413            managed_worktree: None,
26414            truncated: false,
26415            diagnostic: None,
26416        }
26417    }
26418
26419    #[tokio::test]
26420    async fn workspace_inspection_scopes_only_exact_active_managed_branch() {
26421        let root = temporary_workspace_root("managed-git-scope");
26422        std::fs::create_dir_all(&root).unwrap();
26423        run_test_git(&root, &["init"]);
26424        run_test_git(&root, &["config", "user.email", "gate4agent@example.invalid"]);
26425        run_test_git(&root, &["config", "user.name", "Gate4Agent Test"]);
26426        std::fs::write(root.join("seed.txt"), "seed\n").unwrap();
26427        run_test_git(&root, &["add", "seed.txt"]);
26428        run_test_git(&root, &["commit", "-m", "seed"]);
26429        run_test_git(&root, &["checkout", "-b", "gate4agent/a"]);
26430
26431        let workspace_id = WorkspaceId::new("managed-a").unwrap();
26432        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
26433        let catalog = active_registry().unwrap();
26434        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26435        let shared = NodeShared::new(
26436            handle,
26437            "fixture-token".to_owned(),
26438            NodeId::new("node-1").unwrap(),
26439            vec![workspace],
26440            vec![agent("claude")],
26441        );
26442        let mut lease = ManagedWorktreeLeaseRecord {
26443            lease_id: ManagedWorktreeLeaseId::new("mw-a").unwrap(),
26444            source_workspace_id: WorkspaceId::new("primary").unwrap(),
26445            workspace_id: workspace_id.clone(),
26446            profile_id: WorktreeProfileId::new("default").unwrap(),
26447            profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
26448            target_root: root.to_string_lossy().into_owned(),
26449            branch: "gate4agent/a".to_owned(),
26450            base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
26451            expected_head: None,
26452            retention: ManagedWorktreeRetention::Retain,
26453            state: ManagedWorktreeLeaseState::InUse,
26454            session_holders: vec![ManagedWorktreeSessionHolder {
26455                incarnation_id: shared.incarnation_id,
26456                instance_id: AgentInstanceId(7),
26457                generation: SessionGeneration(1),
26458            }],
26459            record_holders: Vec::new(),
26460            cleanup_failure: None,
26461            created_at_unix_ms: 1,
26462            updated_at_unix_ms: 1,
26463        };
26464        *shared
26465            .managed_worktrees
26466            .lock()
26467            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
26468            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
26469
26470        let inspection = shared.inspect_workspace(workspace_id.clone()).await.unwrap();
26471        let scope = inspection.git.managed_worktree.unwrap();
26472        assert_eq!(scope.lease_id, lease.lease_id);
26473        assert_eq!(scope.branch, "gate4agent/a");
26474        assert_eq!(scope.active_session_count, 1);
26475
26476        lease.branch = "gate4agent/b".to_owned();
26477        *shared
26478            .managed_worktrees
26479            .lock()
26480            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
26481            ManagedWorktreeRegistry::from_records(vec![lease], Vec::new()).unwrap();
26482        let mismatch = shared.inspect_workspace(workspace_id).await.unwrap();
26483        assert!(mismatch.git.managed_worktree.is_none());
26484        std::fs::remove_dir_all(root).unwrap();
26485    }
26486
26487    #[tokio::test]
26488    async fn workspace_file_write_requires_controller_and_rejects_stale_revision() {
26489        let root = temporary_workspace_root("workspace-file-write-controller");
26490        std::fs::create_dir_all(root.join("src")).unwrap();
26491        std::fs::write(root.join("src/lib.rs"), b"old\n").unwrap();
26492        let workspace_id = WorkspaceId::new("primary").unwrap();
26493        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
26494        let catalog = active_registry().unwrap();
26495        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26496        let shared = NodeShared::new(
26497            handle,
26498            "fixture-token".to_owned(),
26499            NodeId::new("node-1").unwrap(),
26500            vec![workspace],
26501            vec![agent("claude")],
26502        );
26503        let path = RepositoryPath::utf8("src/lib.rs".to_owned()).unwrap();
26504        let expected_revision = workspace_file_revision(b"old\n");
26505        let request = NodeRequest::WriteWorkspaceFile {
26506            workspace_id: workspace_id.clone(),
26507            path: path.clone(),
26508            expected_revision: expected_revision.clone(),
26509            text: "new\n".to_owned(),
26510        };
26511        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
26512            .await
26513            .unwrap_err();
26514        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26515        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
26516            .await
26517            .unwrap_err();
26518        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
26519        shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
26520        let response = process_request_inner(&shared, 77, ClientRole::Operator, request).await.unwrap();
26521        let NodeResponse::WorkspaceFileWritten { file } = response else {
26522            panic!("workspace file write returned another response");
26523        };
26524        assert_eq!(file.revision, Some(workspace_file_revision(b"new\n")));
26525        assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
26526        let stale = process_request_inner(
26527            &shared,
26528            77,
26529            ClientRole::Operator,
26530            NodeRequest::WriteWorkspaceFile {
26531                workspace_id,
26532                path,
26533                expected_revision,
26534                text: "lost update\n".to_owned(),
26535            },
26536        )
26537        .await
26538        .unwrap_err();
26539        assert_eq!(stale.code, NodeFailureCode::RepositoryFileRevisionConflict);
26540        assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
26541        std::fs::remove_dir_all(root).unwrap();
26542    }
26543
26544    #[tokio::test]
26545    async fn workspace_entry_create_requires_controller_and_never_overwrites() {
26546        let root = temporary_workspace_root("workspace-entry-create-controller");
26547        std::fs::create_dir_all(root.join("src")).unwrap();
26548        let workspace_id = WorkspaceId::new("primary").unwrap();
26549        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
26550        let catalog = active_registry().unwrap();
26551        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26552        let shared = NodeShared::new(
26553            handle,
26554            "fixture-token".to_owned(),
26555            NodeId::new("node-1").unwrap(),
26556            vec![workspace],
26557            vec![agent("claude")],
26558        );
26559        let path = RepositoryPath::utf8("src/new.rs".to_owned()).unwrap();
26560        let request = NodeRequest::CreateWorkspaceFile {
26561            workspace_id: workspace_id.clone(),
26562            path: path.clone(),
26563        };
26564        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
26565            capability.as_str() == NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY
26566        }));
26567        assert!(!request_uses_unnegotiated_capability(
26568            &request,
26569            &baseline_capabilities().unwrap(),
26570        ));
26571        assert!(request_uses_unnegotiated_capability(&request, &[]));
26572        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
26573            .await
26574            .unwrap_err();
26575        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26576        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
26577            .await
26578            .unwrap_err();
26579        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
26580        shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
26581
26582        let response = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
26583            .await
26584            .unwrap();
26585        let NodeResponse::WorkspaceFileCreated { file } = response else {
26586            panic!("workspace file create returned another response");
26587        };
26588        assert_eq!(file.workspace_id, workspace_id);
26589        assert_eq!(file.path, path);
26590        assert_eq!(file.revision, Some(workspace_file_revision(&[])));
26591        assert_eq!(std::fs::read(root.join("src/new.rs")).unwrap(), Vec::<u8>::new());
26592
26593        let duplicate = process_request_inner(&shared, 77, ClientRole::Operator, request)
26594            .await
26595            .unwrap_err();
26596        assert_eq!(duplicate.code, NodeFailureCode::RepositoryEntryAlreadyExists);
26597
26598        let directory_path = RepositoryPath::utf8("src/nested".to_owned()).unwrap();
26599        let response = process_request_inner(
26600            &shared,
26601            77,
26602            ClientRole::Operator,
26603            NodeRequest::CreateWorkspaceDirectory {
26604                workspace_id: workspace_id.clone(),
26605                path: directory_path.clone(),
26606            },
26607        )
26608        .await
26609        .unwrap();
26610        assert_eq!(
26611            response,
26612            NodeResponse::WorkspaceDirectoryCreated {
26613                workspace_id: workspace_id.clone(),
26614                entry: WorkspaceEntry {
26615                    relative_path: directory_path,
26616                    kind: WorkspaceEntryKind::Directory,
26617                },
26618            },
26619        );
26620        assert!(root.join("src/nested").is_dir());
26621
26622        let missing_parent = process_request_inner(
26623            &shared,
26624            77,
26625            ClientRole::Operator,
26626            NodeRequest::CreateWorkspaceFile {
26627                workspace_id: workspace_id.clone(),
26628                path: RepositoryPath::utf8("missing/new.rs".to_owned()).unwrap(),
26629            },
26630        )
26631        .await
26632        .unwrap_err();
26633        assert_eq!(missing_parent.code, NodeFailureCode::RepositoryParentNotFound);
26634        let non_directory_parent = process_request_inner(
26635            &shared,
26636            77,
26637            ClientRole::Operator,
26638            NodeRequest::CreateWorkspaceFile {
26639                workspace_id,
26640                path: RepositoryPath::utf8("src/new.rs/child".to_owned()).unwrap(),
26641            },
26642        )
26643        .await
26644        .unwrap_err();
26645        assert_eq!(
26646            non_directory_parent.code,
26647            NodeFailureCode::RepositoryParentNotDirectory,
26648        );
26649        std::fs::remove_dir_all(root).unwrap();
26650    }
26651
26652    #[tokio::test]
26653    async fn workspace_entry_create_timeout_cancels_before_commit_without_late_mutation() {
26654        let root = temporary_workspace_root("workspace-entry-create-timeout-cancel");
26655        std::fs::create_dir_all(root.join("src")).unwrap();
26656        #[cfg(windows)]
26657        let root = std::fs::canonicalize(root).unwrap();
26658        let path = RepositoryPath::utf8("src/late.rs".to_owned()).unwrap();
26659        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
26660        let barrier = Arc::new(std::sync::Barrier::new(2));
26661        let done = Arc::new(AtomicBool::new(false));
26662        let task_commit_state = Arc::clone(&commit_state);
26663        let task_barrier = Arc::clone(&barrier);
26664        let task_done = Arc::clone(&done);
26665        let task_root = root.clone();
26666        let task = tokio::task::spawn_blocking(move || {
26667            task_barrier.wait();
26668            let result = create_workspace_file_on_disk(
26669                &task_root,
26670                &path,
26671                &task_commit_state,
26672            );
26673            task_done.store(true, Ordering::Release);
26674            result
26675        });
26676
26677        let error = settle_workspace_entry_create(
26678            task,
26679            Arc::clone(&commit_state),
26680            Duration::from_millis(10),
26681            "fixture deadline",
26682            "fixture task failed",
26683        )
26684        .await
26685        .unwrap_err();
26686        assert_eq!(error.code, NodeFailureCode::RepositoryEntryCreateTimedOut);
26687        assert_eq!(
26688            commit_state.load(Ordering::Acquire),
26689            WORKSPACE_ENTRY_CREATE_CANCELED,
26690        );
26691
26692        barrier.wait();
26693        timeout(Duration::from_secs(1), async {
26694            while !done.load(Ordering::Acquire) {
26695                tokio::task::yield_now().await;
26696            }
26697        })
26698        .await
26699        .expect("canceled blocking create did not settle");
26700        assert!(!root.join("src/late.rs").exists());
26701        std::fs::remove_dir_all(root).unwrap();
26702    }
26703
26704}