Skip to main content

hatchery_node/
server.rs

1use crate::git_worktree::{
2    create_worktree as create_git_worktree,
3    create_worktree_with_timeout as create_git_worktree_with_timeout,
4    list_worktrees as list_git_worktrees,
5    list_worktrees_with_deadline as list_git_worktrees_with_deadline,
6    paths_equal as worktree_paths_equal, remove_worktree as remove_git_worktree,
7    removal_lookup_path as normalize_worktree_removal_target,
8    resolve_base_commit_with_timeout,
9    run_git_read_bounded, GitCommandOutput, GitWorktreeError, GitWorktreeErrorKind,
10    NativeGitWorktreeSnapshot,
11};
12use crate::host_directory_browser::{
13    browse_host_directories, HostDirectoryBrowseError, HostDirectoryBrowseErrorKind,
14};
15use crate::environment_profiles::{
16    EnvironmentProfileBinding, NodeEnvironmentProfile, MAX_NODE_ENVIRONMENT_PROFILES,
17};
18use crate::bundle_catalog::{BundleCatalog, NodeBundle};
19use self::bundle_delivery::{DeliveryStore, DeliveryStoreError};
20use self::context_pack_store::ContextPackStore;
21use crate::bundle_provider::{
22    bundle_launch_arguments, validate_bundle_binding, BundleProviderLayout,
23};
24use crate::context_pack::{
25    ContextPackCatalog, ContextPackRepository, ContextPackRepositoryFileSource,
26    ContextPackSelectedFileSkipReason, NodeContextPack, CONTEXT_PACK_SELECTED_FILES,
27};
28use crate::harness_mcp_proxy::{
29    HarnessMcpProxyError, HarnessMcpProxyRegistry, PreparedHarnessMcpSpawn,
30    ReviewedHarnessMcpProgram,
31};
32use crate::session_environment::{
33    MaterializationId, MaterializationOwner, MaterializationOwnershipRecord,
34    MaterializationState, NodeSecretResolver, NodeSessionMaterializationProfile,
35    SessionEnvironmentMaterializeError, SessionEnvironmentMaterializer,
36};
37use crate::worktree_service::{
38    exact_created_worktree, exact_owned_worktree, ManagedWorktreeLeaseRecord,
39    ManagedWorktreeProfile, ManagedWorktreeRegistry, ManagedWorktreeSessionHolder,
40};
41use crate::session_registry::{
42    self, validate_display_name, LoadedNodeState, ManagedWorktreeSpawnReplayRecordV10,
43    ManagedWorktreeSpawnReplayStateV10, MAX_MANAGED_SESSION_RECORDS,
44    MAX_MANAGED_WORKTREE_SPAWN_REPLAYS,
45};
46#[cfg(unix)]
47use crate::workspace_file_unix::{
48    create_workspace_directory as create_workspace_directory_on_disk,
49    create_workspace_file as create_workspace_file_on_disk,
50    read_workspace_file as read_workspace_file_from_disk,
51    write_workspace_file as write_workspace_file_to_disk, WorkspaceFileBytes,
52    WorkspaceFileReadError, WorkspaceFileReadErrorKind,
53    WORKSPACE_ENTRY_CREATE_CANCELED, WORKSPACE_ENTRY_CREATE_COMMITTING,
54    WORKSPACE_ENTRY_CREATE_PENDING,
55};
56#[cfg(windows)]
57use crate::workspace_file_windows::{
58    create_workspace_directory as create_workspace_directory_on_disk,
59    create_workspace_file as create_workspace_file_on_disk,
60    read_workspace_file as read_workspace_file_from_disk,
61    write_workspace_file as write_workspace_file_to_disk, WorkspaceFileBytes,
62    WorkspaceFileReadError, WorkspaceFileReadErrorKind,
63    WORKSPACE_ENTRY_CREATE_CANCELED, WORKSPACE_ENTRY_CREATE_COMMITTING,
64    WORKSPACE_ENTRY_CREATE_PENDING,
65};
66use crate::platform;
67use crate::provider_runtime::{
68    require_policy, ProviderRuntimeAdmissionError, ProviderRuntimeMonitor,
69    ProviderRuntimeRequirement,
70};
71use crate::spawn_spec::SpawnProfileRegistry;
72use crate::standalone_workspace::{
73    prepare_standalone_workspace, StandaloneWorkspaceError, StandaloneWorkspaceErrorKind,
74};
75use crate::protocol::{
76    read_json_frame_limited_body_timeout, write_json_frame, write_json_frame_limited,
77    validate_node_negotiated_handshake_capacity, validate_provider_contract_manifest,
78    provider_id_is_legacy, AdapterContractRevision, AgentStreamChunkKindV1, AgentStreamChunkV1,
79    AgentStreamInteractionOptionV1, AgentStreamNamedIdV1,
80    CapabilityId, ClientFrame, ClientRole,
81    ControllerState, DeliveryBlobChunkHexV1, DeliveryBlobDigestV1,
82    DeliveryBundleManifestV2, DeliveryCommitReceiptV1, DeliveryStageId,
83    AgentProgressAttentionKindV1, AgentProgressAttentionV1, AgentProgressCurrentV1,
84    AgentProgressEventKindV1, AgentProgressUsageV1, AgentProgressV1, FrameError,
85    GitCommitDetails, GitDiff, GitDiffMode, GitDiffRequest, GitHistoryPage,
86    GitObjectId, GitCommitSummary, GitSignatureStatus, GitSnapshot, GitStatusEntry,
87    GitWorktreeSnapshot, HarnessMcpActivationDigest, HarnessMcpReservationId,
88    HostDirectoryListing,
89    NodeCompatibilitySupport, NodeEvent,
90    NodeEventEnvelope, NodeFailure, NodeFailureCode, NodeHello, NodeId, NodeIncarnationId,
91    NegotiatedNodeCompatibility, NodeRequest, NodeResponse, NodeSnapshot, OpaqueHostPath,
92    ProtocolRange,
93    ProviderAdapterContractSupport,
94    ProviderContractRevision, ProviderContractSupport, RepositoryPath, RequestEnvelope,
95    ProviderRuntimeStatuses, ResolvedBundleReceipt, ResolvedEnvironmentProfileReceipt,
96    ContextPackLineageReceipt, ContextPackBytesRead, ResolvedContextPackReceipt,
97    ResolvedHarnessMcpProxyReceiptV1, ResolvedSpawnReceipt,
98    ResolvedSpawnSpec, ResponseEnvelope,
99    ServerChallenge, ServerFrame, SessionAddress, SessionAgentProgress, SessionKey, SessionMode,
100    ManagedSessionRecord,
101    ManagedSessionState, ManagedWorktreeCleanupFailure, ManagedWorktreeLeaseId,
102    SessionRecordRetentionConfig,
103    ManagedWorktreeLeaseSnapshot, ManagedWorktreeLeaseState, ManagedWorktreeRetention,
104    ManagedWorktreeSpawnReceipt, ManagedWorktreeSpawnRequest,
105    ManagedWorktreeSpawnRequestV2, SessionRecordId,
106    NativeSessionCatalogRoute, NativeSessionSelection, SessionTaskBindingV1,
107    SessionTaskTargetV1, TaskId,
108    BlockAuthorityV1, HarnessMcpLaunchV1, SessionHistorySummaryV1, MAX_ACP_BLOCKED_HELP_BYTES, MAX_ACP_BLOCKED_REASON_BYTES,
109    StateSchemaSupport, WorkspaceEntry, WorkspaceEntryKind, WorktreeProfileId,
110    SpawnContextId, SpawnEnvironmentProfileId, SpawnIdempotencyKey,
111    SpawnProfileDefaults, SpawnRequiredCapabilities, SpawnSpec, SpawnSpecResolveError,
112    WorkspaceFileContent, WorkspaceFileRevision,
113    WorkspaceFileRead, WorkspaceId, WorkspaceInspection, WorkspaceInspectionTruncationV1,
114    WorkspaceSnapshot,
115    WorktreeServiceMode,
116    LaunchInventory, ManagedWorktreeProfileSummary, SpawnProfileSummary,
117    WorktreeProfileInventory, MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
118    DEFAULT_CONTROLLER_LEASE_MS,
119    MAX_CONTROLLER_LEASE_MS, MIN_CONTROLLER_LEASE_MS, NODE_COMPATIBILITY_METADATA_CAPABILITY,
120    CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
121    NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY, NODE_REPOSITORY_PATH_CAPABILITY,
122    NODE_PROVIDER_ID_OPEN_CAPABILITY,
123    NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
124    NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY, NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
125    NODE_HISTORY_CONTEXT_PACK_CAPABILITY, NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
126    NODE_NATIVE_SESSION_CATALOG_CAPABILITY, NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
127    NODE_NATIVE_SESSION_INDEX_CAPABILITY, NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
128    NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
129    NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY,
130    NODE_HARNESS_MCP_READ_PROXY_CAPABILITY,
131    NODE_SESSION_TASK_CORRELATION_CAPABILITY,
132    NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY,
133    NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
134    NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
135    NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
136    NODE_SPAWN_PROFILE_REVISION_CAPABILITY, NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
137    NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
138    NODE_AGENT_STREAM_EVENTS_CAPABILITY, NODE_ACP_CONTROL_CAPABILITY,
139    NODE_GIT_READ_CAPABILITY, NODE_WORKSPACE_FILE_READ_CAPABILITY,
140    NODE_WORKSPACE_FILE_WRITE_CAPABILITY, NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
141    NODE_WORKTREE_SELECTION_CAPABILITY,
142    BUILD_STAMP, MAX_NODE_CLIENT_FRAME_BYTES, MAX_NODE_HELLO_FRAME_BYTES,
143    MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS, MAX_AGENT_PROGRESS_ENTRY_BYTES,
144    MAX_NODE_TERMINAL_BYTES, MAX_NODE_TEXT_BYTES,
145    MAX_REPOSITORY_PATH_BYTES,
146    MAX_GIT_DIFF_BYTES, MAX_GIT_HISTORY_COMMITS, MAX_WORKSPACE_FILE_BYTES,
147    MAX_HARNESS_MCP_PENDING_CALLS_PER_NODE,
148    MAX_WORKSPACE_ROOT_BYTES, NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10,
149    SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY, SPAWN_RUNTIME_RAW_PTY_LIFECYCLE,
150    SPAWN_RUNTIME_SEMANTIC_READINESS, SPAWN_RUNTIME_SEMANTIC_RESUME,
151    SPAWN_RUNTIME_STRUCTURED_PROMPT,
152};
153#[cfg(test)]
154use crate::protocol::WorktreeProfileRevision;
155use ring::digest::{digest, SHA256};
156use crate::protocol::correlation::{
157    interaction_correlation, sanitize_progress_tool_label, tool_class_label, tool_correlation,
158    truncate_text,
159};
160use gate4agent_catalog::{builtin_registry, AgentRegistry};
161use gate4agent_handle::{EventSubscription, Gate4AgentHandle, PortDispatchError};
162use gate4agent_runtime_native::{
163    NativeMcpServerLaunchOverlay, NativeInstanceLaunchOverlay,
164    NativeLaunchEnvironmentOverlay,
165    NativeHistoryConfig, NativeLaunchProfileControl, NativeRuntime, NativeRuntimeConfig,
166    NativeSessionCatalogAuthority, NativeSessionCatalogError, NativeSessionPreviewError,
167    ScopedNativeSessionCatalogEntry,
168};
169use gate4agent_runtime_native::shell_efficiency::ShellEfficiencyProfile;
170use std::sync::OnceLock;
171use gate4agent_runtime_native::tick_profile::TickProfileSnapshot;
172use drive_loop_profile::DriveLoopProfiler;
173#[cfg(test)]
174use gate4agent_runtime_native::{HistorySourceLayout, NativeHistoryRoot};
175use gate4agent_node_wire::{
176    auth_proof, negotiated_auth_proof, proofs_match, random_incarnation_id, random_nonce,
177    AuthDirection, OwnerOnlyLocalListener,
178};
179use gate4agent_types::{
180    AdapterBinding, AdapterFamily, 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
213use gate4agent_node_core::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    /// Tip 6: optional mesh underlay UDP bind that relays to local `--bridge-listen`.
1050    /// Requires bridge_listen + bridge_token. HQ never accepts (NodePeer).
1051    bridge_underlay: Option<browser_bridge::BridgeUnderlayAccept>,
1052    /// A relay to CALL, rather than wait to be called by. `None` keeps the
1053    /// node purely a listener, which is what it has always been.
1054    call_home: Option<std::net::SocketAddr>,
1055    pub node_id: NodeId,
1056    pub workspaces: Vec<WorkspaceConfig>,
1057    access_token: String,
1058    pub runtime: NativeRuntimeConfig,
1059    session_record_retention: SessionRecordRetentionConfig,
1060    state_path: Option<PathBuf>,
1061    spawn_profiles: SpawnProfileRegistry,
1062    session_environment: Option<NodeSessionEnvironmentConfig>,
1063    history: Option<NativeHistoryConfig>,
1064    harness_mcp_helper: Option<ReviewedHarnessMcpProgram>,
1065    /// Station network allowlist catalog (opaque ids). Empty default.
1066    /// Loaded at startup from `--network-allowlist-catalog` / env
1067    /// `GATE4AGENT_NODE_NETWORK_ALLOWLIST_CATALOG` file. Never secrets.
1068    network_allowlist_catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog,
1069    #[cfg(feature = "fixture")]
1070    fixture_raw_pty_runtime: bool,
1071}
1072
1073#[derive(Clone)]
1074struct NodeSessionEnvironmentConfig {
1075    root: PathBuf,
1076    resolver: Arc<dyn NodeSecretResolver>,
1077}
1078
1079impl NodeServerConfig {
1080    pub fn new(
1081        endpoint: impl Into<String>,
1082        access_token: impl Into<String>,
1083        node_id: NodeId,
1084        workspaces: impl IntoIterator<Item = WorkspaceConfig>,
1085    ) -> Result<Self, NodeServerError> {
1086        let endpoint = endpoint.into();
1087        let access_token = access_token.into();
1088        if !platform::validate_endpoint(&endpoint) {
1089            return Err(NodeServerError::InvalidEndpoint);
1090        }
1091        if access_token.is_empty() || access_token.len() > 4_096 {
1092            return Err(NodeServerError::InvalidAccessToken);
1093        }
1094        let workspaces = workspaces.into_iter().collect::<Vec<_>>();
1095        if workspaces.is_empty() {
1096            return Err(NodeServerError::NoWorkspaces);
1097        }
1098        let mut workspace_ids = BTreeMap::new();
1099        let mut workspace_roots = BTreeMap::new();
1100        for workspace in &workspaces {
1101            if workspace.worktree_service_mode == WorktreeServiceMode::Managed
1102                && workspace.managed_worktree_profiles.is_empty()
1103            {
1104                return Err(NodeServerError::ManagedWorktreeProfileRequired(
1105                    workspace.workspace_id.clone(),
1106                ));
1107            }
1108            if workspace.worktree_service_mode != WorktreeServiceMode::Managed
1109                && !workspace.managed_worktree_profiles.is_empty()
1110            {
1111                return Err(NodeServerError::ManagedWorktreeProfileModeMismatch(
1112                    workspace.workspace_id.clone(),
1113                ));
1114            }
1115            if workspace_ids
1116                .insert(workspace.workspace_id.clone(), ())
1117                .is_some()
1118            {
1119                return Err(NodeServerError::DuplicateWorkspaceId(
1120                    workspace.workspace_id.clone(),
1121                ));
1122            }
1123            let root_key = platform::root_identity(&workspace.canonical_root);
1124            if let Some(existing) = workspace_roots.insert(
1125                root_key,
1126                workspace.workspace_id.clone(),
1127            ) {
1128                return Err(NodeServerError::DuplicateWorkspaceRoot {
1129                    first: existing,
1130                    second: workspace.workspace_id.clone(),
1131                    root: workspace.canonical_root.clone(),
1132                });
1133            }
1134        }
1135        Ok(Self {
1136            endpoint,
1137            api_listen: None,
1138            bridge_listen: None,
1139            bridge_token: None,
1140            bridge_underlay: None,
1141            call_home: None,
1142            node_id,
1143            workspaces,
1144            access_token,
1145            runtime: NativeRuntimeConfig::default(),
1146            session_record_retention: SessionRecordRetentionConfig::default(),
1147            state_path: None,
1148            spawn_profiles: SpawnProfileRegistry::default(),
1149            session_environment: None,
1150            history: None,
1151            harness_mcp_helper: None,
1152            network_allowlist_catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog::new(),
1153            #[cfg(feature = "fixture")]
1154            fixture_raw_pty_runtime: false,
1155        })
1156    }
1157
1158    pub fn with_api_listen(
1159        mut self,
1160        api_listen: std::net::SocketAddr,
1161    ) -> Result<Self, NodeServerError> {
1162        if !api_listen.ip().is_loopback() {
1163            return Err(NodeServerError::InvalidApiListen(api_listen));
1164        }
1165        self.api_listen = Some(api_listen);
1166        Ok(self)
1167    }
1168
1169    /// Opt-in node-envelope HTTP+WS bridge listener. Loopback only (same spirit
1170    /// as `with_api_listen`). Does not enable by default — libraries and the
1171    /// binary leave this `None` until `--bridge-listen` / `with_bridge_listen`.
1172    pub fn with_bridge_listen(
1173        mut self,
1174        bridge_listen: std::net::SocketAddr,
1175    ) -> Result<Self, NodeServerError> {
1176        if !bridge_listen.ip().is_loopback() {
1177            return Err(NodeServerError::InvalidBridgeListen(bridge_listen));
1178        }
1179        self.bridge_listen = Some(bridge_listen);
1180        Ok(self)
1181    }
1182
1183    /// Optional bridge shared secret, distinct from the C2/node access token.
1184    /// Never log the value. Empty / oversize refused.
1185    pub fn with_bridge_token(
1186        mut self,
1187        bridge_token: impl Into<String>,
1188    ) -> Result<Self, NodeServerError> {
1189        let bridge_token = bridge_token.into();
1190        if bridge_token.is_empty() || bridge_token.len() > 4_096 {
1191            return Err(NodeServerError::InvalidBridgeToken);
1192        }
1193        self.bridge_token = Some(bridge_token);
1194        Ok(self)
1195    }
1196
1197    /// Tip 6: point bridge reachability over mesh underlay (UDP+AEAD relay to
1198    /// local loopback `--bridge-listen`). Requires bridge listen + bridge token
1199    /// already configured. Transport key is 32 raw bytes (never logged).
1200    /// Underlay auth token is distinct from BRIDGE_TOKEN (crypto ≠ auth).
1201    pub fn with_bridge_underlay(
1202        mut self,
1203        bind: std::net::SocketAddr,
1204        transport_key: [u8; 32],
1205        underlay_token: impl Into<String>,
1206    ) -> Result<Self, NodeServerError> {
1207        if self.bridge_listen.is_none() {
1208            return Err(NodeServerError::BridgeUnderlayRequiresBridgeListen);
1209        }
1210        if self.bridge_token.is_none() {
1211            return Err(NodeServerError::BridgeUnderlayRequiresBridgeToken);
1212        }
1213        let underlay_token = underlay_token.into();
1214        if underlay_token.is_empty() || underlay_token.len() > 4_096 {
1215            return Err(NodeServerError::InvalidBridgeUnderlayToken);
1216        }
1217        // Port 0 is allowed (ephemeral). Role lock (no HQ accept) lives in underlay.
1218        self.bridge_underlay = Some(browser_bridge::BridgeUnderlayAccept {
1219            bind,
1220            transport_key,
1221            underlay_token,
1222        });
1223        Ok(self)
1224    }
1225
1226    /// Also dial `relay` and serve this wire over the connection, in
1227    /// addition to accepting on the local endpoint.
1228    ///
1229    /// Who opens the socket and who is the protocol's server are separate
1230    /// questions, and this is the node's half of separating them. The node
1231    /// stays the server either way -- it answers `NodeRequest`s and emits
1232    /// `NodeEvent`s exactly as it does for a relay that dialled it. All
1233    /// that changes is that a node with no address anyone can reach makes
1234    /// the connection itself, and names itself once it arrives (see
1235    /// `gate4agent_node_wire::write_call_home_announce`).
1236    ///
1237    /// Loopback only, and that is not an oversight to be flagged and
1238    /// worked around. This wire carries terminal contents, keystrokes and
1239    /// file bytes as plaintext JSON; its mutual challenge-response
1240    /// authenticates both ends and encrypts nothing. Every listener in
1241    /// this stack is loopback-guarded for that reason, and a dialler must
1242    /// hold the same line -- a node calling a public relay would put the
1243    /// whole control plane on the wire in the clear. Reaching a relay on
1244    /// another host is a transport question, and it stays unanswered here
1245    /// on purpose: this change is about direction, not distance.
1246    pub fn with_call_home(
1247        mut self,
1248        relay: std::net::SocketAddr,
1249    ) -> Result<Self, NodeServerError> {
1250        // Loopback, or the concrete C2 WireGuard tunnel address. Unspecified
1251        // and port 0 refused — plaintext wire must not dial every interface.
1252        if relay.port() == 0 || relay.ip().is_unspecified() {
1253            return Err(NodeServerError::InvalidCallHome(relay));
1254        }
1255        self.call_home = Some(relay);
1256        Ok(self)
1257    }
1258
1259    pub fn with_state_path(
1260        mut self,
1261        state_path: impl Into<PathBuf>,
1262    ) -> Result<Self, NodeServerError> {
1263        let state_path = state_path.into();
1264        if !state_path.is_absolute() || state_path.file_name().is_none() {
1265            return Err(NodeServerError::InvalidStatePath(
1266                state_path.to_string_lossy().into_owned(),
1267            ));
1268        }
1269        self.state_path = Some(state_path);
1270        Ok(self)
1271    }
1272
1273    pub fn with_spawn_profiles(mut self, spawn_profiles: SpawnProfileRegistry) -> Self {
1274        self.spawn_profiles = spawn_profiles;
1275        self
1276    }
1277
1278    /// Sets the age/keep-N policy the drive loop's retention sweep uses to
1279    /// retire dead `ManagedSessionState::Unavailable` records. Both fields
1280    /// default to `0` (disabled) via `SessionRecordRetentionConfig::default`
1281    /// when this is never called -- a node never deletes a durable record
1282    /// until an operator opts in with a real value.
1283    pub fn with_session_record_retention(
1284        mut self,
1285        retention: SessionRecordRetentionConfig,
1286    ) -> Self {
1287        self.session_record_retention = retention;
1288        self
1289    }
1290
1291    pub fn with_session_environment_materialization(
1292        mut self,
1293        root: impl Into<PathBuf>,
1294        resolver: Arc<dyn NodeSecretResolver>,
1295    ) -> Result<Self, NodeServerError> {
1296        let root = root.into();
1297        if !root.is_absolute() {
1298            return Err(NodeServerError::InvalidSessionEnvironmentRoot);
1299        }
1300        self.session_environment = Some(NodeSessionEnvironmentConfig { root, resolver });
1301        Ok(self)
1302    }
1303
1304    /// Enables bounded provider-history discovery and loading from explicit
1305    /// host-owned roots. Ambient vendor homes are never inferred here.
1306    pub fn with_history(mut self, history: NativeHistoryConfig) -> Self {
1307        self.history = Some(history);
1308        self
1309    }
1310
1311    /// Enables the H3B read proxy with one exact operator-reviewed helper file.
1312    /// Linux/macOS use owner-only UDS; Windows uses owner-only named pipes.
1313    /// Both platforms review the helper the same way (absolute regular file +
1314    /// identity hash) — the old `cfg(not(windows))` hard-refuse was a leftover
1315    /// gate after Unix listener/peer-cred support landed.
1316    pub fn with_harness_mcp_helper(
1317        mut self,
1318        helper_program: impl Into<PathBuf>,
1319    ) -> Result<Self, NodeServerError> {
1320        self.harness_mcp_helper = Some(
1321            ReviewedHarnessMcpProgram::review(helper_program.into())
1322                .map_err(|_| NodeServerError::InvalidHarnessMcpHelper)?,
1323        );
1324        Ok(self)
1325    }
1326
1327    /// Install an in-memory station network allowlist catalog (opaque ids +
1328    /// optional node-local permits / provider-native mapping). Empty remains
1329    /// the default — unknown spawn ids refuse at resolve. Never cookies /
1330    /// OAuth / proxy credentials. C2 / inventory still list ids only.
1331    pub fn with_network_allowlist_catalog(
1332        mut self,
1333        catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog,
1334    ) -> Result<Self, NodeServerError> {
1335        if catalog.len() > crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES {
1336            return Err(NodeServerError::NetworkAllowlistCatalogCapacity {
1337                max: crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES,
1338            });
1339        }
1340        self.network_allowlist_catalog = catalog;
1341        Ok(self)
1342    }
1343
1344    /// Load station network allowlist catalog from an absolute regular file
1345    /// (v1 id-list or v2 JSON schema). Unset path is not used here — callers
1346    /// that want env fallback use `resolve_network_allowlist_catalog`.
1347    pub fn with_network_allowlist_catalog_file(
1348        self,
1349        path: impl AsRef<std::path::Path>,
1350    ) -> Result<Self, NodeServerError> {
1351        let catalog = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(path)
1352            .map_err(NodeServerError::from)?;
1353        self.with_network_allowlist_catalog(catalog)
1354    }
1355}
1356
1357pub fn default_state_path(node_id: &NodeId) -> Result<PathBuf, NodeServerError> {
1358    platform::default_state_path(node_id.as_str())
1359        .ok_or(NodeServerError::LocalStateDirectoryUnavailable)
1360}
1361
1362pub fn default_node_endpoint() -> Result<PathBuf, NodeServerError> {
1363    platform::default_node_endpoint().ok_or(NodeServerError::LocalRuntimeDirectoryUnavailable)
1364}
1365
1366fn delivery_store_root_for_state_path(state_path: &Path) -> Option<PathBuf> {
1367    let parent = state_path.parent()?;
1368    let mut name = state_path.file_name()?.to_os_string();
1369    name.push(".delivery-store");
1370    Some(parent.join(name))
1371}
1372
1373fn context_pack_store_root_for_state_path(state_path: &Path) -> Option<PathBuf> {
1374    let parent = state_path.parent()?;
1375    let mut name = state_path.file_name()?.to_os_string();
1376    name.push(".context-pack-store");
1377    Some(parent.join(name))
1378}
1379
1380pub struct NodeServer {
1381    config: NodeServerConfig,
1382    runtime: NativeRuntime,
1383    shared: Arc<NodeShared>,
1384    events: EventSubscription,
1385    state_path_lock: Option<session_registry::StatePathLock>,
1386}
1387
1388#[cfg(feature = "fixture")]
1389#[derive(Clone, Default)]
1390pub struct SpawnManagedWorktreeV2FailureProbe {
1391    latest_code: Arc<Mutex<Option<NodeFailureCode>>>,
1392}
1393
1394#[cfg(feature = "fixture")]
1395impl SpawnManagedWorktreeV2FailureProbe {
1396    pub fn latest_code(&self) -> Option<NodeFailureCode> {
1397        *self
1398            .latest_code
1399            .lock()
1400            .unwrap_or_else(|poisoned| poisoned.into_inner())
1401    }
1402
1403    fn record(&self, code: Option<NodeFailureCode>) {
1404        *self
1405            .latest_code
1406            .lock()
1407            .unwrap_or_else(|poisoned| poisoned.into_inner()) = code;
1408    }
1409}
1410
1411#[cfg(all(feature = "fixture", windows))]
1412struct IsolatedCodexHomeFixtureEnvironment;
1413
1414#[cfg(all(feature = "fixture", windows))]
1415impl gate4agent_runtime_native::NativeChildEnvironmentResolver
1416    for IsolatedCodexHomeFixtureEnvironment
1417{
1418    fn resolve_child_environment(
1419        &self,
1420    ) -> Result<
1421        Vec<gate4agent_catalog::EnvMutation>,
1422        gate4agent_runtime_native::NativeChildEnvironmentResolveError,
1423    > {
1424        Ok(vec![gate4agent_catalog::EnvMutation {
1425            key: OsString::from("USERPROFILE"),
1426            value: None,
1427        }])
1428    }
1429}
1430
1431impl NodeServer {
1432    pub fn new(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1433        let catalog = active_registry()?;
1434        Self::new_with_registry(config, catalog)
1435    }
1436
1437    #[cfg(feature = "fixture")]
1438    pub fn new_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1439        let mut spec = gate4agent_testkit::interactive_agent_spec();
1440        spec.id = AgentId::new("claude").map_err(|error| NodeServerError::Registry(error.to_string()))?;
1441        spec.display_name = "Controlled Claude PTY fixture".to_owned();
1442        Self::new_fixture_with_spec(config, spec)
1443    }
1444
1445    #[cfg(feature = "fixture")]
1446    pub fn new_clean_exit_fixture(
1447        config: NodeServerConfig,
1448        fixture_root: PathBuf,
1449        started_marker: PathBuf,
1450        release_signal: PathBuf,
1451    ) -> Result<Self, NodeServerError> {
1452        let mut spec = gate4agent_testkit::controlled_clean_exit_agent_spec(
1453            &fixture_root,
1454            &started_marker,
1455            &release_signal,
1456        )
1457        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1458        spec.id = AgentId::new("claude")
1459            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1460        spec.display_name = "Controlled Claude clean-exit fixture".to_owned();
1461        Self::new_fixture_with_spec(config, spec)
1462    }
1463
1464    /// A `claude` source whose session establishes a verified provider
1465    /// identity via an early `SessionStart` Hook post (so it is eligible for
1466    /// auto-export-at-exit and its native history is independently
1467    /// loadable), waits for `release_signal`, then exits cleanly — paired
1468    /// on the same Node with a plain `codex` target that never validates
1469    /// anything about what it receives. Built for A2 durable ContextPack
1470    /// E2E coverage: the test itself inspects materialized files and
1471    /// managed-session-record fields directly, rather than having the
1472    /// target script self-validate.
1473    #[cfg(all(feature = "fixture", windows))]
1474    pub fn new_durable_context_pack_clean_exit_fixture(
1475        config: NodeServerConfig,
1476        fixture_root: PathBuf,
1477        started_marker: PathBuf,
1478        release_signal: PathBuf,
1479        session_id: &str,
1480    ) -> Result<Self, NodeServerError> {
1481        let mut source_spec = gate4agent_testkit::identified_clean_exit_agent_spec(
1482            &fixture_root,
1483            &started_marker,
1484            &release_signal,
1485            session_id,
1486        )
1487        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1488        let source_id = AgentId::new("claude")
1489            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1490        let source_provider = builtin_registry().get(&source_id).ok_or_else(|| {
1491            NodeServerError::Registry(
1492                "durable context pack clean-exit fixture source adapter is unavailable"
1493                    .to_owned(),
1494            )
1495        })?;
1496        source_spec.capabilities.adapters.history =
1497            source_provider.capabilities.adapters.history.clone();
1498        source_spec.id = source_id;
1499        source_spec.display_name =
1500            "Controlled Claude durable context-pack clean-exit fixture".to_owned();
1501        // Both specs launch via `powershell.exe`; the registry's ambiguity
1502        // check keys on `detection.command` (+ aliases), not `launch.program`
1503        // -- give each a distinct detection identity, same precedent as
1504        // `context_pack_fixture_catalog`'s per-provider
1505        // `gate4agent-{provider}-context-fixture.cmd` commands.
1506        source_spec.detection.command =
1507            "gate4agent-claude-durable-context-pack-fixture.cmd".to_owned();
1508        source_spec.detection.aliases.clear();
1509
1510        let mut target_spec = gate4agent_testkit::interactive_agent_spec();
1511        target_spec.id = AgentId::new("codex")
1512            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1513        target_spec.display_name =
1514            "Controlled Codex durable context-pack target fixture".to_owned();
1515        target_spec.detection.command =
1516            "gate4agent-codex-durable-context-pack-fixture.cmd".to_owned();
1517        target_spec.detection.aliases.clear();
1518
1519        let catalog = AgentRegistry::new([source_spec, target_spec])
1520            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1521        let mut server = Self::new_with_registry(config, catalog)?;
1522        Arc::get_mut(&mut server.shared)
1523            .expect("fixture Node shared state must remain uniquely owned before run")
1524            .fixture_semantic_hook_policy = true;
1525        Ok(server)
1526    }
1527
1528    #[cfg(feature = "fixture")]
1529    pub fn new_monitoring_hook_fixture(
1530        config: NodeServerConfig,
1531    ) -> Result<Self, NodeServerError> {
1532        let mut spec = gate4agent_testkit::monitoring_hook_agent_spec();
1533        spec.id = AgentId::new("claude")
1534            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1535        let mut server = Self::new_fixture_with_spec(config, spec)?;
1536        Arc::get_mut(&mut server.shared)
1537            .expect("fixture Node shared state must remain uniquely owned before run")
1538            .fixture_semantic_hook_policy = true;
1539        Ok(server)
1540    }
1541
1542    #[cfg(all(feature = "fixture", windows))]
1543    pub fn new_provider_bundle_argv_fixture(
1544        config: NodeServerConfig,
1545        agent_id: AgentId,
1546        proof_path: PathBuf,
1547    ) -> Result<Self, NodeServerError> {
1548        let spec = Self::provider_bundle_argv_fixture_spec(agent_id, proof_path, None)?;
1549        Self::new_fixture_with_spec(config, spec)
1550    }
1551
1552    #[cfg(all(feature = "fixture", windows))]
1553    pub fn new_provider_bundle_argv_hold_fixture(
1554        config: NodeServerConfig,
1555        agent_id: AgentId,
1556        proof_path: PathBuf,
1557        release_signal: PathBuf,
1558    ) -> Result<Self, NodeServerError> {
1559        let spec = Self::provider_bundle_argv_fixture_spec(
1560            agent_id,
1561            proof_path,
1562            Some(release_signal),
1563        )?;
1564        Self::new_fixture_with_spec(config, spec)
1565    }
1566
1567    #[cfg(all(feature = "fixture", windows))]
1568    fn provider_bundle_argv_fixture_spec(
1569        agent_id: AgentId,
1570        proof_path: PathBuf,
1571        release_signal: Option<PathBuf>,
1572    ) -> Result<gate4agent_types::AgentSpec, NodeServerError> {
1573        if !matches!(agent_id.as_str(), "claude" | "codex" | "kimi") {
1574            return Err(NodeServerError::Registry(
1575                "provider bundle argv fixture requires Claude, Codex, or Kimi".to_owned(),
1576            ));
1577        }
1578        let mut spec = gate4agent_testkit::interactive_agent_spec();
1579        spec.id = agent_id.clone();
1580        spec.display_name = format!("Controlled {agent_id} bundle argv fixture");
1581        let provider = builtin_registry()
1582            .get(&agent_id)
1583            .ok_or_else(|| {
1584                NodeServerError::Registry(
1585                    "provider bundle argv fixture adapter is unavailable".to_owned(),
1586                )
1587            })?;
1588        spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1589        if !proof_path.is_absolute()
1590            || !proof_path
1591                .parent()
1592                .is_some_and(|parent| parent.is_dir())
1593        {
1594            return Err(NodeServerError::Registry(
1595                "provider bundle argv proof path requires an existing absolute parent"
1596                    .to_owned(),
1597            ));
1598        }
1599        if let Some(release_signal) = release_signal.as_ref() {
1600            if !release_signal.is_absolute()
1601                || !release_signal
1602                    .parent()
1603                    .is_some_and(|parent| parent.is_dir())
1604            {
1605                return Err(NodeServerError::Registry(
1606                    "provider bundle argv release path requires an existing absolute parent"
1607                        .to_owned(),
1608                ));
1609            }
1610            if release_signal.exists() {
1611                return Err(NodeServerError::Registry(
1612                    "provider bundle argv release path must not exist".to_owned(),
1613                ));
1614            }
1615            let proof_parent = std::fs::canonicalize(
1616                proof_path
1617                    .parent()
1618                    .expect("validated provider bundle proof parent"),
1619            )
1620            .map_err(|_| {
1621                NodeServerError::Registry(
1622                    "provider bundle argv proof parent is unavailable".to_owned(),
1623                )
1624            })?;
1625            let release_parent = std::fs::canonicalize(
1626                release_signal
1627                    .parent()
1628                    .expect("validated provider bundle release parent"),
1629            )
1630            .map_err(|_| {
1631                NodeServerError::Registry(
1632                    "provider bundle argv release parent is unavailable".to_owned(),
1633                )
1634            })?;
1635            if proof_parent != release_parent {
1636                return Err(NodeServerError::Registry(
1637                    "provider bundle argv release path must share the proof parent".to_owned(),
1638                ));
1639            }
1640        }
1641        let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1642            NodeServerError::Registry(
1643                "provider bundle argv proof path is not valid Unicode".to_owned(),
1644            )
1645        })?;
1646        let release_signal = release_signal.map(|release_signal| {
1647            release_signal.into_os_string().into_string().map_err(|_| {
1648                NodeServerError::Registry(
1649                    "provider bundle argv release path is not valid Unicode".to_owned(),
1650                )
1651            })
1652        }).transpose()?;
1653        let script = spec
1654            .launch
1655            .fixed_args
1656            .pop()
1657            .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
1658        let bundle_validation = if agent_id.as_str() == "codex" {
1659            "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') }"
1660        } else if agent_id.as_str() == "claude" {
1661            "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 }"
1662        } else {
1663            "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 }"
1664        };
1665        if let Some(release_signal) = release_signal {
1666            spec.launch.fixed_args.push(format!(
1667                "& {{ 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} }}",
1668            ));
1669            spec.launch.fixed_args.push(proof_path);
1670            spec.launch.fixed_args.push(release_signal);
1671        } else {
1672            spec.launch.fixed_args.push(format!(
1673                "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {bundle_validation}; {script} }}",
1674            ));
1675            spec.launch.fixed_args.push(proof_path);
1676        }
1677        Ok(spec)
1678    }
1679
1680    #[cfg(all(feature = "fixture", windows))]
1681    pub fn new_context_pack_fixture(
1682        config: NodeServerConfig,
1683        proof_path: PathBuf,
1684    ) -> Result<Self, NodeServerError> {
1685        let catalog = Self::context_pack_fixture_catalog(proof_path, None, false)?;
1686        Self::new_with_registry(config, catalog)
1687    }
1688
1689    #[cfg(all(feature = "fixture", windows))]
1690    pub fn new_monitoring_context_pack_fixture(
1691        config: NodeServerConfig,
1692        proof_path: PathBuf,
1693    ) -> Result<Self, NodeServerError> {
1694        let catalog = Self::context_pack_fixture_catalog(proof_path, None, true)?;
1695        let mut server = Self::new_with_registry(config, catalog)?;
1696        Arc::get_mut(&mut server.shared)
1697            .expect("fixture Node shared state must remain uniquely owned before run")
1698            .fixture_semantic_hook_policy = true;
1699        Ok(server)
1700    }
1701
1702    #[cfg(all(feature = "fixture", windows))]
1703    pub fn install_isolated_codex_home_environment_fixture(
1704        &mut self,
1705        profile_id: SpawnEnvironmentProfileId,
1706        profile_revision: crate::protocol::SpawnEnvironmentProfileRevision,
1707    ) -> Result<(), NodeServerError> {
1708        let provider = AgentId::new("codex")
1709            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1710        let native_profile = gate4agent_runtime_native::NativeLaunchProfile::new(
1711            gate4agent_runtime_native::NativeLaunchProfileId::new(format!(
1712                "{}-pty",
1713                profile_id.as_str(),
1714            ))
1715            .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1716            provider.clone(),
1717            TransportKind::Pty,
1718            vec![OsString::from("USERPROFILE")],
1719            Arc::new(IsolatedCodexHomeFixtureEnvironment),
1720        )
1721        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1722        let materialization = NodeSessionMaterializationProfile::new(
1723            Vec::new(),
1724            vec![crate::session_environment::NodeSessionPathBinding::new(
1725                "CODEX_HOME",
1726                crate::session_environment::NodeSessionPathClass::ProviderHome,
1727            )
1728            .map_err(|error| NodeServerError::Registry(error.to_string()))?],
1729            Vec::new(),
1730        )
1731        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1732        self.install_environment_profile(
1733            NodeEnvironmentProfile::new_with_materialization(
1734                profile_id,
1735                profile_revision,
1736                provider,
1737                [native_profile],
1738                Some(materialization),
1739            )
1740            .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1741        )
1742    }
1743
1744    #[cfg(all(feature = "fixture", windows))]
1745    pub fn new_context_only_proof_fixture(
1746        config: NodeServerConfig,
1747        target_provider: AgentId,
1748        proof_path: PathBuf,
1749    ) -> Result<Self, NodeServerError> {
1750        if target_provider.as_str() != "kimi" {
1751            return Err(NodeServerError::Registry(
1752                "context-only proof fixture requires Kimi".to_owned(),
1753            ));
1754        }
1755        let catalog = Self::context_pack_fixture_catalog(
1756            proof_path,
1757            Some(&target_provider),
1758            false,
1759        )?;
1760        Self::new_with_registry(config, catalog)
1761    }
1762
1763    #[cfg(all(feature = "fixture", windows))]
1764    fn context_pack_fixture_catalog(
1765        proof_path: PathBuf,
1766        context_only_target: Option<&AgentId>,
1767        monitoring_claude_source: bool,
1768    ) -> Result<AgentRegistry, NodeServerError> {
1769        if !proof_path.is_absolute()
1770            || !proof_path
1771                .parent()
1772                .is_some_and(|parent| parent.is_dir())
1773        {
1774            return Err(NodeServerError::Registry(
1775                "context pack proof path requires an existing absolute parent".to_owned(),
1776            ));
1777        }
1778        let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1779            NodeServerError::Registry(
1780                "context pack proof path is not valid Unicode".to_owned(),
1781            )
1782        })?;
1783        let providers = ["claude", "codex", "grok", "kimi"];
1784        let builtins = builtin_registry();
1785        let mut specs = Vec::with_capacity(providers.len());
1786        for provider_id in providers {
1787            let agent_id = AgentId::new(provider_id)
1788                .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1789            let provider = builtins.get(&agent_id).ok_or_else(|| {
1790                NodeServerError::Registry(
1791                    "context pack fixture provider adapter is unavailable".to_owned(),
1792                )
1793            })?;
1794            let monitored_provider =
1795                monitoring_claude_source && matches!(provider_id, "claude" | "codex");
1796            let mut spec = if monitored_provider {
1797                gate4agent_testkit::monitoring_hook_agent_spec()
1798            } else {
1799                gate4agent_testkit::interactive_agent_spec()
1800            };
1801            spec.id = agent_id.clone();
1802            spec.display_name = format!("Controlled {agent_id} context pack fixture");
1803            spec.detection.command = format!("gate4agent-{provider_id}-context-fixture.cmd");
1804            spec.detection.aliases.clear();
1805            spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1806            if provider_id == "codex" {
1807                let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1808                    NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1809                })?;
1810                let validation = r#"
1811$bundleArgs = @($bundleArgs)
1812if ($bundleArgs.Count -ne 0) { exit 91 }
1813$codexHome = [Environment]::GetEnvironmentVariable('CODEX_HOME', 'Process')
1814$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1815$cwd = (Get-Location).ProviderPath
1816if ([string]::IsNullOrEmpty($codexHome) -or -not [IO.Path]::IsPathRooted($codexHome) -or -not (Test-Path -LiteralPath $codexHome -PathType Container)) { exit 92 }
1817$skillPath = Join-Path $codexHome 'skills/review-code/SKILL.md'
1818if (-not (Test-Path -LiteralPath $skillPath -PathType Leaf)) { exit 93 }
1819$homeFiles = @(Get-ChildItem -LiteralPath $codexHome -Recurse -Force -File)
1820if ($homeFiles.Count -ne 1 -or [IO.Path]::GetFullPath($homeFiles[0].FullName) -ine [IO.Path]::GetFullPath($skillPath)) { exit 94 }
1821$sha = [Security.Cryptography.SHA256]::Create()
1822try { $skillHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($skillPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1823if ($skillHash -cne 'd78fe03be106b673fcf5415c8359e99f0298b5071cd11822c58ebcb27e52a68d') { exit 95 }
1824if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 96 }
1825$contextPath = Join-Path $contextRoot 'context-pack.json'
1826if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 97 }
1827$contextFiles = @(Get-ChildItem -LiteralPath $contextRoot -Recurse -Force -File)
1828if ($contextFiles.Count -ne 1 -or [IO.Path]::GetFullPath($contextFiles[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 98 }
1829$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1830$codexFull = [IO.Path]::GetFullPath($codexHome).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1831$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1832$separator = [IO.Path]::DirectorySeparatorChar
1833if ($contextFull.Equals($codexFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($codexFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 99 }
1834if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 100 }
1835$rawContext = [IO.File]::ReadAllText($contextPath)
1836if ($rawContext.Contains('g4a-private-provider-session-canary') -or $rawContext.Contains('private-provider-login-identity')) { exit 101 }
1837try { $document = $rawContext | ConvertFrom-Json -ErrorAction Stop } catch { exit 102 }
1838$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')
1839$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')
1840foreach ($property in @($document.PSObject.Properties.Name)) {
1841    if ($allowedTopLevel -cnotcontains [string]$property) { exit 103 }
1842}
1843foreach ($property in $requiredTopLevel) {
1844    if ($null -eq $document.PSObject.Properties[$property]) { exit 104 }
1845}
1846if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 105 }
1847if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 106 }
1848$messages = @($document.retained_messages)
1849if ($messages.Count -eq 0) { exit 107 }
1850foreach ($message in $messages) {
1851    $messageProperties = @($message.PSObject.Properties.Name)
1852    foreach ($property in $messageProperties) {
1853        if (@('role', 'text') -cnotcontains [string]$property) { exit 108 }
1854    }
1855    if ($messageProperties.Count -ne 2 -or @('user', 'assistant') -cnotcontains [string]$message.role -or [string]::IsNullOrEmpty([string]$message.text)) { exit 109 }
1856}
1857$sha = [Security.Cryptography.SHA256]::Create()
1858try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1859[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))
1860$rawProof = [IO.File]::ReadAllText($proofPath)
1861if ($rawProof.Contains('g4a-private-provider-session-canary') -or $rawProof.Contains('private-provider-login-identity')) { exit 110 }
1862[Console]::Out.WriteLine('F7_CODEX_BUNDLE_CONTEXT_VALIDATED')
1863"#;
1864                spec.launch.fixed_args.push(format!(
1865                    "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1866                ));
1867                spec.launch.fixed_args.push(proof_path.clone());
1868            } else if context_only_target.map(AgentId::as_str) == Some(provider_id) {
1869                let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1870                    NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1871                })?;
1872                let validation = r#"
1873$bundleArgs = @($bundleArgs)
1874if ($bundleArgs.Count -ne 0) { exit 91 }
1875$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1876$cwd = (Get-Location).ProviderPath
1877if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 92 }
1878$contextPath = Join-Path $contextRoot 'context-pack.json'
1879if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 93 }
1880$entries = @(Get-ChildItem -LiteralPath $contextRoot -Force)
1881if ($entries.Count -ne 1 -or $entries[0] -isnot [IO.FileInfo] -or [IO.Path]::GetFullPath($entries[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 94 }
1882$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1883$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1884$separator = [IO.Path]::DirectorySeparatorChar
1885if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase) -or $cwdFull.StartsWith($contextFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 95 }
1886try { $document = [IO.File]::ReadAllText($contextPath) | ConvertFrom-Json -ErrorAction Stop } catch { exit 96 }
1887if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 97 }
1888if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 98 }
1889$messages = @($document.retained_messages)
1890if ($messages.Count -eq 0) { exit 99 }
1891$hasUser = $false
1892$hasAssistant = $false
1893foreach ($message in $messages) {
1894    if ([string]::IsNullOrWhiteSpace([string]$message.text)) { exit 100 }
1895    if ([string]$message.role -ceq 'user') { $hasUser = $true }
1896    elseif ([string]$message.role -ceq 'assistant') { $hasAssistant = $true }
1897    else { exit 101 }
1898}
1899if (-not $hasUser -or -not $hasAssistant) { exit 102 }
1900$sha = [Security.Cryptography.SHA256]::Create()
1901try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1902[IO.File]::WriteAllLines($proofPath, [string[]]@('context-only', $contextRoot, $cwd, $contextHash, [string]$document.schema, [string]$document.source_provider, [string]$messages.Count))
1903[Console]::Out.WriteLine('F7_KIMI_CONTEXT_ONLY_VALIDATED')
1904"#;
1905                spec.launch.fixed_args.push(format!(
1906                    "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1907                ));
1908                spec.launch.fixed_args.push(proof_path.clone());
1909            }
1910            specs.push(spec);
1911        }
1912        let catalog = AgentRegistry::new(specs)
1913            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1914        Ok(catalog)
1915    }
1916
1917    #[cfg(feature = "fixture")]
1918    pub fn new_exact_launcher_fixture(
1919        config: NodeServerConfig,
1920        agent_id: AgentId,
1921        launcher: String,
1922    ) -> Result<Self, NodeServerError> {
1923        if !matches!(agent_id.as_str(), "claude" | "codex" | "grok" | "kimi") {
1924            return Err(NodeServerError::Registry(
1925                "exact launcher fixture requires a supported provider".to_owned(),
1926            ));
1927        }
1928        if launcher.contains('\0') {
1929            return Err(NodeServerError::Registry(
1930                "exact launcher fixture path contains NUL".to_owned(),
1931            ));
1932        }
1933        let launcher_path = Path::new(&launcher);
1934        if !launcher_path.is_absolute() {
1935            return Err(NodeServerError::Registry(
1936                "exact launcher fixture path must be absolute".to_owned(),
1937            ));
1938        }
1939        let metadata = std::fs::metadata(launcher_path).map_err(|_| {
1940            NodeServerError::Registry("exact launcher fixture path is unavailable".to_owned())
1941        })?;
1942        if !metadata.is_file() {
1943            return Err(NodeServerError::Registry(
1944                "exact launcher fixture path must be a regular file".to_owned(),
1945            ));
1946        }
1947        let mut spec = builtin_registry()
1948            .get(&agent_id)
1949            .ok_or_else(|| {
1950                NodeServerError::Registry(
1951                    "exact launcher fixture provider is unavailable".to_owned(),
1952                )
1953            })?
1954            .clone();
1955        spec.launch.program = launcher;
1956        Self::new_fixture_with_spec(config, spec)
1957    }
1958
1959    /// Shared H3B validation and spec construction for
1960    /// `new_harness_mcp_proxy_fixture` and
1961    /// `new_harness_mcp_acp_launcher_fixture`: an absolute, exact-regular-
1962    /// file provider launcher plus at most 32 Unicode, NUL-free fixed
1963    /// arguments (65,536 bytes total), replacing `spec.launch` on a clone of
1964    /// the named builtin catalog entry -- detection command and expected
1965    /// process matcher rebased on the launcher's own basename, exactly the
1966    /// same shape `new_harness_mcp_proxy_fixture` always built.
1967    #[cfg(feature = "fixture")]
1968    fn h3b_provider_spec_with_program_and_args(
1969        agent_id: AgentId,
1970        provider_program: PathBuf,
1971        fixed_args: Vec<OsString>,
1972    ) -> Result<AgentSpec, NodeServerError> {
1973        if !provider_program.is_absolute() || fixed_args.len() > 32 {
1974            return Err(NodeServerError::Registry(
1975                "H3B fixture requires an absolute provider and at most 32 arguments".to_owned(),
1976            ));
1977        }
1978        let metadata = std::fs::symlink_metadata(&provider_program).map_err(|_| {
1979            NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
1980        })?;
1981        if !metadata.file_type().is_file() || metadata.file_type().is_symlink() {
1982            return Err(NodeServerError::Registry(
1983                "H3B fixture provider must be an exact regular file".to_owned(),
1984            ));
1985        }
1986        let program = provider_program.into_os_string().into_string().map_err(|_| {
1987            NodeServerError::Registry("H3B fixture provider path is not Unicode".to_owned())
1988        })?;
1989        let fixed_args = fixed_args.into_iter().map(|argument| {
1990            argument.into_string().map_err(|_| NodeServerError::Registry(
1991                "H3B fixture argument is not Unicode".to_owned(),
1992            ))
1993        }).collect::<Result<Vec<_>, _>>()?;
1994        let argument_bytes = fixed_args.iter()
1995            .try_fold(0usize, |total, argument| total.checked_add(argument.len()));
1996        if program.contains('\0')
1997            || fixed_args.iter().any(|argument| argument.contains('\0'))
1998            || !matches!(argument_bytes, Some(total) if total <= 65_536)
1999        {
2000            return Err(NodeServerError::Registry(
2001                "H3B fixture provider or arguments are invalid".to_owned(),
2002            ));
2003        }
2004        let mut spec = builtin_registry().get(&agent_id).ok_or_else(|| {
2005            NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
2006        })?.clone();
2007        let process_name = Path::new(&program).file_name().and_then(|name| name.to_str())
2008            .ok_or_else(|| NodeServerError::Registry(
2009                "H3B fixture provider basename is unavailable".to_owned(),
2010            ))?.to_owned();
2011        spec.detection.command = process_name.clone();
2012        spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
2013            name: process_name,
2014        }];
2015        spec.launch.program = program;
2016        spec.launch.fixed_args = fixed_args;
2017        Ok(spec)
2018    }
2019
2020    #[cfg(feature = "fixture")]
2021    pub fn new_harness_mcp_proxy_fixture(
2022        config: NodeServerConfig,
2023        provider_program: PathBuf,
2024        fixed_args: Vec<OsString>,
2025        harness_mcp_program: PathBuf,
2026    ) -> Result<Self, NodeServerError> {
2027        let mut config = config.with_harness_mcp_helper(harness_mcp_program)?;
2028        let agent_id = AgentId::new("codex")
2029            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2030        let mut spec =
2031            Self::h3b_provider_spec_with_program_and_args(agent_id, provider_program, fixed_args)?;
2032        spec.display_name = "Controlled H3B MCP proxy fixture".to_owned();
2033        config.fixture_raw_pty_runtime = true;
2034        Self::new_fixture_with_spec(config, spec)
2035    }
2036
2037    /// The ACP-mode sibling of `new_harness_mcp_proxy_fixture`: the same
2038    /// `--harness-mcp-helper` wiring and the same absolute-program-plus-
2039    /// fixed-args launcher replacement as `new_exact_launcher_fixture`, but
2040    /// admitting `ProviderRuntimeRequirement::Acp` the honest way instead of
2041    /// hardcoding it. `new_harness_mcp_proxy_fixture` forces `config.
2042    /// fixture_raw_pty_runtime = true`, which tears out the live
2043    /// `ProviderRuntimeMonitor` entirely (`NodeServer::new_with_registry`)
2044    /// and makes `NodeShared::admit_provider_runtime`'s no-monitor branch
2045    /// hardcode `acp_transport = false` for every provider, refusing
2046    /// `ProviderRuntimeRequirement::Acp` unconditionally regardless of what
2047    /// the catalog declares. This constructor leaves `fixture_raw_pty_
2048    /// runtime` at its default `false`, so a real `ProviderRuntimeMonitor`
2049    /// is built from the (cloned, launcher-replaced) catalog spec, and
2050    /// `ProviderRuntimeMonitor::supports_acp_transport` reads the honest,
2051    /// unmodified catalog fact: every builtin provider this accepts
2052    /// (`claude`, `codex`, `grok`, `kimi`) already declares `capabilities.
2053    /// transports.acp` in `gate4agent-catalog::builtin`, so no capability is
2054    /// invented here -- this constructor only removes the fixture-only
2055    /// bypass standing in its way. `provider_runtime::policy_for_transport`'s
2056    /// `TransportKind::Acp` arm (applied unconditionally at spawn-prep time
2057    /// for every ACP session, fixture or not) is what actually grants the
2058    /// ACP-shaped `ProviderRuntimePolicy` downstream of that admission --
2059    /// this constructor never calls it directly, and does not need to.
2060    #[cfg(feature = "fixture")]
2061    pub fn new_harness_mcp_acp_launcher_fixture(
2062        config: NodeServerConfig,
2063        helper_program: PathBuf,
2064        provider: AgentId,
2065        program: PathBuf,
2066        args: Vec<OsString>,
2067    ) -> Result<Self, NodeServerError> {
2068        if !matches!(provider.as_str(), "claude" | "codex" | "grok" | "kimi") {
2069            return Err(NodeServerError::Registry(
2070                "ACP harness-MCP launcher fixture requires a supported provider".to_owned(),
2071            ));
2072        }
2073        let config = config.with_harness_mcp_helper(helper_program)?;
2074        let mut spec = Self::h3b_provider_spec_with_program_and_args(provider, program, args)?;
2075        let launch = spec.launch.clone();
2076        let acp = spec.capabilities.transports.acp.as_mut().ok_or_else(|| {
2077            NodeServerError::Registry(
2078                "ACP harness-MCP launcher fixture provider does not declare an ACP transport"
2079                    .to_owned(),
2080            )
2081        })?;
2082        // Routes the ACP spawn itself through the exact replaced launcher
2083        // (`AcpSession::spawn_with_launch` -> a bare `Command::new(&launch.
2084        // program)`, `gate4agent`'s `src/acp/spawn.rs`) instead of the
2085        // catalog's own vendor-npm resolution (`acp_command`, same file) --
2086        // without this the ACP transport ignores `spec.launch` entirely and
2087        // tries to spawn the real vendor CLI (e.g. `npx -y @agentclientprotocol/
2088        // codex-acp@1.10.0`), which this fixture must never reach.
2089        acp.launch_override = Some(launch);
2090        spec.display_name = "Controlled ACP harness-MCP launcher fixture".to_owned();
2091        Self::new_fixture_with_spec(config, spec)
2092    }
2093
2094    #[cfg(feature = "fixture")]
2095    pub fn new_resume_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
2096        let mut spec = gate4agent_testkit::interactive_agent_spec();
2097        let claude_id = AgentId::new("claude")
2098            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2099        let claude = builtin_registry()
2100            .get(&claude_id)
2101            .ok_or_else(|| NodeServerError::Registry("Claude fixture adapter is unavailable".to_owned()))?;
2102        spec.id = claude_id;
2103        spec.display_name = "Resumable Claude PTY fixture".to_owned();
2104        spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
2105            name: "claude".to_owned(),
2106        }];
2107        spec.capabilities.transports.pty_adapter = claude
2108            .capabilities
2109            .transports
2110            .pty_adapter
2111            .clone();
2112        spec.capabilities.adapters.resume = claude
2113            .capabilities
2114            .adapters
2115            .resume
2116            .clone();
2117        let script = spec
2118            .launch
2119            .fixed_args
2120            .pop()
2121            .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
2122        spec.launch.fixed_args.push(format!(
2123            "& {{ param([Parameter(ValueFromRemainingArguments=$true)][string[]]$ignored) {script} }}",
2124        ));
2125        Self::new_fixture_with_spec(config, spec)
2126    }
2127
2128    #[cfg(feature = "fixture")]
2129    pub fn new_cmd_cwd_fixture(
2130        config: NodeServerConfig,
2131        command: String,
2132    ) -> Result<Self, NodeServerError> {
2133        let mut spec = gate4agent_testkit::interactive_agent_spec();
2134        spec.id = AgentId::new("claude")
2135            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2136        spec.display_name = "Controlled cmd cwd fixture".to_owned();
2137        spec.detection.command = "gate4agent-cwd-fixture.cmd".to_owned();
2138        spec.launch.program = command;
2139        spec.launch.fixed_args.clear();
2140        Self::new_fixture_with_spec(config, spec)
2141    }
2142
2143    #[cfg(feature = "fixture")]
2144    fn new_fixture_with_spec(
2145        config: NodeServerConfig,
2146        spec: gate4agent_types::AgentSpec,
2147    ) -> Result<Self, NodeServerError> {
2148        let catalog = AgentRegistry::new([spec])
2149            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2150        Self::new_with_registry(config, catalog)
2151    }
2152
2153    fn new_with_registry(
2154        config: NodeServerConfig,
2155        catalog: AgentRegistry,
2156    ) -> Result<Self, NodeServerError> {
2157        for profile in config.spawn_profiles.iter() {
2158            if catalog.get(&profile.provider).is_none() {
2159                return Err(NodeServerError::Registry(
2160                    "spawn profile references an unavailable provider".to_owned(),
2161                ));
2162            }
2163        }
2164        let input_settle_timeout_ms = catalog
2165            .iter()
2166            .map(|spec| spec.readiness.timeout_ms)
2167            .max()
2168            .unwrap_or(MUTATION_SETTLE_TIMEOUT_MS)
2169            .saturating_add(READINESS_SETTLE_HEADROOM_MS)
2170            .max(MUTATION_SETTLE_TIMEOUT_MS);
2171        let (provider_contracts, provider_adapter_contracts) =
2172            provider_contract_manifest(&catalog)?;
2173        let compatibility_support = node_compatibility_support_for_manifest(
2174            &provider_contracts,
2175            &provider_adapter_contracts,
2176        )?;
2177        validate_node_negotiated_handshake_capacity(&compatibility_support)
2178            .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2179        let enabled_providers = provider_contracts
2180            .iter()
2181            .map(|contract| contract.provider.clone())
2182            .collect();
2183        #[cfg(feature = "fixture")]
2184        let fixture_raw_pty_runtime = config.fixture_raw_pty_runtime;
2185        #[cfg(not(feature = "fixture"))]
2186        let fixture_raw_pty_runtime = false;
2187        let (provider_runtime_monitor, provider_runtime_statuses) = if fixture_raw_pty_runtime {
2188            let statuses = ProviderRuntimeStatuses::new(catalog.iter().map(|spec| {
2189                crate::protocol::ProviderRuntimeStatus::raw_passthrough(spec.id.clone(), None)
2190            })).expect("fixture provider catalog remains bounded");
2191            (None, statuses)
2192        } else {
2193            let monitor = Arc::new(ProviderRuntimeMonitor::new(&catalog));
2194            let statuses = monitor.collect();
2195            (Some(monitor), statuses)
2196        };
2197        let state_path_lock =
2198            session_registry::StatePathLock::acquire(config.state_path.as_deref())
2199                .map_err(|error| {
2200                    durable_state_server_error(error, DURABLE_STATE_LOCK_FAILED_ERROR)
2201                })?;
2202        let (delivery_store, delivered_bundles) = match config.state_path.as_ref() {
2203            Some(state_path) => {
2204                let delivery_root = delivery_store_root_for_state_path(state_path)
2205                    .ok_or_else(|| {
2206                        NodeServerError::DeliveryStore(
2207                            "delivery store root could not be derived from state path".to_owned(),
2208                        )
2209                    })?;
2210                let (store, bundles) = DeliveryStore::open(delivery_root).map_err(|error| {
2211                    let detail = match &error {
2212                        DeliveryStoreError::Storage(io_error) => {
2213                            format!("{error}: {io_error}")
2214                        }
2215                        _ => error.to_string(),
2216                    };
2217                    NodeServerError::DeliveryStore(detail)
2218                })?;
2219                (Some(store), bundles)
2220            }
2221            None => (None, Vec::new()),
2222        };
2223        let delivered_catalog = BundleCatalog::new(delivered_bundles).map_err(|error| {
2224            NodeServerError::DeliveryStore(format!("bundle catalog: {error}"))
2225        })?;
2226        let (context_pack_store, durable_context_packs) = match config.state_path.as_ref() {
2227            Some(state_path) => {
2228                let context_pack_root = context_pack_store_root_for_state_path(state_path)
2229                    .ok_or(NodeServerError::ContextPackStore)?;
2230                let (store, packs) = ContextPackStore::open(context_pack_root)
2231                    .map_err(|_| NodeServerError::ContextPackStore)?;
2232                (Some(store), packs)
2233            }
2234            None => (None, Vec::new()),
2235        };
2236        let mut durable_context_catalog = ContextPackCatalog::default();
2237        for pack in durable_context_packs {
2238            durable_context_catalog
2239                .insert(pack)
2240                .map_err(|_| NodeServerError::ContextPackStore)?;
2241        }
2242        let session_environment_materializer = config
2243            .session_environment
2244            .as_ref()
2245            .map(|environment| {
2246                SessionEnvironmentMaterializer::new(
2247                    environment.root.clone(),
2248                    Arc::clone(&environment.resolver),
2249                )
2250                .map_err(|_| NodeServerError::SessionEnvironmentMaterializer)
2251            })
2252            .transpose()?;
2253        let native_session_catalog = config.history.clone().map(|history| {
2254            Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)))
2255        });
2256        let (handle, runtime) = match config.history.clone() {
2257            Some(history) => NativeRuntime::new_with_history(catalog, config.runtime, history),
2258            None => NativeRuntime::new(catalog, config.runtime),
2259        };
2260        let native_launch_profile_control = runtime.native_launch_profile_control();
2261        let harness_mcp_registry = config
2262            .harness_mcp_helper
2263            .clone()
2264            .map(HarnessMcpProxyRegistry::new);
2265        let events = handle.subscribe(CONTROL_EVENT_SUBSCRIPTION_CAPACITY);
2266        let incarnation_id = random_incarnation_id()
2267            .map_err(NodeServerError::IncarnationIdentity)?;
2268        let mut loaded = session_registry::load(config.state_path.as_deref(), &config.node_id)
2269            .map_err(durable_state_load_error)?;
2270        let managed_worktrees = std::mem::take(&mut loaded.managed_worktrees);
2271        let managed_worktree_tombstones =
2272            std::mem::take(&mut loaded.managed_worktree_tombstones);
2273        let materializations = std::mem::take(&mut loaded.materializations);
2274        let managed_spawn_replays = std::mem::take(&mut loaded.managed_spawn_replays);
2275        let (workspaces, records, persistence_warning) =
2276            merge_durable_state(&config.workspaces, loaded)?;
2277        let mut shared = NodeShared::new_with_incarnation(
2278            handle,
2279            config.access_token.clone(),
2280            config.node_id.clone(),
2281            incarnation_id,
2282            workspaces,
2283            enabled_providers,
2284            provider_runtime_statuses,
2285            provider_runtime_monitor,
2286            provider_contracts,
2287            provider_adapter_contracts,
2288            config.spawn_profiles.clone(),
2289            Some(native_launch_profile_control),
2290            native_session_catalog,
2291            session_environment_materializer,
2292            config.state_path.clone(),
2293            records,
2294            managed_worktrees,
2295            managed_worktree_tombstones,
2296            materializations,
2297            persistence_warning,
2298            input_settle_timeout_ms,
2299        );
2300        shared.harness_mcp_registry = harness_mcp_registry.clone();
2301        shared.session_record_retention = config.session_record_retention;
2302        shared.network_allowlist_catalog =
2303            RwLock::new(config.network_allowlist_catalog.clone());
2304        shared.bundle_catalog = RwLock::new(delivered_catalog);
2305        shared.delivery_store = Mutex::new(delivery_store);
2306        shared.context_catalog = RwLock::new(durable_context_catalog);
2307        shared.context_pack_store = Mutex::new(context_pack_store);
2308        shared.managed_spawn_replays = Mutex::new(
2309            managed_spawn_replays
2310                .into_iter()
2311                .map(|record| (record.idempotency_key.clone(), record))
2312                .collect(),
2313        );
2314        let shared = Arc::new(shared);
2315        if let Some(registry) = harness_mcp_registry {
2316            let weak = Arc::downgrade(&shared);
2317            registry.set_event_sink(Arc::new(move |event| match weak.upgrade() {
2318                Some(shared) => shared.publish_transient(event),
2319                None => false,
2320            }));
2321        }
2322        if shared.persistence_error().is_none() {
2323            shared.persist_state().map_err(|error| {
2324                durable_state_server_error(error, DURABLE_STATE_COMMIT_FAILED_ERROR)
2325            })?;
2326        }
2327        Ok(Self {
2328            config,
2329            runtime,
2330            shared,
2331            events,
2332            state_path_lock,
2333        })
2334    }
2335
2336    pub fn shutdown_handle(&self) -> NodeShutdownHandle {
2337        NodeShutdownHandle {
2338            shared: Arc::clone(&self.shared),
2339        }
2340    }
2341
2342    #[cfg(feature = "fixture")]
2343    pub fn spawn_managed_worktree_v2_failure_probe_fixture(
2344        &self,
2345    ) -> SpawnManagedWorktreeV2FailureProbe {
2346        self.shared
2347            .fixture_spawn_managed_worktree_v2_failure_probe
2348            .clone()
2349    }
2350
2351    /// Installs one immutable host-local environment profile before the server runs.
2352    ///
2353    /// Native resolvers remain inside the runtime; the shared node registry keeps
2354    /// only public IDs, revisions, provider bindings, and opaque native profile IDs.
2355    pub fn install_environment_profile(
2356        &mut self,
2357        profile: NodeEnvironmentProfile,
2358    ) -> Result<(), NodeServerError> {
2359        if !self.shared.enabled_providers.contains(profile.provider()) {
2360            return Err(NodeServerError::EnvironmentProfileProviderUnavailable(
2361                profile.provider().clone(),
2362            ));
2363        }
2364        let (binding, native_profiles, materialization) = profile.into_parts();
2365        if materialization.is_some() && self.shared.session_environment_materializer.is_none() {
2366            return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2367        }
2368        let mut profiles = self
2369            .shared
2370            .environment_profiles
2371            .write()
2372            .unwrap_or_else(|poisoned| poisoned.into_inner());
2373        if profiles.contains_key(&binding.id) {
2374            return Err(NodeServerError::DuplicateEnvironmentProfile(
2375                binding.id.clone(),
2376            ));
2377        }
2378        if profiles.len() >= MAX_NODE_ENVIRONMENT_PROFILES {
2379            return Err(NodeServerError::EnvironmentProfileCapacity {
2380                max: MAX_NODE_ENVIRONMENT_PROFILES,
2381            });
2382        }
2383        for native_id in binding.native_profile_ids() {
2384            if profiles.values().any(|current| {
2385                current.native_profile_ids().any(|current_id| current_id == native_id)
2386            }) {
2387                return Err(NodeServerError::DuplicateNativeEnvironmentProfile(
2388                    native_id.to_string(),
2389                ));
2390            }
2391        }
2392
2393        let mut installed = Vec::with_capacity(native_profiles.len());
2394        for native_profile in native_profiles {
2395            let native_id = native_profile.id().clone();
2396            if let Err(error) = self.runtime.upsert_native_launch_profile(native_profile) {
2397                for installed_id in installed.into_iter().rev() {
2398                    let _ = self.runtime.remove_native_launch_profile(&installed_id);
2399                }
2400                return Err(NodeServerError::NativeEnvironmentProfile(error.to_string()));
2401            }
2402            installed.push(native_id);
2403        }
2404        if let Some(materialization) = materialization {
2405            self.shared
2406                .environment_materialization_profiles
2407                .write()
2408                .unwrap_or_else(|poisoned| poisoned.into_inner())
2409                .insert(binding.id.clone(), materialization);
2410        }
2411        profiles.insert(binding.id.clone(), binding);
2412        Ok(())
2413    }
2414
2415    /// Installs one immutable host-local bundle before the server runs.
2416    pub fn install_bundle(&mut self, bundle: NodeBundle) -> Result<(), NodeServerError> {
2417        if self.shared.session_environment_materializer.is_none() {
2418            return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2419        }
2420        let mut catalog = self
2421            .shared
2422            .bundle_catalog
2423            .write()
2424            .unwrap_or_else(|poisoned| poisoned.into_inner());
2425        catalog
2426            .insert_idempotent(bundle)
2427            .map_err(|error| NodeServerError::BundleCatalog(error.to_string()))?;
2428        Ok(())
2429    }
2430
2431    pub async fn run_until_ctrl_signal(self) -> Result<(), NodeServerError> {
2432        let shutdown = self.shutdown_handle();
2433        let run = self.run();
2434        tokio::pin!(run);
2435        tokio::select! {
2436            result = &mut run => result,
2437            signal = wait_for_ctrl_signal() => {
2438                signal?;
2439                let shutdown_result = shutdown.request_shutdown().await;
2440                let run_result = run.await;
2441                shutdown_result.and(run_result)
2442            }
2443        }
2444    }
2445
2446    pub async fn run(self) -> Result<(), NodeServerError> {
2447        let NodeServer {
2448            config,
2449            mut runtime,
2450            shared,
2451            events,
2452            state_path_lock: _state_path_lock,
2453        } = self;
2454        shared.reconcile_materializations();
2455        shared.reconcile_managed_worktrees().await;
2456        shared.retire_unavailable_session_records().await;
2457        shared.reconcile_context_pack_exports().await;
2458        // Lifecycle hooks are retired (owner ruling 2026-09-25): the node
2459        // observes sessions through ACP where a provider has it; there is no
2460        // hook ingress.
2461        let endpoint = config.endpoint.clone();
2462        let call_home = config.call_home;
2463        let api_listen = config.api_listen;
2464        let bridge_listen = config.bridge_listen;
2465        let bridge_underlay = config.bridge_underlay;
2466        let bridge_auth = browser_bridge::BridgeAuth::from_optional(config.bridge_token)
2467            .map_err(|_| NodeServerError::InvalidBridgeToken)?;
2468        let accept_shared = Arc::clone(&shared);
2469        let api_shared = Arc::clone(&shared);
2470        let bridge_shared = Arc::clone(&shared);
2471        let shutdown_shared = Arc::clone(&shared);
2472        let shutdown_timeout = Duration::from_millis(
2473            config.runtime.provider_shutdown_timeout_ms.max(1),
2474        );
2475        let result = {
2476            let runtime_loop = drive_runtime_until_shutdown(
2477                &mut runtime,
2478                events,
2479                Arc::clone(&shared),
2480                shutdown_timeout,
2481            );
2482            let accept_loop = accept_connections(&endpoint, call_home, accept_shared);
2483            let api_loop = http_api::run(api_listen, api_shared);
2484            let bridge_loop = browser_bridge::run(bridge_listen, bridge_auth, bridge_shared, bridge_underlay);
2485            tokio::pin!(runtime_loop);
2486            tokio::pin!(accept_loop);
2487            tokio::pin!(api_loop);
2488            tokio::pin!(bridge_loop);
2489            tokio::select! {
2490                runtime_result = &mut runtime_loop => {
2491                    let shutdown_result = shutdown_shared
2492                        .begin_shutdown()
2493                        .await
2494                        .map_err(NodeServerError::ShutdownDispatch);
2495                    let (accept_result, api_result, bridge_result) =
2496                        tokio::join!(&mut accept_loop, &mut api_loop, &mut bridge_loop);
2497                    match runtime_result {
2498                        Err(error) => Err(error),
2499                        Ok(()) => shutdown_result
2500                            .and(accept_result)
2501                            .and(api_result.map_err(NodeServerError::HttpApi))
2502                            .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2503                    }
2504                }
2505                accept_result = &mut accept_loop => {
2506                    let shutdown_result = shutdown_shared
2507                        .begin_shutdown()
2508                        .await
2509                        .map_err(NodeServerError::ShutdownDispatch);
2510                    let (runtime_result, api_result, bridge_result) =
2511                        tokio::join!(&mut runtime_loop, &mut api_loop, &mut bridge_loop);
2512                    match accept_result {
2513                        Err(error) => Err(error),
2514                        Ok(()) => shutdown_result
2515                            .and(runtime_result)
2516                            .and(api_result.map_err(NodeServerError::HttpApi))
2517                            .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2518                    }
2519                }
2520                api_result = &mut api_loop => {
2521                    let shutdown_result = shutdown_shared
2522                        .begin_shutdown()
2523                        .await
2524                        .map_err(NodeServerError::ShutdownDispatch);
2525                    let (runtime_result, accept_result, bridge_result) =
2526                        tokio::join!(&mut runtime_loop, &mut accept_loop, &mut bridge_loop);
2527                    match api_result {
2528                        Err(error) => Err(NodeServerError::HttpApi(error)),
2529                        Ok(()) => shutdown_result
2530                            .and(runtime_result)
2531                            .and(accept_result)
2532                            .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2533                    }
2534                }
2535                bridge_result = &mut bridge_loop => {
2536                    let shutdown_result = shutdown_shared
2537                        .begin_shutdown()
2538                        .await
2539                        .map_err(NodeServerError::ShutdownDispatch);
2540                    let (runtime_result, accept_result, api_result) =
2541                        tokio::join!(&mut runtime_loop, &mut accept_loop, &mut api_loop);
2542                    match bridge_result {
2543                        Err(error) => Err(NodeServerError::BridgeApi(error)),
2544                        Ok(()) => shutdown_result
2545                            .and(runtime_result)
2546                            .and(accept_result)
2547                            .and(api_result.map_err(NodeServerError::HttpApi)),
2548                    }
2549                }
2550            }
2551        };
2552        // Persistence gap fix: durable DeliveryStore / ContextPackStore hold
2553        // exclusive flock (Unix) / share-mode locks (Windows) inside NodeShared.
2554        // NodeShutdownHandle also clones that Arc, so without an explicit release
2555        // here a supervisor that keeps the shutdown handle cannot reopen the same
2556        // state path after run() returns (EAGAIN / Resource temporarily unavailable).
2557        // StatePathLock already drops with this stack frame (`_state_path_lock`).
2558        if let Ok(mut guard) = shared.delivery_store.lock() {
2559            *guard = None;
2560        }
2561        if let Ok(mut guard) = shared.context_pack_store.lock() {
2562            *guard = None;
2563        }
2564        result
2565    }
2566}
2567
2568async fn drive_runtime_until_shutdown(
2569    runtime: &mut NativeRuntime,
2570    events: EventSubscription,
2571    shared: Arc<NodeShared>,
2572    shutdown_timeout: Duration,
2573) -> Result<(), NodeServerError> {
2574    // Published once, never per iteration. The worker loops inside
2575    // `NativeRuntime` fold into this profile continuously; turning it into
2576    // percentiles is what costs, and that is `GET /metrics`'s bill to pay.
2577    let _ = shared
2578        .shell_efficiency_profile
2579        .set(runtime.shell_efficiency_profile());
2580    let mut shutdown_started = None;
2581    let mut last_retention_sweep = Instant::now();
2582    loop {
2583        // Every phase below is timed unconditionally -- see
2584        // `drive_loop_profile`'s own doc comment for why an always-on,
2585        // allocation-free ring is cheap enough to never need a feature
2586        // flag. This is the only place these numbers can be taken: the
2587        // phases are calls made directly from this loop, not something a
2588        // callee could time on our behalf.
2589        let runtime_tick_start = Instant::now();
2590        let tick_result = runtime.tick().await;
2591        let runtime_tick_elapsed = runtime_tick_start.elapsed();
2592
2593        let event_drain_start = Instant::now();
2594        // Every command the kernel refused, named. `NativeRuntimeTick`
2595        // has carried these outcomes all along and nothing read them, so a
2596        // rejected command -- a hook event denied by runtime policy, a
2597        // stale generation, an unsupported capability -- vanished at the
2598        // last step of the loop that produced it. A refusal the operator
2599        // cannot see is indistinguishable from an event that never arrived,
2600        // and telling those two apart is most of debugging this stack.
2601        for outcome in &tick_result.command_outcomes {
2602            if let Err(error) = &outcome.result {
2603                shared.rejected_commands_total.fetch_add(1, Ordering::Relaxed);
2604                tracing::warn!(
2605                    command_id = outcome.command_id.0,
2606                    cause = %error,
2607                    "control command rejected"
2608                );
2609                shared.record_rejected_command(
2610                    outcome.command_id,
2611                    error.to_string(),
2612                    error.is_unsupported_transport(),
2613                );
2614            }
2615        }
2616        let mut events_drained: u64 = 0;
2617        while let Ok(event) = events.try_recv() {
2618            events_drained += 1;
2619            let clean_exit = matches!(
2620                event.event,
2621                ControlEventKind::Exited { exit_code: Some(0), forced: false },
2622            );
2623            let instance_id = event.instance_id;
2624            let generation = event.generation;
2625            // Resolve and spawn before publish_control: publish_control's own
2626            // managed-record bookkeeping (reconcile_managed_record) downgrades
2627            // this exact record from Live to Dormant and clears
2628            // active_session for every Exited event, inside the same
2629            // synchronous call. Capturing (record_id, session) here — before
2630            // that runs — is the only way to observe them still Live and
2631            // still populated; the export itself must still run on its own
2632            // detached task (never awaited inline on this loop) because it
2633            // needs NativeRuntime::tick() to keep running to ever settle, and
2634            // tick() is only ever driven by this same loop. See
2635            // reconcile_context_pack_export_for_record's doc comment.
2636            if clean_exit {
2637                if let Some((record_id, session)) =
2638                    shared.managed_record_export_target_for_instance(instance_id, generation)
2639                {
2640                    let export_shared = Arc::clone(&shared);
2641                    tokio::spawn(async move {
2642                        export_shared
2643                            .reconcile_context_pack_export_for_record(&record_id, session)
2644                            .await;
2645                    });
2646                }
2647            }
2648            shared.publish_control(event);
2649        }
2650        let event_drain_elapsed = event_drain_start.elapsed();
2651
2652        let publish_terminal_frames_start = Instant::now();
2653        shared.publish_terminal_frames();
2654        let publish_terminal_frames_elapsed = publish_terminal_frames_start.elapsed();
2655
2656        // Physical PTY count and the runtime's own per-phase tick timings
2657        // both live inside `NativeRuntime`, which `GET /metrics` (reading
2658        // only `NodeShared`) cannot reach directly -- this loop is the one
2659        // place holding `&mut NativeRuntime`, so it is the only place that
2660        // can copy either out.
2661        shared
2662            .native_session_gauge
2663            .store(runtime.active_native_sessions(), Ordering::Relaxed);
2664        *shared
2665            .runtime_tick_profile
2666            .lock()
2667            .unwrap_or_else(|poisoned| poisoned.into_inner()) = runtime.tick_profile_snapshot();
2668
2669        let remainder_start = Instant::now();
2670        if shared.shutdown.load(Ordering::Acquire) {
2671            let started = *shutdown_started.get_or_insert_with(Instant::now);
2672            let snapshot = shared.handle.snapshot();
2673            let sessions_settled = snapshot.sessions.iter().all(|session| {
2674                !matches!(
2675                    session.status,
2676                    gate4agent_types::SessionStatus::Starting
2677                        | gate4agent_types::SessionStatus::Running
2678                        | gate4agent_types::SessionStatus::Stopping
2679                )
2680            });
2681            if runtime.active_native_sessions() == 0 && sessions_settled {
2682                return Ok(());
2683            }
2684            if started.elapsed() >= shutdown_timeout {
2685                return Err(NodeServerError::ShutdownTimedOut {
2686                    active_native_sessions: runtime.active_native_sessions(),
2687                });
2688            }
2689        }
2690        let remainder_elapsed = remainder_start.elapsed();
2691
2692        {
2693            let mut profile = shared
2694                .drive_loop_profile
2695                .lock()
2696                .unwrap_or_else(|poisoned| poisoned.into_inner());
2697            profile.record_runtime_tick(runtime_tick_elapsed);
2698            profile.record_event_drain(event_drain_elapsed);
2699            profile.record_publish_terminal_frames(publish_terminal_frames_elapsed);
2700            profile.record_remainder(remainder_elapsed);
2701            profile.note_iteration(Instant::now());
2702        }
2703
2704        // "Produced nothing" means this iteration's `NativeRuntime::tick`
2705        // applied zero observations (`tick_result.observations_applied`,
2706        // which already folds in any terminal frames collected -- see
2707        // `NativeEffectDispatcher::drain_observations`) AND the
2708        // control-event subscription drained above forwarded zero events.
2709        // Command ingress and dispatched effects are deliberately NOT part
2710        // of this definition: an idle node still runs `step_control_plane`
2711        // and every provider supervisor's `tick` regardless, every 10ms --
2712        // so "nothing" here means nothing OBSERVABLE left this iteration,
2713        // matching what a reader would assume from `drive_loop_iterations`'s
2714        // own field name, not that the tick itself was free of work.
2715        shared.drive_loop_iterations_total.fetch_add(1, Ordering::Relaxed);
2716        if tick_result.observations_applied == 0 && events_drained == 0 {
2717            shared.drive_loop_iterations_idle.fetch_add(1, Ordering::Relaxed);
2718        }
2719
2720        // A sweep cadence, not a policy value -- see
2721        // `SESSION_RECORD_RETENTION_SWEEP_PERIOD`'s own doc comment. The
2722        // policy itself (whether this does anything at all) lives entirely
2723        // in `NodeShared::session_record_retention`.
2724        if last_retention_sweep.elapsed() >= SESSION_RECORD_RETENTION_SWEEP_PERIOD {
2725            shared.retire_unavailable_session_records().await;
2726            last_retention_sweep = Instant::now();
2727        }
2728
2729        sleep(Duration::from_millis(10)).await;
2730    }
2731}
2732
2733#[derive(Clone)]
2734pub struct NodeShutdownHandle {
2735    shared: Arc<NodeShared>,
2736}
2737
2738impl NodeShutdownHandle {
2739    pub async fn request_shutdown(&self) -> Result<(), NodeServerError> {
2740        self.shared
2741            .begin_shutdown()
2742            .await
2743            .map_err(NodeServerError::ShutdownDispatch)
2744    }
2745}
2746
2747async fn wait_for_ctrl_signal() -> Result<(), NodeServerError> {
2748    #[cfg(windows)]
2749    {
2750    let mut ctrl_c = tokio::signal::windows::ctrl_c()?;
2751    let mut ctrl_break = tokio::signal::windows::ctrl_break()?;
2752    return tokio::select! {
2753        signal = ctrl_c.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2754        signal = ctrl_break.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2755    };
2756    }
2757    #[cfg(unix)]
2758    {
2759        let mut interrupt = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())?;
2760        let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
2761        tokio::select! {
2762            signal = interrupt.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2763            signal = terminate.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2764        }
2765    }
2766}
2767
2768/// The providers this node actually runs.
2769///
2770/// `builtin_registry()` is a reference catalog of everything the project has
2771/// ever described; the fleet is this list, and it is deliberately short. A
2772/// provider only belongs here if it can be exercised for real -- there is a
2773/// subscription behind it and a failure in it can be reproduced and
2774/// debugged. Carrying one that cannot is carrying code nobody can verify,
2775/// which is worse than not supporting it: it looks supported.
2776///
2777/// A provider dropped from the fleet by this rule is removed outright, not
2778/// carried dark: no catalog entry, no adapter, no dead code claiming support
2779/// for something nobody can verify runs.
2780fn active_registry() -> Result<AgentRegistry, NodeServerError> {
2781    let specs = builtin_registry()
2782        .iter()
2783        .filter(|spec| matches!(spec.id.as_str(), "claude" | "codex" | "grok" | "kimi"))
2784        .cloned()
2785        .collect::<Vec<_>>();
2786    AgentRegistry::new(specs).map_err(|error| NodeServerError::Registry(error.to_string()))
2787}
2788
2789fn provider_contract_manifest(
2790    catalog: &AgentRegistry,
2791) -> Result<
2792    (
2793        Vec<ProviderContractSupport>,
2794        Vec<ProviderAdapterContractSupport>,
2795    ),
2796    NodeServerError,
2797> {
2798    let mut provider_contracts = Vec::new();
2799    let mut provider_adapter_contracts = Vec::new();
2800    for spec in catalog.iter() {
2801        let provider = spec.id.clone();
2802        let revision = ProviderContractRevision::new(spec.revision.clone())
2803            .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2804        provider_contracts.push(ProviderContractSupport {
2805            provider: provider.clone(),
2806            revision,
2807        });
2808        for (family, binding) in declared_adapter_bindings(spec)? {
2809            let revision = AdapterContractRevision::new(binding.revision.clone())
2810                .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2811            provider_adapter_contracts.push(ProviderAdapterContractSupport {
2812                provider: provider.clone(),
2813                family,
2814                adapter_id: binding.id.clone(),
2815                revision,
2816            });
2817        }
2818    }
2819    validate_provider_contract_manifest(&provider_contracts, &provider_adapter_contracts)
2820        .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2821    Ok((provider_contracts, provider_adapter_contracts))
2822}
2823
2824fn declared_adapter_bindings(
2825    spec: &AgentSpec,
2826) -> Result<Vec<(AdapterFamily, &AdapterBinding)>, NodeServerError> {
2827    let mut bindings = Vec::new();
2828    push_declared_binding(
2829        &mut bindings,
2830        AdapterFamily::PtySemantic,
2831        spec.capabilities.transports.pty_adapter.as_ref(),
2832    )?;
2833    push_declared_binding(
2834        &mut bindings,
2835        AdapterFamily::Pipe,
2836        spec.capabilities.adapters.pty_sidecar.as_ref(),
2837    )?;
2838    let pipe_transport = spec.capabilities.transports.pipe.as_ref();
2839    let pipe_family = pipe_transport.map(|transport| match transport.protocol {
2840        gate4agent_types::PipeProtocol::SemanticNdjson
2841        | gate4agent_types::PipeProtocol::StructuredJsonl => AdapterFamily::Pipe,
2842        gate4agent_types::PipeProtocol::OneShotText => AdapterFamily::OneShot,
2843    });
2844    push_declared_binding(
2845        &mut bindings,
2846        AdapterFamily::Pipe,
2847        pipe_transport
2848            .filter(|_| pipe_family == Some(AdapterFamily::Pipe))
2849            .map(|transport| &transport.adapter),
2850    )?;
2851    push_declared_binding(
2852        &mut bindings,
2853        AdapterFamily::Acp,
2854        spec.capabilities
2855            .transports
2856            .acp
2857            .as_ref()
2858            .map(|transport| &transport.adapter),
2859    )?;
2860    push_declared_binding(
2861        &mut bindings,
2862        AdapterFamily::Hook,
2863        spec.capabilities.adapters.hook.as_ref(),
2864    )?;
2865    push_declared_binding(
2866        &mut bindings,
2867        AdapterFamily::ManagedHook,
2868        spec.capabilities.adapters.managed_hook.as_ref(),
2869    )?;
2870    push_declared_binding(
2871        &mut bindings,
2872        AdapterFamily::OneShot,
2873        pipe_transport
2874            .filter(|_| pipe_family == Some(AdapterFamily::OneShot))
2875            .map(|transport| &transport.adapter),
2876    )?;
2877    push_declared_binding(
2878        &mut bindings,
2879        AdapterFamily::OneShot,
2880        spec.capabilities.adapters.one_shot.as_ref(),
2881    )?;
2882    push_declared_binding(
2883        &mut bindings,
2884        AdapterFamily::History,
2885        spec.capabilities.adapters.history.as_ref(),
2886    )?;
2887    push_declared_binding(
2888        &mut bindings,
2889        AdapterFamily::Resume,
2890        spec.capabilities.adapters.resume.as_ref(),
2891    )?;
2892    push_declared_binding(
2893        &mut bindings,
2894        AdapterFamily::SessionOptions,
2895        spec.capabilities.adapters.session_options.as_ref(),
2896    )?;
2897    push_declared_binding(
2898        &mut bindings,
2899        AdapterFamily::CapabilityProbe,
2900        spec.capabilities.adapters.capability_probe.as_ref(),
2901    )?;
2902    Ok(bindings)
2903}
2904
2905fn push_declared_binding<'a>(
2906    bindings: &mut Vec<(AdapterFamily, &'a AdapterBinding)>,
2907    family: AdapterFamily,
2908    binding: Option<&'a AdapterBinding>,
2909) -> Result<(), NodeServerError> {
2910    let Some(binding) = binding else {
2911        return Ok(());
2912    };
2913    if let Some((_, existing)) = bindings
2914        .iter()
2915        .find(|(existing_family, _)| *existing_family == family)
2916    {
2917        if *existing == binding {
2918            return Ok(());
2919        }
2920        return Err(NodeServerError::ProviderContractManifest(format!(
2921            "provider declares conflicting {family:?} adapter bindings"
2922        )));
2923    }
2924    bindings.push((family, binding));
2925    Ok(())
2926}
2927
2928fn merge_durable_state(
2929    configured: &[WorkspaceConfig],
2930    mut loaded: LoadedNodeState,
2931) -> Result<(Vec<WorkspaceConfig>, Vec<ManagedSessionRecord>, Option<String>), NodeServerError> {
2932    let mut workspaces = configured
2933        .iter()
2934        .cloned()
2935        .map(|workspace| (workspace.workspace_id.clone(), workspace))
2936        .collect::<BTreeMap<_, _>>();
2937    let mut roots = workspaces
2938        .values()
2939        .map(|workspace| {
2940            (
2941                platform::root_identity(&workspace.canonical_root),
2942                workspace.workspace_id.clone(),
2943            )
2944        })
2945        .collect::<BTreeMap<_, _>>();
2946    let mut warning = loaded
2947        .warning
2948        .take()
2949        .map(|message| session_registry::sanitized_persistence_summary(&message));
2950
2951    for (workspace_id, canonical_root) in loaded.workspaces {
2952        if let Some(existing) = workspaces.get(&workspace_id) {
2953            if !platform::roots_equal(&existing.canonical_root, &canonical_root) {
2954                return Err(NodeServerError::DurableState(io::Error::new(
2955                    io::ErrorKind::InvalidData,
2956                    DURABLE_STATE_CONFLICT_ERROR,
2957                )));
2958            }
2959            continue;
2960        }
2961        if roots.get(&platform::root_identity(&canonical_root)).is_some() {
2962            return Err(NodeServerError::DurableState(io::Error::new(
2963                io::ErrorKind::InvalidData,
2964                DURABLE_STATE_CONFLICT_ERROR,
2965            )));
2966        }
2967        if !Path::new(&canonical_root).is_dir() {
2968            warning.get_or_insert_with(|| {
2969                session_registry::DURABLE_STATE_WORKSPACE_WARNING.to_owned()
2970            });
2971            continue;
2972        }
2973        let workspace = WorkspaceConfig::new(workspace_id.clone(), &canonical_root).map_err(|_| {
2974            NodeServerError::DurableState(io::Error::new(
2975                io::ErrorKind::InvalidData,
2976                DURABLE_STATE_CONFLICT_ERROR,
2977            ))
2978        })?;
2979        roots.insert(
2980            platform::root_identity(&workspace.canonical_root),
2981            workspace.workspace_id.clone(),
2982        );
2983        workspaces.insert(workspace_id, workspace);
2984    }
2985
2986    for record in &mut loaded.records {
2987        match workspaces.get(&record.workspace_id) {
2988            Some(workspace)
2989                if platform::roots_equal(
2990                    &workspace.canonical_root,
2991                    windows_path_text(&record.canonical_root),
2992                ) => {}
2993            Some(_) => {
2994                return Err(NodeServerError::DurableState(io::Error::new(
2995                    io::ErrorKind::InvalidData,
2996                    DURABLE_STATE_CONFLICT_ERROR,
2997                )));
2998            }
2999            None => {
3000                record.state = ManagedSessionState::Unavailable;
3001                record.active_session = None;
3002                record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
3003            }
3004        }
3005    }
3006    Ok((workspaces.into_values().collect(), loaded.records, warning))
3007}
3008
3009/// One entry of `NodeShared::rejected_command_reasons` -- see that field's
3010/// own doc for the mechanism this exists to close.
3011struct RejectedCommandReason {
3012    message: String,
3013    /// True when the kernel refused this command specifically because the
3014    /// agent does not declare the requested transport (`KernelCommandError::
3015    /// UnsupportedTransport`) -- lets the reader choose the named
3016    /// `NodeFailureCode::UnsupportedTransport` over the generic
3017    /// `BackendOperationFailed` bucket, so this one well-known rejection
3018    /// keeps its identity all the way to the operator wire.
3019    unsupported_transport: bool,
3020    expires_at: Instant,
3021}
3022
3023struct NodeShared {
3024    handle: Gate4AgentHandle,
3025    access_token: String,
3026    node_id: NodeId,
3027    incarnation_id: NodeIncarnationId,
3028    started_at_unix_ms: u64,
3029    workspaces: RwLock<BTreeMap<WorkspaceId, String>>,
3030    worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode>,
3031    managed_worktree_profiles:
3032        BTreeMap<WorkspaceId, BTreeMap<WorktreeProfileId, ManagedWorktreeProfile>>,
3033    managed_worktrees: Mutex<ManagedWorktreeRegistry>,
3034    enabled_providers: Vec<AgentId>,
3035    provider_runtime_statuses: ProviderRuntimeStatuses,
3036    provider_runtime_status_updates:
3037        RwLock<BTreeMap<AgentId, crate::protocol::ProviderRuntimeStatus>>,
3038    provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
3039    provider_contracts: Vec<ProviderContractSupport>,
3040    provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
3041    spawn_profiles: SpawnProfileRegistry,
3042    environment_profiles:
3043        RwLock<BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>>,
3044    environment_materialization_profiles:
3045        RwLock<BTreeMap<SpawnEnvironmentProfileId, NodeSessionMaterializationProfile>>,
3046    /// Station **network** allowlist catalog (opaque policy ids only).
3047    /// Empty by default — any `network_allowlist` on spawn must be registered
3048    /// here or resolve refuses with `UnknownNetworkAllowlist`. Optional
3049    /// startup load via `NodeServerConfig::with_network_allowlist_catalog(_file)`
3050    /// / env `GATE4AGENT_NODE_NETWORK_ALLOWLIST_CATALOG`. Plan
3051    /// `station-network-and-browser-profile-knobs-2026-10-02.md` §2.1 / §4.
3052    /// Never holds cookies / OAuth / proxy credentials.
3053    network_allowlist_catalog: RwLock<crate::network_allowlist_catalog::NetworkAllowlistCatalog>,
3054    /// Node-local exclusive dig2browser station profile leases (feature
3055    /// `dig2-station-probe`). Opaque `browser_profile_id` ↔ session holder;
3056    /// cleared on session end. Never ImportSession / cookies on C2.
3057    #[cfg(feature = "dig2-station-probe")]
3058    browser_station_leases: crate::dig2_station_lease::BrowserStationLeaseTable,
3059    bundle_catalog: RwLock<BundleCatalog>,
3060    delivery_store: Mutex<Option<DeliveryStore>>,
3061    context_pack_store: Mutex<Option<ContextPackStore>>,
3062    context_catalog: RwLock<ContextPackCatalog>,
3063    native_launch_profile_control: Option<NativeLaunchProfileControl>,
3064    harness_mcp_registry: Option<HarnessMcpProxyRegistry>,
3065    native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
3066    session_environment_materializer: Option<SessionEnvironmentMaterializer>,
3067    materializations: Mutex<BTreeMap<MaterializationId, MaterializationOwnershipRecord>>,
3068    spawn_idempotency: Mutex<SpawnIdempotencyCache>,
3069    managed_spawn_replays:
3070        Mutex<BTreeMap<SpawnIdempotencyKey, ManagedWorktreeSpawnReplayRecordV10>>,
3071    controller: Mutex<Option<ControllerLease>>,
3072    history: Mutex<NodeEventHistory>,
3073    event_tx: broadcast::Sender<NodeEventEnvelope>,
3074    terminal_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3075    /// The outbound `NodeEvent::AgentStream` push channel -- its own
3076    /// dedicated, capability-gated broadcast, mirroring `terminal_event_tx`
3077    /// exactly rather than riding the durable `event_tx` history (see
3078    /// `publish`'s own assertion): a subscriber who never negotiated
3079    /// `NODE_AGENT_STREAM_EVENTS_CAPABILITY` must never receive one of
3080    /// these, the same guarantee `include_terminal_frame_events` already
3081    /// gives terminal frames.
3082    agent_stream_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3083    /// The outbound `NodeEvent::HarnessMcpReadCall` push channel -- mirrors
3084    /// `agent_stream_event_tx` exactly, its own dedicated, capability-gated
3085    /// broadcast rather than a ride on the durable `event_tx` history. A
3086    /// `HarnessMcpReadCall` is a request/response call a live proxy call is
3087    /// waiting on (`harness_mcp_proxy.rs`'s `begin_call`); the shared bus's
3088    /// finite capacity and its willingness to silently evict a lagging
3089    /// subscriber's backlog is exactly how the call vanished with neither
3090    /// side ever logging it (see `publish_transient`'s own doc).
3091    harness_mcp_proxy_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3092    terminal_frame_watermarks:
3093        Mutex<BTreeMap<AgentInstanceId, (SessionAddress, u64)>>,
3094    next_connection_id: AtomicU64,
3095    next_instance_id: AtomicU64,
3096    next_command_id: AtomicU64,
3097    shutdown: AtomicBool,
3098    shutdown_notify: Notify,
3099    preauth_slots: Arc<Semaphore>,
3100    authenticated_slots: Arc<Semaphore>,
3101    inspection_slots: Arc<Semaphore>,
3102    mutation_gate: AsyncMutex<()>,
3103    session_bindings: Mutex<BTreeMap<AgentInstanceId, SessionBinding>>,
3104    session_records: Mutex<SessionRecords>,
3105    state_path: Option<PathBuf>,
3106    persistence_error: RwLock<Option<String>>,
3107    state_transaction: Mutex<()>,
3108    input_settle_timeout_ms: u64,
3109    /// Drive-loop phase/rate timings -- see `drive_loop_profile`'s own doc
3110    /// comment. Written once per iteration by
3111    /// `drive_runtime_until_shutdown`, read by `GET /metrics`.
3112    drive_loop_profile: Mutex<DriveLoopProfiler>,
3113    /// Latest copy of `NativeRuntime::tick_profile_snapshot()` -- `Copy`,
3114    /// so an overwrite each iteration is cheap. The runtime itself is not
3115    /// reachable from here (it is owned locally by
3116    /// `drive_runtime_until_shutdown`), so this is how its own per-phase
3117    /// tick timings reach `GET /metrics`.
3118    runtime_tick_profile: Mutex<TickProfileSnapshot>,
3119    /// The live shell-efficiency profile, shared with the per-instance
3120    /// worker loops that fold into it.
3121    ///
3122    /// Deliberately NOT the per-iteration snapshot copy `runtime_tick_
3123    /// profile` above keeps: producing a snapshot sorts every ring behind
3124    /// it, and that sort belongs on the read. A `OnceLock` rather than a
3125    /// field set at construction because `NodeShared` is built before the
3126    /// `NativeRuntime` that owns the profile exists -- the drive loop
3127    /// publishes it once, before its first iteration. Unset means the
3128    /// runtime has not started driving yet, which `GET /metrics` reports as
3129    /// empty series rather than as zeroes it never measured.
3130    shell_efficiency_profile: OnceLock<Arc<Mutex<ShellEfficiencyProfile>>>,
3131    /// Physical PTY sessions the native runtime currently owns
3132    /// (`NativeRuntime::active_native_sessions`), refreshed once per drive
3133    /// loop iteration -- deliberately a separate count from
3134    /// `handle.snapshot().sessions.len()` (the control-plane kernel's own
3135    /// session bookkeeping), since the two can disagree and the gap is
3136    /// itself a diagnostic signal `GET /metrics` exposes.
3137    native_session_gauge: AtomicUsize,
3138    /// Lifetime totals for the per-connection serve loop, summed across
3139    /// every live connection.
3140    ///
3141    /// The drive loop's own phase timings account for about ten
3142    /// microseconds an iteration on an idle node, yet the process burns a
3143    /// quarter of a core — and burns it ONLY while a c2 is attached (0.5%
3144    /// with none, 23% with two, measured). That gap is somewhere in
3145    /// serving a connection, and there was no way to see it: iterations
3146    /// says whether the loop is spinning rather than sleeping, and events
3147    /// says whether it is spinning over real work or over nothing.
3148    connection_loop_iterations: AtomicU64,
3149    connection_events_sent: AtomicU64,
3150    /// Lifetime totals for `drive_runtime_until_shutdown`'s own iterations
3151    /// -- `drive_loop_iterations_idle` counts iterations that applied zero
3152    /// `NativeRuntime::tick` observations AND forwarded zero control
3153    /// events; see the exact "produced nothing" definition at the site in
3154    /// `drive_runtime_until_shutdown` that increments these. Read twice
3155    /// and divide by the interval, same rule as `connections` above --
3156    /// `idle / total` is backlog item 5's "wake on work" number.
3157    /// Lifetime count of control commands the kernel refused.
3158    rejected_commands_total: AtomicU64,
3159    /// The named reason behind a recent kernel rejection, by the
3160    /// `CommandId` `dispatch_bounded` handed back when it enqueued that
3161    /// command -- populated by `drive_runtime_until_shutdown` for every
3162    /// `Err` in a tick's `command_outcomes`, and drained by whichever
3163    /// spawn-dispatch waiter recognizes its own `Register`/`Start`
3164    /// `CommandId` among them.
3165    ///
3166    /// A `ControlCommand` only ever fails synchronously (a `NodeFailure`
3167    /// out of `dispatch`/`dispatch_bounded` itself) for a port-level ingress
3168    /// problem -- full or disconnected -- never for the kernel refusing the
3169    /// command's content (an unsupported transport, an unknown agent, ...);
3170    /// that verdict exists only once `NativeRuntime::tick` has processed the
3171    /// command, one drive-loop iteration later, in a different task than
3172    /// whichever caller is awaiting the spawn. Without this map, a spawn
3173    /// wait loop that polls `self.handle.snapshot()` for a session that
3174    /// will now never appear has no way to learn why -- it can only spin
3175    /// until its own commit deadline and report a generic "did not commit"
3176    /// timeout, discarding a reason the kernel already named precisely (see
3177    /// `KernelCommandError`'s variants, e.g. `UnsupportedTransport`).
3178    rejected_command_reasons: Mutex<BTreeMap<CommandId, RejectedCommandReason>>,
3179    drive_loop_iterations_total: AtomicU64,
3180    drive_loop_iterations_idle: AtomicU64,
3181    /// The operator-chosen age/keep-N policy the retention sweep applies to
3182    /// `ManagedSessionState::Unavailable` records. Both `0` by default --
3183    /// see `SessionRecordRetentionConfig`'s own doc comment.
3184    session_record_retention: SessionRecordRetentionConfig,
3185    /// Lifetime count of managed session records the retention sweep has
3186    /// retired. Same tier as `rejected_commands_total` above: a live,
3187    /// process-lifetime instrument, never persisted and never reloaded from
3188    /// `state-v1.json` -- a restart legitimately resets it, matching every
3189    /// other `*_total` counter here.
3190    retired_records_total: AtomicU64,
3191    #[cfg(feature = "fixture")]
3192    fixture_semantic_hook_policy: bool,
3193    #[cfg(feature = "fixture")]
3194    fixture_spawn_managed_worktree_v2_failure_probe: SpawnManagedWorktreeV2FailureProbe,
3195}
3196
3197impl Drop for NodeShared {
3198    fn drop(&mut self) {
3199        SessionLifecycleHook::on_shutdown(self);
3200    }
3201}
3202
3203#[derive(Clone, Debug, Eq, PartialEq)]
3204struct SessionBinding {
3205    workspace_id: WorkspaceId,
3206    generation: SessionGeneration,
3207    runtime_policy: ProviderRuntimePolicy,
3208    pending_resume: Option<(SessionGeneration, CommandId, ProviderRuntimePolicy)>,
3209    record_id: Option<SessionRecordId>,
3210    managed_worktree_lease_id: Option<ManagedWorktreeLeaseId>,
3211    environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
3212    bundle: Option<ResolvedBundleReceipt>,
3213    context: Option<ResolvedContextPackReceipt>,
3214    materialization_id: Option<MaterializationId>,
3215}
3216
3217#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3218enum SpawnRecordPolicy {
3219    ProviderIdentityOnly,
3220    Always,
3221}
3222
3223struct NativeEnvironmentSelectionGuard {
3224    control: Option<NativeLaunchProfileControl>,
3225    instance_id: AgentInstanceId,
3226}
3227
3228struct NativeInstanceOverlayGuard {
3229    control: Option<NativeLaunchProfileControl>,
3230    instance_id: AgentInstanceId,
3231}
3232
3233struct NativeHarnessMcpOverlayGuard {
3234    control: NativeLaunchProfileControl,
3235    instance_id: AgentInstanceId,
3236}
3237
3238impl NativeHarnessMcpOverlayGuard {
3239    fn retain(self) { std::mem::forget(self); }
3240}
3241
3242impl Drop for NativeHarnessMcpOverlayGuard {
3243    fn drop(&mut self) {
3244        self.control
3245            .clear_native_mcp_server_launch_overlay(self.instance_id);
3246    }
3247}
3248
3249impl NativeInstanceOverlayGuard {
3250    fn retain(mut self) {
3251        self.control = None;
3252    }
3253}
3254
3255impl Drop for NativeInstanceOverlayGuard {
3256    fn drop(&mut self) {
3257        if let Some(control) = self.control.as_ref() {
3258            control.clear_native_instance_launch_overlay(self.instance_id);
3259        }
3260    }
3261}
3262
3263enum PreparedNativeLaunchOverlay {
3264    Environment(NativeLaunchEnvironmentOverlay),
3265    Instance(NativeInstanceLaunchOverlay),
3266}
3267
3268impl NativeEnvironmentSelectionGuard {
3269    fn retain(mut self) {
3270        self.control = None;
3271    }
3272}
3273
3274impl Drop for NativeEnvironmentSelectionGuard {
3275    fn drop(&mut self) {
3276        if let Some(control) = self.control.as_ref() {
3277            control.clear_native_launch_profile_selection(self.instance_id);
3278        }
3279    }
3280}
3281
3282struct SessionMaterializationGuard<'a> {
3283    shared: &'a NodeShared,
3284    id: Option<MaterializationId>,
3285    /// Absolute ProviderHome (`home/`) under the session materialization root.
3286    /// Used for Claude station network `--settings` sidecar writes.
3287    provider_home: Option<PathBuf>,
3288}
3289
3290impl SessionMaterializationGuard<'_> {
3291    fn id(&self) -> Option<&MaterializationId> {
3292        self.id.as_ref()
3293    }
3294
3295    fn provider_home(&self) -> Option<&Path> {
3296        self.provider_home.as_deref()
3297    }
3298
3299    fn retain(mut self) {
3300        self.id = None;
3301    }
3302}
3303
3304impl Drop for SessionMaterializationGuard<'_> {
3305    fn drop(&mut self) {
3306        if let Some(id) = self.id.take() {
3307            let _ = self.shared.cleanup_materialization(&id);
3308        }
3309    }
3310}
3311
3312
3313/// Port: product-side reactions to session lifecycle transitions
3314/// (station lease, harness MCP overlay/registry, environment launch overlays).
3315trait SessionLifecycleHook {
3316    /// A binding was removed (unbind or spawn rollback).
3317    fn on_binding_removed(&self, address: &SessionAddress, removed: Option<&SessionBinding>);
3318    /// A session reached a terminal control event that revokes its harness MCP door.
3319    fn on_session_ended(&self, address: &SessionAddress);
3320    /// Node shutdown (or drop): revoke every harness MCP reservation.
3321    fn on_shutdown(&self);
3322    /// A harness MCP reservation was aborted before activation.
3323    fn on_harness_reservation_aborted(&self, instance_id: AgentInstanceId);
3324    /// Release session-owned materialization for a removed binding.
3325    fn cleanup_session_materialization(
3326        &self,
3327        address: &SessionAddress,
3328        binding: &SessionBinding,
3329    ) -> Result<(), NodeFailure>;
3330}
3331
3332impl SessionLifecycleHook for NodeShared {
3333    fn on_session_ended(&self, address: &SessionAddress) {
3334        if let Some(registry) = self.harness_mcp_registry.as_ref() {
3335            registry.revoke_session(address);
3336        }
3337        if let Some(control) = self.native_launch_profile_control.as_ref() {
3338            control.clear_native_mcp_server_launch_overlay(address.session.instance_id);
3339        }
3340    }
3341
3342    fn on_shutdown(&self) {
3343        if let Some(registry) = self.harness_mcp_registry.as_ref() {
3344            let instances = registry.shutdown();
3345            if let Some(control) = self.native_launch_profile_control.as_ref() {
3346                for instance_id in instances {
3347                    control.clear_native_mcp_server_launch_overlay(instance_id);
3348                }
3349            }
3350        }
3351    }
3352
3353    fn on_harness_reservation_aborted(&self, instance_id: AgentInstanceId) {
3354        if let Some(control) = self.native_launch_profile_control.as_ref() {
3355            control.clear_native_mcp_server_launch_overlay(instance_id);
3356        }
3357    }
3358
3359    fn cleanup_session_materialization(
3360        &self,
3361        address: &SessionAddress,
3362        binding: &SessionBinding,
3363    ) -> Result<(), NodeFailure> {
3364        self.cleanup_session_owned_materialization(address, binding)
3365    }
3366
3367    fn on_binding_removed(&self, address: &SessionAddress, removed: Option<&SessionBinding>) {
3368        // Exclusive dig2browser station profile lease is node-local; clear
3369        // on every session unbind / rollback. Never ImportSession/cookies.
3370        #[cfg(feature = "dig2-station-probe")]
3371        {
3372            crate::dig2_station_lease::release_lease_for_holder(
3373                &self.browser_station_leases,
3374                address.session.instance_id,
3375            );
3376        }
3377        if let Some(control) = self.native_launch_profile_control.as_ref() {
3378            control.clear_native_mcp_server_launch_overlay(
3379                address.session.instance_id,
3380            );
3381        }
3382        if let Some(registry) = self.harness_mcp_registry.as_ref() {
3383            registry.revoke_session(address);
3384        }
3385        if removed
3386            .is_some_and(|binding| {
3387                binding.environment_profile.is_some()
3388                    || binding.bundle.is_some()
3389                    || binding.context.is_some()
3390            })
3391        {
3392            if let Some(control) = self.native_launch_profile_control.as_ref() {
3393                control.clear_native_launch_profile_selection(
3394                    address.session.instance_id,
3395                );
3396                control.clear_native_instance_launch_overlay(
3397                    address.session.instance_id,
3398                );
3399            }
3400        }
3401    }
3402}
3403
3404struct SpawnPrepareRequest<'r> {
3405    instance_id: AgentInstanceId,
3406    address: &'r SessionAddress,
3407    provider: &'r AgentId,
3408    mode: SessionMode,
3409    environment_profile: Option<&'r ResolvedEnvironmentProfileReceipt>,
3410    bundle: Option<&'r ResolvedBundleReceipt>,
3411    context: Option<&'r ResolvedContextPackReceipt>,
3412    managed_authority: Option<&'r ManagedWorktreeLeaseId>,
3413    harness_mcp: Option<&'r PreparedHarnessMcpSpawn>,
3414    approval_level: ApprovalLevel,
3415    network_allowlist: Option<&'r crate::protocol::SpawnNetworkAllowlistId>,
3416    browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
3417}
3418
3419/// Guards held between "session identity minted" and "launch committed".
3420/// Field order is the drop order and matches the pre-refactor local order
3421/// (instance overlay, environment selection, materialization, harness, station lease).
3422struct PreparedSpawn<'a> {
3423    instance_overlay: Option<NativeInstanceOverlayGuard>,
3424    environment_selection: Option<NativeEnvironmentSelectionGuard>,
3425    materialization_guard: Option<SessionMaterializationGuard<'a>>,
3426    harness_mcp_overlay: Option<NativeHarnessMcpOverlayGuard>,
3427    #[cfg(feature = "dig2-station-probe")]
3428    browser_station_lease_guard: Option<crate::dig2_station_lease::BrowserStationLeaseGuard>,
3429    materialization_id: Option<MaterializationId>,
3430}
3431
3432impl PreparedSpawn<'_> {
3433    /// Session-owned launch state now belongs to the binding.
3434    fn retain_session_guards(&mut self) {
3435        if let Some(selection) = self.environment_selection.take() {
3436            selection.retain();
3437        }
3438        if let Some(overlay) = self.instance_overlay.take() {
3439            overlay.retain();
3440        }
3441        if let Some(guard) = self.materialization_guard.take() {
3442            guard.retain();
3443        }
3444    }
3445
3446    /// Launch committed: keep harness overlay and station lease for the session lifetime.
3447    fn retain_launch_guards(&mut self) {
3448        if let Some(overlay) = self.harness_mcp_overlay.take() {
3449            overlay.retain();
3450        }
3451        #[cfg(feature = "dig2-station-probe")]
3452        if let Some(guard) = self.browser_station_lease_guard.take() {
3453            guard.retain();
3454        }
3455    }
3456}
3457
3458/// Port: everything product-side that must be prepared before a session is
3459/// bound and started (station lease, harness MCP overlay, materialization,
3460/// environment selection, network overlays, launch overlay).
3461trait SpawnPreparer {
3462    fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure>;
3463    /// Select the native launch profile for an environment-profile session.
3464    fn select_environment(
3465        &self,
3466        instance_id: AgentInstanceId,
3467        provider: &AgentId,
3468        mode: SessionMode,
3469        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3470    ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure>;
3471    /// Install a prepared launch overlay for the instance.
3472    fn install_launch_overlay(
3473        &self,
3474        instance_id: AgentInstanceId,
3475        overlay: PreparedNativeLaunchOverlay,
3476    ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure>;
3477}
3478
3479impl SpawnPreparer for NodeShared {
3480    fn select_environment(
3481        &self,
3482        instance_id: AgentInstanceId,
3483        provider: &AgentId,
3484        mode: SessionMode,
3485        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3486    ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
3487        self.select_environment_profile(instance_id, provider, mode, environment_profile)
3488    }
3489
3490    fn install_launch_overlay(
3491        &self,
3492        instance_id: AgentInstanceId,
3493        overlay: PreparedNativeLaunchOverlay,
3494    ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
3495        self.install_prepared_launch_overlay(instance_id, overlay)
3496    }
3497
3498    fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure> {
3499        let SpawnPrepareRequest {
3500            instance_id,
3501            address,
3502            provider,
3503            mode,
3504            environment_profile,
3505            bundle,
3506            context,
3507            managed_authority,
3508            harness_mcp,
3509            approval_level,
3510            network_allowlist,
3511            browser_profile_id,
3512        } = request;
3513        // Track A follow-on: exclusive node-local BrowserStationLease after
3514        // dig2 probe (feature dig2-station-probe). Opaque profile id only —
3515        // never ImportSession / cookies on C2. Linux/feature-off: probe
3516        // already refused or stub; this block is cfg'd out when feature off.
3517        #[cfg(feature = "dig2-station-probe")]
3518        let mut browser_station_lease_guard = if let Some(profile_id) = browser_profile_id {
3519            use crate::dig2_station_lease::{acquire_lease, BrowserStationLeaseError};
3520            match acquire_lease(
3521                &self.browser_station_leases,
3522                profile_id,
3523                instance_id,
3524                unix_time_ms(),
3525            ) {
3526                Ok(guard) => Some(guard),
3527                Err(BrowserStationLeaseError::ProfileBusy { .. }) => {
3528                    return Err(failure(
3529                        NodeFailureCode::BrowserStationProfileBusy,
3530                        "dig2browser station profile is already leased by another session",
3531                    ));
3532                }
3533                Err(BrowserStationLeaseError::CapacityExceeded) => {
3534                    return Err(failure(
3535                        NodeFailureCode::BrowserStationProfileBusy,
3536                        "dig2browser station profile lease capacity exhausted",
3537                    ));
3538                }
3539                Err(BrowserStationLeaseError::HolderAlreadyLeased) => {
3540                    return Err(failure(
3541                        NodeFailureCode::BrowserStationProfileBusy,
3542                        "session already holds a dig2browser station profile lease",
3543                    ));
3544                }
3545            }
3546        } else {
3547            None
3548        };
3549        #[cfg(not(feature = "dig2-station-probe"))]
3550        let _ = browser_profile_id;
3551        let mut harness_mcp_overlay = if let Some(prepared) = harness_mcp {
3552            // Slice A(ii) of gate4agent-arc-mailbox-and-task-layer: the door
3553            // exists for PTY (env vars) and ACP (`session/new.mcpServers`,
3554            // `gate4agent-shell-native`'s `mcp_server_acp_entry`) alike --
3555            // both read the identical overlay resolved below, one as child
3556            // environment, the other translated into one stdio MCP server
3557            // entry. `Inline`/`Pipe` stays refused: this arc's proof is ACP
3558            // only, and nothing downstream reads a harness-MCP overlay for
3559            // that transport yet.
3560            if !matches!(mode, SessionMode::Pty | SessionMode::Acp) || prepared.provider != *provider
3561            {
3562                return Err(failure(
3563                    NodeFailureCode::BindingMismatch,
3564                    "harness MCP reservation does not match the exact PTY/ACP provider",
3565                ));
3566            }
3567            prepared.verify_helper_program().map_err(harness_mcp_failure)?;
3568            let control = self.native_launch_profile_control.clone().ok_or_else(|| {
3569                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP launch control is unavailable")
3570            })?;
3571            // Every name below is the caller's, carried in the reservation's
3572            // launch description: which environment variables receive the
3573            // endpoint, token and helper program, what the MCP server is called,
3574            // which argv it gets and which stale variables are erased.
3575            let launch = prepared.launch();
3576            let mut env_entries = vec![
3577                (
3578                    OsString::from(launch.endpoint_env.as_str()),
3579                    prepared.endpoint().as_os_str().to_os_string(),
3580                ),
3581                (
3582                    OsString::from(launch.token_env.as_str()),
3583                    prepared.token().expose().into(),
3584                ),
3585                (
3586                    OsString::from(launch.program_env.as_str()),
3587                    prepared.helper_program().as_os_str().to_os_string(),
3588                ),
3589            ];
3590            if let Some(trace_entry) = harness_mcp_trace_env_entry(launch) {
3591                env_entries.push(trace_entry);
3592            }
3593            let overlay = NativeMcpServerLaunchOverlay::new(
3594                provider.clone(),
3595                launch.server_name.as_str(),
3596                prepared.helper_program().as_os_str().to_os_string(),
3597                launch.args.iter().map(OsString::from).collect(),
3598                env_entries,
3599                launch.scrub_env.iter().map(OsString::from).collect(),
3600            ).map_err(|_| failure(
3601                NodeFailureCode::HarnessMcpUnavailable,
3602                "harness MCP launch overlay is unavailable",
3603            ))?;
3604            control
3605                .install_native_mcp_server_launch_overlay(instance_id, overlay)
3606                .map_err(|_| failure(
3607                    NodeFailureCode::HarnessMcpUnavailable,
3608                    "harness MCP launch overlay could not be installed",
3609                ))?;
3610            // State-change line: the overlay is installed and will be read
3611            // back by the PTY child's environment or, for ACP,
3612            // `gate4agent-shell-native`'s `mcp_server_acp_entry` at spawn
3613            // time -- no secrets here, only which instance/provider/mode
3614            // now carries a harness-MCP door.
3615            tracing::info!(
3616                node_id = %self.node_id,
3617                instance_id = ?instance_id,
3618                provider = %provider,
3619                mode = ?mode,
3620                "harness MCP overlay installed for spawn",
3621            );
3622            Some(NativeHarnessMcpOverlayGuard { control, instance_id })
3623        } else {
3624            None
3625        };
3626        let prepared_materialization = self.prepare_session_materialization(
3627            &address,
3628            &provider,
3629            mode,
3630            environment_profile,
3631            bundle,
3632            context,
3633            managed_authority.cloned(),
3634        )?;
3635        let (environment_overlay, mut materialization_guard) = match prepared_materialization {
3636            Some((overlay, guard)) => (Some(overlay), Some(guard)),
3637            None => (None, None),
3638        };
3639        let materialization_id = materialization_guard
3640            .as_ref()
3641            .and_then(SessionMaterializationGuard::id)
3642            .cloned();
3643        let mut environment_selection = SpawnPreparer::select_environment(self, 
3644            instance_id,
3645            &provider,
3646            mode,
3647            environment_profile,
3648        )?;
3649        // Station network overlays: Codex `-c network_access` and/or Claude
3650        // ProviderHome `--settings` sidecar (permits → allowedDomains).
3651        let provider_home = materialization_guard
3652            .as_ref()
3653            .and_then(SessionMaterializationGuard::provider_home);
3654        let network_overlay_args = self.resolve_network_allowlist_overlay_args(
3655            &provider,
3656            mode,
3657            approval_level,
3658            network_allowlist,
3659            provider_home,
3660        )?;
3661        let environment_overlay = match (environment_overlay.flatten(), network_overlay_args) {
3662            (prepared, args) if args.is_empty() => prepared,
3663            (Some(PreparedNativeLaunchOverlay::Instance(mut overlay)), args) => {
3664                overlay.append_extra_args(args).map_err(|_| {
3665                    failure(
3666                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3667                        "network allowlist overlay could not merge with instance launch args",
3668                    )
3669                })?;
3670                Some(PreparedNativeLaunchOverlay::Instance(overlay))
3671            }
3672            (Some(PreparedNativeLaunchOverlay::Environment(overlay)), args) => {
3673                Some(PreparedNativeLaunchOverlay::Instance(
3674                    overlay
3675                        .into_instance_with_extra_args(args)
3676                        .map_err(|_| {
3677                            failure(
3678                                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3679                                "network allowlist overlay could not merge with environment overlay",
3680                            )
3681                        })?,
3682                ))
3683            }
3684            (None, args) => Some(PreparedNativeLaunchOverlay::Instance(
3685                NativeInstanceLaunchOverlay::new(
3686                    provider.clone(),
3687                    match mode {
3688                        SessionMode::Pty => TransportKind::Pty,
3689                        SessionMode::Acp => TransportKind::Acp,
3690                        SessionMode::Inline => TransportKind::Pipe,
3691                    },
3692                    Vec::new(),
3693                    args,
3694                )
3695                .map_err(|_| {
3696                    failure(
3697                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3698                        "network allowlist overlay is invalid for this transport",
3699                    )
3700                })?,
3701            )),
3702        };
3703        let mut instance_overlay = environment_overlay
3704            .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
3705            .transpose()?
3706            .flatten();
3707        Ok(PreparedSpawn {
3708            instance_overlay,
3709            environment_selection,
3710            materialization_guard,
3711            harness_mcp_overlay,
3712            #[cfg(feature = "dig2-station-probe")]
3713            browser_station_lease_guard,
3714            materialization_id,
3715        })
3716    }
3717}
3718
3719struct SessionRecords {
3720    records: BTreeMap<SessionRecordId, ManagedSessionRecord>,
3721}
3722
3723struct SpawnIdempotencyCache {
3724    entries: BTreeMap<SpawnIdempotencyKey, SpawnIdempotencyEntry>,
3725}
3726
3727struct SpawnIdempotencyEntry {
3728    value: SpawnIdempotencyValue,
3729    expires_at: Instant,
3730}
3731
3732enum SpawnIdempotencyValue {
3733    Standard {
3734        spec: SpawnSpec,
3735        result: Result<ResolvedSpawnReceipt, NodeFailure>,
3736    },
3737    Managed {
3738        request: ManagedWorktreeSpawnRequest,
3739        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
3740    },
3741    HarnessMcp,
3742}
3743
3744enum DurableManagedSpawnReplayDecision {
3745    Reserved,
3746    PendingLinked,
3747    Committed(ManagedWorktreeSpawnReceipt),
3748}
3749
3750impl SpawnIdempotencyValue {
3751    fn references_context(&self, context_id: &SpawnContextId) -> bool {
3752        match self {
3753            Self::Standard { result, .. } => result
3754                .as_ref()
3755                .ok()
3756                .and_then(|receipt| receipt.context.as_ref()),
3757            Self::Managed { result, .. } => result
3758                .as_ref()
3759                .ok()
3760                .and_then(|receipt| receipt.spawn.context.as_ref()),
3761            Self::HarnessMcp => None,
3762        }
3763        .is_some_and(|receipt| &receipt.id == context_id)
3764    }
3765}
3766
3767#[derive(Clone, Copy)]
3768struct ControllerLease {
3769    connection_id: u64,
3770    expires_at: Instant,
3771}
3772
3773struct NodeEventHistory {
3774    last_sequence: u64,
3775    replay_floor_sequence: u64,
3776    events: VecDeque<NodeEventEnvelope>,
3777    record_providers: BTreeMap<SessionRecordId, AgentId>,
3778    removed_record_providers: BTreeMap<u64, AgentId>,
3779}
3780
3781impl NodeEventHistory {
3782    fn new(record_providers: BTreeMap<SessionRecordId, AgentId>) -> Self {
3783        Self {
3784            last_sequence: 0,
3785            replay_floor_sequence: 1,
3786            events: VecDeque::with_capacity(NODE_EVENT_HISTORY_MAX),
3787            record_providers,
3788            removed_record_providers: BTreeMap::new(),
3789        }
3790    }
3791}
3792
3793impl NodeShared {
3794    fn new_with_incarnation(
3795        handle: Gate4AgentHandle,
3796        access_token: String,
3797        node_id: NodeId,
3798        incarnation_id: NodeIncarnationId,
3799        workspaces: Vec<WorkspaceConfig>,
3800        enabled_providers: Vec<AgentId>,
3801        provider_runtime_statuses: ProviderRuntimeStatuses,
3802        provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
3803        provider_contracts: Vec<ProviderContractSupport>,
3804        provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
3805        spawn_profiles: SpawnProfileRegistry,
3806        native_launch_profile_control: Option<NativeLaunchProfileControl>,
3807        native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
3808        session_environment_materializer: Option<SessionEnvironmentMaterializer>,
3809        state_path: Option<PathBuf>,
3810        records: Vec<ManagedSessionRecord>,
3811        managed_worktrees: Vec<ManagedWorktreeLeaseRecord>,
3812        managed_worktree_tombstones: Vec<ManagedWorktreeLeaseRecord>,
3813        materializations: Vec<MaterializationOwnershipRecord>,
3814        persistence_warning: Option<String>,
3815        input_settle_timeout_ms: u64,
3816    ) -> Self {
3817        let (event_tx, _) = broadcast::channel(NODE_BROADCAST_CAPACITY);
3818        let (terminal_event_tx, _) = broadcast::channel(NODE_TERMINAL_BROADCAST_CAPACITY);
3819        let (agent_stream_event_tx, _) = broadcast::channel(NODE_AGENT_STREAM_BROADCAST_CAPACITY);
3820        let (harness_mcp_proxy_event_tx, _) =
3821            broadcast::channel(NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY);
3822        let record_providers = records
3823            .iter()
3824            .map(|record| (record.record_id.clone(), record.provider.clone()))
3825            .collect();
3826        let mut worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode> = workspaces
3827            .iter()
3828            .map(|workspace| {
3829                (workspace.workspace_id.clone(), workspace.worktree_service_mode)
3830            })
3831            .collect();
3832        for lease in &managed_worktrees {
3833            worktree_service_modes.insert(lease.workspace_id.clone(), WorktreeServiceMode::Off);
3834        }
3835        let managed_worktree_profiles = workspaces.iter()
3836            .map(|workspace| (
3837                workspace.workspace_id.clone(),
3838                workspace.managed_worktree_profiles.clone(),
3839            ))
3840            .collect();
3841        let mut managed_worktrees = ManagedWorktreeRegistry::from_records(
3842            managed_worktrees,
3843            managed_worktree_tombstones,
3844        ).expect("durable managed worktree registry was validated while loading");
3845        managed_worktrees.clear_stale_session_holders(incarnation_id, unix_time_ms());
3846        managed_worktrees.reattach_record_holders(
3847            &managed_worktree_record_holders(&records),
3848            unix_time_ms(),
3849        );
3850        Self {
3851            handle,
3852            access_token,
3853            node_id,
3854            incarnation_id,
3855            started_at_unix_ms: std::time::SystemTime::now()
3856                .duration_since(std::time::UNIX_EPOCH)
3857                .unwrap_or_default()
3858                .as_millis()
3859                .min(u64::MAX as u128) as u64,
3860            workspaces: RwLock::new(
3861                workspaces
3862                    .into_iter()
3863                    .map(|workspace| (workspace.workspace_id, workspace.canonical_root))
3864                    .collect(),
3865            ),
3866            worktree_service_modes,
3867            managed_worktree_profiles,
3868            managed_worktrees: Mutex::new(managed_worktrees),
3869            enabled_providers,
3870            provider_runtime_statuses,
3871            provider_runtime_status_updates: RwLock::new(BTreeMap::new()),
3872            provider_runtime_monitor,
3873            provider_contracts,
3874            provider_adapter_contracts,
3875            spawn_profiles,
3876            environment_profiles: RwLock::new(BTreeMap::new()),
3877            environment_materialization_profiles: RwLock::new(BTreeMap::new()),
3878            network_allowlist_catalog: RwLock::new(crate::network_allowlist_catalog::NetworkAllowlistCatalog::new()),
3879            #[cfg(feature = "dig2-station-probe")]
3880            browser_station_leases: crate::dig2_station_lease::new_lease_table(),
3881            bundle_catalog: RwLock::new(BundleCatalog::default()),
3882            delivery_store: Mutex::new(None),
3883            context_pack_store: Mutex::new(None),
3884            context_catalog: RwLock::new(ContextPackCatalog::default()),
3885            native_launch_profile_control,
3886            harness_mcp_registry: None,
3887            native_session_catalog,
3888            session_environment_materializer,
3889            materializations: Mutex::new(
3890                materializations
3891                    .into_iter()
3892                    .map(|record| (record.id().clone(), record))
3893                    .collect(),
3894            ),
3895            spawn_idempotency: Mutex::new(SpawnIdempotencyCache {
3896                entries: BTreeMap::new(),
3897            }),
3898            managed_spawn_replays: Mutex::new(BTreeMap::new()),
3899            controller: Mutex::new(None),
3900            history: Mutex::new(NodeEventHistory::new(record_providers)),
3901            event_tx,
3902            terminal_event_tx,
3903            agent_stream_event_tx,
3904            harness_mcp_proxy_event_tx,
3905            terminal_frame_watermarks: Mutex::new(BTreeMap::new()),
3906            next_connection_id: AtomicU64::new(1),
3907            next_instance_id: AtomicU64::new(1),
3908            next_command_id: AtomicU64::new(1),
3909            shutdown: AtomicBool::new(false),
3910            shutdown_notify: Notify::new(),
3911            preauth_slots: Arc::new(Semaphore::new(MAX_PREAUTH_CONNECTIONS)),
3912            authenticated_slots: Arc::new(Semaphore::new(MAX_AUTHENTICATED_CONNECTIONS)),
3913            inspection_slots: Arc::new(Semaphore::new(WORKSPACE_INSPECTION_MAX_CONCURRENCY)),
3914            mutation_gate: AsyncMutex::new(()),
3915            session_bindings: Mutex::new(BTreeMap::new()),
3916            session_records: Mutex::new(SessionRecords {
3917                records: records
3918                    .into_iter()
3919                    .map(|record| (record.record_id.clone(), record))
3920                    .collect(),
3921            }),
3922            state_path,
3923            persistence_error: RwLock::new(
3924                persistence_warning
3925                    .map(|message| session_registry::sanitized_persistence_summary(&message)),
3926            ),
3927            state_transaction: Mutex::new(()),
3928            input_settle_timeout_ms,
3929            drive_loop_profile: Mutex::new(DriveLoopProfiler::default()),
3930            runtime_tick_profile: Mutex::new(TickProfileSnapshot::default()),
3931            shell_efficiency_profile: OnceLock::new(),
3932            native_session_gauge: AtomicUsize::new(0),
3933            connection_loop_iterations: AtomicU64::new(0),
3934            connection_events_sent: AtomicU64::new(0),
3935            rejected_commands_total: AtomicU64::new(0),
3936            rejected_command_reasons: Mutex::new(BTreeMap::new()),
3937            drive_loop_iterations_total: AtomicU64::new(0),
3938            drive_loop_iterations_idle: AtomicU64::new(0),
3939            session_record_retention: SessionRecordRetentionConfig::default(),
3940            retired_records_total: AtomicU64::new(0),
3941            #[cfg(feature = "fixture")]
3942            fixture_semantic_hook_policy: false,
3943            #[cfg(feature = "fixture")]
3944            fixture_spawn_managed_worktree_v2_failure_probe:
3945                SpawnManagedWorktreeV2FailureProbe::default(),
3946        }
3947    }
3948
3949    #[cfg(test)]
3950    fn new(
3951        handle: Gate4AgentHandle,
3952        access_token: String,
3953        node_id: NodeId,
3954        workspaces: Vec<WorkspaceConfig>,
3955        enabled_providers: Vec<AgentId>,
3956    ) -> Self {
3957        Self::new_with_incarnation(
3958            handle,
3959            access_token,
3960            node_id,
3961            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
3962            workspaces,
3963            enabled_providers,
3964            ProviderRuntimeStatuses::default(),
3965            None,
3966            Vec::new(),
3967            Vec::new(),
3968            SpawnProfileRegistry::default(),
3969            None,
3970            None,
3971            None,
3972            None,
3973            Vec::new(),
3974            Vec::new(),
3975            Vec::new(),
3976            Vec::new(),
3977            None,
3978            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
3979        )
3980    }
3981
3982    fn persistence_error(&self) -> Option<String> {
3983        self.persistence_error
3984            .read()
3985            .unwrap_or_else(|poisoned| poisoned.into_inner())
3986            .clone()
3987    }
3988
3989    async fn admit_provider_runtime(
3990        &self,
3991        provider: &AgentId,
3992        requirement: ProviderRuntimeRequirement,
3993    ) -> Result<ProviderRuntimePolicy, NodeFailure> {
3994        let admission = if let Some(monitor) = self.provider_runtime_monitor.clone() {
3995            let pipe_transport = monitor.supports_pipe_transport(provider);
3996            let acp_transport = monitor.supports_acp_transport(provider);
3997            let provider = provider.clone();
3998            let refresh = tokio::task::spawn_blocking(move || monitor.evaluate(&provider));
3999            let (status, admission) = timeout(
4000                Duration::from_millis(PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS),
4001                refresh,
4002            )
4003            .await
4004            .map_err(|_| failure(
4005                NodeFailureCode::BackendBusy,
4006                "provider runtime probe exceeded its bounded deadline",
4007            ))?
4008            .map_err(|_| failure(
4009                NodeFailureCode::BackendOperationFailed,
4010                "provider runtime probe task failed",
4011            ))?;
4012            if let Some(status) = status {
4013                self
4014                    .provider_runtime_status_updates
4015                    .write()
4016                    .unwrap_or_else(|poisoned| poisoned.into_inner())
4017                    .insert(status.provider().clone(), status);
4018            }
4019            admission.and_then(|policy| {
4020                require_policy(policy, pipe_transport, acp_transport, requirement).map(|()| policy)
4021            })
4022        } else {
4023            // No live monitor means no catalog to consult -- there is
4024            // nothing here to confirm this provider declares a Pipe or ACP
4025            // transport, so Inline/Acp are rejected the same conservative
4026            // way an undeclared provider always was, rather than admitted
4027            // on trust.
4028            crate::provider_runtime::admit_status(
4029                &self.provider_runtime_statuses,
4030                provider,
4031                false,
4032                false,
4033                requirement,
4034            )
4035        };
4036        let admission = admission.map(|policy| self.admit_declared_hook_semantics(provider, policy));
4037        admission.map_err(|error| match error {
4038            ProviderRuntimeAdmissionError::LauncherUnavailable => failure(
4039                NodeFailureCode::BackendOperationFailed,
4040                "provider launcher is unavailable",
4041            ),
4042            ProviderRuntimeAdmissionError::SemanticCapabilityUnverified => failure(
4043                NodeFailureCode::UnsupportedCapability,
4044                "provider semantic capability is not verified",
4045            ),
4046            ProviderRuntimeAdmissionError::ProbeBusy => failure(
4047                NodeFailureCode::BackendBusy,
4048                "provider runtime probe is already in progress",
4049            ),
4050        })
4051    }
4052
4053    /// Whether the catalog declares a Pipe transport for `provider` --
4054    /// the fact `require_policy` needs to admit or reject
4055    /// `ProviderRuntimeRequirement::Inline`. With no live monitor there is no
4056    /// catalog to consult, so this stays conservative and rejects, matching
4057    /// `admit_provider_runtime`'s own no-monitor branch.
4058    fn provider_supports_pipe_transport(&self, provider: &AgentId) -> bool {
4059        match self.provider_runtime_monitor.as_deref() {
4060            Some(monitor) => monitor.supports_pipe_transport(provider),
4061            None => false,
4062        }
4063    }
4064
4065    /// Whether the catalog declares an ACP transport for `provider` -- the
4066    /// fact `require_policy` needs to admit or reject
4067    /// `ProviderRuntimeRequirement::Acp`. Mirrors
4068    /// `provider_supports_pipe_transport` exactly, including the
4069    /// conservative no-monitor default.
4070    fn provider_supports_acp_transport(&self, provider: &AgentId) -> bool {
4071        match self.provider_runtime_monitor.as_deref() {
4072            Some(monitor) => monitor.supports_acp_transport(provider),
4073            None => false,
4074        }
4075    }
4076
4077    /// Grants hook ingestion to a provider whose catalog entry declares a
4078    /// Hook adapter, whatever runtime mode its launcher probe landed in.
4079    ///
4080    /// `admit_status`'s `RawPassthrough` arm hands back
4081    /// `ProviderRuntimePolicy::raw_pty()`, which grants nothing semantic --
4082    /// correct for everything it was written to answer, and wrong for hooks
4083    /// specifically. A hook event is not inferred from terminal behaviour,
4084    /// so it has nothing to do with whether a launcher probe verified this
4085    /// CLI version's PTY contract; the authority is the catalog declaring
4086    /// the adapter plus the ingress authenticating the route. Without this
4087    /// the node installs a provider's hooks, the CLI faithfully calls them,
4088    /// and every event is refused -- which is exactly the state grok was in.
4089    ///
4090    /// Applied to both admission paths so the monitor's own derivation and
4091    /// this coarse fallback cannot disagree about the same provider.
4092    fn admit_declared_hook_semantics(
4093        &self,
4094        provider: &AgentId,
4095        policy: ProviderRuntimePolicy,
4096    ) -> ProviderRuntimePolicy {
4097        if policy.hook_semantics || !policy.raw_pty_lifecycle {
4098            return policy;
4099        }
4100        let declares_hook = self.provider_adapter_contracts.iter().any(|contract| {
4101            contract.provider == *provider && contract.family == AdapterFamily::Hook
4102        });
4103        if !declares_hook {
4104            return policy;
4105        }
4106        ProviderRuntimePolicy::new(
4107            policy.raw_pty_lifecycle,
4108            policy.semantic_readiness,
4109            policy.structured_prompt,
4110            policy.provider_session_identity,
4111            policy.semantic_resume,
4112            true,
4113        )
4114        .expect("granting hook semantics over a raw-PTY policy is internally valid")
4115    }
4116
4117    /// Station network allowlist → provider launch overlay argv.
4118    ///
4119    /// - **Codex**: first-slice `-c sandbox_workspace_write.network_access` from
4120    ///   `provider_native` under Moderate (PTY or ACP).
4121    /// - **Claude**: catalog `permits` → ProviderHome sidecar settings JSON +
4122    ///   documented `--settings <path>` on **PTY** only (Linux/macOS/WSL2).
4123    ///   ACP refuses (no measured ACP settings-path surface). Native Windows
4124    ///   refuses (no vendor Bash sandbox). Membership-only (empty permits) =
4125    ///   no overlay.
4126    /// - **Kimi / Grok**: no first-party settings/argv network surface here —
4127    ///   empty overlay when no `provider_native`; invented native keys already
4128    ///   refuse at load / resolve.
4129    ///
4130    /// Empty when no allowlist / nothing to map. Never cookies / secrets.
4131    fn resolve_network_allowlist_overlay_args(
4132        &self,
4133        provider: &AgentId,
4134        mode: SessionMode,
4135        approval_level: ApprovalLevel,
4136        network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4137        provider_home: Option<&Path>,
4138    ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4139        let Some(allowlist_id) = network_allowlist else {
4140            return Ok(Vec::new());
4141        };
4142        let catalog = self
4143            .network_allowlist_catalog
4144            .read()
4145            .unwrap_or_else(|poisoned| poisoned.into_inner());
4146        let Some(entry) = catalog.get(allowlist_id) else {
4147            return Err(failure(
4148                NodeFailureCode::UnknownNetworkAllowlist,
4149                "spawn network allowlist is unavailable on this node",
4150            ));
4151        };
4152
4153        // Claude: settings-shaped overlay from permits (not provider_native).
4154        if provider.as_str() == "claude" && !entry.permits.is_empty() {
4155            crate::network_allowlist_catalog::claude_bash_sandbox_network_os_supported().map_err(
4156                |message| {
4157                    failure(
4158                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4159                        &message,
4160                    )
4161                },
4162            )?;
4163            if !matches!(mode, SessionMode::Pty) {
4164                return Err(failure(
4165                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4166                    "Claude sandbox.network --settings overlay requires PTY transport; ACP has no measured settings-path surface (refuse rather than invent RPC)",
4167                ));
4168            }
4169            let Some(home) = provider_home else {
4170                return Err(failure(
4171                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4172                    "Claude sandbox.network --settings overlay requires ProviderHome materialization (CLAUDE_CONFIG_DIR)",
4173                ));
4174            };
4175            let domains =
4176                crate::network_allowlist_catalog::claude_allowed_domains_from_permits(&entry.permits);
4177            if domains.is_empty() {
4178                return Err(failure(
4179                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4180                    "Claude sandbox.network overlay could not map catalog permits to allowedDomains",
4181                ));
4182            }
4183            let settings_path = home.join(
4184                crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
4185            );
4186            let json =
4187                crate::network_allowlist_catalog::claude_station_network_settings_json(&domains);
4188            crate::session_environment::secure_create_file(&settings_path, json.as_bytes()).map_err(
4189                |_| {
4190                    failure(
4191                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4192                        "Claude station network settings sidecar could not be written under ProviderHome",
4193                    )
4194                },
4195            )?;
4196            let overlay =
4197                crate::network_allowlist_catalog::claude_settings_network_overlay_args(&settings_path);
4198            return Ok(overlay.into_iter().map(std::ffi::OsString::from).collect());
4199        }
4200
4201        let Some(native) = entry.provider_native.as_ref() else {
4202            return Ok(Vec::new());
4203        };
4204        let overlay = crate::network_allowlist_catalog::resolve_provider_native_launch_overlay(
4205            provider.as_str(),
4206            approval_level,
4207            native,
4208        )
4209        .map_err(|message| {
4210            failure(
4211                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4212                &message,
4213            )
4214        })?;
4215        if overlay.is_empty() {
4216            return Ok(Vec::new());
4217        }
4218        if !matches!(mode, SessionMode::Pty | SessionMode::Acp) {
4219            return Err(failure(
4220                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4221                "Codex network_access -c overlay requires PTY or ACP transport",
4222            ));
4223        }
4224        Ok(overlay.into_iter().map(std::ffi::OsString::from).collect())
4225    }
4226
4227    /// Backward-compatible alias used by Codex-focused unit tests.
4228    fn resolve_codex_network_access_overlay_args(
4229        &self,
4230        provider: &AgentId,
4231        mode: SessionMode,
4232        approval_level: ApprovalLevel,
4233        network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4234    ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4235        self.resolve_network_allowlist_overlay_args(
4236            provider,
4237            mode,
4238            approval_level,
4239            network_allowlist,
4240            None,
4241        )
4242    }
4243
4244    fn resolve_environment_profile(
4245        &self,
4246        resolved: &ResolvedSpawnSpec,
4247    ) -> Result<Option<ResolvedEnvironmentProfileReceipt>, NodeFailure> {
4248        // Station network allowlist catalog enforce (plan
4249        // station-network-and-browser-profile-knobs §2.1 / §4): empty-default
4250        // catalog — unknown ids refuse rather than silent ambient. Runs even
4251        // when no environment_profile_id is present so a bare allowlist still
4252        // cannot bypass the catalog. Never cookies / OAuth / proxy credentials.
4253        if let Some(allowlist_id) = resolved.network_allowlist.as_ref() {
4254            let catalog = self
4255                .network_allowlist_catalog
4256                .read()
4257                .unwrap_or_else(|poisoned| poisoned.into_inner());
4258            let Some(entry) = catalog.get(allowlist_id) else {
4259                return Err(failure(
4260                    NodeFailureCode::UnknownNetworkAllowlist,
4261                    "spawn network allowlist is unavailable on this node",
4262                ));
4263            };
4264            // Track B: refuse provider-native mappings this provider cannot
4265            // honor (e.g. codex_network_access on Claude). Never silent ambient.
4266            if let Some(native) = entry.provider_native.as_ref() {
4267                if let Err(message) = crate::network_allowlist_catalog::provider_native_mapping_supported(
4268                    resolved.provider.as_str(),
4269                    resolved.approval_level,
4270                    native,
4271                ) {
4272                    return Err(failure(
4273                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4274                        &message,
4275                    ));
4276                }
4277            }
4278        }
4279        // Station knobs Track A (dig2browser-station-probe-and-network-permit-set):
4280        // when feature `dig2-station-probe` is on and browser_profile_id is set,
4281        // cheap named-pipe path reachability must succeed (Windows
4282        // `\\.\pipe\{suffix}`); platform-unsupported or unreachable → clear
4283        // NodeFailureCode. Runs even without environment_profile_id (same
4284        // placement spirit as allowlist catalog). Feature off keeps stub echo.
4285        // Never cookies / OAuth / proxy credentials. Exclusive lease is
4286        // acquired later in spawn_session_with_deadline (after instance id).
4287        #[cfg(feature = "dig2-station-probe")]
4288        if resolved.browser_profile_id.is_some() {
4289            use crate::dig2_station_probe::{probe_station_reachable, StationProbeError};
4290            match probe_station_reachable() {
4291                Ok(()) => {}
4292                Err(StationProbeError::UnsupportedPlatform)
4293                | Err(StationProbeError::InvalidPipeSuffix) => {
4294                    return Err(failure(
4295                        NodeFailureCode::BrowserStationProbeUnavailable,
4296                        "dig2browser station probe unavailable on this platform",
4297                    ));
4298                }
4299                Err(StationProbeError::Unreachable) => {
4300                    return Err(failure(
4301                        NodeFailureCode::BrowserStationUnreachable,
4302                        "dig2browser station named-pipe is unreachable",
4303                    ));
4304                }
4305            }
4306        }
4307        let Some(profile_id) = resolved.environment_profile_id.as_ref() else {
4308            return Ok(None);
4309        };
4310        let profiles = self
4311            .environment_profiles
4312            .read()
4313            .unwrap_or_else(|poisoned| poisoned.into_inner());
4314        let binding = profiles.get(profile_id).ok_or_else(|| {
4315            failure(
4316                NodeFailureCode::UnknownEnvironmentProfile,
4317                "spawn environment profile is unavailable on this node",
4318            )
4319        })?;
4320        if binding.provider != resolved.provider
4321            || binding.native_profile_id(resolved.mode).is_none()
4322        {
4323            return Err(failure(
4324                NodeFailureCode::EnvironmentProfileBindingMismatch,
4325                "spawn environment profile does not match the provider and mode",
4326            ));
4327        }
4328        // Echo opaque network_allowlist / browser_profile_id onto the receipt
4329        // after catalog + optional dig2 probe above. Empty/whitespace ids
4330        // already refuse at Spawn*Id construction.
4331        Ok(Some(ResolvedEnvironmentProfileReceipt {
4332            profile_id: binding.id.clone(),
4333            profile_revision: binding.revision.clone(),
4334            network_allowlist: resolved.network_allowlist.clone(),
4335            browser_profile_id: resolved.browser_profile_id.clone(),
4336        }))
4337    }
4338
4339    fn resolve_bundle(
4340        &self,
4341        resolved: &ResolvedSpawnSpec,
4342        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4343    ) -> Result<Option<ResolvedBundleReceipt>, NodeFailure> {
4344        let Some(bundle_id) = resolved.bundle_id.as_ref() else {
4345            return Ok(None);
4346        };
4347        let catalog = self.bundle_catalog
4348            .read()
4349            .unwrap_or_else(|poisoned| poisoned.into_inner());
4350        let bundle = catalog.get(bundle_id).ok_or_else(|| {
4351                failure(
4352                    NodeFailureCode::UnknownBundle,
4353                    "spawn bundle is unavailable on this node",
4354                )
4355            })?;
4356        self.bundle_layout(
4357            &resolved.provider,
4358            resolved.mode,
4359            environment_profile,
4360            bundle,
4361        )?;
4362        Ok(Some(bundle.receipt()))
4363    }
4364
4365    fn begin_delivery_stage(
4366        &self,
4367        manifest: DeliveryBundleManifestV2,
4368    ) -> Result<NodeResponse, NodeFailure> {
4369        let mut store = self
4370            .delivery_store
4371            .lock()
4372            .unwrap_or_else(|poisoned| poisoned.into_inner());
4373        let store = store.as_mut().ok_or_else(|| {
4374            failure(
4375                NodeFailureCode::DeliveryStageStorageFailed,
4376                "node delivery store is unavailable",
4377            )
4378        })?;
4379        let begun = store.begin(manifest).map_err(delivery_failure)?;
4380        Ok(NodeResponse::DeliveryStageBegun {
4381            stage_id: begun.stage_id,
4382            manifest_digest: begun.manifest_digest,
4383            missing_blobs: begun.missing_blobs,
4384        })
4385    }
4386
4387    fn put_delivery_blob_chunk(
4388        &self,
4389        stage_id: DeliveryStageId,
4390        blob_digest: DeliveryBlobDigestV1,
4391        offset: u64,
4392        chunk_hex: DeliveryBlobChunkHexV1,
4393    ) -> Result<NodeResponse, NodeFailure> {
4394        let mut store = self
4395            .delivery_store
4396            .lock()
4397            .unwrap_or_else(|poisoned| poisoned.into_inner());
4398        let store = store.as_mut().ok_or_else(|| {
4399            failure(
4400                NodeFailureCode::DeliveryStageStorageFailed,
4401                "node delivery store is unavailable",
4402            )
4403        })?;
4404        let accepted = store
4405            .put_chunk(&stage_id, &blob_digest, offset, &chunk_hex.decode())
4406            .map_err(delivery_failure)?;
4407        Ok(NodeResponse::DeliveryBlobChunkAccepted {
4408            stage_id: accepted.stage_id,
4409            blob_digest: accepted.blob_digest,
4410            next_offset: accepted.next_offset,
4411        })
4412    }
4413
4414    fn commit_delivery_stage(
4415        &self,
4416        stage_id: DeliveryStageId,
4417    ) -> Result<DeliveryCommitReceiptV1, NodeFailure> {
4418        let mut store = self
4419            .delivery_store
4420            .lock()
4421            .unwrap_or_else(|poisoned| poisoned.into_inner());
4422        let store = store.as_mut().ok_or_else(|| {
4423            failure(
4424                NodeFailureCode::DeliveryStageStorageFailed,
4425                "node delivery store is unavailable",
4426            )
4427        })?;
4428        let prepared = store.prepare_commit(&stage_id).map_err(delivery_failure)?;
4429        let mut catalog = self
4430            .bundle_catalog
4431            .write()
4432            .unwrap_or_else(|poisoned| poisoned.into_inner());
4433        let mut next_catalog = catalog.clone();
4434        next_catalog
4435            .insert_idempotent(prepared.bundle().clone())
4436            .map_err(|_| {
4437                failure(
4438                    NodeFailureCode::DeliveryStageConflict,
4439                    "delivery bundle identity conflicts with installed content",
4440                )
4441            })?;
4442        let receipt = store.publish_commit(prepared).map_err(delivery_failure)?;
4443        *catalog = next_catalog;
4444        Ok(receipt)
4445    }
4446
4447    fn abort_delivery_stage(
4448        &self,
4449        stage_id: &DeliveryStageId,
4450    ) -> Result<(), NodeFailure> {
4451        let mut store = self
4452            .delivery_store
4453            .lock()
4454            .unwrap_or_else(|poisoned| poisoned.into_inner());
4455        let store = store.as_mut().ok_or_else(|| {
4456            failure(
4457                NodeFailureCode::DeliveryStageStorageFailed,
4458                "node delivery store is unavailable",
4459            )
4460        })?;
4461        store.abort(stage_id).map_err(delivery_failure)
4462    }
4463
4464    fn resolve_context(
4465        &self,
4466        resolved: &ResolvedSpawnSpec,
4467    ) -> Result<Option<ResolvedContextPackReceipt>, NodeFailure> {
4468        let Some(context_id) = resolved.context_id.as_ref() else {
4469            return Ok(None);
4470        };
4471        self.context_catalog
4472            .read()
4473            .unwrap_or_else(|poisoned| poisoned.into_inner())
4474            .get(context_id)
4475            .map(|context| context.receipt().clone())
4476            .ok_or_else(|| {
4477                failure(
4478                    NodeFailureCode::UnknownContextPack,
4479                    "spawn context pack is unavailable on this node",
4480                )
4481            })
4482            .map(Some)
4483    }
4484
4485    fn bundle_layout(
4486        &self,
4487        provider: &AgentId,
4488        mode: SessionMode,
4489        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4490        bundle: &NodeBundle,
4491    ) -> Result<BundleProviderLayout, NodeFailure> {
4492        let layout = validate_bundle_binding(provider, mode, bundle).map_err(|_| {
4493            failure(
4494                NodeFailureCode::BundleBindingMismatch,
4495                "spawn bundle does not match the provider and mode",
4496            )
4497        })?;
4498        if layout != BundleProviderLayout::Codex {
4499            return Ok(layout);
4500        }
4501        let profile = environment_profile.and_then(|receipt| {
4502            let profiles = self
4503                .environment_profiles
4504                .read()
4505                .unwrap_or_else(|poisoned| poisoned.into_inner());
4506            let binding = profiles.get(&receipt.profile_id)?;
4507            if binding.revision != receipt.profile_revision
4508                || &binding.provider != provider
4509                || binding.native_profile_id(mode).is_none()
4510            {
4511                return None;
4512            }
4513            self.environment_materialization_profiles
4514                .read()
4515                .unwrap_or_else(|poisoned| poisoned.into_inner())
4516                .get(&receipt.profile_id)
4517                .cloned()
4518        });
4519        if !profile.is_some_and(|profile| profile.supports_bundle_layout(layout)) {
4520            return Err(failure(
4521                NodeFailureCode::BundleBindingMismatch,
4522                "Codex bundle requires an exact isolated CODEX_HOME profile",
4523            ));
4524        }
4525        Ok(layout)
4526    }
4527
4528    fn select_environment_profile(
4529        &self,
4530        instance_id: AgentInstanceId,
4531        provider: &AgentId,
4532        mode: SessionMode,
4533        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4534    ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
4535        let Some(environment_profile) = environment_profile else {
4536            return Ok(None);
4537        };
4538        let native_profile_id = {
4539            let profiles = self
4540                .environment_profiles
4541                .read()
4542                .unwrap_or_else(|poisoned| poisoned.into_inner());
4543            let binding = profiles
4544                .get(&environment_profile.profile_id)
4545                .ok_or_else(|| {
4546                    failure(
4547                        NodeFailureCode::UnknownEnvironmentProfile,
4548                        "session environment profile is unavailable on this node",
4549                    )
4550                })?;
4551            if &binding.revision != &environment_profile.profile_revision
4552                || &binding.provider != provider
4553            {
4554                return Err(failure(
4555                    NodeFailureCode::EnvironmentProfileBindingMismatch,
4556                    "session environment profile revision or provider changed",
4557                ));
4558            }
4559            binding.native_profile_id(mode).cloned().ok_or_else(|| {
4560                failure(
4561                    NodeFailureCode::EnvironmentProfileBindingMismatch,
4562                    "session environment profile does not support this mode",
4563                )
4564            })?
4565        };
4566        let control = self.native_launch_profile_control.clone().ok_or_else(|| {
4567            failure(
4568                NodeFailureCode::BackendOperationFailed,
4569                "native environment profile controller is unavailable",
4570            )
4571        })?;
4572        control
4573            .select_native_launch_profile(instance_id, native_profile_id)
4574            .map_err(|_| {
4575                failure(
4576                    NodeFailureCode::BackendOperationFailed,
4577                    "native environment profile selection failed",
4578                )
4579            })?;
4580        Ok(Some(NativeEnvironmentSelectionGuard {
4581            control: Some(control),
4582            instance_id,
4583        }))
4584    }
4585
4586    fn install_prepared_launch_overlay(
4587        &self,
4588        instance_id: AgentInstanceId,
4589        overlay: PreparedNativeLaunchOverlay,
4590    ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
4591        let control = self.native_launch_profile_control.clone().ok_or_else(|| {
4592            failure(
4593                NodeFailureCode::BackendOperationFailed,
4594                "native launch profile controller is unavailable",
4595            )
4596        })?;
4597        match overlay {
4598            PreparedNativeLaunchOverlay::Environment(overlay) => {
4599                control
4600                    .install_native_launch_environment_overlay(instance_id, overlay)
4601                    .map_err(|_| failure(
4602                        NodeFailureCode::BackendOperationFailed,
4603                        "session environment overlay installation failed",
4604                    ))?;
4605                Ok(None)
4606            }
4607            PreparedNativeLaunchOverlay::Instance(overlay) => {
4608                control
4609                    .install_native_instance_launch_overlay(instance_id, overlay)
4610                    .map_err(|_| failure(
4611                        NodeFailureCode::BundleBindingMismatch,
4612                        "session bundle launch overlay installation failed",
4613                    ))?;
4614                Ok(Some(NativeInstanceOverlayGuard {
4615                    control: Some(control),
4616                    instance_id,
4617                }))
4618            }
4619        }
4620    }
4621
4622    fn environment_materialization_profile(
4623        &self,
4624        environment_profile: &ResolvedEnvironmentProfileReceipt,
4625    ) -> Option<NodeSessionMaterializationProfile> {
4626        self.environment_materialization_profiles
4627            .read()
4628            .unwrap_or_else(|poisoned| poisoned.into_inner())
4629            .get(&environment_profile.profile_id)
4630            .cloned()
4631    }
4632
4633    fn materialization_profile(
4634        &self,
4635        provider: &AgentId,
4636        mode: SessionMode,
4637        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4638        bundle: Option<&ResolvedBundleReceipt>,
4639        context: Option<&ResolvedContextPackReceipt>,
4640    ) -> Result<Option<(
4641        NodeSessionMaterializationProfile,
4642        bool,
4643        Option<BundleProviderLayout>,
4644    )>, NodeFailure> {
4645        let environment = environment_profile
4646            .and_then(|receipt| self.environment_materialization_profile(receipt));
4647        let (bundle, bundle_layout) = match bundle {
4648            Some(receipt) => {
4649                let catalog = self.bundle_catalog.read()
4650                    .unwrap_or_else(|poisoned| poisoned.into_inner());
4651                let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
4652                    NodeFailureCode::UnknownBundle,
4653                    "session bundle is unavailable on this node",
4654                ))?;
4655                if installed.receipt() != *receipt {
4656                    return Err(failure(
4657                        NodeFailureCode::BundleBindingMismatch,
4658                        "session bundle revision or digest changed",
4659                    ));
4660                }
4661                let layout = self.bundle_layout(
4662                    provider,
4663                    mode,
4664                    environment_profile,
4665                    installed,
4666                )?;
4667                (Some(installed.clone()), Some(layout))
4668            }
4669            None => (None, None),
4670        };
4671        let has_environment_overlay = environment.is_some() || context.is_some();
4672        let profile = match (environment, bundle.as_ref(), bundle_layout) {
4673            (None, None, None) => None,
4674            (Some(profile), None, None) => Some(Ok(profile)),
4675            (None, Some(bundle), Some(layout)) => {
4676                Some(NodeSessionMaterializationProfile::from_bundle(bundle, layout))
4677            }
4678            (Some(profile), Some(bundle), Some(layout)) => {
4679                Some(profile.with_bundle(bundle, layout))
4680            }
4681            _ => {
4682                return Err(failure(
4683                    NodeFailureCode::BundleBindingMismatch,
4684                    "session bundle provider layout is unavailable",
4685                ));
4686            }
4687        };
4688        let profile = profile
4689            .transpose()
4690            .map_err(|_| failure(
4691                NodeFailureCode::BundleMaterializationFailed,
4692                "session bundle materialization profile is invalid",
4693            ))?;
4694        let profile = match (profile, context) {
4695            (Some(profile), Some(receipt)) => {
4696                let catalog = self.context_catalog
4697                    .read()
4698                    .unwrap_or_else(|poisoned| poisoned.into_inner());
4699                let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
4700                    NodeFailureCode::UnknownContextPack,
4701                    "session context pack is unavailable on this node",
4702                ))?;
4703                if pack.receipt() != receipt {
4704                    return Err(failure(
4705                        NodeFailureCode::ContextPackMaterializationFailed,
4706                        "session context pack identity changed",
4707                    ));
4708                }
4709                profile.with_context(pack)
4710            }
4711            (None, Some(receipt)) => {
4712                let catalog = self.context_catalog
4713                    .read()
4714                    .unwrap_or_else(|poisoned| poisoned.into_inner());
4715                let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
4716                    NodeFailureCode::UnknownContextPack,
4717                    "session context pack is unavailable on this node",
4718                ))?;
4719                if pack.receipt() != receipt {
4720                    return Err(failure(
4721                        NodeFailureCode::ContextPackMaterializationFailed,
4722                        "session context pack identity changed",
4723                    ));
4724                }
4725                NodeSessionMaterializationProfile::from_context(pack)
4726            }
4727            (Some(profile), None) => Ok(profile),
4728            (None, None) => return Ok(None),
4729        }
4730        .map_err(|_| failure(
4731            NodeFailureCode::ContextPackMaterializationFailed,
4732            "session context pack materialization profile is invalid",
4733        ))?;
4734        Ok(Some((profile, has_environment_overlay, bundle_layout)))
4735    }
4736
4737    fn prepare_session_materialization<'a>(
4738        &'a self,
4739        address: &SessionAddress,
4740        provider: &AgentId,
4741        mode: SessionMode,
4742        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4743        bundle: Option<&ResolvedBundleReceipt>,
4744        context: Option<&ResolvedContextPackReceipt>,
4745        managed_lease_id: Option<ManagedWorktreeLeaseId>,
4746    ) -> Result<Option<(Option<PreparedNativeLaunchOverlay>, SessionMaterializationGuard<'a>)>, NodeFailure> {
4747        let Some((profile, has_environment_overlay, bundle_layout)) =
4748            self.materialization_profile(provider, mode, environment_profile, bundle, context)?
4749        else {
4750            return Ok(None);
4751        };
4752        let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
4753            failure(
4754                NodeFailureCode::BackendOperationFailed,
4755                "session environment materializer is unavailable",
4756            )
4757        })?;
4758        let materialization_failure_code = if context.is_some() {
4759            NodeFailureCode::ContextPackMaterializationFailed
4760        } else if bundle.is_some() {
4761            NodeFailureCode::BundleMaterializationFailed
4762        } else {
4763            NodeFailureCode::BackendOperationFailed
4764        };
4765        let id = MaterializationId::new(format!(
4766            "mat-{}-{}-{}",
4767            self.incarnation_id,
4768            address.session.instance_id.0,
4769            address.session.generation.0,
4770        ))
4771        .map_err(|_| {
4772            failure(
4773                NodeFailureCode::BackendOperationFailed,
4774                "session environment materialization identity failed",
4775            )
4776        })?;
4777        let owner = MaterializationOwner::Session {
4778            incarnation_id: self.incarnation_id,
4779            instance_id: address.session.instance_id,
4780            generation: address.session.generation,
4781        };
4782        let mut ownership = materializer
4783            .begin(
4784                id.clone(),
4785                environment_profile.cloned(),
4786                bundle.cloned(),
4787                context.cloned(),
4788                owner,
4789                managed_lease_id,
4790                &profile,
4791                unix_time_ms(),
4792            )
4793            .map_err(|_| {
4794                failure(
4795                    materialization_failure_code,
4796                    "session materialization preparation failed",
4797                )
4798            })?;
4799        {
4800            let _transaction = self
4801                .state_transaction
4802                .lock()
4803                .unwrap_or_else(|poisoned| poisoned.into_inner());
4804            let duplicate = self
4805                .materializations
4806                .lock()
4807                .unwrap_or_else(|poisoned| poisoned.into_inner())
4808                .insert(id.clone(), ownership.clone());
4809            if duplicate.is_some() {
4810                return Err(failure(
4811                    NodeFailureCode::BackendOperationFailed,
4812                    "session environment materialization identity conflict",
4813                ));
4814            }
4815            if let Err(error) = self.persist_state_locked() {
4816                self.materializations
4817                    .lock()
4818                    .unwrap_or_else(|poisoned| poisoned.into_inner())
4819                    .remove(&id);
4820                return Err(persistence_failure(error));
4821            }
4822        }
4823        let environment = match materializer.materialize(&mut ownership, &profile, unix_time_ms()) {
4824            Ok(environment) => environment,
4825            Err(_) => {
4826                let transaction = self
4827                    .state_transaction
4828                    .lock()
4829                    .unwrap_or_else(|poisoned| poisoned.into_inner());
4830                self.materializations
4831                    .lock()
4832                    .unwrap_or_else(|poisoned| poisoned.into_inner())
4833                    .insert(id.clone(), ownership.clone());
4834                if self.persist_state_locked().is_err() {
4835                    self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
4836                }
4837                drop(transaction);
4838                if ownership.state() == MaterializationState::CleanupRequired {
4839                    let _ = self.cleanup_materialization(&id);
4840                }
4841                return Err(failure(
4842                    materialization_failure_code,
4843                    "session materialization failed",
4844                ));
4845            }
4846        };
4847        {
4848            let _transaction = self
4849                .state_transaction
4850                .lock()
4851                .unwrap_or_else(|poisoned| poisoned.into_inner());
4852            self.materializations
4853                .lock()
4854                .unwrap_or_else(|poisoned| poisoned.into_inner())
4855                .insert(id.clone(), ownership.clone());
4856            if let Err(error) = self.persist_state_locked() {
4857                drop(_transaction);
4858                let _ = self.cleanup_materialization(&id);
4859                return Err(persistence_failure(error));
4860            }
4861        }
4862        let guard = SessionMaterializationGuard {
4863            shared: self,
4864            id: Some(id),
4865            provider_home: Some(ownership.provider_home().to_path_buf()),
4866        };
4867        let bundle_arguments = match bundle {
4868            Some(receipt) => {
4869                let catalog = self.bundle_catalog.read()
4870                    .unwrap_or_else(|poisoned| poisoned.into_inner());
4871                let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
4872                    NodeFailureCode::UnknownBundle,
4873                    "session bundle is unavailable on this node",
4874                ))?;
4875                if installed.receipt() != *receipt {
4876                    return Err(failure(
4877                        NodeFailureCode::BundleBindingMismatch,
4878                        "session bundle revision or digest changed",
4879                    ));
4880                }
4881                let layout = bundle_layout.ok_or_else(|| failure(
4882                    NodeFailureCode::BundleBindingMismatch,
4883                    "materialized bundle provider layout is unavailable",
4884                ))?;
4885                materializer.revalidate_bundle(&ownership, installed, layout).map_err(|_| {
4886                    failure(
4887                        NodeFailureCode::BundleMaterializationFailed,
4888                        "session bundle byte revalidation failed",
4889                    )
4890                })?;
4891                Some(bundle_launch_arguments(
4892                    layout,
4893                    ownership.bundle_root(),
4894                    ownership.provider_home(),
4895                ).map_err(|_| failure(
4896                    NodeFailureCode::BundleBindingMismatch,
4897                    "session bundle launch binding is invalid",
4898                ))?)
4899            }
4900            None => None,
4901        };
4902        if let Some(receipt) = context {
4903            materializer
4904                .revalidate_context(&ownership, receipt)
4905                .map_err(|_| failure(
4906                    NodeFailureCode::ContextPackMaterializationFailed,
4907                    "session context pack byte revalidation failed",
4908                ))?;
4909        }
4910        let transport = match mode {
4911            SessionMode::Pty => TransportKind::Pty,
4912            SessionMode::Inline => TransportKind::Pipe,
4913            SessionMode::Acp => TransportKind::Acp,
4914        };
4915        let overlay = if let Some(extra_args) = bundle_arguments {
4916            Some(PreparedNativeLaunchOverlay::Instance(
4917                NativeInstanceLaunchOverlay::new(
4918                    provider.clone(),
4919                    transport,
4920                    if has_environment_overlay {
4921                        environment
4922                    } else {
4923                        Vec::new()
4924                    },
4925                    extra_args,
4926                )
4927                .map_err(|_| {
4928                    failure(
4929                        NodeFailureCode::BundleBindingMismatch,
4930                        "session bundle launch overlay is invalid",
4931                    )
4932                })?,
4933            ))
4934        } else if has_environment_overlay {
4935            let context_only = environment_profile.is_none()
4936                && bundle.is_none()
4937                && context.is_some();
4938            let overlay = if context_only {
4939                NativeLaunchEnvironmentOverlay::new_context_root(
4940                    provider.clone(),
4941                    transport,
4942                    environment,
4943                )
4944                .map_err(|_| {
4945                    failure(
4946                        NodeFailureCode::ContextPackMaterializationFailed,
4947                        "context-only launch overlay is invalid",
4948                    )
4949                })?
4950            } else {
4951                NativeLaunchEnvironmentOverlay::new(
4952                    provider.clone(),
4953                    transport,
4954                    environment,
4955                )
4956                .map_err(|_| {
4957                    failure(
4958                        NodeFailureCode::BackendOperationFailed,
4959                        "session environment overlay is invalid",
4960                    )
4961                })?
4962            };
4963            Some(PreparedNativeLaunchOverlay::Environment(overlay))
4964        } else {
4965            None
4966        };
4967        Ok(Some((overlay, guard)))
4968    }
4969
4970    fn cleanup_materialization(&self, id: &MaterializationId) -> Result<(), NodeFailure> {
4971        let Some(mut ownership) = self
4972            .materializations
4973            .lock()
4974            .unwrap_or_else(|poisoned| poisoned.into_inner())
4975            .get(id)
4976            .cloned()
4977        else {
4978            return Ok(());
4979        };
4980        if ownership.state() != MaterializationState::CleanupRequired {
4981            ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
4982                failure(
4983                    NodeFailureCode::BackendOperationFailed,
4984                    "session environment cleanup state is invalid",
4985                )
4986            })?;
4987        }
4988        {
4989            let _transaction = self
4990                .state_transaction
4991                .lock()
4992                .unwrap_or_else(|poisoned| poisoned.into_inner());
4993            let previous = self
4994                .materializations
4995                .lock()
4996                .unwrap_or_else(|poisoned| poisoned.into_inner())
4997                .insert(id.clone(), ownership.clone());
4998            if let Err(error) = self.persist_state_locked() {
4999                let mut materializations = self
5000                    .materializations
5001                    .lock()
5002                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5003                if let Some(previous) = previous {
5004                    materializations.insert(id.clone(), previous);
5005                }
5006                return Err(persistence_failure(error));
5007            }
5008        }
5009        let cleanup = self
5010            .session_environment_materializer
5011            .as_ref()
5012            .ok_or(())
5013            .and_then(|materializer| materializer.cleanup(&ownership).map_err(|_| ()));
5014        if cleanup.is_err() {
5015            let _ = ownership.mark_recovery_required(unix_time_ms());
5016            self.materializations
5017                .lock()
5018                .unwrap_or_else(|poisoned| poisoned.into_inner())
5019                .insert(id.clone(), ownership);
5020            if self.persist_state().is_err() {
5021                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5022            }
5023            return Err(failure(
5024                NodeFailureCode::BackendOperationFailed,
5025                "session environment cleanup requires recovery",
5026            ));
5027        }
5028        let _transaction = self
5029            .state_transaction
5030            .lock()
5031            .unwrap_or_else(|poisoned| poisoned.into_inner());
5032        self.materializations
5033            .lock()
5034            .unwrap_or_else(|poisoned| poisoned.into_inner())
5035            .remove(id);
5036        if let Err(error) = self.persist_state_locked() {
5037            self.materializations
5038                .lock()
5039                .unwrap_or_else(|poisoned| poisoned.into_inner())
5040                .insert(id.clone(), ownership);
5041            return Err(persistence_failure(error));
5042        }
5043        Ok(())
5044    }
5045
5046    fn resolve_record_materialization(
5047        &self,
5048        record_id: &SessionRecordId,
5049        provider: &AgentId,
5050        mode: SessionMode,
5051        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5052        bundle: Option<&ResolvedBundleReceipt>,
5053        context: Option<&ResolvedContextPackReceipt>,
5054    ) -> Result<Option<(MaterializationId, Option<PreparedNativeLaunchOverlay>)>, NodeFailure> {
5055        let ownership = {
5056            let materializations = self
5057                .materializations
5058                .lock()
5059                .unwrap_or_else(|poisoned| poisoned.into_inner());
5060            let mut matches = materializations.values().filter(|record| {
5061                record.owner()
5062                    == &MaterializationOwner::Record {
5063                        record_id: record_id.clone(),
5064                    }
5065            });
5066            let ownership = matches.next().cloned();
5067            if matches.next().is_some() {
5068                return Err(failure(
5069                    NodeFailureCode::BackendOperationFailed,
5070                    "managed session has conflicting environment ownership",
5071                ));
5072            }
5073            ownership
5074        };
5075        if let (Some(ownership), Some(receipt)) = (ownership.as_ref(), context) {
5076            let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5077                failure(
5078                    NodeFailureCode::ContextPackMaterializationFailed,
5079                    "session context materializer is unavailable",
5080                )
5081            })?;
5082            let pack = materializer
5083                .revalidate_context(ownership, receipt)
5084                .map_err(|_| {
5085                    self.mark_materialization_recovery_required(ownership.id());
5086                    failure(
5087                        NodeFailureCode::ContextPackMaterializationFailed,
5088                        "managed session context pack revalidation failed",
5089                    )
5090                })?;
5091            self.context_catalog
5092                .write()
5093                .unwrap_or_else(|poisoned| poisoned.into_inner())
5094                .insert(pack)
5095                .map_err(|_| failure(
5096                    NodeFailureCode::ContextPackMaterializationFailed,
5097                    "managed session context pack catalog restore failed",
5098                ))?;
5099        }
5100        let materialization = self.materialization_profile(
5101            provider,
5102            mode,
5103            environment_profile,
5104            bundle,
5105            context,
5106        )?;
5107        match (ownership, materialization) {
5108            (None, None) => Ok(None),
5109            (None, Some(_)) | (Some(_), None) => Err(failure(
5110                NodeFailureCode::BackendOperationFailed,
5111                "managed session materialization is unavailable",
5112            )),
5113            (Some(ownership), Some((profile, has_environment_materialization, bundle_layout))) => {
5114                if ownership.environment_profile() != environment_profile
5115                    || ownership.bundle() != bundle
5116                    || ownership.context() != context
5117                    || ownership.state() != MaterializationState::Ready
5118                {
5119                    self.mark_materialization_recovery_required(ownership.id());
5120                    return Err(failure(
5121                        NodeFailureCode::BackendOperationFailed,
5122                        "managed session environment ownership requires recovery",
5123                    ));
5124                }
5125                let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5126                    failure(
5127                        NodeFailureCode::BackendOperationFailed,
5128                        "session environment materializer is unavailable",
5129                    )
5130                })?;
5131                if materializer.revalidate(&ownership).is_err() {
5132                    self.mark_materialization_recovery_required(ownership.id());
5133                    return Err(failure(
5134                        NodeFailureCode::BackendOperationFailed,
5135                        "managed session environment revalidation failed",
5136                    ));
5137                }
5138                let bundle_arguments = if let Some(bundle_receipt) = bundle {
5139                    let catalog = self.bundle_catalog.read()
5140                        .unwrap_or_else(|poisoned| poisoned.into_inner());
5141                    let Some(installed) = catalog.get(&bundle_receipt.id) else {
5142                        return Err(failure(
5143                            NodeFailureCode::UnknownBundle,
5144                            "managed session bundle is unavailable",
5145                        ));
5146                    };
5147                    let layout = bundle_layout.ok_or_else(|| failure(
5148                        NodeFailureCode::BundleBindingMismatch,
5149                        "record bundle provider layout is unavailable",
5150                    ))?;
5151                    if materializer.revalidate_bundle(&ownership, installed, layout).is_err() {
5152                        drop(catalog);
5153                        self.mark_materialization_recovery_required(ownership.id());
5154                        return Err(failure(
5155                            NodeFailureCode::BundleMaterializationFailed,
5156                            "managed session bundle revalidation failed",
5157                        ));
5158                    }
5159                    Some(bundle_launch_arguments(
5160                        layout,
5161                        ownership.bundle_root(),
5162                        ownership.provider_home(),
5163                    ).map_err(|_| failure(
5164                        NodeFailureCode::BundleBindingMismatch,
5165                        "managed session bundle launch binding is invalid",
5166                    ))?)
5167                } else {
5168                    None
5169                };
5170                if !has_environment_materialization {
5171                    let overlay = bundle_arguments.map(|extra_args| {
5172                        NativeInstanceLaunchOverlay::new(
5173                            provider.clone(),
5174                            TransportKind::Pty,
5175                            Vec::new(),
5176                            extra_args,
5177                        )
5178                        .map(PreparedNativeLaunchOverlay::Instance)
5179                        .map_err(|_| failure(
5180                            NodeFailureCode::BundleBindingMismatch,
5181                            "managed session bundle launch overlay is invalid",
5182                        ))
5183                    }).transpose()?;
5184                    return Ok(Some((ownership.id().clone(), overlay)));
5185                }
5186                let environment = match materializer.resolve_environment(&ownership, &profile) {
5187                    Ok(environment) => environment,
5188                    Err(
5189                        SessionEnvironmentMaterializeError::SecretUnavailable
5190                        | SessionEnvironmentMaterializeError::SecretDenied
5191                        | SessionEnvironmentMaterializeError::InvalidSecretValue,
5192                    ) => {
5193                        return Err(failure(
5194                            NodeFailureCode::BackendOperationFailed,
5195                            "managed session environment resolution failed",
5196                        ));
5197                    }
5198                    Err(
5199                        SessionEnvironmentMaterializeError::InvalidRoot
5200                        | SessionEnvironmentMaterializeError::OwnershipMismatch
5201                        | SessionEnvironmentMaterializeError::Filesystem(_)
5202                        | SessionEnvironmentMaterializeError::Record(_),
5203                    ) => {
5204                        self.mark_materialization_recovery_required(ownership.id());
5205                        return Err(failure(
5206                            NodeFailureCode::BackendOperationFailed,
5207                            "managed session environment resolution failed",
5208                        ));
5209                    }
5210                };
5211                let transport = match mode {
5212                    SessionMode::Pty => TransportKind::Pty,
5213                    SessionMode::Inline => TransportKind::Pipe,
5214                    SessionMode::Acp => TransportKind::Acp,
5215                };
5216                let overlay = match bundle_arguments {
5217                    Some(extra_args) => PreparedNativeLaunchOverlay::Instance(
5218                        NativeInstanceLaunchOverlay::new(
5219                            provider.clone(),
5220                            transport,
5221                            environment,
5222                            extra_args,
5223                        )
5224                        .map_err(|_| failure(
5225                            NodeFailureCode::BundleBindingMismatch,
5226                            "managed session bundle launch overlay is invalid",
5227                        ))?,
5228                    ),
5229                    None => PreparedNativeLaunchOverlay::Environment(
5230                        NativeLaunchEnvironmentOverlay::new(
5231                            provider.clone(),
5232                            transport,
5233                            environment,
5234                        )
5235                        .map_err(|_| {
5236                            failure(
5237                                NodeFailureCode::BackendOperationFailed,
5238                                "managed session environment overlay is invalid",
5239                            )
5240                        })?,
5241                    ),
5242                };
5243                Ok(Some((ownership.id().clone(), Some(overlay))))
5244            }
5245        }
5246    }
5247
5248    fn destroy_record_materialization(
5249        &self,
5250        record_id: &SessionRecordId,
5251    ) -> Result<bool, NodeFailure> {
5252        let ids = self
5253            .materializations
5254            .lock()
5255            .unwrap_or_else(|poisoned| poisoned.into_inner())
5256            .values()
5257            .filter_map(|record| {
5258                (record.owner()
5259                    == &MaterializationOwner::Record {
5260                        record_id: record_id.clone(),
5261                    })
5262                .then(|| record.id().clone())
5263            })
5264            .collect::<Vec<_>>();
5265        if ids.len() > 1 {
5266            return Err(failure(
5267                NodeFailureCode::BackendOperationFailed,
5268                "managed session has conflicting environment ownership",
5269            ));
5270        }
5271        let Some(id) = ids.first().cloned() else {
5272            return Ok(false);
5273        };
5274        let (terminal_record, mut cleanup_ownership) = {
5275            let _transaction = self
5276                .state_transaction
5277                .lock()
5278                .unwrap_or_else(|poisoned| poisoned.into_inner());
5279            let original_record = self.record(record_id)?;
5280            let mut terminal_record = original_record.clone();
5281            terminal_record.provider_session = None;
5282            terminal_record.active_session = None;
5283            terminal_record.state = ManagedSessionState::Unavailable;
5284            terminal_record.last_error = Some("environment-profile-unavailable".to_owned());
5285            terminal_record.updated_at_unix_ms = unix_time_ms();
5286            let original_ownership = self
5287                .materializations
5288                .lock()
5289                .unwrap_or_else(|poisoned| poisoned.into_inner())
5290                .get(&id)
5291                .cloned()
5292                .ok_or_else(|| {
5293                    failure(
5294                        NodeFailureCode::BackendOperationFailed,
5295                        "managed session environment ownership disappeared during cleanup",
5296                    )
5297                })?;
5298            let mut cleanup_ownership = original_ownership.clone();
5299            if cleanup_ownership.state() != MaterializationState::CleanupRequired {
5300                cleanup_ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5301                    failure(
5302                        NodeFailureCode::BackendOperationFailed,
5303                        "managed session environment cleanup state is invalid",
5304                    )
5305                })?;
5306            }
5307            self.session_records
5308                .lock()
5309                .unwrap_or_else(|poisoned| poisoned.into_inner())
5310                .records
5311                .insert(record_id.clone(), terminal_record.clone());
5312            self.materializations
5313                .lock()
5314                .unwrap_or_else(|poisoned| poisoned.into_inner())
5315                .insert(id.clone(), cleanup_ownership.clone());
5316            if let Err(error) = self.persist_state_locked() {
5317                self.session_records
5318                    .lock()
5319                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5320                    .records
5321                    .insert(record_id.clone(), original_record);
5322                self.materializations
5323                    .lock()
5324                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5325                    .insert(id.clone(), original_ownership);
5326                return Err(persistence_failure(error));
5327            }
5328            (terminal_record, cleanup_ownership)
5329        };
5330        let cleanup = self
5331            .session_environment_materializer
5332            .as_ref()
5333            .ok_or(())
5334            .and_then(|materializer| materializer.cleanup(&cleanup_ownership).map_err(|_| ()));
5335        if cleanup.is_err() {
5336            let _ = cleanup_ownership.mark_recovery_required(unix_time_ms());
5337            self.materializations
5338                .lock()
5339                .unwrap_or_else(|poisoned| poisoned.into_inner())
5340                .insert(id.clone(), cleanup_ownership);
5341            if self.persist_state().is_err() {
5342                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5343            }
5344            return Err(failure(
5345                NodeFailureCode::BackendOperationFailed,
5346                "managed session environment cleanup requires recovery",
5347            ));
5348        }
5349        let _transaction = self
5350            .state_transaction
5351            .lock()
5352            .unwrap_or_else(|poisoned| poisoned.into_inner());
5353        self.remove_record_memory(record_id);
5354        self.materializations
5355            .lock()
5356            .unwrap_or_else(|poisoned| poisoned.into_inner())
5357            .remove(&id);
5358        if let Err(error) = self.persist_state_locked() {
5359            self.session_records
5360                .lock()
5361                .unwrap_or_else(|poisoned| poisoned.into_inner())
5362                .records
5363                .insert(record_id.clone(), terminal_record);
5364            self.materializations
5365                .lock()
5366                .unwrap_or_else(|poisoned| poisoned.into_inner())
5367                .insert(id, cleanup_ownership);
5368            return Err(persistence_failure(error));
5369        }
5370        Ok(true)
5371    }
5372
5373    fn mark_materialization_recovery_required(&self, id: &MaterializationId) {
5374        let changed = {
5375            let mut materializations = self
5376                .materializations
5377                .lock()
5378                .unwrap_or_else(|poisoned| poisoned.into_inner());
5379            let Some(ownership) = materializations.get_mut(id) else {
5380                return;
5381            };
5382            ownership.mark_recovery_required(unix_time_ms()).is_ok()
5383        };
5384        if changed && self.persist_state().is_err() {
5385            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5386        }
5387    }
5388
5389    fn reconcile_materializations(&self) {
5390        let records = self
5391            .materializations
5392            .lock()
5393            .unwrap_or_else(|poisoned| poisoned.into_inner())
5394            .values()
5395            .cloned()
5396            .collect::<Vec<_>>();
5397        for mut ownership in records {
5398            let result = match (ownership.state(), ownership.owner()) {
5399                (MaterializationState::Preparing, _)
5400                | (MaterializationState::CleanupRequired, _) => {
5401                    self.cleanup_materialization(ownership.id())
5402                }
5403                (MaterializationState::Ready, MaterializationOwner::Session { .. }) => {
5404                    if ownership.mark_recovery_required(unix_time_ms()).is_ok() {
5405                        self.materializations
5406                            .lock()
5407                            .unwrap_or_else(|poisoned| poisoned.into_inner())
5408                            .insert(ownership.id().clone(), ownership.clone());
5409                        self.persist_state().map_err(persistence_failure)
5410                    } else {
5411                        Err(failure(
5412                            NodeFailureCode::BackendOperationFailed,
5413                            "session environment reconciliation failed",
5414                        ))
5415                    }
5416                }
5417                (MaterializationState::Ready, MaterializationOwner::Record { record_id }) => self
5418                    .session_environment_materializer
5419                    .as_ref()
5420                    .ok_or_else(|| {
5421                        failure(
5422                            NodeFailureCode::BackendOperationFailed,
5423                            "session environment materializer is unavailable",
5424                        )
5425                    })
5426                    .and_then(|materializer| {
5427                        materializer.revalidate(&ownership).map_err(|_| failure(
5428                            NodeFailureCode::BackendOperationFailed,
5429                            "session materialization revalidation failed",
5430                        ))?;
5431                        let record = self.record(record_id)?;
5432                        if record.environment_profile.as_ref() != ownership.environment_profile()
5433                            || record.bundle.as_ref() != ownership.bundle()
5434                            || record.context.as_ref() != ownership.context()
5435                        {
5436                            return Err(failure(
5437                                NodeFailureCode::BackendOperationFailed,
5438                                "session materialization ownership changed after restart",
5439                            ));
5440                        }
5441                        if let Some(receipt) = ownership.bundle() {
5442                            let catalog = self.bundle_catalog.read()
5443                                .unwrap_or_else(|poisoned| poisoned.into_inner());
5444                            let bundle = catalog.get(&receipt.id).ok_or_else(|| failure(
5445                                NodeFailureCode::UnknownBundle,
5446                                "session bundle is unavailable after restart",
5447                            ))?;
5448                            if bundle.receipt() != *receipt {
5449                                return Err(failure(
5450                                    NodeFailureCode::BundleBindingMismatch,
5451                                    "session bundle changed after restart",
5452                                ));
5453                            }
5454                            let layout = self.bundle_layout(
5455                                &record.provider,
5456                                record.mode,
5457                                record.environment_profile.as_ref(),
5458                                bundle,
5459                            )?;
5460                            materializer
5461                                .revalidate_bundle(&ownership, bundle, layout)
5462                                .map_err(|_| failure(
5463                                    NodeFailureCode::BundleMaterializationFailed,
5464                                    "session bundle revalidation failed",
5465                                ))?;
5466                        }
5467                        if let Some(receipt) = ownership.context() {
5468                            let pack = materializer
5469                                .revalidate_context(&ownership, receipt)
5470                                .map_err(|_| {
5471                                    failure(
5472                                        NodeFailureCode::ContextPackMaterializationFailed,
5473                                        "session context revalidation failed",
5474                                    )
5475                                })?;
5476                            self.context_catalog
5477                                .write()
5478                                .unwrap_or_else(|poisoned| poisoned.into_inner())
5479                                .insert(pack)
5480                                .map_err(|_| {
5481                                    failure(
5482                                        NodeFailureCode::ContextPackMaterializationFailed,
5483                                        "session context catalog restore failed",
5484                                    )
5485                                })?;
5486                        }
5487                        Ok(())
5488                    }),
5489                (MaterializationState::RecoveryRequired, _) => Ok(()),
5490            };
5491            if result.is_err() && ownership.state() != MaterializationState::RecoveryRequired {
5492                let _ = ownership.mark_recovery_required(unix_time_ms());
5493                self.materializations
5494                    .lock()
5495                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5496                    .insert(ownership.id().clone(), ownership);
5497                if self.persist_state().is_err() {
5498                    self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5499                }
5500            }
5501        }
5502        let materialized_profiles = self
5503            .environment_materialization_profiles
5504            .read()
5505            .unwrap_or_else(|poisoned| poisoned.into_inner())
5506            .keys()
5507            .cloned()
5508            .collect::<Vec<_>>();
5509        let ownership = self
5510            .materializations
5511            .lock()
5512            .unwrap_or_else(|poisoned| poisoned.into_inner())
5513            .values()
5514            .cloned()
5515            .collect::<Vec<_>>();
5516        let changed = {
5517            let mut session_records = self
5518                .session_records
5519                .lock()
5520                .unwrap_or_else(|poisoned| poisoned.into_inner());
5521            let mut changed = false;
5522            for record in session_records.records.values_mut() {
5523                let environment_profile = record.environment_profile.as_ref();
5524                if environment_profile.is_none()
5525                    && record.bundle.is_none()
5526                    && record.context.is_none()
5527                {
5528                    continue;
5529                }
5530                if record.bundle.is_none()
5531                    && environment_profile.is_some_and(|profile| {
5532                        !materialized_profiles.contains(&profile.profile_id)
5533                    })
5534                {
5535                    continue;
5536                }
5537                let has_exact_ownership = ownership.iter().any(|candidate| {
5538                    candidate.state() == MaterializationState::Ready
5539                        && candidate.environment_profile() == environment_profile
5540                        && candidate.bundle() == record.bundle.as_ref()
5541                        && candidate.context() == record.context.as_ref()
5542                        && candidate.owner()
5543                            == &MaterializationOwner::Record {
5544                                record_id: record.record_id.clone(),
5545                            }
5546                });
5547                if !has_exact_ownership {
5548                    record.provider_session = None;
5549                    record.active_session = None;
5550                    record.state = ManagedSessionState::Unavailable;
5551                    record.last_error = Some(if record.bundle.is_some() {
5552                        "bundle-unavailable".to_owned()
5553                    } else if record.context.is_some() {
5554                        "context-unavailable".to_owned()
5555                    } else {
5556                        "environment-profile-unavailable".to_owned()
5557                    });
5558                    record.updated_at_unix_ms = unix_time_ms();
5559                    changed = true;
5560                }
5561            }
5562            changed
5563        };
5564        if changed && self.persist_state().is_err() {
5565            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5566        }
5567    }
5568
5569    fn replay_spawn_spec(
5570        &self,
5571        spec: &SpawnSpec,
5572        now: Instant,
5573    ) -> Result<Option<Result<ResolvedSpawnReceipt, NodeFailure>>, NodeFailure> {
5574        let mut cache = self
5575            .spawn_idempotency
5576            .lock()
5577            .unwrap_or_else(|poisoned| poisoned.into_inner());
5578        cache.entries.retain(|_, entry| entry.expires_at > now);
5579        if let Some(entry) = cache.entries.get(&spec.idempotency_key) {
5580            let SpawnIdempotencyValue::Standard { spec: existing, result } = &entry.value else {
5581                return Err(failure(
5582                    NodeFailureCode::SpawnIdempotencyConflict,
5583                    "spawn idempotency key was reused across spawn kinds",
5584                ));
5585            };
5586            if existing != spec {
5587                return Err(failure(
5588                    NodeFailureCode::SpawnIdempotencyConflict,
5589                    "spawn idempotency key was reused with a different specification",
5590                ));
5591            }
5592            return Ok(Some(result.clone()));
5593        }
5594        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5595            return Err(failure(
5596                NodeFailureCode::SpawnIdempotencyCapacity,
5597                "spawn idempotency cache reached its bounded capacity",
5598            ));
5599        }
5600        Ok(None)
5601    }
5602
5603    fn remember_spawn_spec(
5604        &self,
5605        spec: SpawnSpec,
5606        result: Result<ResolvedSpawnReceipt, NodeFailure>,
5607        accepted_at: Instant,
5608    ) {
5609        self
5610            .spawn_idempotency
5611            .lock()
5612            .unwrap_or_else(|poisoned| poisoned.into_inner())
5613            .entries
5614            .insert(
5615                spec.idempotency_key.clone(),
5616                SpawnIdempotencyEntry {
5617                    value: SpawnIdempotencyValue::Standard { spec, result },
5618                    expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5619                },
5620            );
5621    }
5622
5623    fn claim_harness_mcp_spawn(
5624        &self,
5625        _reservation_id: &HarnessMcpReservationId,
5626        _activation_digest: &HarnessMcpActivationDigest,
5627        spec: &SpawnSpec,
5628        now: Instant,
5629    ) -> Result<(), NodeFailure> {
5630        let mut cache = self.spawn_idempotency.lock()
5631            .unwrap_or_else(|poisoned| poisoned.into_inner());
5632        cache.entries.retain(|_, entry| entry.expires_at > now);
5633        if cache.entries.contains_key(&spec.idempotency_key) {
5634            return Err(failure(
5635                NodeFailureCode::SpawnIdempotencyConflict,
5636                "spawn idempotency key was reused across spawn kinds",
5637            ));
5638        }
5639        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5640            return Err(failure(
5641                NodeFailureCode::SpawnIdempotencyCapacity,
5642                "spawn idempotency cache reached its bounded capacity",
5643            ));
5644        }
5645        cache.entries.insert(spec.idempotency_key.clone(), SpawnIdempotencyEntry {
5646            value: SpawnIdempotencyValue::HarnessMcp,
5647            expires_at: now + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5648        });
5649        Ok(())
5650    }
5651
5652    fn replay_managed_spawn(
5653        &self,
5654        request: &ManagedWorktreeSpawnRequest,
5655        now: Instant,
5656    ) -> Result<Option<Result<ManagedWorktreeSpawnReceipt, NodeFailure>>, NodeFailure> {
5657        let mut cache = self.spawn_idempotency.lock()
5658            .unwrap_or_else(|poisoned| poisoned.into_inner());
5659        cache.entries.retain(|_, entry| entry.expires_at > now);
5660        if let Some(entry) = cache.entries.get(&request.spawn_spec.idempotency_key) {
5661            let SpawnIdempotencyValue::Managed { request: existing, result } = &entry.value else {
5662                return Err(failure(
5663                    NodeFailureCode::SpawnIdempotencyConflict,
5664                    "spawn idempotency key was reused across spawn kinds",
5665                ));
5666            };
5667            if existing != request {
5668                return Err(failure(
5669                    NodeFailureCode::SpawnIdempotencyConflict,
5670                    "spawn idempotency key was reused with a different managed specification",
5671                ));
5672            }
5673            return Ok(Some(result.clone()));
5674        }
5675        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5676            return Err(failure(
5677                NodeFailureCode::SpawnIdempotencyCapacity,
5678                "spawn idempotency cache reached its bounded capacity",
5679            ));
5680        }
5681        Ok(None)
5682    }
5683
5684    fn remember_managed_spawn(
5685        &self,
5686        request: ManagedWorktreeSpawnRequest,
5687        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
5688        accepted_at: Instant,
5689    ) {
5690        self.spawn_idempotency.lock()
5691            .unwrap_or_else(|poisoned| poisoned.into_inner())
5692            .entries
5693            .insert(
5694                request.spawn_spec.idempotency_key.clone(),
5695                SpawnIdempotencyEntry {
5696                    value: SpawnIdempotencyValue::Managed { request, result },
5697                    expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5698                },
5699            );
5700    }
5701
5702    fn remember_managed_spawn_attempt(
5703        &self,
5704        durable_v2_key: Option<&SpawnIdempotencyKey>,
5705        request: ManagedWorktreeSpawnRequest,
5706        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
5707        accepted_at: Instant,
5708    ) {
5709        if durable_v2_key.is_none() {
5710            self.remember_managed_spawn(request, result, accepted_at);
5711        }
5712    }
5713
5714    fn acquire_durable_managed_spawn_v2(
5715        &self,
5716        request: &ManagedWorktreeSpawnRequestV2,
5717        request_digest: String,
5718    ) -> Result<DurableManagedSpawnReplayDecision, NodeFailure> {
5719        if self.state_path.is_none() {
5720            return Err(failure(
5721                NodeFailureCode::BackendOperationFailed,
5722                "managed worktree V2 requires durable node state",
5723            ));
5724        }
5725        let _transaction = self.state_transaction
5726            .lock()
5727            .unwrap_or_else(|poisoned| poisoned.into_inner());
5728        let key = request.spawn_spec.idempotency_key.clone();
5729        let mut previous = None;
5730        {
5731            let mut replays = self.managed_spawn_replays
5732                .lock()
5733                .unwrap_or_else(|poisoned| poisoned.into_inner());
5734            if let Some(record) = replays.get_mut(&key) {
5735                if record.request_digest != request_digest {
5736                    return Err(failure(
5737                        NodeFailureCode::SpawnIdempotencyConflict,
5738                        "managed worktree V2 idempotency key was reused with a different request",
5739                    ));
5740                }
5741                match &record.state {
5742                    ManagedWorktreeSpawnReplayStateV10::Pending {
5743                        owner_incarnation_id,
5744                        lease_id: None,
5745                    } if owner_incarnation_id != &self.incarnation_id => {
5746                        previous = Some(record.clone());
5747                        record.state = ManagedWorktreeSpawnReplayStateV10::Pending {
5748                            owner_incarnation_id: self.incarnation_id,
5749                            lease_id: None,
5750                        };
5751                        record.updated_at_unix_ms = unix_time_ms();
5752                    }
5753                    ManagedWorktreeSpawnReplayStateV10::Pending {
5754                        lease_id: None,
5755                        ..
5756                    } => {
5757                        return Err(failure(
5758                            NodeFailureCode::BackendBusy,
5759                            "managed worktree V2 reservation is already active",
5760                        ));
5761                    }
5762                    ManagedWorktreeSpawnReplayStateV10::Pending {
5763                        lease_id: Some(_),
5764                        ..
5765                    } => {
5766                        return Ok(DurableManagedSpawnReplayDecision::PendingLinked);
5767                    }
5768                    ManagedWorktreeSpawnReplayStateV10::Committed { receipt } => {
5769                        return Ok(DurableManagedSpawnReplayDecision::Committed(
5770                            receipt.clone(),
5771                        ));
5772                    }
5773                }
5774            } else if replays.len() >= MAX_MANAGED_WORKTREE_SPAWN_REPLAYS {
5775                return Err(failure(
5776                    NodeFailureCode::SpawnIdempotencyCapacity,
5777                    "durable managed worktree replay ledger reached its bounded capacity",
5778                ));
5779            } else {
5780                let now = unix_time_ms();
5781                replays.insert(key.clone(), ManagedWorktreeSpawnReplayRecordV10 {
5782                    idempotency_key: key.clone(),
5783                    request_digest,
5784                    source_workspace_id: request.spawn_spec.target.workspace_id.clone(),
5785                    profile_id: request.worktree_profile_id.clone(),
5786                    expected_profile_revision: request.expected_profile_revision.clone(),
5787                    state: ManagedWorktreeSpawnReplayStateV10::Pending {
5788                        owner_incarnation_id: self.incarnation_id,
5789                        lease_id: None,
5790                    },
5791                    created_at_unix_ms: now,
5792                    updated_at_unix_ms: now,
5793                });
5794            }
5795        }
5796        if let Err(error) = self.persist_state_locked() {
5797            let mut replays = self.managed_spawn_replays
5798                .lock()
5799                .unwrap_or_else(|poisoned| poisoned.into_inner());
5800            match previous {
5801                Some(previous) => {
5802                    replays.insert(key, previous);
5803                }
5804                None => {
5805                    replays.remove(&key);
5806                }
5807            }
5808            return Err(persistence_failure(error));
5809        }
5810        Ok(DurableManagedSpawnReplayDecision::Reserved)
5811    }
5812
5813    fn link_durable_managed_spawn_v2_lease_locked(
5814        &self,
5815        key: &SpawnIdempotencyKey,
5816        lease_id: &ManagedWorktreeLeaseId,
5817    ) -> Result<ManagedWorktreeSpawnReplayRecordV10, NodeFailure> {
5818        let mut replays = self.managed_spawn_replays
5819            .lock()
5820            .unwrap_or_else(|poisoned| poisoned.into_inner());
5821        let record = replays.get_mut(key).ok_or_else(|| {
5822            failure(
5823                NodeFailureCode::ManagedWorktreeRecoveryRequired,
5824                "managed worktree V2 reservation disappeared before allocation",
5825            )
5826        })?;
5827        let previous = record.clone();
5828        match &mut record.state {
5829            ManagedWorktreeSpawnReplayStateV10::Pending {
5830                lease_id: current,
5831                ..
5832            }
5833                if current.is_none() =>
5834            {
5835                *current = Some(lease_id.clone());
5836                record.updated_at_unix_ms = unix_time_ms();
5837                Ok(previous)
5838            }
5839            _ => Err(failure(
5840                NodeFailureCode::ManagedWorktreeRecoveryRequired,
5841                "managed worktree V2 reservation is not allocation-ready",
5842            )),
5843        }
5844    }
5845
5846    fn restore_durable_managed_spawn_v2_locked(
5847        &self,
5848        key: &SpawnIdempotencyKey,
5849        previous: ManagedWorktreeSpawnReplayRecordV10,
5850    ) {
5851        self.managed_spawn_replays
5852            .lock()
5853            .unwrap_or_else(|poisoned| poisoned.into_inner())
5854            .insert(key.clone(), previous);
5855    }
5856
5857    fn commit_durable_managed_spawn_v2(
5858        &self,
5859        request: &ManagedWorktreeSpawnRequestV2,
5860        receipt: ManagedWorktreeSpawnReceipt,
5861    ) -> Result<(), NodeFailure> {
5862        let _transaction = self.state_transaction
5863            .lock()
5864            .unwrap_or_else(|poisoned| poisoned.into_inner());
5865        let key = &request.spawn_spec.idempotency_key;
5866        let previous = {
5867            let mut replays = self.managed_spawn_replays
5868                .lock()
5869                .unwrap_or_else(|poisoned| poisoned.into_inner());
5870            let record = replays.get_mut(key).ok_or_else(|| {
5871                failure(
5872                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
5873                    "managed worktree V2 reservation disappeared before commit",
5874                )
5875            })?;
5876            let previous = record.clone();
5877            match &record.state {
5878                ManagedWorktreeSpawnReplayStateV10::Pending {
5879                    lease_id: Some(lease_id),
5880                    ..
5881                } if lease_id == &receipt.lease.lease_id => {}
5882                _ => {
5883                    return Err(failure(
5884                        NodeFailureCode::ManagedWorktreeRecoveryRequired,
5885                        "managed worktree V2 reservation is not linked to the spawned lease",
5886                    ));
5887                }
5888            }
5889            record.state = ManagedWorktreeSpawnReplayStateV10::Committed { receipt };
5890            record.updated_at_unix_ms = unix_time_ms();
5891            previous
5892        };
5893        if let Err(error) = self.persist_state_locked() {
5894            self.restore_durable_managed_spawn_v2_locked(key, previous);
5895            return Err(persistence_failure(error));
5896        }
5897        Ok(())
5898    }
5899
5900    fn remove_unallocated_durable_managed_spawn_v2(
5901        &self,
5902        request: &ManagedWorktreeSpawnRequestV2,
5903    ) -> Result<(), NodeFailure> {
5904        let _transaction = self.state_transaction
5905            .lock()
5906            .unwrap_or_else(|poisoned| poisoned.into_inner());
5907        let key = &request.spawn_spec.idempotency_key;
5908        let removed = {
5909            let mut replays = self.managed_spawn_replays
5910                .lock()
5911                .unwrap_or_else(|poisoned| poisoned.into_inner());
5912            match replays.get(key).map(|record| &record.state) {
5913                Some(ManagedWorktreeSpawnReplayStateV10::Pending {
5914                    lease_id: None,
5915                    ..
5916                }) => {
5917                    replays.remove(key)
5918                }
5919                _ => None,
5920            }
5921        };
5922        let Some(removed) = removed else {
5923            return Ok(());
5924        };
5925        if let Err(error) = self.persist_state_locked() {
5926            self.managed_spawn_replays
5927                .lock()
5928                .unwrap_or_else(|poisoned| poisoned.into_inner())
5929                .insert(key.clone(), removed);
5930            return Err(persistence_failure(error));
5931        }
5932        Ok(())
5933    }
5934
5935    fn resolve_spawn_spec(&self, spec: &SpawnSpec) -> Result<ResolvedSpawnSpec, NodeFailure> {
5936        if spec.target.node_id != self.node_id {
5937            return Err(failure(
5938                NodeFailureCode::SpawnTargetMismatch,
5939                "spawn target does not match this node",
5940            ));
5941        }
5942        let defaults = self.spawn_profiles.get(&spec.profile_id).ok_or_else(|| {
5943            failure(
5944                NodeFailureCode::UnknownSpawnProfile,
5945                "spawn profile is unavailable on this node",
5946            )
5947        })?;
5948        let resolved = spec.resolve(defaults).map_err(|error| match error {
5949            SpawnSpecResolveError::ProfileRevisionMismatch { .. } => failure(
5950                NodeFailureCode::SpawnProfileRevisionMismatch,
5951                "spawn profile revision does not match the loaded profile",
5952            ),
5953            _ => failure(
5954                NodeFailureCode::InvalidRequest,
5955                "spawn specification could not be resolved",
5956            ),
5957        })?;
5958        if resolved.target.worktree_id.is_some() {
5959            self.require_worktree_service(&resolved.target.workspace_id)?;
5960        }
5961        let environment_profile = self.resolve_environment_profile(&resolved)?;
5962        self.resolve_bundle(&resolved, environment_profile.as_ref())?;
5963        self.resolve_context(&resolved)?;
5964        Ok(resolved)
5965    }
5966
5967    async fn resolve_spawn_workspace(
5968        &self,
5969        resolved: &ResolvedSpawnSpec,
5970        deadline: Instant,
5971    ) -> Result<WorkspaceId, NodeFailure> {
5972        let Some(worktree_id) = resolved.target.worktree_id.as_ref() else {
5973            return Ok(resolved.target.workspace_id.clone());
5974        };
5975        if self.managed_worktrees.lock()
5976            .unwrap_or_else(|poisoned| poisoned.into_inner())
5977            .lease_for_workspace(worktree_id)
5978            .is_some()
5979        {
5980            return Err(failure(
5981                NodeFailureCode::ManagedWorktreeOwnershipConflict,
5982                "managed worktree targets may only be spawned through their lease request",
5983            ));
5984        }
5985        self.require_worktree_service(&resolved.target.workspace_id)?;
5986        let source_root = self.workspace_root(&resolved.target.workspace_id)?;
5987        let selected_root = self.workspace_root(worktree_id)?;
5988        let remaining = spawn_deadline_remaining(deadline)?;
5989        let worktrees = timeout(remaining, list_git_worktrees(&source_root))
5990            .await
5991            .map_err(|_| {
5992                failure(
5993                    NodeFailureCode::SpawnDeadlineExceeded,
5994                    "Git worktree selection exceeded the spawn deadline",
5995                )
5996            })?
5997            .map_err(git_worktree_failure)?;
5998        validate_selected_worktree(&source_root, &selected_root, &worktrees)?;
5999        Ok(worktree_id.clone())
6000    }
6001
6002    async fn spawn_from_spec(
6003        &self,
6004        spec: SpawnSpec,
6005    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6006        self.spawn_from_spec_with_proxy(spec, None).await
6007    }
6008
6009    async fn spawn_from_spec_with_proxy(
6010        &self,
6011        spec: SpawnSpec,
6012        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
6013    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6014        let accepted_at = Instant::now();
6015        if harness_mcp.is_none() {
6016            if let Some(replayed) = self.replay_spawn_spec(&spec, accepted_at)? {
6017                return replayed;
6018            }
6019        }
6020        let resolved = self.resolve_spawn_spec(&spec)?;
6021        let environment_profile = self.resolve_environment_profile(&resolved)?;
6022        let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6023        let context = self.resolve_context(&resolved)?;
6024        let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6025        let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6026        let spawn_workspace_id = self.resolve_spawn_workspace(&resolved, deadline).await;
6027        let spawn_workspace_id = match spawn_workspace_id {
6028            Ok(workspace_id) => workspace_id,
6029            Err(error) => {
6030                let result = Err(error);
6031                if harness_mcp.is_none() {
6032                    self.remember_spawn_spec(spec, result.clone(), accepted_at);
6033                }
6034                return result;
6035            }
6036        };
6037        if self.managed_worktrees.lock()
6038            .unwrap_or_else(|poisoned| poisoned.into_inner())
6039            .lease_for_workspace(&spawn_workspace_id)
6040            .is_some()
6041        {
6042            let result = Err(failure(
6043                NodeFailureCode::ManagedWorktreeOwnershipConflict,
6044                "managed worktree targets may only be spawned through their lease request",
6045            ));
6046            if harness_mcp.is_none() {
6047                self.remember_spawn_spec(spec, result.clone(), accepted_at);
6048            }
6049            return result;
6050        }
6051        let result = self
6052            .spawn_session_with_deadline(
6053                spawn_workspace_id,
6054                resolved.provider.clone(),
6055                resolved.mode,
6056                resolved.terminal_size,
6057                resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
6058                environment_profile.clone(),
6059                bundle.clone(),
6060                context.clone(),
6061                None,
6062                None,
6063                SpawnRecordPolicy::Always,
6064                Some(deadline),
6065                &required_capabilities,
6066                harness_mcp,
6067                resolved.approval_level,
6068                resolved.network_allowlist.clone(),
6069                resolved.browser_profile_id.clone(),
6070            )
6071            .await
6072            .map(|(session, _runtime_policy)| {
6073                resolved.receipt_with_materialization(
6074                    self.incarnation_id,
6075                    session,
6076                    environment_profile,
6077                    bundle,
6078                    context,
6079                )
6080            });
6081        if harness_mcp.is_none() {
6082            self.remember_spawn_spec(spec, result.clone(), accepted_at);
6083        }
6084        result
6085    }
6086
6087    async fn spawn_from_spec_with_harness_mcp(
6088        &self,
6089        reservation_id: HarnessMcpReservationId,
6090        activation_digest: HarnessMcpActivationDigest,
6091        spec: SpawnSpec,
6092        deadline_unix_ms: u64,
6093    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6094        let registry = self.harness_mcp_registry.as_ref().ok_or_else(|| {
6095            failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
6096        })?;
6097        if let Some(receipt) = registry
6098            .replay_spawn(&reservation_id, &activation_digest, &spec)
6099            .map_err(harness_mcp_failure)?
6100        {
6101            return Ok(receipt);
6102        }
6103        let resolved = self.resolve_spawn_spec(&spec)?;
6104        let prepared = registry
6105            .prepare_spawn(
6106                &reservation_id,
6107                &activation_digest,
6108                &spec,
6109                deadline_unix_ms,
6110                resolved.provider.clone(),
6111            )
6112            .map_err(harness_mcp_failure)?;
6113        self.claim_harness_mcp_spawn(
6114            &reservation_id,
6115            &activation_digest,
6116            &spec,
6117            Instant::now(),
6118        )?;
6119        let spawn = self.spawn_from_spec_with_proxy(spec, Some(&prepared)).await;
6120        let mut receipt = match spawn {
6121            Ok(receipt) => receipt,
6122            Err(error) => {
6123                let _ = registry.abort(&reservation_id, &activation_digest);
6124                return Err(error);
6125            }
6126        };
6127        let record_id = self
6128            .session_bindings
6129            .lock()
6130            .unwrap_or_else(|poisoned| poisoned.into_inner())
6131            .get(&receipt.session.session.instance_id)
6132            .and_then(|binding| binding.record_id.clone());
6133        let record_id = match record_id {
6134            Some(record_id) => record_id,
6135            None => {
6136                let rollback = self.remove_session(&receipt.session).await;
6137                let _ = registry.abort(&reservation_id, &activation_digest);
6138                if let Err(rollback_error) = rollback {
6139                    return Err(rollback_error);
6140                }
6141                return Err(failure(
6142                    NodeFailureCode::BindingMismatch,
6143                    "harness MCP spawn did not retain its exact managed record",
6144                ));
6145            }
6146        };
6147        receipt.harness_mcp_proxy = Some(ResolvedHarnessMcpProxyReceiptV1 {
6148            reservation_id: reservation_id.clone(),
6149            activation_digest: activation_digest.clone(),
6150        });
6151        if let Err(error) = registry.mark_spawned(
6152            &prepared,
6153            receipt.session.clone(),
6154            record_id,
6155            receipt.clone(),
6156        ) {
6157            let rollback = self.remove_session(&receipt.session).await;
6158            let _ = registry.abort(&reservation_id, &activation_digest);
6159            if let Err(rollback_error) = rollback {
6160                return Err(rollback_error);
6161            }
6162            return Err(harness_mcp_failure(error));
6163        }
6164        Ok(receipt)
6165    }
6166
6167    async fn spawn_managed_worktree(
6168        &self,
6169        request: ManagedWorktreeSpawnRequest,
6170    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6171        self.spawn_managed_worktree_inner(request, None).await
6172    }
6173
6174    async fn spawn_managed_worktree_inner(
6175        &self,
6176        request: ManagedWorktreeSpawnRequest,
6177        durable_v2_key: Option<&SpawnIdempotencyKey>,
6178    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6179        let accepted_at = Instant::now();
6180        if durable_v2_key.is_none() {
6181            if let Some(replayed) = self.replay_managed_spawn(&request, accepted_at)? {
6182                return replayed;
6183            }
6184        }
6185        let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6186        if request.spawn_spec.target.worktree_id.is_some() {
6187            let result = Err(failure(
6188                NodeFailureCode::InvalidRequest,
6189                "managed worktree spawn must not select a caller-provided worktree",
6190            ));
6191            self.remember_managed_spawn_attempt(
6192                durable_v2_key,
6193                request,
6194                result.clone(),
6195                accepted_at,
6196            );
6197            return result;
6198        }
6199        let environment_profile = self.resolve_environment_profile(&resolved)?;
6200        let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6201        let context = self.resolve_context(&resolved)?;
6202        let source_workspace_id = resolved.target.workspace_id.clone();
6203        let profile = self.managed_profile(&source_workspace_id, &request.worktree_profile_id)?;
6204        let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6205        let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6206        let runtime_requirement = match (resolved.mode, resolved.prompt.is_some()) {
6207            (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
6208            (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
6209            (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
6210            (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
6211        };
6212        let runtime_policy = timeout(
6213            spawn_deadline_remaining(deadline)?,
6214            self.admit_provider_runtime(&resolved.provider, runtime_requirement),
6215        ).await.map_err(|_| failure(
6216            NodeFailureCode::SpawnDeadlineExceeded,
6217            "provider admission exceeded the managed spawn deadline",
6218        ))??;
6219        if required_capabilities.iter().any(|capability| !runtime_policy.admits(*capability)) {
6220            return Err(failure(
6221                NodeFailureCode::UnsupportedSpawnCapability,
6222                "provider runtime does not admit a required spawn capability",
6223            ));
6224        }
6225        self.ensure_binding_capacity()?;
6226        let source_root = self.workspace_root(&source_workspace_id)?;
6227        profile.validate_for_workspace(&source_root)
6228            .map_err(|message| failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message))?;
6229        let base_timeout_ms = u64::try_from(
6230            spawn_deadline_remaining(deadline)?.as_millis(),
6231        ).unwrap_or(u64::MAX).max(1);
6232        let base_commit = resolve_base_commit_with_timeout(
6233            &source_root,
6234            profile.base(),
6235            base_timeout_ms,
6236        ).await.map_err(|error| {
6237            if Instant::now() >= deadline || error.message.contains("timed out") {
6238                failure(
6239                    NodeFailureCode::SpawnDeadlineExceeded,
6240                    "managed worktree base resolution exceeded the spawn deadline",
6241                )
6242            } else {
6243                managed_git_worktree_failure(error)
6244            }
6245        })?;
6246
6247        let lease = {
6248            let _transaction = self.state_transaction.lock()
6249                .unwrap_or_else(|poisoned| poisoned.into_inner());
6250            let previous = self.managed_worktrees.lock()
6251                .unwrap_or_else(|poisoned| poisoned.into_inner()).clone();
6252            let lease = self.managed_worktrees.lock()
6253                .unwrap_or_else(|poisoned| poisoned.into_inner())
6254                .allocate(source_workspace_id.clone(), &profile, base_commit.clone(), unix_time_ms())
6255                .map_err(|message| failure(NodeFailureCode::BackendBusy, &message))?;
6256            let previous_replay = match durable_v2_key
6257                .map(|key| self.link_durable_managed_spawn_v2_lease_locked(key, &lease.lease_id))
6258                .transpose()
6259            {
6260                Ok(previous) => previous,
6261                Err(error) => {
6262                    *self.managed_worktrees.lock()
6263                        .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6264                    return Err(error);
6265                }
6266            };
6267            if let Err(error) = self.persist_state_locked() {
6268                *self.managed_worktrees.lock()
6269                    .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6270                if let (Some(key), Some(previous_replay)) =
6271                    (durable_v2_key, previous_replay)
6272                {
6273                    self.restore_durable_managed_spawn_v2_locked(key, previous_replay);
6274                }
6275                return Err(persistence_failure(error));
6276            }
6277            lease
6278        };
6279        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() });
6280        if let Err(message) = profile.validate_target_authority(&lease.lease_id, &lease.target_root) {
6281            self.managed_worktrees.lock()
6282                .unwrap_or_else(|poisoned| poisoned.into_inner())
6283                .remove_unmutated(&lease.lease_id);
6284            if self.persist_state().is_err() {
6285                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6286            }
6287            let result = Err(failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message));
6288            self.remember_managed_spawn_attempt(
6289                durable_v2_key,
6290                request,
6291                result.clone(),
6292                accepted_at,
6293            );
6294            return result;
6295        }
6296        let mutation_timeout_ms = match spawn_deadline_remaining(deadline) {
6297            Ok(remaining) => u64::try_from(remaining.as_millis()).unwrap_or(u64::MAX).max(1),
6298            Err(error) => {
6299                self.reconcile_failed_allocation(&lease, &source_root).await;
6300                let result = Err(error);
6301                self.remember_managed_spawn_attempt(
6302                    durable_v2_key,
6303                    request,
6304                    result.clone(),
6305                    accepted_at,
6306                );
6307                return result;
6308            }
6309        };
6310        let created = match create_git_worktree_with_timeout(
6311                &source_root,
6312                &lease.target_root,
6313                &lease.branch,
6314                Some(&base_commit),
6315                mutation_timeout_ms,
6316            ).await {
6317            Ok(created) => created,
6318            Err(error) => {
6319                self.reconcile_failed_allocation(&lease, &source_root).await;
6320                let result = Err(if Instant::now() >= deadline
6321                    || error.message.contains("timed out")
6322                    || error.message.contains("deadline")
6323                {
6324                    failure(
6325                        NodeFailureCode::SpawnDeadlineExceeded,
6326                        "managed worktree creation exceeded the spawn deadline",
6327                    )
6328                } else {
6329                    managed_git_worktree_failure(error)
6330                });
6331                self.remember_managed_spawn_attempt(
6332                    durable_v2_key,
6333                    request,
6334                    result.clone(),
6335                    accepted_at,
6336                );
6337                return result;
6338            }
6339        };
6340        if !exact_created_worktree(&lease, &created.path, created.branch.as_deref(), &created.head) {
6341            self.mark_managed_recovery_required(&lease.lease_id, ManagedWorktreeCleanupFailure::OwnershipConflict);
6342            let result = Err(failure(
6343                NodeFailureCode::ManagedWorktreeRecoveryRequired,
6344                "created worktree identity did not match its durable lease",
6345            ));
6346            self.remember_managed_spawn_attempt(
6347                durable_v2_key,
6348                request,
6349                result.clone(),
6350                accepted_at,
6351            );
6352            return result;
6353        }
6354        {
6355            let mut registry = self.managed_worktrees.lock()
6356                .unwrap_or_else(|poisoned| poisoned.into_inner());
6357            let current = registry.get_mut(&lease.lease_id)
6358                .expect("allocated managed worktree lease remains present");
6359            current.expected_head = Some(created.head.clone());
6360            current.state = ManagedWorktreeLeaseState::Ready;
6361            current.updated_at_unix_ms = unix_time_ms();
6362        }
6363        if let Err(error) = self.persist_state() {
6364            self.mark_managed_recovery_required(
6365                &lease.lease_id,
6366                ManagedWorktreeCleanupFailure::Backend,
6367            );
6368            let result = Err(persistence_failure(error));
6369            self.remember_managed_spawn_attempt(
6370                durable_v2_key,
6371                request,
6372                result.clone(),
6373                accepted_at,
6374            );
6375            return result;
6376        }
6377        let ready = self.managed_worktrees.lock()
6378            .unwrap_or_else(|poisoned| poisoned.into_inner())
6379            .get(&lease.lease_id)
6380            .expect("ready managed worktree lease remains present")
6381            .snapshot();
6382        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: ready });
6383        if let Err(error) = self.register_workspace(lease.workspace_id.clone(), created.path.clone()).await {
6384            let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
6385            let result = Err(error);
6386            self.remember_managed_spawn_attempt(
6387                durable_v2_key,
6388                request,
6389                result.clone(),
6390                accepted_at,
6391            );
6392            return result;
6393        }
6394        let spawn = self.spawn_session_with_deadline(
6395            lease.workspace_id.clone(),
6396            resolved.provider.clone(),
6397            resolved.mode,
6398            resolved.terminal_size,
6399            resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
6400            environment_profile.clone(),
6401            bundle.clone(),
6402            context.clone(),
6403            Some(lease.lease_id.clone()),
6404            Some(runtime_policy),
6405            SpawnRecordPolicy::Always,
6406            Some(deadline),
6407            &required_capabilities,
6408            None,
6409            resolved.approval_level,
6410            resolved.network_allowlist.clone(),
6411            resolved.browser_profile_id.clone(),
6412        ).await;
6413        let (session, effective_runtime_policy) = match spawn {
6414            Ok(spawned) => spawned,
6415            Err(error) => {
6416                if self
6417                    .cleanup_managed_worktree(&lease.lease_id, true)
6418                    .await
6419                    .is_err()
6420                {
6421                    self.mark_managed_recovery_required(
6422                        &lease.lease_id,
6423                        ManagedWorktreeCleanupFailure::Backend,
6424                    );
6425                }
6426                let result = Err(error);
6427                self.remember_managed_spawn_attempt(
6428                    durable_v2_key,
6429                    request,
6430                    result.clone(),
6431                    accepted_at,
6432                );
6433                return result;
6434            }
6435        };
6436        let (record_id, raw_inventory_record_id) = {
6437            let mut bindings = self.session_bindings.lock()
6438                .unwrap_or_else(|poisoned| poisoned.into_inner());
6439            let binding = bindings.get_mut(&session.session.instance_id)
6440                .expect("successful spawn retains its session binding");
6441            binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
6442            managed_spawn_record_ownership(binding, effective_runtime_policy)
6443        };
6444        let bound = self.managed_worktrees.lock()
6445            .unwrap_or_else(|poisoned| poisoned.into_inner())
6446            .bind_session(
6447                &lease.lease_id,
6448                ManagedWorktreeSessionHolder {
6449                    incarnation_id: self.incarnation_id,
6450                    instance_id: session.session.instance_id,
6451                    generation: session.session.generation,
6452                },
6453                record_id,
6454                unix_time_ms(),
6455            )
6456            .map_err(|message| failure(NodeFailureCode::ManagedWorktreeRecoveryRequired, &message));
6457        let snapshot = match bound {
6458            Ok(snapshot) => snapshot,
6459            Err(error) => {
6460                if raw_inventory_record_id
6461                    .as_ref()
6462                    .is_some_and(|record_id| self.discard_record(record_id).is_err())
6463                {
6464                    self.mark_managed_recovery_required(
6465                        &lease.lease_id,
6466                        ManagedWorktreeCleanupFailure::Backend,
6467                    );
6468                }
6469                let _ = self.remove_session(&session).await;
6470                self.mark_managed_recovery_required(
6471                    &lease.lease_id,
6472                    ManagedWorktreeCleanupFailure::Backend,
6473                );
6474                let result = Err(error);
6475                self.remember_managed_spawn_attempt(
6476                    durable_v2_key,
6477                    request,
6478                    result.clone(),
6479                    accepted_at,
6480                );
6481                return result;
6482            }
6483        };
6484        if let Err(error) = self.persist_state() {
6485            if raw_inventory_record_id
6486                .as_ref()
6487                .is_some_and(|record_id| self.discard_record(record_id).is_err())
6488            {
6489                self.mark_managed_recovery_required(
6490                    &lease.lease_id,
6491                    ManagedWorktreeCleanupFailure::Backend,
6492                );
6493            }
6494            let _ = self.remove_session(&session).await;
6495            if self
6496                .cleanup_managed_worktree(&lease.lease_id, true)
6497                .await
6498                .is_err()
6499            {
6500                self.mark_managed_recovery_required(
6501                    &lease.lease_id,
6502                    ManagedWorktreeCleanupFailure::Backend,
6503                );
6504            }
6505            let result = Err(persistence_failure(error));
6506            self.remember_managed_spawn_attempt(
6507                durable_v2_key,
6508                request,
6509                result.clone(),
6510                accepted_at,
6511            );
6512            return result;
6513        }
6514        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
6515        let receipt = managed_spawn_receipt(
6516            &resolved,
6517            self.incarnation_id,
6518            session,
6519            snapshot,
6520            environment_profile,
6521            bundle,
6522            context,
6523        );
6524        let result = Ok(receipt);
6525        self.remember_managed_spawn_attempt(
6526            durable_v2_key,
6527            request,
6528            result.clone(),
6529            accepted_at,
6530        );
6531        result
6532    }
6533
6534    async fn spawn_managed_worktree_v2(
6535        &self,
6536        request: ManagedWorktreeSpawnRequestV2,
6537    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6538        if request.spawn_spec.target.node_id != self.node_id {
6539            return Err(failure(
6540                NodeFailureCode::SpawnTargetMismatch,
6541                "spawn target does not match this node",
6542            ));
6543        }
6544        if request.spawn_spec.target.worktree_id.is_some() {
6545            return Err(failure(
6546                NodeFailureCode::InvalidRequest,
6547                "managed worktree spawn must not select a caller-provided worktree",
6548            ));
6549        }
6550        let request_digest = managed_spawn_request_digest_v2(&request)?;
6551        let replay = self.acquire_durable_managed_spawn_v2(&request, request_digest)?;
6552        match replay {
6553            DurableManagedSpawnReplayDecision::Committed(receipt) => return Ok(receipt),
6554            DurableManagedSpawnReplayDecision::PendingLinked => {
6555                return Err(failure(
6556                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
6557                    "managed worktree V2 reservation has an incomplete durable allocation",
6558                ));
6559            }
6560            DurableManagedSpawnReplayDecision::Reserved => {}
6561        }
6562        let validated = (|| {
6563            let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6564            let profile = self.managed_profile(
6565                &resolved.target.workspace_id,
6566                &request.worktree_profile_id,
6567            )?;
6568            if profile.revision() != &request.expected_profile_revision {
6569                return Err(failure(
6570                    NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
6571                    "managed worktree profile revision does not match the loaded profile",
6572                ));
6573            }
6574            Ok(())
6575        })();
6576        match validated {
6577            Ok(()) => {}
6578            Err(error) => {
6579                self.remove_unallocated_durable_managed_spawn_v2(&request)?;
6580                return Err(error);
6581            }
6582        }
6583        let legacy = ManagedWorktreeSpawnRequest {
6584            spawn_spec: request.spawn_spec.clone(),
6585            worktree_profile_id: request.worktree_profile_id.clone(),
6586        };
6587        match self
6588            .spawn_managed_worktree_inner(
6589                legacy,
6590                Some(&request.spawn_spec.idempotency_key),
6591            )
6592            .await
6593        {
6594            Ok(receipt) => {
6595                self.commit_durable_managed_spawn_v2(&request, receipt.clone())?;
6596                Ok(receipt)
6597            }
6598            Err(error) => {
6599                self.remove_unallocated_durable_managed_spawn_v2(&request)?;
6600                Err(error)
6601            }
6602        }
6603    }
6604
6605    fn managed_profile(
6606        &self,
6607        workspace_id: &WorkspaceId,
6608        profile_id: &WorktreeProfileId,
6609    ) -> Result<ManagedWorktreeProfile, NodeFailure> {
6610        if self.worktree_service_modes.get(workspace_id).copied()
6611            != Some(WorktreeServiceMode::Managed)
6612        {
6613            return Err(failure(
6614                NodeFailureCode::UnsupportedSpawnCapability,
6615                "workspace does not enable managed worktrees",
6616            ));
6617        }
6618        self.managed_worktree_profiles.get(workspace_id)
6619            .and_then(|profiles| profiles.get(profile_id))
6620            .cloned()
6621            .ok_or_else(|| failure(
6622                NodeFailureCode::InvalidRequest,
6623                "managed worktree profile is unavailable for this workspace",
6624            ))
6625    }
6626
6627    async fn reconcile_failed_allocation(
6628        &self,
6629        lease: &ManagedWorktreeLeaseRecord,
6630        source_root: &str,
6631    ) {
6632        match list_git_worktrees(source_root).await {
6633            Ok(worktrees) => {
6634                match worktrees.iter().find(|item| worktree_paths_equal(&item.path, &lease.target_root)) {
6635                    None => {
6636                        match std::fs::symlink_metadata(&lease.target_root) {
6637                            Err(error) if error.kind() == io::ErrorKind::NotFound => {
6638                                self.managed_worktrees.lock()
6639                                    .unwrap_or_else(|poisoned| poisoned.into_inner())
6640                                    .remove_unmutated(&lease.lease_id);
6641                                let _ = self.persist_state();
6642                            }
6643                            _ => self.mark_managed_recovery_required(
6644                                &lease.lease_id,
6645                                ManagedWorktreeCleanupFailure::OwnershipConflict,
6646                            ),
6647                        }
6648                    }
6649                    Some(item) if exact_owned_worktree(
6650                        lease,
6651                        &item.path,
6652                        item.branch.as_deref(),
6653                        &item.head,
6654                    ) => {
6655                        let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
6656                    }
6657                    Some(_) => self.mark_managed_recovery_required(
6658                        &lease.lease_id,
6659                        ManagedWorktreeCleanupFailure::OwnershipConflict,
6660                    ),
6661                }
6662            }
6663            Err(_) => self.mark_managed_recovery_required(
6664                &lease.lease_id,
6665                ManagedWorktreeCleanupFailure::Backend,
6666            ),
6667        }
6668    }
6669
6670    async fn reconcile_managed_worktrees(&self) {
6671        let leases = self.managed_worktrees.lock()
6672            .unwrap_or_else(|poisoned| poisoned.into_inner())
6673            .active_records()
6674            .cloned()
6675            .collect::<Vec<_>>();
6676        for lease in leases {
6677            let profile = match self.managed_profile(&lease.source_workspace_id, &lease.profile_id) {
6678                Ok(profile) if profile.revision() == &lease.profile_revision => profile,
6679                _ => {
6680                    self.mark_managed_recovery_required(
6681                        &lease.lease_id,
6682                        ManagedWorktreeCleanupFailure::OwnershipConflict,
6683                    );
6684                    continue;
6685                }
6686            };
6687            if profile.validate_target_authority(&lease.lease_id, &lease.target_root).is_err() {
6688                self.mark_managed_recovery_required(
6689                    &lease.lease_id,
6690                    ManagedWorktreeCleanupFailure::OwnershipConflict,
6691                );
6692                continue;
6693            }
6694            let source_root = match self.workspace_root(&lease.source_workspace_id) {
6695                Ok(root) => root,
6696                Err(_) => {
6697                    self.mark_managed_recovery_required(
6698                        &lease.lease_id,
6699                        ManagedWorktreeCleanupFailure::OwnershipConflict,
6700                    );
6701                    continue;
6702                }
6703            };
6704            let worktrees = match list_git_worktrees(&source_root).await {
6705                Ok(worktrees) => worktrees,
6706                Err(_) => {
6707                    self.mark_managed_recovery_required(
6708                        &lease.lease_id,
6709                        ManagedWorktreeCleanupFailure::Backend,
6710                    );
6711                    continue;
6712                }
6713            };
6714            let listed = worktrees.iter()
6715                .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
6716            let Some(listed) = listed else {
6717                match std::fs::symlink_metadata(&lease.target_root) {
6718                    Err(error) if error.kind() == io::ErrorKind::NotFound
6719                        && lease.state == ManagedWorktreeLeaseState::Allocating
6720                        && !lease.has_holders() => {
6721                            let removed = self.managed_worktrees.lock()
6722                                .unwrap_or_else(|poisoned| poisoned.into_inner())
6723                                .tombstone(&lease.lease_id, unix_time_ms());
6724                            if self.persist_state().is_ok() && removed.is_some() {
6725                                self.publish(NodeEvent::ManagedWorktreeRemoved {
6726                                    lease_id: lease.lease_id.clone(),
6727                                });
6728                            }
6729                        }
6730                    _ => self.mark_managed_recovery_required(
6731                        &lease.lease_id,
6732                        ManagedWorktreeCleanupFailure::OwnershipConflict,
6733                    ),
6734                }
6735                continue;
6736            };
6737            if listed.is_main || listed.is_bare || !exact_owned_worktree(
6738                &lease,
6739                &listed.path,
6740                listed.branch.as_deref(),
6741                &listed.head,
6742            ) {
6743                self.mark_managed_recovery_required(
6744                    &lease.lease_id,
6745                    ManagedWorktreeCleanupFailure::OwnershipConflict,
6746                );
6747                continue;
6748            }
6749            if !self.workspaces.read()
6750                .unwrap_or_else(|poisoned| poisoned.into_inner())
6751                .contains_key(&lease.workspace_id)
6752            {
6753                if self.register_workspace(lease.workspace_id.clone(), listed.path.clone()).await.is_err() {
6754                    self.mark_managed_recovery_required(
6755                        &lease.lease_id,
6756                        ManagedWorktreeCleanupFailure::Backend,
6757                    );
6758                    continue;
6759                }
6760            }
6761            let snapshot = {
6762                let mut registry = self.managed_worktrees.lock()
6763                    .unwrap_or_else(|poisoned| poisoned.into_inner());
6764                let current = registry.get_mut(&lease.lease_id)
6765                    .expect("reconciled managed lease remains present");
6766                current.expected_head = Some(listed.head.clone());
6767                current.cleanup_failure = None;
6768                current.state = if current.has_holders() {
6769                    ManagedWorktreeLeaseState::InUse
6770                } else if current.retention == ManagedWorktreeRetention::Retain {
6771                    ManagedWorktreeLeaseState::Retained
6772                } else {
6773                    ManagedWorktreeLeaseState::Ready
6774                };
6775                current.updated_at_unix_ms = unix_time_ms();
6776                current.snapshot()
6777            };
6778            if self.persist_state().is_err() {
6779                self.mark_managed_recovery_required(
6780                    &lease.lease_id,
6781                    ManagedWorktreeCleanupFailure::Backend,
6782                );
6783                continue;
6784            }
6785            self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
6786            if snapshot.state == ManagedWorktreeLeaseState::Ready {
6787                let _ = self.cleanup_managed_worktree(&lease.lease_id, false).await;
6788            }
6789        }
6790    }
6791
6792    /// Crash-recovery startup sweep: durably capture the pack for every
6793    /// `Live` managed record whose most recent session has not yet produced
6794    /// one (recovers a session whose `Exited{0}` event was observed but the
6795    /// Node crashed before the export finished). The reactive fast path for
6796    /// a single just-exited record does not go through this — see
6797    /// `drive_runtime_until_shutdown`, which resolves and spawns directly so
6798    /// it can capture the exact `SessionAddress` before `publish_control`
6799    /// clears it, rather than re-deriving it from (by then likely already
6800    /// `Dormant`) record state the way a rescan naturally would.
6801    ///
6802    /// Returns as soon as every eligible record's export has been *spawned*,
6803    /// not completed — each one runs on its own detached task (see
6804    /// `reconcile_context_pack_export_for_record`'s own doc comment for why:
6805    /// `NativeRuntime::tick()` has not even started its own loop yet at the
6806    /// point this is called from `NodeServer::run()`, so nothing could ever
6807    /// drain the history round trip if this awaited it inline here either).
6808    /// Best-effort throughout: never panics, no unwrap/expect; every failure
6809    /// is a silent skip for THIS record — an unusable source or a racing
6810    /// record is not surfaced to any caller.
6811    async fn reconcile_context_pack_exports(self: &Arc<Self>) {
6812        let candidates: Vec<(SessionRecordId, SessionAddress)> = self
6813            .session_records
6814            .lock()
6815            .unwrap_or_else(|poisoned| poisoned.into_inner())
6816            .records
6817            .values()
6818            .filter(|record| {
6819                record.state == ManagedSessionState::Live
6820                    && record.exported_context.is_none()
6821                    && record.provider_session.is_some()
6822            })
6823            .filter_map(|record| {
6824                record
6825                    .active_session
6826                    .clone()
6827                    .map(|session| (record.record_id.clone(), session))
6828            })
6829            .collect();
6830        for (record_id, session) in candidates {
6831            let shared = Arc::clone(self);
6832            tokio::spawn(async move {
6833                shared
6834                    .reconcile_context_pack_export_for_record(&record_id, session)
6835                    .await;
6836            });
6837        }
6838    }
6839
6840    /// Runs on its own detached task (`tokio::spawn`, see both call sites),
6841    /// never inline on `drive_runtime_until_shutdown`'s own task: the history
6842    /// round trip this performs (`discover_history`/`load_history`, via
6843    /// `export_context_pack_for_session_record`) can only ever settle once
6844    /// `NativeRuntime::tick()` runs again to drain the authority worker's
6845    /// completion — and `tick()` is exclusively owned by, and only ever
6846    /// called from, that same drive-loop task. Awaiting this inline there
6847    /// would starve `tick()` and this call could never observe its own
6848    /// command complete.
6849    ///
6850    /// Because this now genuinely races the drive loop's own
6851    /// `publish_control` (which downgrades this exact record from `Live` to
6852    /// `Dormant`, clearing `active_session`, for the very `Exited` event that
6853    /// triggered this reconcile), the record legitimately may already be
6854    /// `Dormant` by the time this reads it, or may transition mid-flight —
6855    /// `allow_clean_detachment: true` on the downstream export call is what
6856    /// tolerates exactly that transition (and only that transition; see
6857    /// `session_record_export_target_matches`).
6858    async fn reconcile_context_pack_export_for_record(
6859        &self,
6860        record_id: &SessionRecordId,
6861        session: SessionAddress,
6862    ) {
6863        let Ok(record) = self.record(record_id) else {
6864            return;
6865        };
6866        if !matches!(
6867            record.state,
6868            ManagedSessionState::Live | ManagedSessionState::Dormant
6869        ) || record.exported_context.is_some()
6870            || record.provider_session.is_none()
6871        {
6872            return;
6873        }
6874        // Pack bytes are already durably committed inside this call, via
6875        // commit_context_pack_for_session_record's own durable
6876        // write-through (context_pack_store) — no separate step here.
6877        // Unclean exits (non-zero/forced/no loadable history) are already
6878        // rejected by context_pack_source_status_is_usable inside
6879        // materialize_context_pack: this simply returns Err and is skipped.
6880        let Ok(receipt) = self
6881            .export_context_pack_for_session_record(&record.record_id, &session, true)
6882            .await
6883        else {
6884            return;
6885        };
6886        self.set_exported_context(&record.record_id, receipt);
6887    }
6888
6889    /// Records the record's most recent clean-exit pack. No-op (not an
6890    /// error) if the record disappeared, or if it already carries an
6891    /// `exported_context` — first-write-wins, matching the harness-side
6892    /// idempotent reconciliation that consumes this field.
6893    fn set_exported_context(&self, record_id: &SessionRecordId, receipt: ResolvedContextPackReceipt) {
6894        let transaction = self
6895            .state_transaction
6896            .lock()
6897            .unwrap_or_else(|poisoned| poisoned.into_inner());
6898        let (previous, updated) = {
6899            let mut records = self
6900                .session_records
6901                .lock()
6902                .unwrap_or_else(|poisoned| poisoned.into_inner());
6903            let Some(record) = records.records.get_mut(record_id) else {
6904                return;
6905            };
6906            if record.exported_context.is_some() {
6907                return;
6908            }
6909            let previous = record.clone();
6910            record.exported_context = Some(receipt);
6911            record.updated_at_unix_ms = unix_time_ms().max(record.updated_at_unix_ms);
6912            (previous, record.clone())
6913        };
6914        if self.persist_state_locked().is_err() {
6915            self.session_records
6916                .lock()
6917                .unwrap_or_else(|poisoned| poisoned.into_inner())
6918                .records
6919                .insert(record_id.clone(), previous);
6920            return;
6921        }
6922        drop(transaction);
6923        self.publish_record(updated);
6924    }
6925
6926    /// Resolves the managed record and its exact `SessionAddress` currently
6927    /// bound to `instance_id` at exactly `generation`, used by the reactive
6928    /// `Exited{0}` hook in `drive_runtime_until_shutdown` to target a single
6929    /// record without a full rescan. Mirrors the lookup already inlined in
6930    /// `publish_control`. Captured synchronously, before `publish_control`
6931    /// for the same event clears the record's own `active_session` — the
6932    /// caller passes the resolved address through to a detached
6933    /// reconciliation task rather than re-deriving it from the record later,
6934    /// after that race has necessarily already happened.
6935    fn managed_record_export_target_for_instance(
6936        &self,
6937        instance_id: AgentInstanceId,
6938        generation: SessionGeneration,
6939    ) -> Option<(SessionRecordId, SessionAddress)> {
6940        let bindings = self
6941            .session_bindings
6942            .lock()
6943            .unwrap_or_else(|poisoned| poisoned.into_inner());
6944        let binding = bindings.get(&instance_id).filter(|binding| binding.generation == generation)?;
6945        let record_id = binding.record_id.clone()?;
6946        Some((
6947            record_id,
6948            SessionAddress {
6949                workspace_id: binding.workspace_id.clone(),
6950                session: SessionKey { instance_id, generation },
6951            },
6952        ))
6953    }
6954
6955    fn mark_managed_recovery_required(
6956        &self,
6957        lease_id: &ManagedWorktreeLeaseId,
6958        failure_kind: ManagedWorktreeCleanupFailure,
6959    ) {
6960        let snapshot = {
6961            let mut registry = self.managed_worktrees.lock()
6962                .unwrap_or_else(|poisoned| poisoned.into_inner());
6963            let Some(lease) = registry.get_mut(lease_id) else { return };
6964            lease.state = ManagedWorktreeLeaseState::RecoveryRequired;
6965            lease.cleanup_failure = Some(failure_kind);
6966            lease.updated_at_unix_ms = unix_time_ms();
6967            lease.snapshot()
6968        };
6969        let _ = self.persist_state();
6970        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
6971    }
6972
6973    fn set_persistence_error(&self, error: Option<String>) {
6974        *self
6975            .persistence_error
6976            .write()
6977            .unwrap_or_else(|poisoned| poisoned.into_inner()) = error
6978            .map(|message| session_registry::sanitized_persistence_summary(&message));
6979    }
6980
6981    fn persist_state(&self) -> io::Result<()> {
6982        let _transaction = self
6983            .state_transaction
6984            .lock()
6985            .unwrap_or_else(|poisoned| poisoned.into_inner());
6986        self.persist_state_locked()
6987    }
6988
6989    fn persist_state_locked(&self) -> io::Result<()> {
6990        let workspaces = self
6991            .workspaces
6992            .read()
6993            .unwrap_or_else(|poisoned| poisoned.into_inner())
6994            .clone();
6995        let records = self
6996            .session_records
6997            .lock()
6998            .unwrap_or_else(|poisoned| poisoned.into_inner())
6999            .records
7000            .values()
7001            .cloned()
7002            .collect::<Vec<_>>();
7003        let mut managed = self.managed_worktrees
7004            .lock()
7005            .unwrap_or_else(|poisoned| poisoned.into_inner());
7006        managed.reattach_record_holders(
7007            &managed_worktree_record_holders(&records),
7008            unix_time_ms(),
7009        );
7010        let materializations = self
7011            .materializations
7012            .lock()
7013            .unwrap_or_else(|poisoned| poisoned.into_inner())
7014            .values()
7015            .cloned()
7016            .collect::<Vec<_>>();
7017        let managed_spawn_replays = self.managed_spawn_replays
7018            .lock()
7019            .unwrap_or_else(|poisoned| poisoned.into_inner())
7020            .values()
7021            .cloned()
7022            .collect::<Vec<_>>();
7023        let result = session_registry::save_v10(
7024            self.state_path.as_deref(),
7025            &self.node_id,
7026            &workspaces,
7027            &records,
7028            &managed.records(),
7029            &managed.tombstones(),
7030            &materializations,
7031            &managed_spawn_replays,
7032        );
7033        match &result {
7034            Ok(warning) => self.set_persistence_error(warning.clone()),
7035            Err(_) => self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned())),
7036        }
7037        result.map(|_| ())
7038    }
7039
7040    async fn begin_shutdown(&self) -> Result<(), NodeFailure> {
7041        let _mutation_guard = self.mutation_gate.lock().await;
7042        self.begin_shutdown_locked().await
7043    }
7044
7045    async fn begin_shutdown_locked(&self) -> Result<(), NodeFailure> {
7046        if self.shutdown.swap(true, Ordering::AcqRel) {
7047            return Ok(());
7048        }
7049        SessionLifecycleHook::on_shutdown(self);
7050        let deadline = Instant::now() + Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
7051        let mut first_error = None;
7052        for session in &self.handle.snapshot().sessions {
7053            let remaining = deadline.saturating_duration_since(Instant::now());
7054            if remaining.is_zero() {
7055                first_error.get_or_insert_with(|| {
7056                    failure(NodeFailureCode::BackendBusy, "stop-all dispatch exceeded its bounded deadline")
7057                });
7058                break;
7059            }
7060            if let Err(error) = self
7061                .dispatch_bounded(
7062                    ControlCommand::Stop {
7063                        instance_id: session.instance_id,
7064                        force: true,
7065                    },
7066                    remaining,
7067                )
7068                .await
7069            {
7070                first_error.get_or_insert(error);
7071            }
7072        }
7073        self.shutdown_notify.notify_waiters();
7074        match first_error {
7075            Some(error) => Err(error),
7076            None => Ok(()),
7077        }
7078    }
7079
7080    fn ensure_binding_capacity(&self) -> Result<(), NodeFailure> {
7081        let bindings = self
7082            .session_bindings
7083            .lock()
7084            .unwrap_or_else(|poisoned| poisoned.into_inner());
7085        if bindings.len() >= CONTROL_SESSIONS_MAX {
7086            Err(failure(
7087                NodeFailureCode::BackendBusy,
7088                "node session-binding capacity is full; remove an existing session first",
7089            ))
7090        } else {
7091            Ok(())
7092        }
7093    }
7094
7095    /// One-owner workspace/env collision guard: a live session on
7096    /// `workspace_id` with a different `environment_profile` (option
7097    /// equality on the resolved receipt, including Some vs None) blocks
7098    /// another spawn on that same cwd. Same profile may still share the
7099    /// workspace; distinct profiles need distinct workspace ids (managed
7100    /// worktree).
7101    fn ensure_workspace_environment_profile_compatible(
7102        &self,
7103        workspace_id: &WorkspaceId,
7104        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
7105    ) -> Result<(), NodeFailure> {
7106        let bindings = self
7107            .session_bindings
7108            .lock()
7109            .unwrap_or_else(|poisoned| poisoned.into_inner());
7110        for binding in bindings.values() {
7111            if &binding.workspace_id != workspace_id {
7112                continue;
7113            }
7114            if binding.environment_profile.as_ref() != environment_profile {
7115                return Err(failure(
7116                    NodeFailureCode::WorkspaceBusy,
7117                    "workspace already has a live session with a different environment profile; use a managed worktree for concurrent distinct ProviderHome/env bindings on one repo",
7118                ));
7119            }
7120        }
7121        Ok(())
7122    }
7123
7124    #[cfg(test)]
7125    fn bind_session(
7126        &self,
7127        instance_id: AgentInstanceId,
7128        workspace_id: WorkspaceId,
7129        generation: SessionGeneration,
7130    ) {
7131        self.bind_session_with_policy(
7132            &SessionAddress {
7133                workspace_id,
7134                session: SessionKey { instance_id, generation },
7135            },
7136            ProviderRuntimePolicy::raw_pty(),
7137            None,
7138        );
7139    }
7140
7141    #[cfg(test)]
7142    fn bind_session_with_policy(
7143        &self,
7144        address: &SessionAddress,
7145        runtime_policy: ProviderRuntimePolicy,
7146        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7147    ) {
7148        self.bind_session_with_materialization(
7149            address,
7150            runtime_policy,
7151            environment_profile,
7152            None,
7153            None,
7154            None,
7155        );
7156    }
7157
7158    fn bind_session_with_materialization(
7159        &self,
7160        address: &SessionAddress,
7161        runtime_policy: ProviderRuntimePolicy,
7162        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7163        bundle: Option<ResolvedBundleReceipt>,
7164        context: Option<ResolvedContextPackReceipt>,
7165        materialization_id: Option<MaterializationId>,
7166    ) {
7167        let mut bindings = self
7168            .session_bindings
7169            .lock()
7170            .unwrap_or_else(|poisoned| poisoned.into_inner());
7171        debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7172        debug_assert!(!bindings.contains_key(&address.session.instance_id));
7173        bindings.insert(address.session.instance_id, SessionBinding {
7174            workspace_id: address.workspace_id.clone(),
7175            generation: address.session.generation,
7176            runtime_policy,
7177            pending_resume: None,
7178            record_id: None,
7179            managed_worktree_lease_id: None,
7180            environment_profile,
7181            bundle,
7182            context,
7183            materialization_id,
7184        });
7185    }
7186
7187    #[cfg(test)]
7188    fn bind_managed_session(
7189        &self,
7190        address: &SessionAddress,
7191        record_id: SessionRecordId,
7192        runtime_policy: ProviderRuntimePolicy,
7193        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7194    ) {
7195        self.bind_managed_session_with_materialization(
7196            address,
7197            record_id,
7198            runtime_policy,
7199            environment_profile,
7200            None,
7201            None,
7202            None,
7203        );
7204    }
7205
7206    fn bind_managed_session_with_materialization(
7207        &self,
7208        address: &SessionAddress,
7209        record_id: SessionRecordId,
7210        runtime_policy: ProviderRuntimePolicy,
7211        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7212        bundle: Option<ResolvedBundleReceipt>,
7213        context: Option<ResolvedContextPackReceipt>,
7214        materialization_id: Option<MaterializationId>,
7215    ) {
7216        let mut bindings = self
7217            .session_bindings
7218            .lock()
7219            .unwrap_or_else(|poisoned| poisoned.into_inner());
7220        debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7221        debug_assert!(!bindings.contains_key(&address.session.instance_id));
7222        bindings.insert(
7223            address.session.instance_id,
7224            SessionBinding {
7225                workspace_id: address.workspace_id.clone(),
7226                generation: address.session.generation,
7227                runtime_policy,
7228                pending_resume: None,
7229                record_id: Some(record_id),
7230                managed_worktree_lease_id: None,
7231                environment_profile,
7232                bundle,
7233                context,
7234                materialization_id,
7235            },
7236        );
7237    }
7238
7239    #[cfg(test)]
7240    fn bind_spawn_session(
7241        &self,
7242        address: &SessionAddress,
7243        provider: AgentId,
7244        mode: SessionMode,
7245        runtime_policy: ProviderRuntimePolicy,
7246        record_policy: SpawnRecordPolicy,
7247        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7248    ) -> Result<Option<SessionRecordId>, NodeFailure> {
7249        self.bind_spawn_session_with_materialization(
7250            address,
7251            provider,
7252            mode,
7253            runtime_policy,
7254            record_policy,
7255            environment_profile,
7256            None,
7257            None,
7258            None,
7259        )
7260    }
7261
7262    fn bind_spawn_session_with_materialization(
7263        &self,
7264        address: &SessionAddress,
7265        provider: AgentId,
7266        mode: SessionMode,
7267        runtime_policy: ProviderRuntimePolicy,
7268        record_policy: SpawnRecordPolicy,
7269        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7270        bundle: Option<ResolvedBundleReceipt>,
7271        context: Option<ResolvedContextPackReceipt>,
7272        materialization_id: Option<MaterializationId>,
7273    ) -> Result<Option<SessionRecordId>, NodeFailure> {
7274        if record_policy == SpawnRecordPolicy::ProviderIdentityOnly
7275            && !runtime_policy.provider_session_identity
7276        {
7277            self.bind_session_with_materialization(
7278                address,
7279                runtime_policy,
7280                environment_profile,
7281                bundle,
7282                context,
7283                materialization_id,
7284            );
7285            return Ok(None);
7286        }
7287        let record_owns_lifecycle = runtime_policy.provider_session_identity;
7288        let record = self.new_record(
7289            address,
7290            provider,
7291            mode,
7292            active_record_state(runtime_policy.provider_session_identity, false),
7293            None,
7294            record_owns_lifecycle.then(|| environment_profile.clone()).flatten(),
7295            record_owns_lifecycle.then(|| bundle.clone()).flatten(),
7296            record_owns_lifecycle.then(|| context.clone()).flatten(),
7297        )?;
7298        let record_id = record.record_id.clone();
7299        let transaction = self
7300            .state_transaction
7301            .lock()
7302            .unwrap_or_else(|poisoned| poisoned.into_inner());
7303        self.insert_record(record.clone())?;
7304        let previous_materialization = if record_owns_lifecycle {
7305            if let Some(materialization_id) = materialization_id.as_ref() {
7306                let mut materializations = self
7307                    .materializations
7308                    .lock()
7309                    .unwrap_or_else(|poisoned| poisoned.into_inner());
7310                let Some(ownership) = materializations.get_mut(materialization_id) else {
7311                    drop(materializations);
7312                    self.remove_record_memory(&record_id);
7313                    return Err(failure(
7314                        NodeFailureCode::BackendOperationFailed,
7315                        "session environment ownership disappeared before binding",
7316                    ));
7317                };
7318                let previous = ownership.clone();
7319                if ownership
7320                    .transfer_to_record(record_id.clone(), unix_time_ms())
7321                    .is_err()
7322                {
7323                    drop(materializations);
7324                    self.remove_record_memory(&record_id);
7325                    return Err(failure(
7326                        NodeFailureCode::BackendOperationFailed,
7327                        "session environment ownership transfer failed",
7328                    ));
7329                }
7330                Some(previous)
7331            } else {
7332                None
7333            }
7334        } else {
7335            None
7336        };
7337        self.bind_managed_session_with_materialization(
7338            address,
7339            record_id.clone(),
7340            runtime_policy,
7341            environment_profile,
7342            bundle,
7343            context,
7344            materialization_id.clone(),
7345        );
7346        if let Err(error) = self.persist_state_locked() {
7347            self.remove_binding(address);
7348            self.remove_record_memory(&record_id);
7349            if let (Some(materialization_id), Some(previous)) =
7350                (materialization_id.as_ref(), previous_materialization)
7351            {
7352                self.materializations
7353                    .lock()
7354                    .unwrap_or_else(|poisoned| poisoned.into_inner())
7355                    .insert(materialization_id.clone(), previous);
7356            }
7357            return Err(persistence_failure(error));
7358        }
7359        drop(transaction);
7360        self.publish_record(record);
7361        Ok(Some(record_id))
7362    }
7363
7364    fn allocate_record_id(&self) -> Result<SessionRecordId, NodeFailure> {
7365        for _ in 0..8 {
7366            let bytes = random_nonce().map_err(|error| {
7367                failure(
7368                    NodeFailureCode::BackendOperationFailed,
7369                    &format!("session record identity generation failed: {error}"),
7370                )
7371            })?;
7372            let mut value = String::with_capacity(27);
7373            value.push_str("sr-");
7374            for byte in &bytes[..12] {
7375                use std::fmt::Write as _;
7376                let _ = write!(&mut value, "{byte:02x}");
7377            }
7378            let record_id = SessionRecordId::new(value).map_err(|error| {
7379                failure(NodeFailureCode::BackendOperationFailed, &error.to_string())
7380            })?;
7381            let records = self
7382                .session_records
7383                .lock()
7384                .unwrap_or_else(|poisoned| poisoned.into_inner());
7385            if !records.records.contains_key(&record_id) {
7386                return Ok(record_id);
7387            }
7388        }
7389        Err(failure(
7390            NodeFailureCode::BackendBusy,
7391            "could not allocate a unique session record ID",
7392        ))
7393    }
7394
7395    fn allocate_task_id(&self) -> Result<TaskId, NodeFailure> {
7396        for _ in 0..8 {
7397            let bytes = random_nonce().map_err(|error| {
7398                failure(
7399                    NodeFailureCode::BackendOperationFailed,
7400                    &format!("task identity generation failed: {error}"),
7401                )
7402            })?;
7403            let mut nonce = [0_u8; 12];
7404            nonce.copy_from_slice(&bytes[..12]);
7405            let task_id = TaskId::from_nonce(nonce);
7406            let records = self
7407                .session_records
7408                .lock()
7409                .unwrap_or_else(|poisoned| poisoned.into_inner());
7410            if !records.records.values().any(|record| {
7411                record.task_binding.as_ref().and_then(|binding| binding.task_id.as_ref())
7412                    == Some(&task_id)
7413            }) {
7414                return Ok(task_id);
7415            }
7416        }
7417        Err(failure(
7418            NodeFailureCode::BackendBusy,
7419            "could not allocate a unique task ID",
7420        ))
7421    }
7422
7423    fn default_record_name(&self, provider: &AgentId) -> String {
7424        let records = self
7425            .session_records
7426            .lock()
7427            .unwrap_or_else(|poisoned| poisoned.into_inner());
7428        let ordinal = records
7429            .records
7430            .values()
7431            .filter(|record| &record.provider == provider)
7432            .count()
7433            .saturating_add(1);
7434        format!("{} #{ordinal}", provider.as_str())
7435    }
7436
7437    fn new_record(
7438        &self,
7439        address: &SessionAddress,
7440        provider: AgentId,
7441        mode: SessionMode,
7442        state: ManagedSessionState,
7443        provider_session: Option<gate4agent_types::ProviderSessionIdentity>,
7444        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7445        bundle: Option<ResolvedBundleReceipt>,
7446        context: Option<ResolvedContextPackReceipt>,
7447    ) -> Result<ManagedSessionRecord, NodeFailure> {
7448        let canonical_root = self.workspace_root(&address.workspace_id)?;
7449        let now = unix_time_ms();
7450        Ok(ManagedSessionRecord {
7451            record_id: self.allocate_record_id()?,
7452            display_name: self.default_record_name(&provider),
7453            provider,
7454            mode,
7455            state,
7456            workspace_id: address.workspace_id.clone(),
7457            canonical_root: opaque_windows_path(canonical_root),
7458            provider_session,
7459            active_session: Some(address.clone()),
7460            environment_profile,
7461            bundle,
7462            context_id: context.as_ref().map(|receipt| receipt.id.clone()),
7463            context,
7464            exported_context: None,
7465            task_binding: None,
7466            created_at_unix_ms: now,
7467            updated_at_unix_ms: now,
7468            last_error: None,
7469        })
7470    }
7471
7472    fn insert_record(&self, record: ManagedSessionRecord) -> Result<(), NodeFailure> {
7473        let mut records = self
7474            .session_records
7475            .lock()
7476            .unwrap_or_else(|poisoned| poisoned.into_inner());
7477        if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
7478            return Err(failure(
7479                NodeFailureCode::BackendBusy,
7480                "managed session record capacity is full; forget an inactive record first",
7481            ));
7482        }
7483        if let Some(identity) = record.provider_session.as_ref() {
7484            if records.records.values().any(|candidate| {
7485                candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
7486                    session_registry::same_provider_session(
7487                        &candidate.provider,
7488                        candidate_identity,
7489                        &record.provider,
7490                        identity,
7491                    )
7492                })
7493            }) {
7494                return Err(failure(
7495                    NodeFailureCode::SessionRecordConflict,
7496                    "provider session is already represented by another managed record",
7497                ));
7498            }
7499        }
7500        match records.records.entry(record.record_id.clone()) {
7501            std::collections::btree_map::Entry::Vacant(entry) => {
7502                entry.insert(record);
7503            }
7504            std::collections::btree_map::Entry::Occupied(_) => {
7505                return Err(failure(
7506                    NodeFailureCode::SessionRecordConflict,
7507                    "session record ID collision",
7508                ));
7509            }
7510        }
7511        Ok(())
7512    }
7513
7514    fn record(&self, record_id: &SessionRecordId) -> Result<ManagedSessionRecord, NodeFailure> {
7515        self.session_records
7516            .lock()
7517            .unwrap_or_else(|poisoned| poisoned.into_inner())
7518            .records
7519            .get(record_id)
7520            .cloned()
7521            .ok_or_else(|| {
7522                failure(
7523                    NodeFailureCode::UnknownSessionRecord,
7524                    "managed session record does not exist",
7525                )
7526            })
7527    }
7528
7529    fn revalidate_session_record_preview(
7530        &self,
7531        expected: &ManagedSessionRecord,
7532        identity: &ProviderSessionIdentity,
7533    ) -> Result<(), NodeFailure> {
7534        let current = self.record(&expected.record_id)?;
7535        let current_root = self.workspace_root(&current.workspace_id)?;
7536        if current.provider != expected.provider
7537            || current.workspace_id != expected.workspace_id
7538            || current.canonical_root != expected.canonical_root
7539            || current.provider_session.as_ref() != Some(identity)
7540            || !platform::roots_equal(
7541                &current_root,
7542                windows_path_text(&current.canonical_root),
7543            )
7544        {
7545            return Err(failure(
7546                NodeFailureCode::SessionRecordConflict,
7547                "managed session changed while its preview was loading",
7548            ));
7549        }
7550        Ok(())
7551    }
7552
7553    fn revalidate_session_record_context_export(
7554        &self,
7555        expected: &ManagedSessionRecord,
7556        identity: Option<&ProviderSessionIdentity>,
7557        session: &SessionAddress,
7558        allow_clean_detachment: bool,
7559    ) -> Result<(), NodeFailure> {
7560        let current = self.record(&expected.record_id)?;
7561        let current_root = self.workspace_root(&current.workspace_id)?;
7562        let snapshot = self.internal_session_snapshot(session)?;
7563        let binding = self
7564            .session_bindings
7565            .lock()
7566            .unwrap_or_else(|poisoned| poisoned.into_inner())
7567            .get(&session.session.instance_id)
7568            .cloned()
7569            .ok_or_else(|| {
7570                failure(
7571                    NodeFailureCode::SessionRecordConflict,
7572                    "managed session runtime binding disappeared during context export",
7573                )
7574            })?;
7575        if !session_record_context_export_binding_is_exact(
7576            expected,
7577            &current,
7578            identity,
7579            session,
7580            &binding,
7581            &snapshot.agent_id,
7582            &current_root,
7583            allow_clean_detachment,
7584        ) || !session_record_context_export_source_is_usable(
7585            &current,
7586            &snapshot.status,
7587            allow_clean_detachment,
7588        ) {
7589            return Err(failure(
7590                NodeFailureCode::SessionRecordConflict,
7591                "managed session binding changed during context export",
7592            ));
7593        }
7594        Ok(())
7595    }
7596
7597    fn revalidate_loaded_history(
7598        &self,
7599        session: &SessionAddress,
7600        candidate_id: &str,
7601        loaded: &HistorySessionRecord,
7602    ) -> Result<(), NodeFailure> {
7603        let snapshot = self.internal_session_snapshot(session)?;
7604        if snapshot.history.pending.is_some()
7605            || snapshot.history.loaded_candidate_id.as_deref() != Some(candidate_id)
7606            || snapshot.history.loaded.as_ref() != Some(loaded)
7607        {
7608            return Err(failure(
7609                NodeFailureCode::SessionRecordConflict,
7610                "managed session history changed during context export",
7611            ));
7612        }
7613        Ok(())
7614    }
7615
7616    fn remove_record_memory(&self, record_id: &SessionRecordId) -> Option<ManagedSessionRecord> {
7617        self.session_records
7618            .lock()
7619            .unwrap_or_else(|poisoned| poisoned.into_inner())
7620            .records
7621            .remove(record_id)
7622    }
7623
7624    fn discard_record(&self, record_id: &SessionRecordId) -> Result<(), NodeFailure> {
7625        if self.destroy_record_materialization(record_id)? {
7626            self.publish(NodeEvent::SessionRecordRemoved {
7627                record_id: record_id.clone(),
7628            });
7629            return Ok(());
7630        }
7631        let transaction = self
7632            .state_transaction
7633            .lock()
7634            .unwrap_or_else(|poisoned| poisoned.into_inner());
7635        let Some(removed) = self.remove_record_memory(record_id) else {
7636            return Ok(());
7637        };
7638        if let Err(error) = self.persist_state_locked() {
7639            let _ = self.insert_record(removed);
7640            return Err(persistence_failure(error));
7641        }
7642        drop(transaction);
7643        self.publish(NodeEvent::SessionRecordRemoved {
7644            record_id: record_id.clone(),
7645        });
7646        Ok(())
7647    }
7648
7649    fn publish_record(&self, record: ManagedSessionRecord) {
7650        self.publish(NodeEvent::SessionRecordUpserted { record });
7651    }
7652
7653    fn mark_record_error(
7654        &self,
7655        record_id: &SessionRecordId,
7656        message: &str,
7657    ) -> Result<(), NodeFailure> {
7658        let transaction = self
7659            .state_transaction
7660            .lock()
7661            .unwrap_or_else(|poisoned| poisoned.into_inner());
7662        let previous = {
7663            let mut records = self
7664                .session_records
7665                .lock()
7666                .unwrap_or_else(|poisoned| poisoned.into_inner());
7667            let Some(record) = records.records.get_mut(record_id) else {
7668                return Ok(());
7669            };
7670            let previous = record.clone();
7671            record.state = ManagedSessionState::Unavailable;
7672            record.last_error = Some(session_registry::sanitized_record_error_summary(message));
7673            record.updated_at_unix_ms = unix_time_ms();
7674            previous
7675        };
7676        if let Err(error) = self.persist_state_locked() {
7677            self.session_records
7678                .lock()
7679                .unwrap_or_else(|poisoned| poisoned.into_inner())
7680                .records
7681                .insert(record_id.clone(), previous);
7682            return Err(persistence_failure(error));
7683        }
7684        let record = self.record(record_id)?;
7685        drop(transaction);
7686        self.publish_record(record);
7687        Ok(())
7688    }
7689
7690    fn rename_session_record(
7691        &self,
7692        record_id: &SessionRecordId,
7693        display_name: String,
7694    ) -> Result<ManagedSessionRecord, NodeFailure> {
7695        validate_display_name(&display_name)
7696            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
7697        let _transaction = self
7698            .state_transaction
7699            .lock()
7700            .unwrap_or_else(|poisoned| poisoned.into_inner());
7701        let previous = {
7702            let mut records = self
7703                .session_records
7704                .lock()
7705                .unwrap_or_else(|poisoned| poisoned.into_inner());
7706            let record = records.records.get_mut(record_id).ok_or_else(|| {
7707                failure(
7708                    NodeFailureCode::UnknownSessionRecord,
7709                    "managed session record does not exist",
7710                )
7711            })?;
7712            let previous = record.clone();
7713            record.display_name = display_name;
7714            record.updated_at_unix_ms = unix_time_ms();
7715            previous
7716        };
7717        if let Err(error) = self.persist_state_locked() {
7718            self.session_records
7719                .lock()
7720                .unwrap_or_else(|poisoned| poisoned.into_inner())
7721                .records
7722                .insert(record_id.clone(), previous);
7723            return Err(persistence_failure(error));
7724        }
7725        let updated = self.record(record_id)?;
7726        self.publish_record(updated.clone());
7727        Ok(updated)
7728    }
7729
7730    fn set_session_task(
7731        &self,
7732        record_id: &SessionRecordId,
7733        expected_revision: u64,
7734        target: SessionTaskTargetV1,
7735    ) -> Result<ManagedSessionRecord, NodeFailure> {
7736        let transaction = self
7737            .state_transaction
7738            .lock()
7739            .unwrap_or_else(|poisoned| poisoned.into_inner());
7740        let current = self.record(record_id)?;
7741        let current_task_id = current
7742            .task_binding
7743            .as_ref()
7744            .and_then(|binding| binding.task_id.as_ref());
7745        let current_revision = current
7746            .task_binding
7747            .as_ref()
7748            .map_or(0, |binding| binding.revision);
7749        if expected_revision != current_revision {
7750            return Err(failure(
7751                NodeFailureCode::SessionRecordConflict,
7752                "managed session task binding revision changed",
7753            ));
7754        }
7755        let exact = match &target {
7756            SessionTaskTargetV1::Existing { task_id } => current_task_id == Some(task_id),
7757            SessionTaskTargetV1::Clear => current_task_id.is_none(),
7758            SessionTaskTargetV1::New => false,
7759        };
7760        if exact {
7761            return Ok(current);
7762        }
7763        let revision = current_revision.checked_add(1).ok_or_else(|| {
7764            failure(
7765                NodeFailureCode::SessionRecordConflict,
7766                "managed session task binding revision is exhausted",
7767            )
7768        })?;
7769        let task_id = match target {
7770            SessionTaskTargetV1::New => Some(self.allocate_task_id()?),
7771            SessionTaskTargetV1::Existing { task_id } => Some(task_id),
7772            SessionTaskTargetV1::Clear => None,
7773        };
7774        let changed_at_unix_ms = unix_time_ms()
7775            .max(current.updated_at_unix_ms.checked_add(1).ok_or_else(|| {
7776                failure(
7777                    NodeFailureCode::SessionRecordConflict,
7778                    "managed session record timestamp is exhausted",
7779                )
7780            })?);
7781        let updated = {
7782            let mut records = self
7783                .session_records
7784                .lock()
7785                .unwrap_or_else(|poisoned| poisoned.into_inner());
7786            let record = records.records.get_mut(record_id).ok_or_else(|| {
7787                failure(
7788                    NodeFailureCode::UnknownSessionRecord,
7789                    "managed session record does not exist",
7790                )
7791            })?;
7792            record.task_binding = Some(SessionTaskBindingV1 {
7793                revision,
7794                task_id,
7795                changed_at_unix_ms,
7796            });
7797            record.updated_at_unix_ms = changed_at_unix_ms;
7798            record.clone()
7799        };
7800        if let Err(error) = self.persist_state_locked() {
7801            self.session_records
7802                .lock()
7803                .unwrap_or_else(|poisoned| poisoned.into_inner())
7804                .records
7805                .insert(record_id.clone(), current);
7806            return Err(persistence_failure(error));
7807        }
7808        drop(transaction);
7809        self.publish_record(updated.clone());
7810        Ok(updated)
7811    }
7812
7813    fn index_provider_session(
7814        &self,
7815        workspace_id: WorkspaceId,
7816        provider: AgentId,
7817        identity: ProviderSessionIdentity,
7818        display_name: String,
7819    ) -> Result<ManagedSessionRecord, NodeFailure> {
7820        self.index_provider_session_with_policy(
7821            workspace_id,
7822            provider,
7823            identity,
7824            display_name,
7825            false,
7826        )
7827    }
7828
7829    fn index_provider_session_with_policy(
7830        &self,
7831        workspace_id: WorkspaceId,
7832        provider: AgentId,
7833        identity: ProviderSessionIdentity,
7834        display_name: String,
7835        allow_validated_transcript_path: bool,
7836    ) -> Result<ManagedSessionRecord, NodeFailure> {
7837        validate_display_name(&display_name)
7838            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
7839        identity
7840            .validate()
7841            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
7842        if identity.transcript_path.is_some() && !allow_validated_transcript_path {
7843            return Err(failure(
7844                NodeFailureCode::InvalidRequest,
7845                "provider session reference index v1 does not accept transcript paths",
7846            ));
7847        }
7848        let canonical_root = self.workspace_root(&workspace_id)?;
7849        if !self.enabled_providers.contains(&provider) {
7850            return Err(failure(
7851                NodeFailureCode::InvalidRequest,
7852                "provider is not enabled on this node",
7853            ));
7854        }
7855
7856        let transaction = self
7857            .state_transaction
7858            .lock()
7859            .unwrap_or_else(|poisoned| poisoned.into_inner());
7860        {
7861            let records = self
7862                .session_records
7863                .lock()
7864                .unwrap_or_else(|poisoned| poisoned.into_inner());
7865            if let Some(existing) = records.records.values().find(|candidate| {
7866                candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
7867                    candidate_identity.key == identity.key && candidate_identity.id == identity.id
7868                })
7869            }) {
7870                if existing.provider == provider && existing.workspace_id == workspace_id {
7871                    return Ok(existing.clone());
7872                }
7873                return Err(failure(
7874                    NodeFailureCode::SessionRecordConflict,
7875                    "provider session reference conflicts with another provider or workspace",
7876                ));
7877            }
7878            if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
7879                return Err(failure(
7880                    NodeFailureCode::BackendBusy,
7881                    "managed session record capacity is full; forget an inactive record first",
7882                ));
7883            }
7884        }
7885
7886        let now = unix_time_ms();
7887        let record = ManagedSessionRecord {
7888            record_id: self.allocate_record_id()?,
7889            display_name,
7890            provider,
7891            mode: SessionMode::Pty,
7892            state: ManagedSessionState::Dormant,
7893            workspace_id,
7894            canonical_root: opaque_windows_path(canonical_root),
7895            provider_session: Some(identity),
7896            active_session: None,
7897            environment_profile: None,
7898            bundle: None,
7899            context_id: None,
7900            context: None,
7901            exported_context: None,
7902            task_binding: None,
7903            created_at_unix_ms: now,
7904            updated_at_unix_ms: now,
7905            last_error: None,
7906        };
7907        self.insert_record(record.clone())?;
7908        if let Err(error) = self.persist_state_locked() {
7909            self.remove_record_memory(&record.record_id);
7910            return Err(persistence_failure(error));
7911        }
7912        drop(transaction);
7913        self.publish_record(record.clone());
7914        Ok(record)
7915    }
7916
7917    fn bound_address_for_record(&self, record_id: &SessionRecordId) -> Option<SessionAddress> {
7918        self.session_bindings
7919            .lock()
7920            .unwrap_or_else(|poisoned| poisoned.into_inner())
7921            .iter()
7922            .find_map(|(instance_id, binding)| {
7923                (binding.record_id.as_ref() == Some(record_id)).then(|| SessionAddress {
7924                    workspace_id: binding.workspace_id.clone(),
7925                    session: SessionKey {
7926                        instance_id: *instance_id,
7927                        generation: binding.generation,
7928                    },
7929                })
7930            })
7931    }
7932
7933    async fn forget_session_record(
7934        &self,
7935        record_id: &SessionRecordId,
7936    ) -> Result<(), NodeFailure> {
7937        let record = self.record(record_id)?;
7938        if record.active_session.is_some()
7939            || matches!(
7940                record.state,
7941                ManagedSessionState::Live | ManagedSessionState::IdentityPending
7942            )
7943        {
7944            return Err(failure(
7945                NodeFailureCode::SessionRecordBusy,
7946                "stop the managed session before forgetting it",
7947            ));
7948        }
7949        if let Some(address) = self.bound_address_for_record(record_id) {
7950            self.remove_session(&address).await?;
7951        }
7952        self.remove_managed_session_record(record_id, &record.workspace_id).await
7953    }
7954
7955    /// Removes one managed session record from durable state and notifies
7956    /// every listener -- the shared transaction body behind both
7957    /// `forget_session_record`'s operator-driven path (busy-checked above
7958    /// it, and clears any still-live session binding before calling in
7959    /// here) and the retention sweep (`retire_unavailable_session_records`,
7960    /// which only ever selects `ManagedSessionState::Unavailable` records
7961    /// that are already known never-busy and never bound, so it calls
7962    /// straight into this with neither check). Cleans a managed-worktree
7963    /// lease if the record held one, removes the record's environment
7964    /// materialization (or, absent one, removes it from memory directly),
7965    /// persists under `state_transaction` with rollback on persist failure,
7966    /// then publishes `NodeEvent::SessionRecordRemoved` so c2's own
7967    /// legacy-provider bookkeeping (`record_is_legacy`) stays in sync
7968    /// exactly as it does for an operator-driven forget.
7969    async fn remove_managed_session_record(
7970        &self,
7971        record_id: &SessionRecordId,
7972        workspace_id: &WorkspaceId,
7973    ) -> Result<(), NodeFailure> {
7974        let managed_lease_id = self.managed_worktrees.lock()
7975            .unwrap_or_else(|poisoned| poisoned.into_inner())
7976            .lease_for_workspace(workspace_id);
7977        if !self.destroy_record_materialization(record_id)? {
7978            let transaction = self
7979                .state_transaction
7980                .lock()
7981                .unwrap_or_else(|poisoned| poisoned.into_inner());
7982            let removed = self.remove_record_memory(record_id).ok_or_else(|| {
7983                failure(
7984                    NodeFailureCode::UnknownSessionRecord,
7985                    "managed session record does not exist",
7986                )
7987            })?;
7988            if let Err(error) = self.persist_state_locked() {
7989                let _ = self.insert_record(removed);
7990                return Err(persistence_failure(error));
7991            }
7992            drop(transaction);
7993        }
7994        self.publish(NodeEvent::SessionRecordRemoved {
7995            record_id: record_id.clone(),
7996        });
7997        if let Some(lease_id) = managed_lease_id {
7998            if let Some(snapshot) = self.managed_worktrees.lock()
7999                .unwrap_or_else(|poisoned| poisoned.into_inner())
8000                .get(&lease_id)
8001                .map(ManagedWorktreeLeaseRecord::snapshot)
8002            {
8003                self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
8004            }
8005            let _ = self.cleanup_managed_worktree(&lease_id, false).await;
8006        }
8007        Ok(())
8008    }
8009
8010    /// Retires `ManagedSessionState::Unavailable` records against the
8011    /// node's configured `session_record_retention` age/keep-N policy.
8012    /// Never touches `Live`, `IdentityPending`, or `Dormant` records --
8013    /// `Dormant` is resumable and folding it into this sweep would delete
8014    /// records a caller can still act on. Both policy fields default to
8015    /// `0` (disabled); when both are `0` this is a no-op. When both are
8016    /// set, a record is retired only if it fails BOTH tests (older than
8017    /// `age_ms` AND beyond the newest `keep_per_workspace` in its
8018    /// workspace); when only one is set, that one alone decides. Called
8019    /// once at startup (alongside `reconcile_managed_worktrees`) and then
8020    /// on a `SESSION_RECORD_RETENTION_SWEEP_PERIOD` cadence from the drive
8021    /// loop -- see both call sites.
8022    async fn retire_unavailable_session_records(&self) {
8023        let policy = self.session_record_retention;
8024        let age_enabled = policy.age_ms > 0;
8025        let keep_enabled = policy.keep_per_workspace > 0;
8026        if !age_enabled && !keep_enabled {
8027            return;
8028        }
8029        let now = unix_time_ms();
8030        let candidates: Vec<(SessionRecordId, WorkspaceId, AgentId, u64)> = {
8031            self.session_records
8032                .lock()
8033                .unwrap_or_else(|poisoned| poisoned.into_inner())
8034                .records
8035                .values()
8036                .filter(|record| record.state == ManagedSessionState::Unavailable)
8037                .map(|record| {
8038                    (
8039                        record.record_id.clone(),
8040                        record.workspace_id.clone(),
8041                        record.provider.clone(),
8042                        record.updated_at_unix_ms,
8043                    )
8044                })
8045                .collect()
8046        };
8047        if candidates.is_empty() {
8048            return;
8049        }
8050        let mut by_workspace: BTreeMap<WorkspaceId, Vec<(SessionRecordId, AgentId, u64)>> =
8051            BTreeMap::new();
8052        for (record_id, workspace_id, provider, updated_at_unix_ms) in candidates {
8053            by_workspace
8054                .entry(workspace_id)
8055                .or_default()
8056                .push((record_id, provider, updated_at_unix_ms));
8057        }
8058        let reason = if age_enabled && keep_enabled {
8059            "age+keep-n"
8060        } else if age_enabled {
8061            "age"
8062        } else {
8063            "keep-n"
8064        };
8065        let mut retired: u64 = 0;
8066        for (workspace_id, mut records) in by_workspace {
8067            // Newest `updated_at_unix_ms` first, so index `>=
8068            // keep_per_workspace` names exactly "beyond the newest N".
8069            records.sort_by(|left, right| right.2.cmp(&left.2));
8070            for (index, (record_id, provider, updated_at_unix_ms)) in
8071                records.into_iter().enumerate()
8072            {
8073                let record_age_ms = now.saturating_sub(updated_at_unix_ms);
8074                let age_hit = age_enabled && record_age_ms >= policy.age_ms;
8075                let keep_hit = keep_enabled && index >= policy.keep_per_workspace as usize;
8076                let should_retire = if age_enabled && keep_enabled {
8077                    age_hit && keep_hit
8078                } else {
8079                    age_hit || keep_hit
8080                };
8081                if !should_retire {
8082                    continue;
8083                }
8084                match self.remove_managed_session_record(&record_id, &workspace_id).await {
8085                    Ok(()) => {
8086                        self.retired_records_total.fetch_add(1, Ordering::Relaxed);
8087                        retired += 1;
8088                        tracing::info!(
8089                            node_id = %self.node_id,
8090                            record_id = %record_id,
8091                            workspace_id = %workspace_id,
8092                            provider = %provider,
8093                            age_ms = record_age_ms,
8094                            reason,
8095                            "managed session record retired"
8096                        );
8097                    }
8098                    Err(error) => tracing::warn!(
8099                        node_id = %self.node_id,
8100                        record_id = %record_id,
8101                        workspace_id = %workspace_id,
8102                        code = ?error.code,
8103                        cause = %error.message,
8104                        "session record retention sweep failed to retire a record"
8105                    ),
8106                }
8107            }
8108        }
8109        if retired > 0 {
8110            let remaining_unavailable = self
8111                .session_records
8112                .lock()
8113                .unwrap_or_else(|poisoned| poisoned.into_inner())
8114                .records
8115                .values()
8116                .filter(|record| record.state == ManagedSessionState::Unavailable)
8117                .count();
8118            tracing::info!(
8119                node_id = %self.node_id,
8120                retired,
8121                remaining_unavailable,
8122                "session record retention sweep completed"
8123            );
8124        }
8125    }
8126
8127    async fn resume_session_record(
8128        &self,
8129        record_id: &SessionRecordId,
8130        terminal_size: gate4agent_types::TerminalSize,
8131        initial_prompt: Option<String>,
8132    ) -> Result<(ManagedSessionRecord, SessionAddress), NodeFailure> {
8133        let record = self.record(record_id)?;
8134        if record.active_session.is_some()
8135            || matches!(
8136                record.state,
8137                ManagedSessionState::Live | ManagedSessionState::IdentityPending
8138            )
8139        {
8140            return Err(failure(
8141                NodeFailureCode::SessionRecordBusy,
8142                "managed session already has a live runtime binding",
8143            ));
8144        }
8145        let identity = record.provider_session.clone().ok_or_else(|| {
8146            failure(
8147                NodeFailureCode::SessionRecordNotResumable,
8148                "managed session has no verified provider session identity",
8149            )
8150        })?;
8151        let working_directory = self.workspace_root(&record.workspace_id)?;
8152        if !platform::roots_equal(
8153            &working_directory,
8154            windows_path_text(&record.canonical_root),
8155        ) {
8156            return Err(failure(
8157                NodeFailureCode::SessionRecordConflict,
8158                "managed session workspace root changed; refusing to resume in another directory",
8159            ));
8160        }
8161        {
8162            let records = self
8163                .session_records
8164                .lock()
8165                .unwrap_or_else(|poisoned| poisoned.into_inner());
8166            if records.records.values().any(|candidate| {
8167                candidate.record_id != *record_id
8168                    && candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8169                        session_registry::same_provider_session(
8170                            &candidate.provider,
8171                            candidate_identity,
8172                            &record.provider,
8173                            &identity,
8174                        )
8175                    })
8176                    && (candidate.active_session.is_some()
8177                        || matches!(
8178                            candidate.state,
8179                            ManagedSessionState::Live | ManagedSessionState::IdentityPending
8180                        ))
8181            }) {
8182                return Err(failure(
8183                    NodeFailureCode::SessionRecordConflict,
8184                    "provider session is already active through another managed record",
8185                ));
8186            }
8187        }
8188        let runtime_requirement = if initial_prompt.is_some() {
8189            ProviderRuntimeRequirement::ResumeWithPrompt
8190        } else {
8191            ProviderRuntimeRequirement::Resume
8192        };
8193        let runtime_policy = self.admit_provider_runtime(
8194            &record.provider,
8195            runtime_requirement,
8196        )
8197        .await?;
8198        let request = ResumeLaunchRequest {
8199            working_directory,
8200            terminal_size,
8201            initial_prompt,
8202        };
8203        request
8204            .validate()
8205            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8206        self.ensure_binding_capacity()?;
8207        let resolved_materialization = match self.resolve_record_materialization(
8208            record_id,
8209            &record.provider,
8210            record.mode,
8211            record.environment_profile.as_ref(),
8212            record.bundle.as_ref(),
8213            record.context.as_ref(),
8214        ) {
8215            Ok(materialization) => materialization,
8216            Err(error) => {
8217                let label = match error.code {
8218                    NodeFailureCode::UnknownBundle => "bundle-unavailable",
8219                    NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
8220                    NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
8221                    _ => "environment-profile-unavailable",
8222                };
8223                self.mark_record_error(record_id, label)?;
8224                return Err(error);
8225            }
8226        };
8227        let (materialization_id, environment_overlay) = match resolved_materialization {
8228            Some((id, overlay)) => (Some(id), overlay),
8229            None => (None, None),
8230        };
8231        let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
8232        let mut environment_selection = match SpawnPreparer::select_environment(self, 
8233            instance_id,
8234            &record.provider,
8235            record.mode,
8236            record.environment_profile.as_ref(),
8237        ) {
8238            Ok(selection) => selection,
8239            Err(error) => {
8240                self.mark_record_error(record_id, "environment-profile-unavailable")?;
8241                return Err(error);
8242            }
8243        };
8244        let mut instance_overlay = environment_overlay
8245            .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
8246            .transpose()?
8247            .flatten();
8248        if let Some(stale_address) = self.bound_address_for_record(record_id) {
8249            self.remove_session(&stale_address).await?;
8250        }
8251        let address = SessionAddress {
8252            workspace_id: record.workspace_id.clone(),
8253            session: SessionKey {
8254                instance_id,
8255                generation: SessionGeneration(1),
8256            },
8257        };
8258        let transaction = self
8259            .state_transaction
8260            .lock()
8261            .unwrap_or_else(|poisoned| poisoned.into_inner());
8262        let current = self.record(record_id)?;
8263        if current.active_session.is_some()
8264            || matches!(
8265                current.state,
8266                ManagedSessionState::Live | ManagedSessionState::IdentityPending
8267            )
8268        {
8269            return Err(failure(
8270                NodeFailureCode::SessionRecordBusy,
8271                "managed session became active while resume was being prepared",
8272            ));
8273        }
8274        self.bind_managed_session_with_materialization(
8275            &address,
8276            record_id.clone(),
8277            runtime_policy,
8278            record.environment_profile.clone(),
8279            record.bundle.clone(),
8280            record.context.clone(),
8281            materialization_id,
8282        );
8283        if let Some(selection) = environment_selection.take() {
8284            selection.retain();
8285        }
8286        if let Some(overlay) = instance_overlay.take() {
8287            overlay.retain();
8288        }
8289        drop(transaction);
8290
8291        let transport = match record.mode {
8292            SessionMode::Pty => TransportKind::Pty,
8293            SessionMode::Inline => TransportKind::Pipe,
8294            SessionMode::Acp => TransportKind::Acp,
8295        };
8296        let agent_id = record.provider.clone();
8297        let dispatch_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
8298        if let Err(error) = self
8299            .dispatch_bounded(
8300                ControlCommand::Register {
8301                    instance_id,
8302                    agent_id,
8303                    transport,
8304                },
8305                dispatch_timeout,
8306            )
8307            .await
8308        {
8309            if let Some(binding) = self.remove_binding(&address) {
8310                SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
8311            }
8312            return Err(error);
8313        }
8314        let command = self.prepare_command(ControlCommand::Resume {
8315            instance_id,
8316            target: ResumeTarget::ProviderSession { identity },
8317            runtime_policy,
8318            request,
8319        });
8320        let started_after = self.current_sequence();
8321        if let Err(error) = self.arm_resume(&address, command.id, runtime_policy) {
8322            self.rollback_spawn(&address, dispatch_timeout).await?;
8323            return Err(error);
8324        }
8325        if let Err(error) = self.dispatch_envelope(command) {
8326            self.clear_armed_resume(&address);
8327            self.rollback_spawn(&address, dispatch_timeout).await?;
8328            return Err(error);
8329        }
8330        match self
8331            .wait_for_record_resume(&address, started_after)
8332            .await
8333        {
8334            Ok(settled_address) => {
8335                let managed_lease_id = {
8336                    self.managed_worktrees.lock()
8337                        .unwrap_or_else(|poisoned| poisoned.into_inner())
8338                        .lease_for_workspace(&record.workspace_id)
8339                };
8340                if let Some(lease_id) = managed_lease_id {
8341                    {
8342                        let mut bindings = self.session_bindings.lock()
8343                            .unwrap_or_else(|poisoned| poisoned.into_inner());
8344                        let binding = bindings.get_mut(&settled_address.session.instance_id)
8345                            .ok_or_else(|| failure(
8346                                NodeFailureCode::ManagedWorktreeRecoveryRequired,
8347                                "resumed managed session lost its exact binding",
8348                            ))?;
8349                        if binding.generation != settled_address.session.generation {
8350                            return Err(failure(
8351                                NodeFailureCode::ManagedWorktreeRecoveryRequired,
8352                                "resumed managed session generation diverged",
8353                            ));
8354                        }
8355                        binding.managed_worktree_lease_id = Some(lease_id.clone());
8356                    }
8357                    let snapshot = self.managed_worktrees.lock()
8358                        .unwrap_or_else(|poisoned| poisoned.into_inner())
8359                        .bind_session(
8360                            &lease_id,
8361                            ManagedWorktreeSessionHolder {
8362                                incarnation_id: self.incarnation_id,
8363                                instance_id: settled_address.session.instance_id,
8364                                generation: settled_address.session.generation,
8365                            },
8366                            Some(record_id.clone()),
8367                            unix_time_ms(),
8368                        ).map_err(|message| failure(
8369                            NodeFailureCode::ManagedWorktreeRecoveryRequired,
8370                            &message,
8371                        ))?;
8372                    if let Err(error) = self.persist_state() {
8373                        let _ = self.remove_session(&settled_address).await;
8374                        return Err(persistence_failure(error));
8375                    }
8376                    self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
8377                }
8378                Ok((self.record(record_id)?, settled_address))
8379            }
8380            Err(error) => {
8381                if matches!(
8382                    error.code,
8383                    NodeFailureCode::SessionRecordNotResumable
8384                        | NodeFailureCode::BackendOperationFailed
8385                ) {
8386                    if let Some(bound) = self.bound_address_for_record(record_id) {
8387                        let _ = self.remove_session(&bound).await;
8388                    }
8389                }
8390                Err(error)
8391            }
8392        }
8393    }
8394
8395    async fn wait_for_record_resume(
8396        &self,
8397        initial_address: &SessionAddress,
8398        after_sequence: u64,
8399    ) -> Result<SessionAddress, NodeFailure> {
8400        let deadline = Instant::now()
8401            + Duration::from_millis(MANAGED_RESUME_SETTLE_TIMEOUT_MS);
8402        let mut scan_after = after_sequence;
8403        loop {
8404            let events = self
8405                .history
8406                .lock()
8407                .unwrap_or_else(|poisoned| poisoned.into_inner())
8408                .events
8409                .iter()
8410                .filter(|event| event.sequence > scan_after)
8411                .cloned()
8412                .collect::<Vec<_>>();
8413            for envelope in events {
8414                scan_after = scan_after.max(envelope.sequence);
8415                let NodeEvent::Control { address, event } = envelope.event else {
8416                    continue;
8417                };
8418                if address.session.instance_id != initial_address.session.instance_id {
8419                    continue;
8420                }
8421                match event.event {
8422                    ControlEventKind::Resumed { .. } => return Ok(address),
8423                    ControlEventKind::ResumeDenied { reason } => {
8424                        return Err(failure(
8425                            NodeFailureCode::SessionRecordNotResumable,
8426                            &reason,
8427                        ));
8428                    }
8429                    ControlEventKind::ResumeFailed { message }
8430                    | ControlEventKind::CommandRejected { message } => {
8431                        return Err(failure(
8432                            NodeFailureCode::BackendOperationFailed,
8433                            &message,
8434                        ));
8435                    }
8436                    _ => {}
8437                }
8438            }
8439            if Instant::now() >= deadline {
8440                return Err(failure(
8441                    NodeFailureCode::BackendBusy,
8442                    "managed resume did not settle before the bounded deadline",
8443                ));
8444            }
8445            sleep(Duration::from_millis(5)).await;
8446        }
8447    }
8448
8449    fn workspace_root(&self, workspace_id: &WorkspaceId) -> Result<String, NodeFailure> {
8450        self.workspaces
8451            .read()
8452            .unwrap_or_else(|poisoned| poisoned.into_inner())
8453            .get(workspace_id)
8454            .cloned()
8455            .ok_or_else(|| failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"))
8456    }
8457
8458    fn workspace_roots(&self) -> Vec<PathBuf> {
8459        self.workspaces
8460            .read()
8461            .unwrap_or_else(|poisoned| poisoned.into_inner())
8462            .values()
8463            .map(PathBuf::from)
8464            .collect()
8465    }
8466
8467    async fn inspect_workspace(
8468        &self,
8469        workspace_id: WorkspaceId,
8470    ) -> Result<WorkspaceInspection, NodeFailure> {
8471        let _permit = self
8472            .inspection_slots
8473            .clone()
8474            .try_acquire_owned()
8475            .map_err(|_| {
8476                tracing::warn!(
8477                    workspace_id = %workspace_id,
8478                    "workspace inspection rejected: inspection capacity is busy",
8479                );
8480                failure(
8481                    NodeFailureCode::BackendBusy,
8482                    "workspace inspection capacity is busy",
8483                )
8484            })?;
8485        let canonical_root = self.workspace_root(&workspace_id).map_err(|error| {
8486            tracing::warn!(
8487                workspace_id = %workspace_id,
8488                code = ?error.code,
8489                "workspace inspection rejected: workspace is not registered",
8490            );
8491            error
8492        })?;
8493        let tree_root = canonical_root.clone();
8494        let started_at = Instant::now();
8495        let time_budget_ms = workspace_inspection_time_budget_ms();
8496        let entry_cap = workspace_inspection_entry_cap();
8497        let deadline = started_at + Duration::from_millis(time_budget_ms);
8498        let (entries, tree_truncated, walk_budget) = tokio::task::spawn_blocking(move || {
8499            collect_workspace_entries(Path::new(&tree_root), deadline, entry_cap)
8500        })
8501        .await
8502        .map_err(|error| {
8503            tracing::warn!(
8504                workspace_id = %workspace_id,
8505                error = %error,
8506                "workspace inspection rejected: walk task failed",
8507            );
8508            failure(
8509                NodeFailureCode::BackendOperationFailed,
8510                &format!("workspace inspection task failed: {error}"),
8511            )
8512        })?;
8513        let (mut git, git_time_budget_exceeded) =
8514            inspect_git_workspace(&canonical_root, deadline).await;
8515        if !git.worktrees.is_empty() {
8516            let registered = self
8517                .workspaces
8518                .read()
8519                .unwrap_or_else(|poisoned| poisoned.into_inner())
8520                .iter()
8521                .map(|(workspace_id, root)| (workspace_id.clone(), root.clone()))
8522                .collect::<Vec<_>>();
8523            for worktree in &mut git.worktrees {
8524                worktree.workspace_id = registered
8525                    .iter()
8526                    .find(|(_, root)| {
8527                        worktree_paths_equal(root, windows_path_text(&worktree.path))
8528                    })
8529                    .map(|(workspace_id, _)| workspace_id.clone());
8530            }
8531        }
8532        if git.is_repository {
8533            git.managed_worktree = self
8534                .managed_worktrees
8535                .lock()
8536                .unwrap_or_else(|poisoned| poisoned.into_inner())
8537                .git_scope(&workspace_id, git.branch.as_deref());
8538        }
8539        let truncation = workspace_inspection_truncation(
8540            &walk_budget,
8541            git_time_budget_exceeded,
8542            started_at.elapsed(),
8543        );
8544        if let Some(truncation) = &truncation {
8545            tracing::info!(
8546                workspace_id = %workspace_id,
8547                walk_time_budget_exceeded = truncation.walk_time_budget_exceeded,
8548                walk_entry_cap_exceeded = truncation.walk_entry_cap_exceeded,
8549                git_time_budget_exceeded = truncation.git_time_budget_exceeded,
8550                entries_visited = truncation.entries_visited,
8551                elapsed_ms = truncation.elapsed_ms,
8552                "workspace inspection truncated by its inner budget",
8553            );
8554        }
8555        Ok(WorkspaceInspection {
8556            workspace_id,
8557            entries,
8558            tree_truncated,
8559            git,
8560            truncation,
8561        })
8562    }
8563
8564    async fn browse_host_directories(
8565        &self,
8566        directory: Option<OpaqueHostPath>,
8567        after: Option<OpaqueHostPath>,
8568    ) -> Result<HostDirectoryListing, NodeFailure> {
8569        let permit = self
8570            .inspection_slots
8571            .clone()
8572            .try_acquire_owned()
8573            .map_err(|_| {
8574                failure(
8575                    NodeFailureCode::BackendBusy,
8576                    "host directory browse capacity is busy",
8577                )
8578            })?;
8579        let task = tokio::task::spawn_blocking(move || {
8580            let _permit = permit;
8581            browse_host_directories(directory, after)
8582        });
8583        timeout(Duration::from_millis(HOST_DIRECTORY_BROWSE_TIMEOUT_MS), task)
8584            .await
8585            .map_err(|_| {
8586                failure(
8587                    NodeFailureCode::HostDirectoryReadTimedOut,
8588                    "host directory browse exceeded its bounded deadline",
8589                )
8590            })?
8591            .map_err(|_| {
8592                failure(
8593                    NodeFailureCode::HostDirectoryReadFailed,
8594                    "host directory browse task failed",
8595                )
8596            })?
8597            .map_err(host_directory_failure)
8598    }
8599
8600    async fn read_workspace_file(
8601        &self,
8602        workspace_id: WorkspaceId,
8603        path: RepositoryPath,
8604    ) -> Result<WorkspaceFileRead, NodeFailure> {
8605        let canonical_root = self.workspace_root(&workspace_id)?;
8606        let permit = self
8607            .inspection_slots
8608            .clone()
8609            .try_acquire_owned()
8610            .map_err(|_| {
8611                failure(
8612                    NodeFailureCode::BackendBusy,
8613                    "workspace file read capacity is busy",
8614                )
8615            })?;
8616        let path_to_read = path.clone();
8617        let task = tokio::task::spawn_blocking(move || {
8618            let _permit = permit;
8619            read_workspace_file_from_disk(Path::new(&canonical_root), &path_to_read)
8620        });
8621        let content = timeout(
8622            Duration::from_millis(WORKSPACE_FILE_READ_TIMEOUT_MS),
8623            task,
8624        )
8625        .await
8626        .map_err(|_| {
8627            failure(
8628                NodeFailureCode::RepositoryFileReadTimedOut,
8629                "repository file read exceeded its bounded deadline",
8630            )
8631        })?
8632        .map_err(|_| {
8633            failure(
8634                NodeFailureCode::RepositoryFileReadFailed,
8635                "repository file read task failed",
8636            )
8637        })?
8638        .map_err(workspace_file_failure)?;
8639        let content = match content {
8640            WorkspaceFileBytes::Utf8(text) => {
8641                let revision = Some(workspace_file_revision(text.as_bytes()));
8642                return Ok(WorkspaceFileRead {
8643                    workspace_id,
8644                    path,
8645                    content: WorkspaceFileContent::Utf8 {
8646                        byte_len: u32::try_from(text.len())
8647                            .expect("bounded workspace text length must fit u32"),
8648                        text,
8649                    },
8650                    revision,
8651                });
8652            }
8653            WorkspaceFileBytes::NonUtf8 { byte_length } => {
8654                WorkspaceFileContent::NonUtf8 {
8655                    byte_len: u32::try_from(byte_length)
8656                        .expect("bounded workspace file length must fit u32"),
8657                }
8658            }
8659            WorkspaceFileBytes::TooLarge => WorkspaceFileContent::TooLarge {
8660                limit_bytes: MAX_WORKSPACE_FILE_BYTES as u32,
8661            },
8662        };
8663        Ok(WorkspaceFileRead {
8664            workspace_id,
8665            path,
8666            content,
8667            revision: None,
8668        })
8669    }
8670
8671    async fn write_workspace_file(
8672        &self,
8673        workspace_id: WorkspaceId,
8674        path: RepositoryPath,
8675        expected_revision: WorkspaceFileRevision,
8676        text: String,
8677    ) -> Result<WorkspaceFileRead, NodeFailure> {
8678        let canonical_root = self.workspace_root(&workspace_id)?;
8679        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8680            failure(NodeFailureCode::BackendBusy, "workspace file write capacity is busy")
8681        })?;
8682        let path_to_write = path.clone();
8683        let expected = expected_revision.as_str().to_owned();
8684        let text_to_write = text.clone();
8685        let task = tokio::task::spawn_blocking(move || {
8686            let _permit = permit;
8687            write_workspace_file_to_disk(
8688                Path::new(&canonical_root),
8689                &path_to_write,
8690                &expected,
8691                &text_to_write,
8692            )
8693        });
8694        let revision = timeout(Duration::from_millis(WORKSPACE_FILE_WRITE_TIMEOUT_MS), task)
8695            .await
8696            .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteTimedOut, "repository file write exceeded its bounded deadline"))?
8697            .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteFailed, "repository file write task failed"))?
8698            .map_err(workspace_file_write_failure)?;
8699        Ok(WorkspaceFileRead {
8700            workspace_id,
8701            path,
8702            content: WorkspaceFileContent::Utf8 {
8703                byte_len: u32::try_from(text.len()).expect("bounded workspace text length must fit u32"),
8704                text,
8705            },
8706            revision: Some(WorkspaceFileRevision::new(revision)
8707                .expect("workspace writer returns a valid SHA-256 revision")),
8708        })
8709    }
8710
8711    async fn create_workspace_file(
8712        &self,
8713        workspace_id: WorkspaceId,
8714        path: RepositoryPath,
8715    ) -> Result<WorkspaceFileRead, NodeFailure> {
8716        let canonical_root = self.workspace_root(&workspace_id)?;
8717        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8718            failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
8719        })?;
8720        let path_to_create = path.clone();
8721        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
8722        let task_commit_state = Arc::clone(&commit_state);
8723        let task = tokio::task::spawn_blocking(move || {
8724            let _permit = permit;
8725            create_workspace_file_on_disk(
8726                Path::new(&canonical_root),
8727                &path_to_create,
8728                &task_commit_state,
8729            )
8730        });
8731        let revision = settle_workspace_entry_create(
8732            task,
8733            commit_state,
8734            Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
8735            "repository file create exceeded its bounded deadline",
8736            "repository file create task failed",
8737        )
8738        .await?;
8739        Ok(WorkspaceFileRead {
8740            workspace_id,
8741            path,
8742            content: WorkspaceFileContent::Utf8 {
8743                text: String::new(),
8744                byte_len: 0,
8745            },
8746            revision: Some(WorkspaceFileRevision::new(revision)
8747                .expect("workspace creator returns a valid SHA-256 revision")),
8748        })
8749    }
8750
8751    async fn create_workspace_directory(
8752        &self,
8753        workspace_id: WorkspaceId,
8754        path: RepositoryPath,
8755    ) -> Result<WorkspaceEntry, NodeFailure> {
8756        let canonical_root = self.workspace_root(&workspace_id)?;
8757        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8758            failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
8759        })?;
8760        let path_to_create = path.clone();
8761        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
8762        let task_commit_state = Arc::clone(&commit_state);
8763        let task = tokio::task::spawn_blocking(move || {
8764            let _permit = permit;
8765            create_workspace_directory_on_disk(
8766                Path::new(&canonical_root),
8767                &path_to_create,
8768                &task_commit_state,
8769            )
8770        });
8771        settle_workspace_entry_create(
8772            task,
8773            commit_state,
8774            Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
8775            "repository directory create exceeded its bounded deadline",
8776            "repository directory create task failed",
8777        )
8778        .await?;
8779        Ok(WorkspaceEntry {
8780            relative_path: path,
8781            kind: WorkspaceEntryKind::Directory,
8782        })
8783    }
8784
8785    async fn read_git_history(
8786        &self,
8787        workspace_id: WorkspaceId,
8788        path: Option<RepositoryPath>,
8789        before: Option<GitObjectId>,
8790        limit: u16,
8791    ) -> Result<GitHistoryPage, NodeFailure> {
8792        let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8793            failure(NodeFailureCode::BackendBusy, "git history capacity is busy")
8794        })?;
8795        let root = self.workspace_root(&workspace_id)?;
8796        read_git_history_bounded(&root, path.as_ref(), before.as_ref(), limit).await
8797    }
8798
8799    async fn read_git_diff(
8800        &self,
8801        workspace_id: WorkspaceId,
8802        request: GitDiffRequest,
8803    ) -> Result<GitDiff, NodeFailure> {
8804        let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8805            failure(NodeFailureCode::BackendBusy, "git diff capacity is busy")
8806        })?;
8807        let root = self.workspace_root(&workspace_id)?;
8808        read_git_diff_bounded(&root, request).await
8809    }
8810
8811    fn native_session_route_root(
8812        &self,
8813        route: &NativeSessionCatalogRoute,
8814    ) -> Result<Option<String>, NodeFailure> {
8815        route
8816            .validate()
8817            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
8818        match route.scope {
8819            gate4agent_types::NativeSessionCatalogScope::Workspace => route
8820                .workspace_id
8821                .as_ref()
8822                .map(|workspace_id| self.workspace_root(workspace_id))
8823                .transpose(),
8824            gate4agent_types::NativeSessionCatalogScope::Unregistered => Ok(None),
8825        }
8826    }
8827
8828    fn project_native_session_entries(
8829        &self,
8830        route: &NativeSessionCatalogRoute,
8831        entries: Vec<ScopedNativeSessionCatalogEntry>,
8832    ) -> Vec<crate::protocol::NativeSessionCatalogEntry> {
8833        let current_root = route
8834            .workspace_id
8835            .as_ref()
8836            .and_then(|workspace_id| self.workspace_root(workspace_id).ok());
8837        let records = self
8838            .session_records
8839            .lock()
8840            .unwrap_or_else(|poisoned| poisoned.into_inner());
8841        let mut seen_records = std::collections::HashSet::new();
8842        entries
8843            .into_iter()
8844            .map(|entry| {
8845                let record_id = route.workspace_id.as_ref().and_then(|workspace_id| {
8846                    let matches = records
8847                        .records
8848                        .values()
8849                        .filter(|record| {
8850                            record.provider == route.provider
8851                                && record.workspace_id == *workspace_id
8852                                && current_root.as_deref().is_some_and(|root| {
8853                                    platform::roots_equal(
8854                                        root,
8855                                        windows_path_text(&record.canonical_root),
8856                                    )
8857                                })
8858                                && record.provider_session.as_ref().is_some_and(|identity| {
8859                                    session_registry::same_provider_session(
8860                                        &record.provider,
8861                                        identity,
8862                                        &route.provider,
8863                                        &entry.provider_session,
8864                                    )
8865                                })
8866                        })
8867                        .map(|record| record.record_id.clone())
8868                        .collect::<Vec<_>>();
8869                    (matches.len() == 1
8870                        && seen_records.insert(matches[0].clone()))
8871                        .then(|| matches[0].clone())
8872                });
8873                let metadata = entry.metadata;
8874                crate::protocol::NativeSessionCatalogEntry {
8875                    selection_id: metadata.selection_id,
8876                    title: metadata.title,
8877                    modified_at_unix_ms: metadata.modified_at_unix_ms,
8878                    model: metadata.model,
8879                    message_count: metadata.message_count,
8880                    completed_turn_count: metadata.completed_turn_count,
8881                    external_group: entry.external_group,
8882                    record_id,
8883                }
8884            })
8885            .collect()
8886    }
8887
8888    async fn catalog_native_sessions(
8889        &self,
8890        route: NativeSessionCatalogRoute,
8891        limit: u16,
8892    ) -> Result<(
8893        Vec<crate::protocol::NativeSessionCatalogEntry>,
8894        crate::protocol::NativeSessionCatalogSummary,
8895    ), NodeFailure> {
8896        let canonical_root = self.native_session_route_root(&route)?;
8897        let registered_workspace_roots = self.workspace_roots();
8898        let provider = route.provider.clone();
8899        let scope = route.scope;
8900        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
8901            failure(
8902                NodeFailureCode::InvalidRequest,
8903                "native session catalog is not configured",
8904            )
8905        })?;
8906        let _permit = Arc::clone(&self.inspection_slots)
8907            .try_acquire_owned()
8908            .map_err(|_| {
8909                failure(
8910                    NodeFailureCode::BackendBusy,
8911                    "native session catalog capacity is busy",
8912                )
8913            })?;
8914        let page = run_native_session_catalog_operation(
8915            catalog,
8916            "native session catalog",
8917            move |catalog| {
8918                catalog.catalog_initial_for_scope(
8919                    &provider,
8920                    scope,
8921                    canonical_root.as_deref().map(Path::new),
8922                    &registered_workspace_roots,
8923                    limit,
8924                )
8925            },
8926        )
8927        .await?;
8928        let summary = crate::protocol::NativeSessionCatalogSummary {
8929            catalog_revision: page.revision,
8930            recent_cutoff_unix_ms: page.cutoff_unix_ms,
8931            recent_total_count: page.recent_total_count,
8932            older_total_count: page.older_total_count,
8933            recent_next_after_selection_id: page.next_after_selection_id,
8934            recent_has_more: page.remaining_count > 0,
8935        };
8936        Ok((self.project_native_session_entries(&route, page.entries), summary))
8937    }
8938
8939    async fn page_native_sessions(
8940        &self,
8941        route: NativeSessionCatalogRoute,
8942        window: crate::protocol::NativeSessionCatalogWindow,
8943        catalog_revision: u64,
8944        recent_cutoff_unix_ms: u64,
8945        after_selection_id: Option<String>,
8946        limit: u16,
8947    ) -> Result<crate::protocol::NativeSessionCatalogPage, NodeFailure> {
8948        let canonical_root = self.native_session_route_root(&route)?;
8949        let registered_workspace_roots = self.workspace_roots();
8950        let provider = route.provider.clone();
8951        let scope = route.scope;
8952        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
8953            failure(
8954                NodeFailureCode::InvalidRequest,
8955                "native session catalog is not configured",
8956            )
8957        })?;
8958        let _permit = Arc::clone(&self.inspection_slots)
8959            .try_acquire_owned()
8960            .map_err(|_| {
8961                failure(
8962                    NodeFailureCode::BackendBusy,
8963                    "native session catalog capacity is busy",
8964                )
8965            })?;
8966        let page = run_native_session_catalog_operation(
8967            catalog,
8968            "native session catalog page",
8969            move |catalog| {
8970                Ok::<_, std::convert::Infallible>(catalog.catalog_page_for_scope(
8971                    &provider,
8972                    scope,
8973                    canonical_root.as_deref().map(Path::new),
8974                    &registered_workspace_roots,
8975                    window,
8976                    catalog_revision,
8977                    recent_cutoff_unix_ms,
8978                    after_selection_id.as_deref(),
8979                    limit,
8980                ))
8981            },
8982        )
8983        .await?;
8984        let page = page.map_err(|error| match error {
8985            NativeSessionCatalogError::StaleCatalog => failure(
8986                NodeFailureCode::StaleNativeSessionCatalog,
8987                "native session catalog revision or cursor is stale",
8988            ),
8989            _ => failure(
8990                NodeFailureCode::BackendOperationFailed,
8991                "native session catalog page failed",
8992            ),
8993        })?;
8994        let entries = self.project_native_session_entries(&route, page.entries);
8995        Ok(crate::protocol::NativeSessionCatalogPage {
8996            window: page.window,
8997            revision: page.revision,
8998            entries,
8999            next_after_selection_id: page.next_after_selection_id,
9000            remaining_count: page.remaining_count,
9001            has_more: page.remaining_count > 0,
9002        })
9003    }
9004
9005    async fn preview_native_session(
9006        &self,
9007        selection: NativeSessionSelection,
9008        message_limit: u16,
9009    ) -> Result<crate::protocol::NativeSessionPreview, NodeFailure> {
9010        selection
9011            .validate()
9012            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9013        let canonical_root = self.native_session_route_root(&selection.route)?;
9014        let registered_workspace_roots = self.workspace_roots();
9015        let provider = selection.route.provider.clone();
9016        let scope = selection.route.scope;
9017        let catalog_revision = selection.catalog_revision;
9018        let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9019        let selection_id = selection.selection_id.clone();
9020        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9021            failure(
9022                NodeFailureCode::InvalidRequest,
9023                "native session preview is not configured",
9024            )
9025        })?;
9026        let _permit = Arc::clone(&self.inspection_slots)
9027            .try_acquire_owned()
9028            .map_err(|_| {
9029                failure(
9030                    NodeFailureCode::BackendBusy,
9031                    "native session preview capacity is busy",
9032                )
9033            })?;
9034        let preview = run_native_session_catalog_operation(
9035            catalog,
9036            "native session preview",
9037            move |catalog| {
9038                Ok::<_, std::convert::Infallible>(catalog.preview_for_scope(
9039                    &provider,
9040                    scope,
9041                    canonical_root.as_deref().map(Path::new),
9042                    &registered_workspace_roots,
9043                    catalog_revision,
9044                    recent_cutoff_unix_ms,
9045                    &selection_id,
9046                    message_limit,
9047                ))
9048            },
9049        )
9050        .await?;
9051        preview
9052            .map(Into::into)
9053            .map_err(native_session_preview_failure)
9054    }
9055
9056    async fn index_native_session(
9057        &self,
9058        selection: NativeSessionSelection,
9059        display_name: String,
9060    ) -> Result<ManagedSessionRecord, NodeFailure> {
9061        selection
9062            .validate()
9063            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9064        if selection.route.scope
9065            != gate4agent_types::NativeSessionCatalogScope::Workspace
9066        {
9067            return Err(failure(
9068                NodeFailureCode::WorkspaceRegistrationRequired,
9069                "register the external project as a workspace before indexing or resuming it",
9070            ));
9071        }
9072        let workspace_id = selection
9073            .route
9074            .workspace_id
9075            .clone()
9076            .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9077        let canonical_root = self
9078            .native_session_route_root(&selection.route)?
9079            .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9080        let registered_workspace_roots = self.workspace_roots();
9081        let provider = selection.route.provider.clone();
9082        let catalog_revision = selection.catalog_revision;
9083        let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9084        let selection_id = selection.selection_id.clone();
9085        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9086            failure(
9087                NodeFailureCode::InvalidRequest,
9088                "native session index is not configured",
9089            )
9090        })?;
9091        let _permit = Arc::clone(&self.inspection_slots)
9092            .try_acquire_owned()
9093            .map_err(|_| {
9094                failure(
9095                    NodeFailureCode::BackendBusy,
9096                    "native session index capacity is busy",
9097                )
9098            })?;
9099        let resolved = run_native_session_catalog_operation(
9100            catalog,
9101            "native session index resolution",
9102            move |catalog| {
9103                Ok::<_, std::convert::Infallible>(catalog.resolve_selection_for_scope(
9104                    &provider,
9105                    gate4agent_types::NativeSessionCatalogScope::Workspace,
9106                    Some(Path::new(&canonical_root)),
9107                    &registered_workspace_roots,
9108                    catalog_revision,
9109                    recent_cutoff_unix_ms,
9110                    &selection_id,
9111                ))
9112            },
9113        )
9114        .await?
9115        .map_err(native_session_preview_failure)?;
9116        self.index_provider_session_with_policy(
9117            workspace_id,
9118            selection.route.provider,
9119            resolved.identity,
9120            display_name,
9121            true,
9122        )
9123    }
9124
9125    async fn preview_native_session_by_session_id(
9126        &self,
9127        workspace_id: WorkspaceId,
9128        provider: AgentId,
9129        session_id: String,
9130        message_limit: u16,
9131    ) -> Result<gate4agent_types::NativeSessionPreview, NodeFailure> {
9132        let canonical_root = self.workspace_root(&workspace_id)?;
9133        let registered_workspace_roots = self.workspace_roots();
9134        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9135            failure(
9136                NodeFailureCode::InvalidRequest,
9137                "native session preview is not configured",
9138            )
9139        })?;
9140        let _permit = Arc::clone(&self.inspection_slots)
9141            .try_acquire_owned()
9142            .map_err(|_| {
9143                failure(
9144                    NodeFailureCode::BackendBusy,
9145                    "native session preview capacity is busy",
9146                )
9147            })?;
9148        run_native_session_catalog_operation(
9149            catalog,
9150            "native session preview",
9151            move |catalog| {
9152                catalog.preview_session_id_for_workspace(
9153                    &provider,
9154                    Path::new(&canonical_root),
9155                    &registered_workspace_roots,
9156                    &session_id,
9157                    message_limit,
9158                )
9159            },
9160        )
9161        .await
9162    }
9163
9164    async fn register_workspace(
9165        &self,
9166        workspace_id: WorkspaceId,
9167        root: String,
9168    ) -> Result<WorkspaceSnapshot, NodeFailure> {
9169        validate_workspace_request_root(&root)?;
9170        let canonicalize_workspace_id = workspace_id.clone();
9171        let workspace = tokio::task::spawn_blocking(move || {
9172            WorkspaceConfig::new(canonicalize_workspace_id, root)
9173        })
9174        .await
9175        .map_err(|error| {
9176            failure(
9177                NodeFailureCode::BackendOperationFailed,
9178                &format!("workspace canonicalization task failed: {error}"),
9179            )
9180        })?
9181        .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
9182        let transaction = self
9183            .state_transaction
9184            .lock()
9185            .unwrap_or_else(|poisoned| poisoned.into_inner());
9186        let mut workspaces = self
9187            .workspaces
9188            .write()
9189            .unwrap_or_else(|poisoned| poisoned.into_inner());
9190        if workspaces.contains_key(&workspace_id) {
9191            return Err(failure(
9192                NodeFailureCode::DuplicateWorkspaceId,
9193                "workspace ID is already registered",
9194            ));
9195        }
9196        if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
9197            platform::roots_equal(existing_root, workspace.canonical_root())
9198        }) {
9199            return Err(failure(
9200                NodeFailureCode::DuplicateWorkspaceRoot,
9201                &format!("workspace root is already registered as '{existing_id}'"),
9202            ));
9203        }
9204        {
9205            let records = self
9206                .session_records
9207                .lock()
9208                .unwrap_or_else(|poisoned| poisoned.into_inner());
9209            if let Some(record) = records.records.values().find(|record| {
9210                record.workspace_id == workspace_id
9211                    && !record
9212                        .canonical_root
9213                        .as_utf8()
9214                        .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9215            }) {
9216                return Err(failure(
9217                    NodeFailureCode::SessionRecordConflict,
9218                    &format!(
9219                        "workspace ID is retained by session record '{}' at another root",
9220                        record.record_id,
9221                    ),
9222                ));
9223            }
9224        }
9225        let snapshot = WorkspaceSnapshot {
9226            workspace_id: workspace_id.clone(),
9227            canonical_root: opaque_windows_path(workspace.canonical_root().to_owned()),
9228            sessions: Vec::new(),
9229            worktree_service_mode: Some(WorktreeServiceMode::Manual),
9230            managed_worktree_profiles: Some(WorktreeProfileInventory {
9231                profiles: Vec::new(),
9232            }),
9233        };
9234        workspaces.insert(workspace_id.clone(), workspace.canonical_root().to_owned());
9235        drop(workspaces);
9236        let previous_records = {
9237            let mut records = self
9238                .session_records
9239                .lock()
9240                .unwrap_or_else(|poisoned| poisoned.into_inner());
9241            let previous = records.records.clone();
9242            for record in records.records.values_mut().filter(|record| {
9243                record.workspace_id == workspace_id
9244                    && record
9245                        .canonical_root
9246                        .as_utf8()
9247                        .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9248            }) {
9249                if record.active_session.is_none() {
9250                    record.state = detached_record_state(record);
9251                }
9252                if record.last_error.as_deref() == Some(WORKSPACE_UNAVAILABLE_ERROR) {
9253                    record.last_error = None;
9254                }
9255                record.updated_at_unix_ms = unix_time_ms();
9256            }
9257            previous
9258        };
9259        if let Err(error) = self.persist_state_locked() {
9260            self.workspaces
9261                .write()
9262                .unwrap_or_else(|poisoned| poisoned.into_inner())
9263                .remove(&workspace_id);
9264            self.session_records
9265                .lock()
9266                .unwrap_or_else(|poisoned| poisoned.into_inner())
9267                .records = previous_records;
9268            return Err(persistence_failure(error));
9269        }
9270        let restored = self
9271            .session_records
9272            .lock()
9273            .unwrap_or_else(|poisoned| poisoned.into_inner())
9274            .records
9275            .values()
9276            .filter(|record| record.workspace_id == workspace_id)
9277            .cloned()
9278            .collect::<Vec<_>>();
9279        drop(transaction);
9280        self.publish(NodeEvent::WorkspaceAdded {
9281            workspace: snapshot.clone(),
9282        });
9283        for record in restored {
9284            self.publish_record(record);
9285        }
9286        Ok(snapshot)
9287    }
9288
9289    async fn create_standalone_workspace(
9290        &self,
9291        workspace_id: WorkspaceId,
9292        root: String,
9293        initial_branch: Option<String>,
9294    ) -> Result<WorkspaceSnapshot, NodeFailure> {
9295        validate_workspace_request_root(&root)?;
9296        validate_git_revision("initial branch", initial_branch.as_deref())?;
9297        let preflight_workspace_id = workspace_id.clone();
9298        let preflight_root = root.clone();
9299        let canonical_candidate = tokio::task::spawn_blocking(move || {
9300            canonical_standalone_workspace_candidate(&preflight_workspace_id, &preflight_root)
9301        })
9302        .await
9303        .map_err(|_| failure(
9304            NodeFailureCode::BackendOperationFailed,
9305            "standalone workspace root preflight task failed",
9306        ))?
9307        .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
9308        {
9309            let workspaces = self
9310                .workspaces
9311                .read()
9312                .unwrap_or_else(|poisoned| poisoned.into_inner());
9313            if workspaces.contains_key(&workspace_id) {
9314                return Err(failure(
9315                    NodeFailureCode::DuplicateWorkspaceId,
9316                    "workspace ID is already registered",
9317                ));
9318            }
9319            if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
9320                platform::roots_equal(existing_root, &canonical_candidate)
9321            }) {
9322                return Err(failure(
9323                    NodeFailureCode::DuplicateWorkspaceRoot,
9324                    &format!("workspace root is already registered as '{existing_id}'"),
9325                ));
9326            }
9327        }
9328        let prepared = prepare_standalone_workspace(root, initial_branch.as_deref())
9329            .await
9330            .map_err(standalone_workspace_failure)?;
9331        prepared
9332            .verify_for_registration()
9333            .await
9334            .map_err(standalone_workspace_failure)?;
9335        match self
9336            .register_workspace(workspace_id, prepared.root().to_owned())
9337            .await
9338        {
9339            Ok(workspace) => Ok(workspace),
9340            Err(registration_error) => {
9341                if prepared.compensate_registration_failure().await {
9342                    Err(registration_error)
9343                } else {
9344                    Err(failure(
9345                        NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
9346                        "standalone workspace initialization succeeded but registration failed; recovery is required",
9347                    ))
9348                }
9349            }
9350        }
9351    }
9352
9353    fn unregister_workspace(&self, workspace_id: &WorkspaceId) -> Result<(), NodeFailure> {
9354        let bindings = self
9355            .session_bindings
9356            .lock()
9357            .unwrap_or_else(|poisoned| poisoned.into_inner());
9358        if bindings.values().any(|binding| &binding.workspace_id == workspace_id) {
9359            return Err(failure(
9360                NodeFailureCode::WorkspaceBusy,
9361                "workspace retains a bound session; remove every session before unregistering it",
9362            ));
9363        }
9364        drop(bindings);
9365        let transaction = self
9366            .state_transaction
9367            .lock()
9368            .unwrap_or_else(|poisoned| poisoned.into_inner());
9369        let mut workspaces = self
9370            .workspaces
9371            .write()
9372            .unwrap_or_else(|poisoned| poisoned.into_inner());
9373        if !workspaces.contains_key(workspace_id) {
9374            return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
9375        }
9376        if workspaces.len() == 1 {
9377            return Err(failure(
9378                NodeFailureCode::LastWorkspace,
9379                "node must retain at least one workspace",
9380            ));
9381        }
9382        let removed_root = workspaces
9383            .remove(workspace_id)
9384            .expect("workspace presence was validated");
9385        drop(workspaces);
9386        let previous_records = {
9387            let mut records = self
9388                .session_records
9389                .lock()
9390                .unwrap_or_else(|poisoned| poisoned.into_inner());
9391            let previous = records.records.clone();
9392            for record in records
9393                .records
9394                .values_mut()
9395                .filter(|record| &record.workspace_id == workspace_id)
9396            {
9397                record.active_session = None;
9398                record.state = ManagedSessionState::Unavailable;
9399                record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
9400                record.updated_at_unix_ms = unix_time_ms();
9401            }
9402            previous
9403        };
9404        if let Err(error) = self.persist_state_locked() {
9405            self.workspaces
9406                .write()
9407                .unwrap_or_else(|poisoned| poisoned.into_inner())
9408                .insert(workspace_id.clone(), removed_root);
9409            self.session_records
9410                .lock()
9411                .unwrap_or_else(|poisoned| poisoned.into_inner())
9412                .records = previous_records;
9413            return Err(persistence_failure(error));
9414        }
9415        let unavailable = self
9416            .session_records
9417            .lock()
9418            .unwrap_or_else(|poisoned| poisoned.into_inner())
9419            .records
9420            .values()
9421            .filter(|record| &record.workspace_id == workspace_id)
9422            .cloned()
9423            .collect::<Vec<_>>();
9424        drop(transaction);
9425        self.publish(NodeEvent::WorkspaceRemoved {
9426            workspace_id: workspace_id.clone(),
9427        });
9428        for record in unavailable {
9429            self.publish_record(record);
9430        }
9431        Ok(())
9432    }
9433
9434    async fn create_worktree(
9435        &self,
9436        source_workspace_id: WorkspaceId,
9437        workspace_id: WorkspaceId,
9438        target_root: String,
9439        branch: String,
9440        base: Option<String>,
9441    ) -> Result<(GitWorktreeSnapshot, WorkspaceSnapshot), NodeFailure> {
9442        self.require_worktree_service(&source_workspace_id)?;
9443        validate_workspace_request_root(&target_root)?;
9444        validate_git_revision("worktree base", base.as_deref())?;
9445        let source_root = self.workspace_root(&source_workspace_id)?;
9446        {
9447            let workspaces = self
9448                .workspaces
9449                .read()
9450                .unwrap_or_else(|poisoned| poisoned.into_inner());
9451            if workspaces.contains_key(&workspace_id) {
9452                return Err(failure(
9453                    NodeFailureCode::DuplicateWorkspaceId,
9454                    "workspace ID is already registered",
9455                ));
9456            }
9457            if let Some((existing_id, _)) = workspaces
9458                .iter()
9459                .find(|(_, root)| worktree_paths_equal(root, &target_root))
9460            {
9461                return Err(failure(
9462                    NodeFailureCode::DuplicateWorkspaceRoot,
9463                    &format!("workspace root is already registered as '{existing_id}'"),
9464                ));
9465            }
9466        }
9467        let mut worktree = create_git_worktree(
9468            &source_root,
9469            &target_root,
9470            &branch,
9471            base.as_deref(),
9472        )
9473        .await
9474        .map_err(git_worktree_failure)?;
9475        let workspace = match self
9476            .register_workspace(workspace_id, worktree.path.clone())
9477            .await
9478        {
9479            Ok(workspace) => workspace,
9480            Err(registration_error) => {
9481                if remove_git_worktree(&source_root, &worktree.path).await.is_err() {
9482                    return Err(failure(
9483                        NodeFailureCode::ManagedWorktreeRecoveryRequired,
9484                        "worktree registration failed and compensation requires recovery",
9485                    ));
9486                }
9487                return Err(failure(
9488                    registration_error.code,
9489                    &registration_error.message,
9490                ));
9491            }
9492        };
9493        worktree.workspace_id = Some(workspace.workspace_id.clone());
9494        Ok((protocol_worktree(worktree), workspace))
9495    }
9496
9497    async fn remove_worktree(
9498        &self,
9499        source_workspace_id: WorkspaceId,
9500        target_root: String,
9501    ) -> Result<Option<WorkspaceId>, NodeFailure> {
9502        self.require_worktree_service(&source_workspace_id)?;
9503        validate_workspace_request_root(&target_root)?;
9504        let source_root = self.workspace_root(&source_workspace_id)?;
9505        let target_root = normalize_worktree_removal_target(&target_root)
9506            .map_err(git_worktree_failure)?;
9507        let target = list_git_worktrees(&source_root)
9508            .await
9509            .map_err(git_worktree_failure)?
9510            .into_iter()
9511            .find(|worktree| worktree_paths_equal(&worktree.path, &target_root))
9512            .ok_or_else(|| {
9513                failure(
9514                    NodeFailureCode::WorktreeProtected,
9515                    "refusing to remove a path that is not in Git's worktree listing",
9516                )
9517            })?;
9518        let registered_workspace_id = {
9519            let workspaces = self
9520                .workspaces
9521                .read()
9522                .unwrap_or_else(|poisoned| poisoned.into_inner());
9523            let matched = workspaces
9524                .iter()
9525                .find(|(_, root)| worktree_paths_equal(root, &target.path))
9526                .map(|(workspace_id, _)| workspace_id.clone());
9527            if matched.is_some() && workspaces.len() == 1 {
9528                return Err(failure(
9529                    NodeFailureCode::LastWorkspace,
9530                    "node must retain at least one workspace",
9531                ));
9532            }
9533            matched
9534        };
9535        if let Some(workspace_id) = registered_workspace_id.as_ref() {
9536            let bindings = self
9537                .session_bindings
9538                .lock()
9539                .unwrap_or_else(|poisoned| poisoned.into_inner());
9540            if bindings
9541                .values()
9542                .any(|binding| &binding.workspace_id == workspace_id)
9543            {
9544                return Err(failure(
9545                    NodeFailureCode::WorkspaceBusy,
9546                    "worktree workspace retains a bound session; remove every session first",
9547                ));
9548            }
9549        }
9550        remove_git_worktree(&source_root, &target.path)
9551            .await
9552            .map_err(git_worktree_failure)?;
9553        if let Some(workspace_id) = registered_workspace_id.as_ref() {
9554            self.unregister_workspace(workspace_id)
9555                .map_err(|error| failure(error.code, &error.message))?;
9556        }
9557        Ok(registered_workspace_id)
9558    }
9559
9560    async fn cleanup_managed_worktree(
9561        &self,
9562        lease_id: &ManagedWorktreeLeaseId,
9563        explicit: bool,
9564    ) -> Result<ManagedWorktreeLeaseSnapshot, NodeFailure> {
9565        let lease = self.managed_worktrees.lock()
9566            .unwrap_or_else(|poisoned| poisoned.into_inner())
9567            .get(lease_id)
9568            .cloned()
9569            .ok_or_else(|| failure(
9570                NodeFailureCode::UnknownManagedWorktreeLease,
9571                "managed worktree lease does not exist",
9572            ))?;
9573        if lease.state == ManagedWorktreeLeaseState::Removed {
9574            return Ok(lease.snapshot());
9575        }
9576        if lease.has_holders() {
9577            return Err(failure(
9578                NodeFailureCode::ManagedWorktreeBusy,
9579                "managed worktree lease still has a session or durable-record holder",
9580            ));
9581        }
9582        if self
9583            .materializations
9584            .lock()
9585            .unwrap_or_else(|poisoned| poisoned.into_inner())
9586            .values()
9587            .any(|record| record.managed_lease_id() == Some(lease_id))
9588        {
9589            return Err(failure(
9590                NodeFailureCode::ManagedWorktreeRecoveryRequired,
9591                "managed worktree cleanup is blocked by session environment ownership",
9592            ));
9593        }
9594        if !explicit && lease.retention == ManagedWorktreeRetention::Retain {
9595            let snapshot = {
9596                let mut registry = self.managed_worktrees.lock()
9597                    .unwrap_or_else(|poisoned| poisoned.into_inner());
9598                let current = registry.get_mut(lease_id)
9599                    .expect("managed worktree lease remains present");
9600                current.state = ManagedWorktreeLeaseState::Retained;
9601                current.cleanup_failure = None;
9602                current.updated_at_unix_ms = unix_time_ms();
9603                current.snapshot()
9604            };
9605            if self.persist_state().is_err() {
9606                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
9607            }
9608            self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
9609            return Ok(snapshot);
9610        }
9611        let profile = self.managed_profile(&lease.source_workspace_id, &lease.profile_id)?;
9612        if profile.revision() != &lease.profile_revision {
9613            self.mark_managed_recovery_required(
9614                lease_id,
9615                ManagedWorktreeCleanupFailure::OwnershipConflict,
9616            );
9617            return Err(failure(
9618                NodeFailureCode::ManagedWorktreeRecoveryRequired,
9619                "managed worktree profile revision changed",
9620            ));
9621        }
9622        profile.validate_target_authority(&lease.lease_id, &lease.target_root).map_err(|message| {
9623            self.mark_managed_recovery_required(
9624                lease_id,
9625                ManagedWorktreeCleanupFailure::OwnershipConflict,
9626            );
9627            failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message)
9628        })?;
9629        let source_root = self.workspace_root(&lease.source_workspace_id)?;
9630        let listed = list_git_worktrees(&source_root).await.map_err(|error| {
9631            self.mark_managed_cleanup_blocked(lease_id, ManagedWorktreeCleanupFailure::Backend);
9632            managed_git_worktree_failure(error)
9633        })?;
9634        let target = listed.iter()
9635            .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
9636        if let Some(target) = target {
9637            if target.is_main || target.is_bare || !exact_owned_worktree(
9638                &lease,
9639                &target.path,
9640                target.branch.as_deref(),
9641                &target.head,
9642            ) {
9643                self.mark_managed_recovery_required(
9644                    lease_id,
9645                    ManagedWorktreeCleanupFailure::OwnershipConflict,
9646                );
9647                return Err(failure(
9648                    NodeFailureCode::ManagedWorktreeOwnershipConflict,
9649                    "Git worktree identity no longer matches the managed lease",
9650                ));
9651            }
9652            if target.prunable {
9653                self.mark_managed_cleanup_blocked(
9654                    lease_id,
9655                    ManagedWorktreeCleanupFailure::Prunable,
9656                );
9657                return Err(failure(
9658                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
9659                    "managed worktree is prunable and requires recovery",
9660                ));
9661            }
9662            if let Err(error) = remove_git_worktree(&source_root, &target.path).await {
9663                let failure_kind = match error.kind {
9664                    GitWorktreeErrorKind::Dirty => ManagedWorktreeCleanupFailure::Dirty,
9665                    GitWorktreeErrorKind::Locked => ManagedWorktreeCleanupFailure::Locked,
9666                    GitWorktreeErrorKind::Protected => ManagedWorktreeCleanupFailure::OwnershipConflict,
9667                    GitWorktreeErrorKind::Conflict => ManagedWorktreeCleanupFailure::Busy,
9668                    GitWorktreeErrorKind::Invalid | GitWorktreeErrorKind::NotRepository
9669                    | GitWorktreeErrorKind::Failed => ManagedWorktreeCleanupFailure::Backend,
9670                };
9671                if failure_kind == ManagedWorktreeCleanupFailure::OwnershipConflict {
9672                    self.mark_managed_recovery_required(lease_id, failure_kind);
9673                } else {
9674                    self.mark_managed_cleanup_blocked(lease_id, failure_kind);
9675                }
9676                return Err(managed_git_worktree_failure(error));
9677            }
9678        } else {
9679            match std::fs::symlink_metadata(&lease.target_root) {
9680                Err(error) if error.kind() == io::ErrorKind::NotFound => {}
9681                Ok(_) => {
9682                    self.mark_managed_recovery_required(
9683                        lease_id,
9684                        ManagedWorktreeCleanupFailure::OwnershipConflict,
9685                    );
9686                    return Err(failure(
9687                        NodeFailureCode::ManagedWorktreeOwnershipConflict,
9688                        "managed target exists but is not owned by Git",
9689                    ));
9690                }
9691                Err(_) => {
9692                    self.mark_managed_cleanup_blocked(
9693                        lease_id,
9694                        ManagedWorktreeCleanupFailure::Backend,
9695                    );
9696                    return Err(failure(
9697                        NodeFailureCode::BackendOperationFailed,
9698                        "managed target identity could not be inspected",
9699                    ));
9700                }
9701            }
9702        }
9703        let registered = self.workspaces.read()
9704            .unwrap_or_else(|poisoned| poisoned.into_inner())
9705            .contains_key(&lease.workspace_id);
9706        if registered {
9707            self.unregister_workspace(&lease.workspace_id)?;
9708        }
9709        let removed = {
9710            self.managed_worktrees.lock()
9711                .unwrap_or_else(|poisoned| poisoned.into_inner())
9712                .tombstone(lease_id, unix_time_ms())
9713                .expect("managed worktree lease remains present during cleanup")
9714        };
9715        self.persist_state().map_err(persistence_failure)?;
9716        self.publish(NodeEvent::ManagedWorktreeRemoved { lease_id: lease_id.clone() });
9717        Ok(removed.snapshot())
9718    }
9719
9720    fn mark_managed_cleanup_blocked(
9721        &self,
9722        lease_id: &ManagedWorktreeLeaseId,
9723        failure_kind: ManagedWorktreeCleanupFailure,
9724    ) {
9725        let snapshot = {
9726            let mut registry = self.managed_worktrees.lock()
9727                .unwrap_or_else(|poisoned| poisoned.into_inner());
9728            let Some(lease) = registry.get_mut(lease_id) else { return };
9729            lease.state = ManagedWorktreeLeaseState::CleanupBlocked;
9730            lease.cleanup_failure = Some(failure_kind);
9731            lease.updated_at_unix_ms = unix_time_ms();
9732            lease.snapshot()
9733        };
9734        let _ = self.persist_state();
9735        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
9736    }
9737
9738    fn require_worktree_service(
9739        &self,
9740        source_workspace_id: &WorkspaceId,
9741    ) -> Result<(), NodeFailure> {
9742        if self.managed_worktrees.lock()
9743            .unwrap_or_else(|poisoned| poisoned.into_inner())
9744            .lease_for_workspace(source_workspace_id)
9745            .is_some()
9746        {
9747            return Err(failure(
9748                NodeFailureCode::ManagedWorktreeOwnershipConflict,
9749                "managed worktree targets are internal-only workspaces",
9750            ));
9751        }
9752        match self
9753            .worktree_service_modes
9754            .get(source_workspace_id)
9755            .copied()
9756            .unwrap_or(WorktreeServiceMode::Manual)
9757        {
9758            WorktreeServiceMode::Manual => Ok(()),
9759            WorktreeServiceMode::Managed | WorktreeServiceMode::Off => Err(failure(
9760                NodeFailureCode::UnsupportedSpawnCapability,
9761                "Git worktree service mode is unavailable in this node build",
9762            )),
9763        }
9764    }
9765
9766    fn managed_reservation_conflict(
9767        &self,
9768        workspace_id: Option<&WorkspaceId>,
9769        root: Option<&str>,
9770    ) -> bool {
9771        self.managed_worktrees.lock()
9772            .unwrap_or_else(|poisoned| poisoned.into_inner())
9773            .active_records()
9774            .any(|lease| {
9775                workspace_id.is_some_and(|workspace_id| {
9776                    workspace_id == &lease.source_workspace_id
9777                        || workspace_id == &lease.workspace_id
9778                }) || root.is_some_and(|root| worktree_paths_equal(root, &lease.target_root))
9779            })
9780    }
9781
9782    fn reject_managed_reservation(
9783        &self,
9784        workspace_id: Option<&WorkspaceId>,
9785        root: Option<&str>,
9786    ) -> Result<(), NodeFailure> {
9787        if self.managed_reservation_conflict(workspace_id, root) {
9788            Err(failure(
9789                NodeFailureCode::ManagedWorktreeOwnershipConflict,
9790                "managed worktree ownership may only be mutated through its lease cleanup",
9791            ))
9792        } else {
9793            Ok(())
9794        }
9795    }
9796
9797    fn bound_workspace_root(&self, address: &SessionAddress) -> Result<String, NodeFailure> {
9798        self.validate_address(address)?;
9799        self.workspace_root(&address.workspace_id)
9800    }
9801
9802    /// `Resume`/`ResumeWithPrompt` are always a raw-PTY relaunch (see
9803    /// `require_policy`'s own doc on those two requirements), so they keep
9804    /// gating on `binding.runtime_policy`'s PTY-terminal-text-inference
9805    /// flags exactly as before; `pipe_transport`/`acp_transport` stay
9806    /// `false` on the `require_policy` call below for them because a PTY
9807    /// resume never needs those facts, not because this method cannot look
9808    /// them up.
9809    ///
9810    /// `SemanticPrompt` -- the requirement `Prompt`/`Paste` share -- reads
9811    /// the SAME `binding.runtime_policy` every other requirement reads here
9812    /// (one computed policy, read by every gate). That policy is no longer
9813    /// PTY-terminal-verification-shaped for an ACP session --
9814    /// `spawn_session_with_deadline` computes it BY TRANSPORT now, through
9815    /// `provider_runtime::policy_for_transport` -- so consulting it for
9816    /// `Prompt`/`Paste` on ACP is sound where it used to gate the wrong fact
9817    /// (this is what made an ACP `Prompt` come back `UnsupportedCapability`
9818    /// even though a prompt is an ACP session's only interface). Pipe keeps
9819    /// reading the same PTY-probe-derived value it always has --
9820    /// `policy_for_transport` passes Pipe through unchanged this pass (see
9821    /// its own doc for why).
9822    ///
9823    /// What this method still adds on top, for ACP/Pipe only, is a
9824    /// turn-in-flight PRECONDITION ahead of that policy check: a session
9825    /// takes one turn at a time, so a `Prompt`/`Paste` is refused by name
9826    /// (`NodeFailureCode::TurnInFlight`) while a turn is already running,
9827    /// read off the same live `SessionSnapshot` state `require_acp_session`
9828    /// already reads for the ACP control verbs -- `TurnStarted` observed
9829    /// with no matching `TurnCompleted` yet is exactly `ProviderSnapshot::
9830    /// lead_activity != Idle`. Sending a second prompt mid-turn would put
9831    /// its fate in vendor-specific behaviour this node cannot observe (one
9832    /// agent may queue it, another drop it, another interleave it), so this
9833    /// refuses it outright rather than gambling on that -- a rule with no
9834    /// PTY analogue, which is why PTY does not take this extra precondition.
9835    fn require_session_runtime_policy(
9836        &self,
9837        address: &SessionAddress,
9838        requirement: ProviderRuntimeRequirement,
9839    ) -> Result<ProviderRuntimePolicy, NodeFailure> {
9840        let runtime_policy = {
9841            let bindings = self
9842                .session_bindings
9843                .lock()
9844                .unwrap_or_else(|poisoned| poisoned.into_inner());
9845            let binding = bindings
9846                .get(&address.session.instance_id)
9847                .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
9848            if binding.workspace_id != address.workspace_id {
9849                return Err(failure(
9850                    NodeFailureCode::SessionWorkspaceMismatch,
9851                    "session belongs to another workspace",
9852                ));
9853            }
9854            if binding.generation != address.session.generation {
9855                return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
9856            }
9857            binding.runtime_policy
9858        };
9859        if requirement == ProviderRuntimeRequirement::SemanticPrompt {
9860            let snapshot = self.internal_session_snapshot(address)?;
9861            if matches!(snapshot.transport, TransportKind::Acp | TransportKind::Pipe)
9862                && snapshot.provider.lead_activity != ProviderActivity::Idle
9863            {
9864                return Err(failure(
9865                    NodeFailureCode::TurnInFlight,
9866                    "session already has a turn in flight; wait for it to complete before sending another prompt",
9867                ));
9868            }
9869        }
9870        require_policy(runtime_policy, false, false, requirement)
9871            .map(|()| runtime_policy)
9872            .map_err(|_| failure(
9873                NodeFailureCode::UnsupportedCapability,
9874                "session runtime policy does not admit this semantic operation",
9875            ))
9876    }
9877
9878    fn remove_binding(&self, address: &SessionAddress) -> Option<SessionBinding> {
9879        let mut bindings = self
9880            .session_bindings
9881            .lock()
9882            .unwrap_or_else(|poisoned| poisoned.into_inner());
9883        if bindings.get(&address.session.instance_id).is_some_and(|binding| {
9884            binding.workspace_id == address.workspace_id
9885                && binding.generation == address.session.generation
9886        }) {
9887            let removed = bindings.remove(&address.session.instance_id);
9888            self.clear_terminal_frame_watermark(address);
9889            SessionLifecycleHook::on_binding_removed(self, address, removed.as_ref());
9890            return removed;
9891        }
9892        None
9893    }
9894
9895    fn cleanup_session_owned_materialization(
9896        &self,
9897        address: &SessionAddress,
9898        binding: &SessionBinding,
9899    ) -> Result<(), NodeFailure> {
9900        let Some(id) = binding.materialization_id.as_ref() else {
9901            return Ok(());
9902        };
9903        let owner = self
9904            .materializations
9905            .lock()
9906            .unwrap_or_else(|poisoned| poisoned.into_inner())
9907            .get(id)
9908            .map(|record| record.owner().clone());
9909        match owner {
9910            None | Some(MaterializationOwner::Record { .. }) => Ok(()),
9911            Some(MaterializationOwner::Session {
9912                incarnation_id,
9913                instance_id,
9914                generation,
9915            }) if incarnation_id == self.incarnation_id
9916                && instance_id == address.session.instance_id
9917                && generation == address.session.generation =>
9918            {
9919                self.cleanup_materialization(id)
9920            }
9921            Some(MaterializationOwner::Session { .. }) => Err(failure(
9922                NodeFailureCode::BackendOperationFailed,
9923                "session environment ownership requires recovery",
9924            )),
9925        }
9926    }
9927
9928    fn clear_terminal_frame_watermark(&self, address: &SessionAddress) {
9929        let mut watermarks = self
9930            .terminal_frame_watermarks
9931            .lock()
9932            .unwrap_or_else(|poisoned| poisoned.into_inner());
9933        if watermarks
9934            .get(&address.session.instance_id)
9935            .is_some_and(|(current, _)| current == address)
9936        {
9937            watermarks.remove(&address.session.instance_id);
9938        }
9939    }
9940
9941    #[cfg(test)]
9942    fn address_for(
9943        &self,
9944        instance_id: AgentInstanceId,
9945        generation: SessionGeneration,
9946    ) -> Option<SessionAddress> {
9947        let binding = self.session_bindings
9948            .lock()
9949            .unwrap_or_else(|poisoned| poisoned.into_inner())
9950            .get(&instance_id)
9951            .cloned()?;
9952        (binding.generation == generation).then_some(SessionAddress {
9953            workspace_id: binding.workspace_id,
9954            session: SessionKey { instance_id, generation },
9955        })
9956    }
9957
9958    fn arm_resume(
9959        &self,
9960        address: &SessionAddress,
9961        command_id: CommandId,
9962        runtime_policy: ProviderRuntimePolicy,
9963    ) -> Result<(), NodeFailure> {
9964        let mut bindings = self
9965            .session_bindings
9966            .lock()
9967            .unwrap_or_else(|poisoned| poisoned.into_inner());
9968        let binding = bindings
9969            .get_mut(&address.session.instance_id)
9970            .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
9971        if binding.workspace_id != address.workspace_id {
9972            return Err(failure(
9973                NodeFailureCode::SessionWorkspaceMismatch,
9974                "session belongs to another workspace",
9975            ));
9976        }
9977        if binding.generation != address.session.generation {
9978            return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
9979        }
9980        binding.pending_resume = Some((binding.generation, command_id, runtime_policy));
9981        Ok(())
9982    }
9983
9984    fn clear_armed_resume(&self, address: &SessionAddress) {
9985        let mut bindings = self
9986            .session_bindings
9987            .lock()
9988            .unwrap_or_else(|poisoned| poisoned.into_inner());
9989        if let Some(binding) = bindings.get_mut(&address.session.instance_id) {
9990            if binding.workspace_id == address.workspace_id
9991                && binding.generation == address.session.generation
9992            {
9993                binding.pending_resume = None;
9994            }
9995        }
9996    }
9997
9998    fn publish_control(&self, event: ControlEvent) {
9999        let revokes_harness_mcp = matches!(
10000            &event.event,
10001            ControlEventKind::Exited { .. }
10002                | ControlEventKind::Failed { .. }
10003                | ControlEventKind::Removed
10004        );
10005        let (address, previous_address, record_id, identity_admitted, resume_command_rejected) = {
10006            let mut bindings = self
10007                .session_bindings
10008                .lock()
10009                .unwrap_or_else(|poisoned| poisoned.into_inner());
10010            let Some(binding) = bindings.get_mut(&event.instance_id) else {
10011                return;
10012            };
10013            let previous_address = SessionAddress {
10014                workspace_id: binding.workspace_id.clone(),
10015                session: SessionKey {
10016                    instance_id: event.instance_id,
10017                    generation: binding.generation,
10018                },
10019            };
10020            let mut resume_command_rejected = false;
10021            if binding.generation == event.generation {
10022                if let Some((pending_generation, pending_command_id, _)) = binding.pending_resume {
10023                    let rejected_resume = matches!(
10024                        &event.event,
10025                        ControlEventKind::CommandRejected { .. }
10026                    ) && event.command_id == Some(pending_command_id);
10027                    let resume_failed = matches!(
10028                        &event.event,
10029                        ControlEventKind::ResumeDenied { .. }
10030                            | ControlEventKind::ResumeFailed { .. }
10031                    );
10032                    if pending_generation == binding.generation
10033                        && (rejected_resume || resume_failed)
10034                    {
10035                        resume_command_rejected = rejected_resume;
10036                        binding.pending_resume = None;
10037                    }
10038                }
10039            } else {
10040                let expected = binding.generation.0.checked_add(1);
10041                let authorized = matches!(&event.event, ControlEventKind::ResumeAuthorized { .. });
10042                if !binding.pending_resume.is_some_and(|(generation, _, _)| {
10043                    generation == binding.generation
10044                })
10045                    || expected != Some(event.generation.0)
10046                    || !authorized
10047                {
10048                    return;
10049                }
10050                let (_, _, runtime_policy) = binding
10051                    .pending_resume
10052                    .expect("authorized resume retains its pending runtime policy");
10053                binding.generation = event.generation;
10054                binding.runtime_policy = runtime_policy;
10055                binding.pending_resume = None;
10056            }
10057            (
10058                SessionAddress {
10059                    workspace_id: binding.workspace_id.clone(),
10060                    session: SessionKey {
10061                        instance_id: event.instance_id,
10062                        generation: event.generation,
10063                    },
10064                },
10065                (previous_address.session.generation != event.generation)
10066                    .then_some(previous_address),
10067                binding.record_id.clone(),
10068                binding.runtime_policy.provider_session_identity,
10069                resume_command_rejected,
10070            )
10071        };
10072        if let Some(previous_address) = previous_address.as_ref() {
10073            self.clear_terminal_frame_watermark(previous_address);
10074        }
10075        if let Some(record_id) = record_id {
10076            self.reconcile_managed_record(
10077                &record_id,
10078                &address,
10079                &event,
10080                identity_admitted,
10081                resume_command_rejected,
10082            );
10083        }
10084        // Every state change a session's process lifecycle can reach gets a
10085        // line here: this is the single chokepoint every `ControlEventKind`
10086        // passes through regardless of transport (PTY/pipe/ACP) or whether it
10087        // came from a fresh spawn or a resume. Per-message provider chatter
10088        // (`ProviderEvent`/`ProviderGap`/terminal frames) is deliberately not
10089        // matched here -- that is exactly the per-frame noise this crate
10090        // family's level discipline forbids at INFO.
10091        match &event.event {
10092            ControlEventKind::Running { process_id } => {
10093                tracing::info!(
10094                    node_id = %self.node_id,
10095                    workspace_id = %address.workspace_id,
10096                    session = ?address.session,
10097                    pid = ?process_id,
10098                    "session process is running",
10099                );
10100            }
10101            ControlEventKind::Exited { exit_code, forced } => {
10102                tracing::info!(
10103                    node_id = %self.node_id,
10104                    workspace_id = %address.workspace_id,
10105                    session = ?address.session,
10106                    exit_code = ?exit_code,
10107                    forced = forced,
10108                    "session process exited",
10109                );
10110            }
10111            ControlEventKind::Failed { message } => {
10112                tracing::warn!(
10113                    node_id = %self.node_id,
10114                    workspace_id = %address.workspace_id,
10115                    session = ?address.session,
10116                    cause = %message,
10117                    "session failed",
10118                );
10119            }
10120            ControlEventKind::CommandRejected { message } => {
10121                tracing::warn!(
10122                    node_id = %self.node_id,
10123                    workspace_id = %address.workspace_id,
10124                    session = ?address.session,
10125                    cause = %message,
10126                    "session command rejected",
10127                );
10128            }
10129            ControlEventKind::Removed => {
10130                tracing::info!(
10131                    node_id = %self.node_id,
10132                    workspace_id = %address.workspace_id,
10133                    session = ?address.session,
10134                    "session removed",
10135                );
10136            }
10137            ControlEventKind::ResumeAuthorized { session: provider_session } => {
10138                tracing::info!(
10139                    node_id = %self.node_id,
10140                    workspace_id = %address.workspace_id,
10141                    session = ?address.session,
10142                    provider_session = ?provider_session,
10143                    "session resume authorized",
10144                );
10145            }
10146            ControlEventKind::Resumed { session: provider_session, process_id } => {
10147                tracing::info!(
10148                    node_id = %self.node_id,
10149                    workspace_id = %address.workspace_id,
10150                    session = ?address.session,
10151                    provider_session = ?provider_session,
10152                    pid = ?process_id,
10153                    "session resumed",
10154                );
10155            }
10156            ControlEventKind::ResumeDenied { reason } => {
10157                tracing::warn!(
10158                    node_id = %self.node_id,
10159                    workspace_id = %address.workspace_id,
10160                    session = ?address.session,
10161                    cause = %reason,
10162                    "session resume denied",
10163                );
10164            }
10165            ControlEventKind::ResumeFailed { message } => {
10166                tracing::warn!(
10167                    node_id = %self.node_id,
10168                    workspace_id = %address.workspace_id,
10169                    session = ?address.session,
10170                    cause = %message,
10171                    "session resume failed",
10172                );
10173            }
10174            _ => {}
10175        }
10176        let stream_chunk = agent_stream_chunk(&event);
10177        self.publish(NodeEvent::Control {
10178            address: address.clone(),
10179            event,
10180        });
10181        if let Some(chunk) = stream_chunk {
10182            self.publish_agent_stream_chunk(address.clone(), chunk);
10183        }
10184        if revokes_harness_mcp {
10185            SessionLifecycleHook::on_session_ended(self, &address);
10186        }
10187    }
10188
10189    fn reconcile_managed_record(
10190        &self,
10191        record_id: &SessionRecordId,
10192        address: &SessionAddress,
10193        event: &ControlEvent,
10194        identity_admitted: bool,
10195        resume_command_rejected: bool,
10196    ) {
10197        let _transaction = self
10198            .state_transaction
10199            .lock()
10200            .unwrap_or_else(|poisoned| poisoned.into_inner());
10201        let observed_identity = match &event.event {
10202            ControlEventKind::ProviderEvent {
10203                event: gate4agent_types::ProviderEvent::SessionIdentityObserved { identity },
10204                ..
10205            } if identity_admitted => Some(identity.clone()),
10206            _ => None,
10207        };
10208        let identity_was_observed = observed_identity.is_some();
10209        let replacement_id = observed_identity
10210            .as_ref()
10211            .and_then(|_| self.allocate_record_id().ok());
10212        let mut upserts = Vec::new();
10213        let mut removals = Vec::new();
10214        let mut rebind = None;
10215        let original_records;
10216        let original_materializations = self
10217            .materializations
10218            .lock()
10219            .unwrap_or_else(|poisoned| poisoned.into_inner())
10220            .clone();
10221        {
10222            let mut records = self
10223                .session_records
10224                .lock()
10225                .unwrap_or_else(|poisoned| poisoned.into_inner());
10226            original_records = records.records.clone();
10227            let Some(current_snapshot) = records.records.get(record_id).cloned() else {
10228                return;
10229            };
10230            if let Some(identity) = observed_identity {
10231                let existing_id = records
10232                    .records
10233                    .values()
10234                    .find(|record| {
10235                        record.record_id != *record_id
10236                            && record.provider_session.as_ref().is_some_and(|record_identity| {
10237                                session_registry::same_provider_session(
10238                                    &record.provider,
10239                                    record_identity,
10240                                    &current_snapshot.provider,
10241                                    &identity,
10242                                )
10243                            })
10244                    })
10245                    .map(|record| record.record_id.clone());
10246                if let Some(existing_id) = existing_id {
10247                    let scope_matches = records.records.get(&existing_id).is_some_and(|record| {
10248                        record.mode == current_snapshot.mode
10249                            && record.workspace_id == current_snapshot.workspace_id
10250                            && record.environment_profile == current_snapshot.environment_profile
10251                            && record.bundle == current_snapshot.bundle
10252                            && record.context_id == current_snapshot.context_id
10253                            && record.context == current_snapshot.context
10254                            && record
10255                                .canonical_root
10256                                .as_utf8()
10257                                .zip(current_snapshot.canonical_root.as_utf8())
10258                                .is_some_and(|(left, right)| platform::roots_equal(left, right))
10259                    });
10260                    if !scope_matches {
10261                        if let Some(current) = records.records.get_mut(record_id) {
10262                            current.state = ManagedSessionState::Unavailable;
10263                            current.last_error =
10264                                Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR.to_owned());
10265                            current.updated_at_unix_ms = unix_time_ms();
10266                            upserts.push(current.clone());
10267                        }
10268                    } else {
10269                        let existing_is_active = records
10270                            .records
10271                            .get(&existing_id)
10272                            .is_some_and(|record| record.active_session.is_some());
10273                        if existing_is_active {
10274                            if let Some(current) = records.records.get_mut(record_id) {
10275                                current.state = ManagedSessionState::Unavailable;
10276                                current.last_error =
10277                                    Some(PROVIDER_SESSION_LIVE_CONFLICT_ERROR.to_owned());
10278                                current.updated_at_unix_ms = unix_time_ms();
10279                                upserts.push(current.clone());
10280                            }
10281                        } else {
10282                            if current_snapshot.provider_session.is_some() {
10283                                if let Some(current) = records.records.get_mut(record_id) {
10284                                    current.active_session = None;
10285                                    current.state = ManagedSessionState::Dormant;
10286                                    current.updated_at_unix_ms = unix_time_ms();
10287                                    upserts.push(current.clone());
10288                                }
10289                            } else {
10290                                records.records.remove(record_id);
10291                                removals.push(record_id.clone());
10292                            }
10293                            if let Some(existing) = records.records.get_mut(&existing_id) {
10294                                existing.provider_session = Some(identity.clone());
10295                                existing.active_session = Some(address.clone());
10296                                existing.state = ManagedSessionState::Live;
10297                                existing.updated_at_unix_ms = unix_time_ms();
10298                                existing.last_error = None;
10299                                upserts.push(existing.clone());
10300                            }
10301                            rebind = Some(existing_id);
10302                        }
10303                    }
10304                } else if current_snapshot
10305                    .provider_session
10306                    .as_ref()
10307                    .is_some_and(|current| {
10308                        !session_registry::same_provider_session(
10309                            &current_snapshot.provider,
10310                            current,
10311                            &current_snapshot.provider,
10312                            &identity,
10313                        )
10314                    })
10315                {
10316                    if let Some(current) = records.records.get_mut(record_id) {
10317                        current.active_session = None;
10318                        if current.bundle.is_some() || current.context.is_some() {
10319                            let had_bundle = current.bundle.is_some();
10320                            current.provider_session = None;
10321                            current.bundle = None;
10322                            current.context_id = None;
10323                            current.context = None;
10324                            current.state = ManagedSessionState::Unavailable;
10325                            current.last_error = Some(if had_bundle {
10326                                "bundle-unavailable".to_owned()
10327                            } else {
10328                                "context-unavailable".to_owned()
10329                            });
10330                        } else {
10331                            current.state = ManagedSessionState::Dormant;
10332                        }
10333                        current.updated_at_unix_ms = unix_time_ms();
10334                        upserts.push(current.clone());
10335                    }
10336                    if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
10337                        if let Some(current) = records.records.get_mut(record_id) {
10338                            current.state = ManagedSessionState::Unavailable;
10339                            current.last_error =
10340                                Some(MANAGED_SESSION_CAPACITY_ERROR.to_owned());
10341                            current.updated_at_unix_ms = unix_time_ms();
10342                            upserts.push(current.clone());
10343                        }
10344                    } else if let Some(new_record_id) = replacement_id {
10345                        let now = unix_time_ms();
10346                        let replacement = ManagedSessionRecord {
10347                            record_id: new_record_id.clone(),
10348                            display_name: format!(
10349                                "{} #{}",
10350                                current_snapshot.provider.as_str(),
10351                                records
10352                                    .records
10353                                    .values()
10354                                    .filter(|record| {
10355                                        record.provider == current_snapshot.provider
10356                                    })
10357                                    .count()
10358                                    .saturating_add(1),
10359                            ),
10360                            provider: current_snapshot.provider.clone(),
10361                            mode: current_snapshot.mode,
10362                            state: ManagedSessionState::Live,
10363                            workspace_id: current_snapshot.workspace_id,
10364                            canonical_root: current_snapshot.canonical_root,
10365                            provider_session: Some(identity),
10366                            active_session: Some(address.clone()),
10367                            environment_profile: current_snapshot.environment_profile,
10368                            bundle: current_snapshot.bundle,
10369                            context_id: current_snapshot.context_id,
10370                            context: current_snapshot.context,
10371                            // This is a fresh record_id representing the session under its
10372                            // newly-observed identity; it has not yet produced any clean
10373                            // exit of its own, so it starts with no exported pack (the old
10374                            // record, still addressable under its own record_id, keeps
10375                            // whatever exported_context it already had).
10376                            exported_context: None,
10377                            task_binding: current_snapshot.task_binding,
10378                            created_at_unix_ms: now,
10379                            updated_at_unix_ms: now,
10380                            last_error: None,
10381                        };
10382                        records
10383                            .records
10384                            .insert(new_record_id.clone(), replacement.clone());
10385                        upserts.push(replacement);
10386                        rebind = Some(new_record_id);
10387                    } else if let Some(current) = records.records.get_mut(record_id) {
10388                        current.state = ManagedSessionState::Unavailable;
10389                        current.last_error =
10390                            Some(PROVIDER_IDENTITY_ALLOCATION_ERROR.to_owned());
10391                        upserts.push(current.clone());
10392                    }
10393                } else if let Some(current) = records.records.get_mut(record_id) {
10394                    current.provider_session = Some(identity);
10395                    current.active_session = Some(address.clone());
10396                    current.state = ManagedSessionState::Live;
10397                    current.updated_at_unix_ms = unix_time_ms();
10398                    current.last_error = None;
10399                    upserts.push(current.clone());
10400                }
10401            } else if let Some(current) = records.records.get_mut(record_id) {
10402                if resume_command_rejected {
10403                    current.active_session = None;
10404                    current.state = detached_record_state(current);
10405                    current.updated_at_unix_ms = unix_time_ms();
10406                    current.last_error =
10407                        Some(PROVIDER_RESUME_REJECTED_ERROR.to_owned());
10408                    upserts.push(current.clone());
10409                } else {
10410                    match &event.event {
10411                    ControlEventKind::Running { .. }
10412                    | ControlEventKind::ResumeAuthorized { .. }
10413                    | ControlEventKind::Resumed { .. } => {
10414                        current.active_session = Some(address.clone());
10415                        current.state = active_record_state(
10416                            identity_admitted,
10417                            current.provider_session.is_some(),
10418                        );
10419                        current.updated_at_unix_ms = unix_time_ms();
10420                        current.last_error = None;
10421                        upserts.push(current.clone());
10422                    }
10423                    ControlEventKind::Exited { .. } | ControlEventKind::Removed => {
10424                        current.active_session = None;
10425                        current.state = detached_record_state(current);
10426                        current.updated_at_unix_ms = unix_time_ms();
10427                        upserts.push(current.clone());
10428                    }
10429                    ControlEventKind::Failed { message }
10430                    | ControlEventKind::ResumeFailed { message } => {
10431                        current.active_session = None;
10432                        current.state = detached_record_state(current);
10433                        current.updated_at_unix_ms = unix_time_ms();
10434                        current.last_error = Some(
10435                            session_registry::sanitized_record_error_summary(message),
10436                        );
10437                        upserts.push(current.clone());
10438                    }
10439                    ControlEventKind::ResumeDenied { reason } => {
10440                        current.active_session = None;
10441                        current.state = detached_record_state(current);
10442                        current.updated_at_unix_ms = unix_time_ms();
10443                        current.last_error = Some(
10444                            session_registry::sanitized_record_error_summary(reason),
10445                        );
10446                        upserts.push(current.clone());
10447                    }
10448                        _ => {}
10449                    }
10450                }
10451            }
10452        }
10453        let materialization_owner_record = identity_was_observed.then(|| {
10454            rebind.clone().unwrap_or_else(|| record_id.clone())
10455        }).and_then(|candidate| {
10456            self.session_records
10457                .lock()
10458                .unwrap_or_else(|poisoned| poisoned.into_inner())
10459                .records
10460                .get(&candidate)
10461                .filter(|record| {
10462                    record.provider_session.is_some()
10463                        && record.active_session.as_ref() == Some(address)
10464                })
10465                .map(|_| candidate)
10466        });
10467        if let Some(owner_record_id) = materialization_owner_record {
10468            let materialization_id = self
10469                .session_bindings
10470                .lock()
10471                .unwrap_or_else(|poisoned| poisoned.into_inner())
10472                .get(&address.session.instance_id)
10473                .and_then(|binding| binding.materialization_id.clone());
10474            if let Some(materialization_id) = materialization_id {
10475                let mut transfer_failed = false;
10476                {
10477                    let mut materializations = self
10478                        .materializations
10479                        .lock()
10480                        .unwrap_or_else(|poisoned| poisoned.into_inner());
10481                    if let Some(ownership) = materializations.get_mut(&materialization_id) {
10482                        match ownership.owner() {
10483                            MaterializationOwner::Session {
10484                                incarnation_id,
10485                                instance_id,
10486                                generation,
10487                            } if *incarnation_id == self.incarnation_id
10488                                && *instance_id == address.session.instance_id
10489                                && *generation == address.session.generation =>
10490                            {
10491                                transfer_failed = ownership
10492                                    .transfer_to_record(owner_record_id.clone(), unix_time_ms())
10493                                    .is_err();
10494                            }
10495                            MaterializationOwner::Record { record_id }
10496                                if record_id == &owner_record_id => {}
10497                            MaterializationOwner::Record { record_id } => {
10498                                let expected = record_id.clone();
10499                                transfer_failed = ownership
10500                                    .transfer_record_owner(
10501                                        &expected,
10502                                        owner_record_id.clone(),
10503                                        unix_time_ms(),
10504                                    )
10505                                    .is_err();
10506                            }
10507                            _ => transfer_failed = true,
10508                        }
10509                        if transfer_failed {
10510                            let _ = ownership.mark_recovery_required(unix_time_ms());
10511                        }
10512                    } else {
10513                        transfer_failed = true;
10514                    }
10515                }
10516                if transfer_failed {
10517                    if let Some(record) = self
10518                        .session_records
10519                        .lock()
10520                        .unwrap_or_else(|poisoned| poisoned.into_inner())
10521                        .records
10522                        .get_mut(&owner_record_id)
10523                    {
10524                        record.state = ManagedSessionState::Unavailable;
10525                        record.last_error = Some("environment-profile-unavailable".to_owned());
10526                        record.updated_at_unix_ms = unix_time_ms();
10527                        upserts.push(record.clone());
10528                    }
10529                }
10530            }
10531        }
10532        if let Some(new_record_id) = rebind.clone() {
10533            if let Some(binding) = self
10534                .session_bindings
10535                .lock()
10536                .unwrap_or_else(|poisoned| poisoned.into_inner())
10537                .get_mut(&address.session.instance_id)
10538            {
10539                binding.record_id = Some(new_record_id);
10540            }
10541        }
10542        if upserts.is_empty() && removals.is_empty() {
10543            return;
10544        }
10545        if self.persist_state_locked().is_err() {
10546            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
10547            let mut records = self
10548                .session_records
10549                .lock()
10550                .unwrap_or_else(|poisoned| poisoned.into_inner());
10551            records.records = original_records;
10552            *self
10553                .materializations
10554                .lock()
10555                .unwrap_or_else(|poisoned| poisoned.into_inner()) = original_materializations;
10556            if let Some(current) = records.records.get_mut(record_id) {
10557                current.state = ManagedSessionState::Unavailable;
10558                current.last_error = Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned());
10559                current.updated_at_unix_ms = unix_time_ms();
10560                upserts = vec![current.clone()];
10561            } else {
10562                upserts.clear();
10563            }
10564            removals.clear();
10565            drop(records);
10566            if let Some(binding) = self
10567                .session_bindings
10568                .lock()
10569                .unwrap_or_else(|poisoned| poisoned.into_inner())
10570                .get_mut(&address.session.instance_id)
10571            {
10572                binding.record_id = Some(record_id.clone());
10573            }
10574            eprintln!("gate4agent-node: {DURABLE_STATE_COMMIT_FAILED_ERROR}");
10575        }
10576        for record in upserts {
10577            self.publish_record(record);
10578        }
10579        for record_id in removals {
10580            self.publish(NodeEvent::SessionRecordRemoved { record_id });
10581        }
10582    }
10583
10584    fn snapshot(&self) -> NodeSnapshot {
10585        let control = self.handle.snapshot();
10586        let bindings = self
10587            .session_bindings
10588            .lock()
10589            .unwrap_or_else(|poisoned| poisoned.into_inner())
10590            .clone();
10591        let workspace_roots = self
10592            .workspaces
10593            .read()
10594            .unwrap_or_else(|poisoned| poisoned.into_inner())
10595            .clone();
10596        let environment_profiles = self
10597            .environment_profiles
10598            .read()
10599            .unwrap_or_else(|poisoned| poisoned.into_inner());
10600        let mut workspaces = workspace_roots
10601            .iter()
10602            .map(|(workspace_id, canonical_root)| {
10603                (
10604                    workspace_id.clone(),
10605                    WorkspaceSnapshot {
10606                        workspace_id: workspace_id.clone(),
10607                        canonical_root: opaque_windows_path(canonical_root.clone()),
10608                        sessions: Vec::new(),
10609                        worktree_service_mode: Some(
10610                            self.worktree_service_modes
10611                                .get(workspace_id)
10612                                .copied()
10613                                .unwrap_or(WorktreeServiceMode::Manual),
10614                        ),
10615                        managed_worktree_profiles: Some(WorktreeProfileInventory {
10616                            profiles: self
10617                                .managed_worktree_profiles
10618                                .get(workspace_id)
10619                                .into_iter()
10620                                .flat_map(BTreeMap::values)
10621                                .map(|profile| ManagedWorktreeProfileSummary {
10622                                    id: profile.profile_id().clone(),
10623                                    revision: profile.revision().clone(),
10624                                    retention: profile.retention(),
10625                                })
10626                                .collect(),
10627                        }),
10628                    },
10629                )
10630            })
10631            .collect::<BTreeMap<_, _>>();
10632        let mut agent_progress = Vec::new();
10633        for session in &control.sessions {
10634            let Some(binding) = bindings.get(&session.instance_id) else {
10635                continue;
10636            };
10637            if binding.generation != session.generation {
10638                continue;
10639            }
10640            if let Some(workspace) = workspaces.get_mut(&binding.workspace_id) {
10641                let address = SessionAddress {
10642                    workspace_id: binding.workspace_id.clone(),
10643                    session: SessionKey {
10644                        instance_id: session.instance_id,
10645                        generation: session.generation,
10646                    },
10647                };
10648                if let Some(progress) =
10649                    agent_progress_from_provider_snapshot(address, &session.provider)
10650                {
10651                    agent_progress.push(progress);
10652                }
10653                workspace.sessions.push(session.clone());
10654            }
10655        }
10656        NodeSnapshot {
10657            node_id: self.node_id.clone(),
10658            enabled_providers: self.enabled_providers.clone(),
10659            provider_runtime_statuses: {
10660                let updates = self
10661                    .provider_runtime_status_updates
10662                    .read()
10663                    .unwrap_or_else(|poisoned| poisoned.into_inner());
10664                ProviderRuntimeStatuses::new(
10665                    self.provider_runtime_statuses
10666                        .iter()
10667                        .filter(|status| !updates.contains_key(status.provider()))
10668                        .cloned()
10669                        .chain(updates.values().cloned()),
10670                )
10671                .expect("refreshed provider runtime inventory remains bounded and unique")
10672            },
10673            workspaces: workspaces.into_values().collect(),
10674            session_records: self
10675                .session_records
10676                .lock()
10677                .unwrap_or_else(|poisoned| poisoned.into_inner())
10678                .records
10679                .values()
10680                .cloned()
10681                .collect(),
10682            managed_worktrees: self.managed_worktrees
10683                .lock()
10684                .unwrap_or_else(|poisoned| poisoned.into_inner())
10685                .snapshots(),
10686            launch_inventory: Some(LaunchInventory {
10687                spawn_profiles: Some(
10688                    self.spawn_profiles
10689                        .iter()
10690                        .filter_map(|profile| {
10691                            resolved_spawn_profile_summary(profile, &environment_profiles)
10692                        })
10693                        .collect(),
10694                ),
10695                bundles: Some(
10696                    self.bundle_catalog
10697                        .read()
10698                        .unwrap_or_else(|poisoned| poisoned.into_inner())
10699                        .iter()
10700                        .map(NodeBundle::receipt)
10701                        .collect(),
10702                ),
10703                // Opaque station allowlist ids from empty-default catalog /
10704                // optional startup loader. Never secrets. Dig2 probe stays stub.
10705                network_allowlists: {
10706                    let catalog = self
10707                        .network_allowlist_catalog
10708                        .read()
10709                        .unwrap_or_else(|poisoned| poisoned.into_inner());
10710                    if catalog.is_empty() {
10711                        None
10712                    } else {
10713                        Some(catalog.ids().cloned().collect())
10714                    }
10715                },
10716            }),
10717            agent_progress,
10718        }
10719    }
10720
10721    fn publish(&self, event: NodeEvent) -> NodeEventEnvelope {
10722        assert!(
10723            !matches!(
10724                &event,
10725                NodeEvent::TerminalFrame { .. } | NodeEvent::AgentStream { .. }
10726            ),
10727            "terminal frame and agent stream events must use their own dedicated live channel",
10728        );
10729        let mut history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
10730        let sequence = history
10731            .last_sequence
10732            .checked_add(1)
10733            .expect("node event sequence exhausted");
10734        if history.events.len() == NODE_EVENT_HISTORY_MAX {
10735            if let Some(evicted) = history.events.pop_front() {
10736                history.replay_floor_sequence = evicted
10737                    .sequence
10738                    .checked_add(1)
10739                    .expect("node replay floor sequence exhausted");
10740                history.removed_record_providers.remove(&evicted.sequence);
10741            }
10742        }
10743        match &event {
10744            NodeEvent::HarnessMcpReadCall { .. } => {
10745                panic!("transient harness MCP calls must not enter durable event history")
10746            }
10747            NodeEvent::SessionRecordUpserted { record } => {
10748                history
10749                    .record_providers
10750                    .insert(record.record_id.clone(), record.provider.clone());
10751            }
10752            NodeEvent::SessionRecordRemoved { record_id } => {
10753                if let Some(provider) = history.record_providers.remove(record_id) {
10754                    history.removed_record_providers.insert(sequence, provider);
10755                }
10756            }
10757            NodeEvent::ManagedWorktreeUpserted { .. }
10758            | NodeEvent::ManagedWorktreeRemoved { .. }
10759            | NodeEvent::Control { .. }
10760            | NodeEvent::ControllerChanged { .. }
10761            | NodeEvent::SessionRecordHistorySummarized { .. }
10762            | NodeEvent::WorkspaceAdded { .. }
10763            | NodeEvent::WorkspaceRemoved { .. }
10764            | NodeEvent::ResyncRequired { .. }
10765            | NodeEvent::AgentStream { .. }
10766            | NodeEvent::TerminalFrame { .. } => {}
10767        }
10768        let envelope = NodeEventEnvelope { sequence, event };
10769        history.events.push_back(envelope.clone());
10770        history.last_sequence = sequence;
10771        let _ = self.event_tx.send(envelope.clone());
10772        drop(history);
10773        envelope
10774    }
10775
10776    /// Publishes a transient (never durable) `NodeEvent` -- today only
10777    /// `NodeEvent::HarnessMcpReadCall` -- onto the dedicated
10778    /// `harness_mcp_proxy_event_tx` broadcast (see that field's own doc for
10779    /// why this must never ride the shared `event_tx` bus). Returns whether
10780    /// the send reached at least one subscriber: `broadcast::Sender::send`
10781    /// fails in exactly one way, no active receiver at that instant, and
10782    /// the caller (`HarnessMcpProxyRegistry::begin_call`) uses a `false`
10783    /// return to fail the pending call immediately rather than leave it to
10784    /// expire silently at `LOCAL_READ_TIMEOUT`/
10785    /// `MAX_HARNESS_MCP_CALL_DEADLINE_MS`.
10786    fn publish_transient(&self, event: NodeEvent) -> bool {
10787        assert!(event.requires_harness_mcp_proxy_capability());
10788        assert!(event.harness_mcp_contract_is_valid_at(unix_time_ms()));
10789        let envelope = NodeEventEnvelope { sequence: 0, event };
10790        match self.harness_mcp_proxy_event_tx.send(Arc::new(vec![envelope])) {
10791            Ok(_) => true,
10792            Err(broadcast::error::SendError(undelivered)) => {
10793                if let Some(NodeEventEnvelope {
10794                    event: NodeEvent::HarnessMcpReadCall { call_id, request, .. },
10795                    ..
10796                }) = undelivered.first() {
10797                    // `request.content_type` only, never a per-tool label --
10798                    // this crate does not decode `request.body` and does not
10799                    // hold `hatchery-harness-api` (Nested Control Plane
10800                    // doctrine, Law 3; see this crate's dependency on
10801                    // `gate4agent-node-protocol`'s own `Forbidden:` line).
10802                    tracing::warn!(
10803                        call_id = call_id.as_str(),
10804                        content_type = ?request.content_type,
10805                        cause = "no-subscribers",
10806                        "harness MCP proxy event could not be published",
10807                    );
10808                }
10809                false
10810            }
10811        }
10812    }
10813
10814    fn publish_terminal_frames(&self) {
10815        let snapshot = self.handle.snapshot();
10816        let bindings = self
10817            .session_bindings
10818            .lock()
10819            .unwrap_or_else(|poisoned| poisoned.into_inner());
10820        let candidates = snapshot
10821            .sessions
10822            .iter()
10823            .filter_map(|session| {
10824                let binding = bindings.get(&session.instance_id)?;
10825                if binding.generation != session.generation {
10826                    return None;
10827                }
10828                let frame = session.terminal_frame.clone()?;
10829                Some((
10830                    SessionAddress {
10831                        workspace_id: binding.workspace_id.clone(),
10832                        session: SessionKey {
10833                            instance_id: session.instance_id,
10834                            generation: session.generation,
10835                        },
10836                    },
10837                    frame,
10838                ))
10839            })
10840            .collect();
10841        drop(bindings);
10842        self.publish_terminal_frame_candidates(candidates);
10843    }
10844
10845    fn publish_terminal_frame_candidates(
10846        &self,
10847        candidates: Vec<(SessionAddress, TerminalFrame)>,
10848    ) {
10849        let mut watermarks = self
10850            .terminal_frame_watermarks
10851            .lock()
10852            .unwrap_or_else(|poisoned| poisoned.into_inner());
10853        let mut events = Vec::with_capacity(candidates.len());
10854        for (address, frame) in candidates {
10855            let is_first_frame = !watermarks.contains_key(&address.session.instance_id);
10856            let advanced = watermarks
10857                .get(&address.session.instance_id)
10858                .map_or(true, |(current_address, sequence)| {
10859                    current_address != &address || frame.sequence > *sequence
10860                });
10861            if !advanced {
10862                continue;
10863            }
10864            if is_first_frame {
10865                // This is the first PTY output this node has observed for
10866                // this session -- a one-time event per spawn, not per-frame
10867                // chatter (the watermark makes sure of that). A session that
10868                // reaches this line with no controller attached anywhere on
10869                // the node is running unobserved: exactly how processes from
10870                // repeated spawn attempts went orphaned for hours without a
10871                // single line about it.
10872                if self.controller_state().is_none() {
10873                    tracing::warn!(
10874                        node_id = %self.node_id,
10875                        workspace_id = %address.workspace_id,
10876                        session = ?address.session,
10877                        terminal_size = ?frame.size,
10878                        "session producing output with no controller attached",
10879                    );
10880                } else {
10881                    tracing::info!(
10882                        node_id = %self.node_id,
10883                        workspace_id = %address.workspace_id,
10884                        session = ?address.session,
10885                        terminal_size = ?frame.size,
10886                        "session produced its first output",
10887                    );
10888                }
10889            }
10890            watermarks.insert(
10891                address.session.instance_id,
10892                (address.clone(), frame.sequence),
10893            );
10894            events.push(NodeEvent::TerminalFrame { address, frame });
10895        }
10896        drop(watermarks);
10897        if events.is_empty() {
10898            return;
10899        }
10900        let mut history = self
10901            .history
10902            .lock()
10903            .unwrap_or_else(|poisoned| poisoned.into_inner());
10904        let mut envelopes = Vec::with_capacity(events.len());
10905        for event in events {
10906            let sequence = history
10907                .last_sequence
10908                .checked_add(1)
10909                .expect("node event sequence exhausted");
10910            history.last_sequence = sequence;
10911            envelopes.push(NodeEventEnvelope { sequence, event });
10912        }
10913        let _ = self.terminal_event_tx.send(Arc::new(envelopes));
10914        drop(history);
10915    }
10916
10917    /// Publishes one `AgentStreamChunkV1` onto the dedicated agent-stream
10918    /// channel, mirroring `publish_terminal_frame_candidates` above: the
10919    /// envelope's `sequence` is drawn from the same `history.last_sequence`
10920    /// counter every other event kind shares, never a counter of its own --
10921    /// `queue_connection_event`/`queue_connection_event_batch` compare
10922    /// `envelope.sequence` against one `discard_events_through` watermark
10923    /// regardless of which broadcast channel it arrived on, so a
10924    /// per-channel counter would desynchronize that comparison.
10925    fn publish_agent_stream_chunk(&self, address: SessionAddress, chunk: AgentStreamChunkV1) {
10926        let mut history = self
10927            .history
10928            .lock()
10929            .unwrap_or_else(|poisoned| poisoned.into_inner());
10930        let sequence = history
10931            .last_sequence
10932            .checked_add(1)
10933            .expect("node event sequence exhausted");
10934        history.last_sequence = sequence;
10935        drop(history);
10936        // Debug, not info/warn -- this fires on every single chunk, but it
10937        // is exactly the counter the next drop-measurement pass needs: what
10938        // the node actually emitted (`source_sequence`, `kind`) versus what
10939        // a harness subscriber's own forwarder later logs having received
10940        // (`hatchery-harness-service::runtime`'s "forwarding agent stream
10941        // event"), so a gap between the two logs names itself instead of
10942        // needing a new counter added ad hoc.
10943        tracing::debug!(
10944            source_sequence = chunk.source_sequence,
10945            kind = agent_stream_chunk_kind_label(&chunk.kind),
10946            envelope_sequence = sequence,
10947            "publishing agent stream chunk",
10948        );
10949        let envelope = NodeEventEnvelope {
10950            sequence,
10951            event: NodeEvent::AgentStream { address, chunk },
10952        };
10953        let _ = self.agent_stream_event_tx.send(Arc::new(vec![envelope]));
10954    }
10955
10956    fn current_sequence(&self) -> u64 {
10957        self.history
10958            .lock()
10959            .unwrap_or_else(|poisoned| poisoned.into_inner())
10960            .last_sequence
10961    }
10962
10963    fn controller_state(&self) -> Option<ControllerState> {
10964        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
10965        let lease = controller.as_ref().copied()?;
10966        let remaining = lease.expires_at.saturating_duration_since(Instant::now());
10967        if remaining.is_zero() {
10968            *controller = None;
10969            return None;
10970        }
10971        Some(ControllerState {
10972            connection_id: lease.connection_id,
10973            lease_remaining_ms: remaining.as_millis().min(u64::MAX as u128) as u64,
10974        })
10975    }
10976
10977    fn acquire_controller(&self, connection_id: u64, role: ClientRole, lease_ms: u64) -> Result<ControllerState, NodeFailure> {
10978        if role != ClientRole::Operator {
10979            tracing::warn!(
10980                node_id = %self.node_id,
10981                connection_id,
10982                "controller lease rejected: observer connections cannot acquire control",
10983            );
10984            return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections cannot acquire control"));
10985        }
10986        let lease_ms = if lease_ms == 0 {
10987            DEFAULT_CONTROLLER_LEASE_MS
10988        } else {
10989            lease_ms.clamp(MIN_CONTROLLER_LEASE_MS, MAX_CONTROLLER_LEASE_MS)
10990        };
10991        let now = Instant::now();
10992        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
10993        if let Some(active) = controller.as_ref() {
10994            if active.connection_id != connection_id && active.expires_at > now {
10995                tracing::warn!(
10996                    node_id = %self.node_id,
10997                    connection_id,
10998                    holder_connection_id = active.connection_id,
10999                    "controller lease rejected: another operator holds it",
11000                );
11001                return Err(failure(NodeFailureCode::ControllerBusy, "another operator holds the controller lease"));
11002            }
11003        }
11004        // A reconnect (this connection already holds the lease, renewing
11005        // before expiry) happens on a periodic cadence; logging it at INFO
11006        // every renewal would be exactly the heartbeat chatter this crate
11007        // family's level discipline forbids. A genuine attach -- the
11008        // controller identity actually changing -- is a state change and
11009        // gets INFO; a same-connection renewal gets DEBUG.
11010        let is_new_attach = match controller.as_ref() {
11011            Some(active) => active.connection_id != connection_id,
11012            None => true,
11013        };
11014        *controller = Some(ControllerLease {
11015            connection_id,
11016            expires_at: now + Duration::from_millis(lease_ms),
11017        });
11018        drop(controller);
11019        let state = ControllerState { connection_id, lease_remaining_ms: lease_ms };
11020        if is_new_attach {
11021            tracing::info!(
11022                node_id = %self.node_id,
11023                connection_id,
11024                lease_ms,
11025                "controller lease acquired",
11026            );
11027        } else {
11028            tracing::debug!(
11029                node_id = %self.node_id,
11030                connection_id,
11031                lease_ms,
11032                "controller lease renewed",
11033            );
11034        }
11035        self.publish(NodeEvent::ControllerChanged { controller: Some(state.clone()) });
11036        Ok(state)
11037    }
11038
11039    fn release_controller(&self, connection_id: u64) -> bool {
11040        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11041        let released = controller.as_ref().is_some_and(|active| active.connection_id == connection_id);
11042        if released {
11043            *controller = None;
11044        }
11045        drop(controller);
11046        if released {
11047            tracing::info!(
11048                node_id = %self.node_id,
11049                connection_id,
11050                "controller lease released",
11051            );
11052            self.publish(NodeEvent::ControllerChanged { controller: None });
11053        }
11054        released
11055    }
11056
11057    fn require_controller(&self, connection_id: u64, role: ClientRole) -> Result<(), NodeFailure> {
11058        if role != ClientRole::Operator {
11059            return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections are read-only"));
11060        }
11061        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11062        let now = Instant::now();
11063        match controller.as_ref().copied() {
11064            Some(active) if active.connection_id == connection_id && active.expires_at > now => Ok(()),
11065            Some(active) if active.expires_at <= now => {
11066                *controller = None;
11067                Err(failure(NodeFailureCode::ControllerRequired, "controller lease expired"))
11068            }
11069            _ => Err(failure(NodeFailureCode::ControllerRequired, "controller lease is required")),
11070        }
11071    }
11072
11073    fn validate_address(&self, address: &SessionAddress) -> Result<AgentId, NodeFailure> {
11074        if !self
11075            .workspaces
11076            .read()
11077            .unwrap_or_else(|poisoned| poisoned.into_inner())
11078            .contains_key(&address.workspace_id)
11079        {
11080            return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
11081        }
11082        let binding = self
11083            .session_bindings
11084            .lock()
11085            .unwrap_or_else(|poisoned| poisoned.into_inner())
11086            .get(&address.session.instance_id)
11087            .cloned()
11088            .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
11089        if binding.workspace_id != address.workspace_id {
11090            return Err(failure(
11091                NodeFailureCode::SessionWorkspaceMismatch,
11092                "session belongs to another workspace",
11093            ));
11094        }
11095        if binding.generation != address.session.generation {
11096            return Err(failure(
11097                NodeFailureCode::StaleGeneration,
11098                &format!(
11099                    "stale session generation: expected {}, received {}",
11100                    binding.generation.0,
11101                    address.session.generation.0,
11102                ),
11103            ));
11104        }
11105        let snapshot = self.handle.snapshot();
11106        let Some(current) = snapshot
11107            .sessions
11108            .iter()
11109            .find(|item| item.instance_id == address.session.instance_id)
11110        else {
11111            return Err(failure(NodeFailureCode::UnknownSession, "session instance does not exist"));
11112        };
11113        if current.generation != address.session.generation {
11114            return Err(failure(
11115                NodeFailureCode::StaleGeneration,
11116                &format!(
11117                    "stale session generation: expected {}, received {}",
11118                    current.generation.0,
11119                    address.session.generation.0,
11120                ),
11121            ));
11122        }
11123        Ok(current.agent_id.clone())
11124    }
11125
11126    fn dispatch(&self, command: ControlCommand) -> Result<CommandId, NodeFailure> {
11127        self.dispatch_envelope(self.prepare_command(command))
11128    }
11129
11130    fn prepare_command(&self, command: ControlCommand) -> CommandEnvelope {
11131        let id = self.next_command_id.fetch_add(1, Ordering::AcqRel);
11132        CommandEnvelope {
11133            id: CommandId(id),
11134            command,
11135        }
11136    }
11137
11138    fn dispatch_envelope(&self, envelope: CommandEnvelope) -> Result<CommandId, NodeFailure> {
11139        let command_id = envelope.id;
11140        self.handle
11141            .dispatch(envelope)
11142            .map_err(|error| match error {
11143                PortDispatchError::Full => failure(NodeFailureCode::BackendBusy, "backend command ingress is full"),
11144                PortDispatchError::Disconnected => failure(NodeFailureCode::BackendDisconnected, "backend command ingress is disconnected"),
11145            })?;
11146        Ok(command_id)
11147    }
11148
11149    /// Records the kernel's own reason for asynchronously refusing
11150    /// `command_id` -- see `rejected_command_reasons`'s doc for why a
11151    /// spawn-dispatch waiter otherwise has no way to learn it. Bounded by
11152    /// `REJECTED_COMMAND_REASON_MAX_ENTRIES`: expired entries are swept
11153    /// first, and if the map is still at capacity after that, the single
11154    /// oldest surviving entry is evicted -- `CommandId` is monotonically
11155    /// assigned (`next_command_id.fetch_add`), so the `BTreeMap`'s first key
11156    /// is always the oldest.
11157    fn record_rejected_command(
11158        &self,
11159        command_id: CommandId,
11160        message: String,
11161        unsupported_transport: bool,
11162    ) {
11163        let now = Instant::now();
11164        let mut reasons = self.rejected_command_reasons.lock()
11165            .unwrap_or_else(|poisoned| poisoned.into_inner());
11166        reasons.retain(|_, reason| reason.expires_at > now);
11167        if reasons.len() >= REJECTED_COMMAND_REASON_MAX_ENTRIES {
11168            if let Some(&oldest) = reasons.keys().next() {
11169                reasons.remove(&oldest);
11170            }
11171        }
11172        reasons.insert(
11173            command_id,
11174            RejectedCommandReason {
11175                message,
11176                unsupported_transport,
11177                expires_at: now + Duration::from_millis(REJECTED_COMMAND_REASON_TTL_MS),
11178            },
11179        );
11180    }
11181
11182    /// Removes and returns the kernel's reason for refusing `command_id`,
11183    /// if the drive loop has recorded one and it has not expired -- see
11184    /// `rejected_command_reasons`'s doc. Consumed at most once: a spawn
11185    /// wait loop calls this instead of blindly polling out its whole
11186    /// deadline when the command it is waiting on turns out to have been
11187    /// refused rather than merely slow.
11188    fn take_rejected_command_reason(&self, command_id: CommandId) -> Option<RejectedCommandReason> {
11189        let now = Instant::now();
11190        let mut reasons = self.rejected_command_reasons.lock()
11191            .unwrap_or_else(|poisoned| poisoned.into_inner());
11192        match reasons.remove(&command_id) {
11193            Some(reason) if reason.expires_at > now => Some(reason),
11194            _ => None,
11195        }
11196    }
11197
11198    async fn dispatch_bounded(
11199        &self,
11200        command: ControlCommand,
11201        timeout_duration: Duration,
11202    ) -> Result<CommandId, NodeFailure> {
11203        let deadline = Instant::now() + timeout_duration;
11204        loop {
11205            match self.dispatch(command.clone()) {
11206                Ok(command_id) => return Ok(command_id),
11207                Err(error) if error.code == NodeFailureCode::BackendBusy && Instant::now() < deadline => {
11208                    sleep(Duration::from_millis(2)).await;
11209                }
11210                Err(error) => return Err(error),
11211            }
11212        }
11213    }
11214
11215    fn internal_session_snapshot(
11216        &self,
11217        address: &SessionAddress,
11218    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11219        self.validate_address(address)?;
11220        self.handle
11221            .snapshot()
11222            .sessions
11223            .clone()
11224            .into_iter()
11225            .find(|session| {
11226                session.instance_id == address.session.instance_id
11227                    && session.generation == address.session.generation
11228            })
11229            .ok_or_else(|| {
11230                failure(
11231                    NodeFailureCode::UnknownSession,
11232                    "session snapshot is unavailable",
11233                )
11234            })
11235    }
11236
11237    /// Confirms `address` names a live session spawned over the ACP
11238    /// transport and returns its current snapshot -- the gate every
11239    /// `acp-control-v1` verb shares. `require_session_runtime_policy`
11240    /// cannot answer this by itself: `ProviderRuntimePolicy` carries no
11241    /// transport bit for `Acp`/`Inline` (see that method's own doc), only
11242    /// the session's own recorded `TransportKind` does, and that lives on
11243    /// the live snapshot, not the binding.
11244    fn require_acp_session(
11245        &self,
11246        address: &SessionAddress,
11247    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11248        let snapshot = self.internal_session_snapshot(address)?;
11249        if snapshot.transport != TransportKind::Acp {
11250            return Err(failure(
11251                NodeFailureCode::UnsupportedTransport,
11252                "this verb requires a session spawned over the ACP transport",
11253            ));
11254        }
11255        Ok(snapshot)
11256    }
11257
11258    async fn dispatch_history_bounded(
11259        &self,
11260        address: &SessionAddress,
11261        command: ControlCommand,
11262        operation: HistoryOperation,
11263    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11264        self.validate_address(address)?;
11265        let started_after = self.current_sequence();
11266        let command_id = self
11267            .dispatch_bounded(
11268                command,
11269                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
11270            )
11271            .await?;
11272        let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
11273        let mut scan_after = started_after;
11274        let mut accepted_after = None;
11275        loop {
11276            let events = self
11277                .history
11278                .lock()
11279                .unwrap_or_else(|poisoned| poisoned.into_inner())
11280                .events
11281                .iter()
11282                .filter(|envelope| envelope.sequence > scan_after)
11283                .cloned()
11284                .collect::<Vec<_>>();
11285            for envelope in events {
11286                scan_after = scan_after.max(envelope.sequence);
11287                let NodeEvent::Control {
11288                    address: event_address,
11289                    event,
11290                } = envelope.event
11291                else {
11292                    continue;
11293                };
11294                if event_address != *address {
11295                    continue;
11296                }
11297                if event.command_id == Some(command_id) {
11298                    match &event.event {
11299                        ControlEventKind::CommandRejected { .. } => {
11300                            return Err(failure(
11301                                NodeFailureCode::InvalidRequest,
11302                                "history request was rejected",
11303                            ));
11304                        }
11305                        ControlEventKind::HistoryRequested {
11306                            operation: requested,
11307                            ..
11308                        } if requested == &operation => {
11309                            accepted_after = Some(envelope.sequence);
11310                            continue;
11311                        }
11312                        _ => {}
11313                    }
11314                }
11315                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
11316                    match (&operation, &event.event) {
11317                        (
11318                            HistoryOperation::Discover { .. },
11319                            ControlEventKind::HistoryDiscovered { .. },
11320                        )
11321                        | (
11322                            HistoryOperation::Load { .. },
11323                            ControlEventKind::HistoryLoaded { .. },
11324                        ) => return self.internal_session_snapshot(address),
11325                        (_, ControlEventKind::HistoryFailed { .. }) => {
11326                            return Err(failure(
11327                                NodeFailureCode::BackendOperationFailed,
11328                                "history operation failed",
11329                            ));
11330                        }
11331                        _ => {}
11332                    }
11333                }
11334            }
11335            if Instant::now() >= deadline {
11336                return Err(failure(
11337                    NodeFailureCode::BackendBusy,
11338                    if accepted_after.is_some() {
11339                        "history operation did not settle before the bounded deadline"
11340                    } else {
11341                        "history operation was not accepted before the bounded deadline"
11342                    },
11343                ));
11344            }
11345            sleep(Duration::from_millis(2)).await;
11346        }
11347    }
11348
11349    async fn discover_history(
11350        &self,
11351        address: &SessionAddress,
11352        limit: u16,
11353    ) -> Result<Vec<HistoryCandidateSummary>, NodeFailure> {
11354        let query = HistoryQuery {
11355            working_directory: Some(self.workspace_root(&address.workspace_id)?),
11356            limit,
11357        };
11358        query
11359            .validate()
11360            .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history query"))?;
11361        let snapshot = self
11362            .dispatch_history_bounded(
11363                address,
11364                ControlCommand::DiscoverHistory {
11365                    instance_id: address.session.instance_id,
11366                    query: query.clone(),
11367                },
11368                HistoryOperation::Discover { query },
11369            )
11370            .await?;
11371        if snapshot.history.pending.is_some() || snapshot.history.loaded.is_some() {
11372            return Err(failure(
11373                NodeFailureCode::BackendOperationFailed,
11374                "history discovery settled into an invalid state",
11375            ));
11376        }
11377        Ok(snapshot.history.candidates)
11378    }
11379
11380    async fn load_history(
11381        &self,
11382        address: &SessionAddress,
11383        candidate_id: String,
11384    ) -> Result<HistorySessionRecord, NodeFailure> {
11385        validate_candidate_id(&candidate_id)
11386            .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history candidate"))?;
11387        let snapshot = self
11388            .dispatch_history_bounded(
11389                address,
11390                ControlCommand::LoadHistory {
11391                    instance_id: address.session.instance_id,
11392                    candidate_id: candidate_id.clone(),
11393                },
11394                HistoryOperation::Load {
11395                    candidate_id: candidate_id.clone(),
11396                },
11397            )
11398            .await?;
11399        if snapshot.history.pending.is_some()
11400            || snapshot.history.loaded_candidate_id.as_ref() != Some(&candidate_id)
11401        {
11402            return Err(failure(
11403                NodeFailureCode::BackendOperationFailed,
11404                "history load settled into an invalid state",
11405            ));
11406        }
11407        snapshot.history.loaded.ok_or_else(|| {
11408            failure(
11409                NodeFailureCode::BackendOperationFailed,
11410                "history load produced no bounded session",
11411            )
11412        })
11413    }
11414
11415    /// `allow_clean_detachment`: `false` for the on-demand
11416    /// `NodeRequest::ExportContextPackForSessionRecord` API path (the
11417    /// managed record must stay untouched, still live, for the whole call);
11418    /// `true` only for the reactive auto-export-at-exit path (`§2.1`), which
11419    /// reacts to the very clean-exit event that detaches the record out from
11420    /// under this same call — see `session_record_export_target_matches`.
11421    async fn export_context_pack_for_session_record(
11422        &self,
11423        record_id: &SessionRecordId,
11424        session: &SessionAddress,
11425        allow_clean_detachment: bool,
11426    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11427        // C2 replaces a relayed failure's prose with a canned per-code
11428        // string (`C2NodeFailure`'s own `From`), so this path's dozen
11429        // distinct refusals are indistinguishable to anyone downstream --
11430        // and a refusal here expires a continuation, which keeps no field
11431        // for why. The prose stays on the node, where it was written.
11432        let outcome = self
11433            .export_context_pack_for_session_record_inner(record_id, session, allow_clean_detachment)
11434            .await;
11435        if let Err(failure) = &outcome {
11436            tracing::warn!(
11437                record = %record_id,
11438                code = ?failure.code,
11439                reason = %failure.message,
11440                "session-record context-pack export refused",
11441            );
11442        }
11443        outcome
11444    }
11445
11446    async fn export_context_pack_for_session_record_inner(
11447        &self,
11448        record_id: &SessionRecordId,
11449        session: &SessionAddress,
11450        allow_clean_detachment: bool,
11451    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11452        // Several steps here refuse with the same BackendOperationFailed
11453        // code and no prose of their own, so which step failed is
11454        // otherwise unrecoverable -- and a refusal here expires a
11455        // continuation.
11456        let step = |name: &'static str| move |failure: NodeFailure| {
11457            tracing::warn!(step = name, code = ?failure.code, "context-pack export step refused");
11458            failure
11459        };
11460        let record = self.record(record_id).map_err(step("record-lookup"))?;
11461        // `provider_session` is `Some` only for a provider whose resolved
11462        // runtime policy admits live identity verification (raw PTY plus a
11463        // semantic PTY adapter -- `provider_runtime::policy_from_capability_flags`).
11464        // The PTY-sidecar-observation shape (no fleet provider today, but
11465        // capability-keyed rather than vendor-locked) hardcodes
11466        // `provider_session_identity: false` in `provider_runtime::evaluate`,
11467        // so its records go straight to `Live` with `provider_session: None`
11468        // and stay that way for the record's entire life -- that is this
11469        // shape's permanent resting state, not a race with observation, and
11470        // `discover_history`/`load_history` below do not need an identity to
11471        // do their job for it. `IdentityPending` is the actual race (an
11472        // identity-admitted provider whose observation has not landed yet):
11473        // it keeps refusing here rather than falling back, because that
11474        // provider's own history the fallback would otherwise trust is not
11475        // guaranteed to be exact without the identity it is still waiting on.
11476        let identity = match record.provider_session.clone() {
11477            Some(identity) => {
11478                if identity.key != ProviderSessionKey::SessionId {
11479                    return Err(step("provider-identity-key")(failure(
11480                        NodeFailureCode::BackendOperationFailed,
11481                        "managed session provider identity cannot bind history exactly",
11482                    )));
11483                }
11484                Some(identity)
11485            }
11486            None if record.state == ManagedSessionState::IdentityPending => {
11487                return Err(step("provider-session-identity")(failure(
11488                    NodeFailureCode::BackendOperationFailed,
11489                    "managed session has not yet observed its provider session identity",
11490                )));
11491            }
11492            None => None,
11493        };
11494        self.revalidate_session_record_context_export(
11495            &record,
11496            identity.as_ref(),
11497            session,
11498            allow_clean_detachment,
11499        ).map_err(step("revalidate-before-discovery"))?;
11500        let candidates = self
11501            .discover_history(session, HISTORY_DISCOVERY_LIMIT_MAX)
11502            .await
11503            .map_err(step("discover-history"))?;
11504        // With no verified identity to filter by, the discovered set must
11505        // still resolve to exactly one candidate: the same ambiguity refusal
11506        // an identity filter would otherwise produce is the only safety
11507        // property left available for this shape.
11508        let candidate_id = select_history_candidate(
11509            &candidates,
11510            identity.as_ref().map(|identity| identity.id.as_str()),
11511        ).map_err(step("unique-candidate"))?;
11512        let loaded = self.load_history(session, candidate_id.clone()).await
11513            .map_err(step("load-history"))?;
11514        if let Some(identity) = identity.as_ref() {
11515            if loaded.session_id != identity.id {
11516                return Err(failure(
11517                    NodeFailureCode::SessionRecordConflict,
11518                    "loaded history identity does not match the managed session record",
11519                ));
11520            }
11521        }
11522        self.revalidate_session_record_context_export(
11523            &record,
11524            identity.as_ref(),
11525            session,
11526            allow_clean_detachment,
11527        )?;
11528        self.revalidate_loaded_history(session, &candidate_id, &loaded)
11529            .map_err(step("revalidate-loaded-history"))?;
11530        let pack = self.materialize_context_pack(session).await
11531            .map_err(step("materialize-context-pack"))?;
11532        self.commit_context_pack_for_session_record(
11533            &record,
11534            identity.as_ref(),
11535            session,
11536            &candidate_id,
11537            &loaded,
11538            pack,
11539            allow_clean_detachment,
11540        )
11541    }
11542
11543    async fn export_context_pack(
11544        &self,
11545        address: &SessionAddress,
11546    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11547        let pack = self.materialize_context_pack(address).await?;
11548        self.commit_context_pack(pack)
11549    }
11550
11551    async fn materialize_context_pack(
11552        &self,
11553        address: &SessionAddress,
11554    ) -> Result<NodeContextPack, NodeFailure> {
11555        let snapshot = self.internal_session_snapshot(address)?;
11556        if !context_pack_source_status_is_usable(&snapshot.status) {
11557            return Err(failure(
11558                NodeFailureCode::InvalidRequest,
11559                "context export requires a running or successfully exited source session",
11560            ));
11561        }
11562        if snapshot.history.pending.is_some()
11563            || snapshot.history.loaded_candidate_id.is_none()
11564        {
11565            return Err(failure(
11566                NodeFailureCode::InvalidRequest,
11567                "context export requires settled loaded history",
11568            ));
11569        }
11570        let loaded = snapshot.history.loaded.clone().ok_or_else(|| {
11571            failure(
11572                NodeFailureCode::InvalidRequest,
11573                "context export requires settled loaded history",
11574            )
11575        })?;
11576        let source_provider = snapshot.agent_id.clone();
11577        let loaded_candidate_id = snapshot.history.loaded_candidate_id.clone();
11578        let repository = self
11579            .context_pack_repository(&address.workspace_id)
11580            .await?;
11581        let current = self.internal_session_snapshot(address)?;
11582        if !context_pack_source_status_is_usable(&current.status)
11583            || current.agent_id != source_provider
11584            || current.history.pending.is_some()
11585            || current.history.loaded_candidate_id != loaded_candidate_id
11586            || current.history.loaded.as_ref() != Some(&loaded)
11587        {
11588            return Err(failure(
11589                NodeFailureCode::ContextPackMaterializationFailed,
11590                "context source changed during bounded repository capture",
11591            ));
11592        }
11593        NodeContextPack::export_with_repository(
11594            ContextPackLineageReceipt {
11595                source_node_id: self.node_id.clone(),
11596                source_session: address.clone(),
11597                source_provider,
11598            },
11599            &loaded,
11600            Some(repository),
11601        )
11602        .map_err(|_| {
11603            failure(
11604                NodeFailureCode::ContextPackMaterializationFailed,
11605                "bounded context pack export failed",
11606            )
11607        })
11608    }
11609
11610    /// Write-through to the durable [`ContextPackStore`], if one is
11611    /// configured (`state_path` is `Some`). A node with no durable state
11612    /// path — fixtures, tests, ephemeral runs — has no `ContextPackStore`
11613    /// open at all and this is a no-op, matching `persist_state`'s own
11614    /// "durability optional, degrade gracefully when unconfigured" idiom.
11615    fn commit_context_pack_durably(&self, pack: &NodeContextPack) -> Result<(), NodeFailure> {
11616        let mut store = self
11617            .context_pack_store
11618            .lock()
11619            .unwrap_or_else(|poisoned| poisoned.into_inner());
11620        let Some(store) = store.as_mut() else {
11621            return Ok(());
11622        };
11623        store.commit(pack).map_err(|_| {
11624            failure(
11625                NodeFailureCode::ContextPackMaterializationFailed,
11626                "durable context pack commit failed",
11627            )
11628        })
11629    }
11630
11631    fn commit_context_pack(
11632        &self,
11633        pack: NodeContextPack,
11634    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11635        self.commit_context_pack_durably(&pack)?;
11636        self.context_catalog
11637            .write()
11638            .unwrap_or_else(|poisoned| poisoned.into_inner())
11639            .insert(pack)
11640            .map_err(|_| {
11641                failure(
11642                    NodeFailureCode::ContextPackMaterializationFailed,
11643                    "context pack catalog is full",
11644                )
11645            })
11646    }
11647
11648    fn commit_context_pack_for_session_record(
11649        &self,
11650        expected: &ManagedSessionRecord,
11651        identity: Option<&ProviderSessionIdentity>,
11652        session: &SessionAddress,
11653        candidate_id: &str,
11654        loaded: &HistorySessionRecord,
11655        pack: NodeContextPack,
11656        allow_clean_detachment: bool,
11657    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11658        let current_root = self.workspace_root(&expected.workspace_id)?;
11659        let _transaction = self
11660            .state_transaction
11661            .lock()
11662            .unwrap_or_else(|poisoned| poisoned.into_inner());
11663        let records = self
11664            .session_records
11665            .lock()
11666            .unwrap_or_else(|poisoned| poisoned.into_inner());
11667        let current = records.records.get(&expected.record_id).ok_or_else(|| {
11668            failure(
11669                NodeFailureCode::SessionRecordConflict,
11670                "managed session record disappeared during context export",
11671            )
11672        })?;
11673        if !session_record_export_target_matches(expected, current, session, allow_clean_detachment) {
11674            return Err(failure(
11675                NodeFailureCode::SessionRecordConflict,
11676                "managed session record changed during context export",
11677            ));
11678        }
11679        let bindings = self
11680            .session_bindings
11681            .lock()
11682            .unwrap_or_else(|poisoned| poisoned.into_inner());
11683        let binding = bindings.get(&session.session.instance_id).ok_or_else(|| {
11684            failure(
11685                NodeFailureCode::SessionRecordConflict,
11686                "managed session runtime binding disappeared during context export",
11687            )
11688        })?;
11689        let runtime = self
11690            .handle
11691            .snapshot()
11692            .sessions
11693            .iter()
11694            .find(|snapshot| {
11695                snapshot.instance_id == session.session.instance_id
11696                    && snapshot.generation == session.session.generation
11697            })
11698            .cloned()
11699            .ok_or_else(|| {
11700                failure(
11701                    NodeFailureCode::SessionRecordConflict,
11702                    "managed session runtime disappeared during context export",
11703                )
11704            })?;
11705        let receipt = pack.receipt();
11706        if !session_record_context_export_binding_is_exact(
11707            expected,
11708            current,
11709            identity,
11710            session,
11711            binding,
11712            &runtime.agent_id,
11713            &current_root,
11714            allow_clean_detachment,
11715        ) || !session_record_context_export_source_is_usable(
11716            current,
11717            &runtime.status,
11718            allow_clean_detachment,
11719        ) || runtime.history.pending.is_some()
11720            || runtime.history.loaded_candidate_id.as_deref() != Some(candidate_id)
11721            || runtime.history.loaded.as_ref() != Some(loaded)
11722            || receipt.lineage.source_node_id != self.node_id
11723            || receipt.lineage.source_session != *session
11724            || receipt.lineage.source_provider != current.provider
11725            || receipt.source_message_count != loaded.message_count
11726        {
11727            return Err(failure(
11728                NodeFailureCode::SessionRecordConflict,
11729                "managed session source changed before context catalog commit",
11730            ));
11731        }
11732        self.commit_context_pack_durably(&pack)?;
11733        self.context_catalog
11734            .write()
11735            .unwrap_or_else(|poisoned| poisoned.into_inner())
11736            .insert(pack)
11737            .map_err(|_| {
11738                failure(
11739                    NodeFailureCode::ContextPackMaterializationFailed,
11740                    "context pack catalog is full",
11741                )
11742            })
11743    }
11744
11745    async fn context_pack_repository(
11746        &self,
11747        workspace_id: &WorkspaceId,
11748    ) -> Result<ContextPackRepository, NodeFailure> {
11749        let first = self
11750            .context_pack_repository_observation(workspace_id)
11751            .await?;
11752        let second = self
11753            .context_pack_repository_observation(workspace_id)
11754            .await?;
11755        stable_context_pack_repository(first, second)
11756    }
11757
11758    async fn context_pack_repository_observation(
11759        &self,
11760        workspace_id: &WorkspaceId,
11761    ) -> Result<ContextPackRepositoryObservation, NodeFailure> {
11762        let git = self.inspect_workspace(workspace_id.clone()).await?.git;
11763        let head = if git.is_repository {
11764            let root = self.workspace_root(workspace_id)?;
11765            match resolve_base_commit_with_timeout(
11766                &root,
11767                "HEAD",
11768                GIT_COMMAND_TIMEOUT_MS,
11769            )
11770            .await
11771            {
11772                Ok(commit) => ContextPackRepositoryHead::Commit(commit),
11773                Err(error) if error.kind == GitWorktreeErrorKind::Conflict => {
11774                    ContextPackRepositoryHead::Unborn
11775                }
11776                Err(_) => {
11777                    return Err(failure(
11778                        NodeFailureCode::ContextPackMaterializationFailed,
11779                        "context pack HEAD observation failed",
11780                    ));
11781                }
11782            }
11783        } else {
11784            ContextPackRepositoryHead::NotRepository
11785        };
11786        let mut files = Vec::with_capacity(CONTEXT_PACK_SELECTED_FILES.len());
11787        for selected_path in CONTEXT_PACK_SELECTED_FILES {
11788            let path = RepositoryPath::utf8(selected_path.to_owned()).map_err(|_| {
11789                failure(
11790                    NodeFailureCode::InvalidRepositoryPath,
11791                    "context pack selected file path is invalid",
11792                )
11793            })?;
11794            match self.read_workspace_file(workspace_id.clone(), path).await {
11795                Ok(WorkspaceFileRead {
11796                    content: WorkspaceFileContent::Utf8 { text, byte_len },
11797                    ..
11798                }) => files.push(ContextPackRepositoryFileSource::utf8(
11799                    selected_path,
11800                    text,
11801                    byte_len,
11802                )),
11803                Ok(WorkspaceFileRead {
11804                    content: WorkspaceFileContent::NonUtf8 { byte_len },
11805                    ..
11806                }) => files.push(ContextPackRepositoryFileSource::skipped(
11807                    selected_path,
11808                    ContextPackSelectedFileSkipReason::NonUtf8,
11809                    Some(byte_len),
11810                )),
11811                Ok(WorkspaceFileRead {
11812                    content: WorkspaceFileContent::TooLarge { .. },
11813                    ..
11814                }) => files.push(ContextPackRepositoryFileSource::skipped(
11815                    selected_path,
11816                    ContextPackSelectedFileSkipReason::TooLarge,
11817                    None,
11818                )),
11819                Err(error) => {
11820                    let reason = if matches!(
11821                        error.code,
11822                        NodeFailureCode::InvalidRepositoryPath
11823                            | NodeFailureCode::RepositoryFileNotRegular
11824                            | NodeFailureCode::RepositoryPathUnsafe
11825                    ) {
11826                        ContextPackSelectedFileSkipReason::Unsafe
11827                    } else {
11828                        ContextPackSelectedFileSkipReason::Unavailable
11829                    };
11830                    files.push(ContextPackRepositoryFileSource::skipped(
11831                        selected_path,
11832                        reason,
11833                        None,
11834                    ));
11835                }
11836            }
11837        }
11838        Ok(ContextPackRepositoryObservation { head, git, files })
11839    }
11840
11841    fn forget_context_pack(&self, context_id: &SpawnContextId) -> Result<(), NodeFailure> {
11842        let referenced_by_binding = self
11843            .session_bindings
11844            .lock()
11845            .unwrap_or_else(|poisoned| poisoned.into_inner())
11846            .values()
11847            .any(|binding| {
11848                binding.context.as_ref().map(|receipt| &receipt.id) == Some(context_id)
11849            });
11850        let referenced_by_record = self
11851            .session_records
11852            .lock()
11853            .unwrap_or_else(|poisoned| poisoned.into_inner())
11854            .records
11855            .values()
11856            .any(|record| record.context_id.as_ref() == Some(context_id));
11857        let referenced_by_materialization = self
11858            .materializations
11859            .lock()
11860            .unwrap_or_else(|poisoned| poisoned.into_inner())
11861            .values()
11862            .any(|ownership| {
11863                ownership.context().map(|receipt| &receipt.id) == Some(context_id)
11864            });
11865        if referenced_by_binding
11866            || referenced_by_record
11867            || referenced_by_materialization
11868        {
11869            return Err(failure(
11870                NodeFailureCode::ContextPackBusy,
11871                "context pack is still referenced",
11872            ));
11873        }
11874        let mut catalog = self.context_catalog
11875            .write()
11876            .unwrap_or_else(|poisoned| poisoned.into_inner());
11877        if catalog.get(context_id).is_none() {
11878            return Err(failure(
11879                NodeFailureCode::UnknownContextPack,
11880                "context pack is unavailable",
11881            ));
11882        }
11883        self.spawn_idempotency
11884            .lock()
11885            .unwrap_or_else(|poisoned| poisoned.into_inner())
11886            .entries
11887            .retain(|_, entry| !entry.value.references_context(context_id));
11888        let removed = catalog.remove(context_id);
11889        debug_assert!(removed.is_some(), "validated context pack remains present");
11890        Ok(())
11891    }
11892
11893    async fn dispatch_input_bounded(
11894        &self,
11895        address: &SessionAddress,
11896        action: InputAction,
11897    ) -> Result<(), NodeFailure> {
11898        let started_after = self.current_sequence();
11899        let command_id = self
11900            .dispatch_bounded(
11901                ControlCommand::SendInput {
11902                    instance_id: address.session.instance_id,
11903                    action,
11904                },
11905                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
11906            )
11907            .await?;
11908        // A semantic prompt may spend the provider's full readiness budget before
11909        // the runtime emits InputCompleted. Keep the node request deadline bounded,
11910        // but strictly beyond every readiness policy in this node's catalog.
11911        let deadline = Instant::now() + Duration::from_millis(self.input_settle_timeout_ms);
11912        let mut scan_after = started_after;
11913        let mut accepted_after = None;
11914        loop {
11915            let events = self
11916                .history
11917                .lock()
11918                .unwrap_or_else(|poisoned| poisoned.into_inner())
11919                .events
11920                .iter()
11921                .filter(|envelope| envelope.sequence > scan_after)
11922                .cloned()
11923                .collect::<Vec<_>>();
11924            for envelope in events {
11925                scan_after = scan_after.max(envelope.sequence);
11926                let NodeEvent::Control {
11927                    address: event_address,
11928                    event,
11929                } = envelope.event
11930                else {
11931                    continue;
11932                };
11933                if event_address != *address {
11934                    continue;
11935                }
11936                if event.command_id == Some(command_id) {
11937                    match &event.event {
11938                        ControlEventKind::CommandRejected { message } => {
11939                            return Err(failure(NodeFailureCode::InvalidRequest, message));
11940                        }
11941                        ControlEventKind::InputRequested { .. } => {
11942                            accepted_after = Some(envelope.sequence);
11943                            continue;
11944                        }
11945                        _ => {}
11946                    }
11947                }
11948                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
11949                    match &event.event {
11950                        ControlEventKind::InputCompleted { .. } => return Ok(()),
11951                        ControlEventKind::InputFailed { message, .. } => {
11952                            return Err(failure(
11953                                NodeFailureCode::BackendOperationFailed,
11954                                message,
11955                            ));
11956                        }
11957                        _ => {}
11958                    }
11959                }
11960            }
11961            if Instant::now() >= deadline {
11962                return Err(failure(
11963                    NodeFailureCode::BackendBusy,
11964                    if accepted_after.is_some() {
11965                        "input was accepted but did not settle before the bounded deadline"
11966                    } else {
11967                        "input was not accepted before the bounded deadline"
11968                    },
11969                ));
11970            }
11971            sleep(Duration::from_millis(2)).await;
11972        }
11973    }
11974
11975    async fn dispatch_resize_bounded(
11976        &self,
11977        address: &SessionAddress,
11978        size: gate4agent_types::TerminalSize,
11979    ) -> Result<(), NodeFailure> {
11980        let started_after = self.current_sequence();
11981        let command_id = self
11982            .dispatch_bounded(
11983                ControlCommand::Resize {
11984                    instance_id: address.session.instance_id,
11985                    size,
11986                },
11987                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
11988            )
11989            .await?;
11990        let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
11991        let mut scan_after = started_after;
11992        let mut accepted_after = None;
11993        loop {
11994            let events = self
11995                .history
11996                .lock()
11997                .unwrap_or_else(|poisoned| poisoned.into_inner())
11998                .events
11999                .iter()
12000                .filter(|envelope| envelope.sequence > scan_after)
12001                .cloned()
12002                .collect::<Vec<_>>();
12003            for envelope in events {
12004                scan_after = scan_after.max(envelope.sequence);
12005                let NodeEvent::Control {
12006                    address: event_address,
12007                    event,
12008                } = envelope.event
12009                else {
12010                    continue;
12011                };
12012                if event_address != *address {
12013                    continue;
12014                }
12015                if event.command_id == Some(command_id) {
12016                    match &event.event {
12017                        ControlEventKind::CommandRejected { message } => {
12018                            return Err(failure(NodeFailureCode::InvalidRequest, message));
12019                        }
12020                        ControlEventKind::ResizeRequested {
12021                            size: requested_size,
12022                            ..
12023                        } if *requested_size == size => {
12024                            accepted_after = Some(envelope.sequence);
12025                            continue;
12026                        }
12027                        _ => {}
12028                    }
12029                }
12030                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12031                    match &event.event {
12032                        ControlEventKind::Resized { size: settled_size }
12033                            if *settled_size == size =>
12034                        {
12035                            return Ok(());
12036                        }
12037                        ControlEventKind::ResizeFailed { message } => {
12038                            return Err(failure(
12039                                NodeFailureCode::BackendOperationFailed,
12040                                message,
12041                            ));
12042                        }
12043                        _ => {}
12044                    }
12045                }
12046            }
12047            if Instant::now() >= deadline {
12048                return Err(failure(
12049                    NodeFailureCode::BackendBusy,
12050                    if accepted_after.is_some() {
12051                        "resize was accepted but did not settle before the bounded deadline"
12052                    } else {
12053                        "resize was not accepted before the bounded deadline"
12054                    },
12055                ));
12056            }
12057            sleep(Duration::from_millis(2)).await;
12058        }
12059    }
12060
12061    async fn spawn_session(
12062        &self,
12063        workspace_id: WorkspaceId,
12064        provider: AgentId,
12065        mode: SessionMode,
12066        terminal_size: gate4agent_types::TerminalSize,
12067        initial_prompt: Option<String>,
12068    ) -> Result<SessionAddress, NodeFailure> {
12069        if self.managed_worktrees.lock()
12070            .unwrap_or_else(|poisoned| poisoned.into_inner())
12071            .lease_for_workspace(&workspace_id)
12072            .is_some()
12073        {
12074            return Err(failure(
12075                NodeFailureCode::ManagedWorktreeOwnershipConflict,
12076                "managed worktree targets may only be spawned through their lease request",
12077            ));
12078        }
12079        self
12080            .spawn_session_with_deadline(
12081                workspace_id,
12082                provider,
12083                mode,
12084                terminal_size,
12085                initial_prompt,
12086                None,
12087                None,
12088                None,
12089                None,
12090                None,
12091                SpawnRecordPolicy::ProviderIdentityOnly,
12092                None,
12093                &[],
12094                None,
12095                // `NodeRequest::Spawn` (this method's only caller) carries no
12096                // approval-level selection of its own -- see
12097                // `HarnessOperatorRequestV1::SpawnSession`'s own field for
12098                // the one operator-wire path that can choose something other
12099                // than the axis default.
12100                ApprovalLevel::default(),
12101                None,
12102                None,
12103            )
12104            .await
12105            .map(|(session, _runtime_policy)| session)
12106    }
12107
12108    async fn spawn_session_with_deadline(
12109        &self,
12110        workspace_id: WorkspaceId,
12111        provider: AgentId,
12112        mode: SessionMode,
12113        terminal_size: gate4agent_types::TerminalSize,
12114        initial_prompt: Option<String>,
12115        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
12116        bundle: Option<ResolvedBundleReceipt>,
12117        context: Option<ResolvedContextPackReceipt>,
12118        managed_authority: Option<ManagedWorktreeLeaseId>,
12119        admitted_runtime_policy: Option<ProviderRuntimePolicy>,
12120        record_policy: SpawnRecordPolicy,
12121        deadline: Option<Instant>,
12122        required_capabilities: &[ProviderRuntimeCapability],
12123        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
12124        approval_level: ApprovalLevel,
12125        network_allowlist: Option<crate::protocol::SpawnNetworkAllowlistId>,
12126        // Opaque dig2browser station profile id (ids only). Feature
12127        // `dig2-station-probe`: after probe at resolve, spawn takes an
12128        // exclusive node-local lease; cleared on session end. Never
12129        // ImportSession / cookies on C2. Feature off: ignored (stub echo
12130        // already happened on the receipt path).
12131        browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
12132    ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
12133        let reserved_lease = self.managed_worktrees.lock()
12134            .unwrap_or_else(|poisoned| poisoned.into_inner())
12135            .lease_for_workspace(&workspace_id);
12136        match (reserved_lease.as_ref(), managed_authority.as_ref()) {
12137            (Some(expected), Some(provided)) if expected == provided => {}
12138            (None, None) => {}
12139            _ => {
12140                return Err(failure(
12141                    NodeFailureCode::ManagedWorktreeOwnershipConflict,
12142                    "spawn lacks exact managed worktree lease authority",
12143                ));
12144            }
12145        }
12146        // Refuse concurrent distinct environment profiles (incl. ProviderHome
12147        // bindings) on the same workspace cwd. Parallel path: managed worktree
12148        // spawn so each profile gets its own workspace_id.
12149        self.ensure_workspace_environment_profile_compatible(
12150            &workspace_id,
12151            environment_profile.as_ref(),
12152        )?;
12153        let runtime_requirement = match (mode, initial_prompt.is_some()) {
12154            (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
12155            (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
12156            (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
12157            (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
12158        };
12159        // This session's own transport -- computed once, here, from `mode`,
12160        // and reused both to admit spawn (below) and, further down, to
12161        // compute the final `runtime_policy` this session actually operates
12162        // under (`provider_runtime::policy_for_transport`). Registration
12163        // reuses this exact value rather than recomputing it a second time.
12164        let transport = match mode {
12165            SessionMode::Pty => TransportKind::Pty,
12166            SessionMode::Inline => TransportKind::Pipe,
12167            SessionMode::Acp => TransportKind::Acp,
12168        };
12169        let pipe_transport = self.provider_supports_pipe_transport(&provider);
12170        let acp_transport = self.provider_supports_acp_transport(&provider);
12171        let runtime_policy = if let Some(runtime_policy) = admitted_runtime_policy {
12172            require_policy(runtime_policy, pipe_transport, acp_transport, runtime_requirement).map_err(|_| failure(
12173                NodeFailureCode::UnsupportedSpawnCapability,
12174                "pre-admitted provider runtime no longer satisfies spawn requirements",
12175            ))?;
12176            runtime_policy
12177        } else if let Some(deadline) = deadline {
12178            let remaining = spawn_deadline_remaining(deadline)?;
12179            timeout(
12180                remaining,
12181                self.admit_provider_runtime(&provider, runtime_requirement),
12182            )
12183            .await
12184            .map_err(|_| {
12185                failure(
12186                    NodeFailureCode::SpawnDeadlineExceeded,
12187                    "provider admission exceeded the spawn deadline",
12188                )
12189            })??
12190        } else {
12191            self.admit_provider_runtime(&provider, runtime_requirement).await?
12192        };
12193        let runtime_policy = self.effective_spawn_runtime_policy(runtime_policy);
12194        // The single point where this session's `runtime_policy` becomes
12195        // transport-correct: `admit_provider_runtime` (and the pre-admitted
12196        // branch above) only ever compute a PTY-terminal-verification-shaped
12197        // value, sound outright only for `TransportKind::Pty` (Pipe still
12198        // relies on that same value downstream -- see `policy_for_transport`'s
12199        // own doc for why). `policy_for_transport` is the explicit,
12200        // exhaustive match Item 1 asks for -- this is the ONE place its
12201        // output becomes the session's `runtime_policy`, so every later
12202        // reader (`ControlCommand::Start`, `session_bindings`, and every
12203        // engine gate that reads `state.runtime_policy`) sees the same,
12204        // correctly-computed value.
12205        let runtime_policy =
12206            crate::provider_runtime::policy_for_transport(transport, runtime_policy);
12207        if required_capabilities
12208            .iter()
12209            .any(|capability| !runtime_policy.admits(*capability))
12210        {
12211            return Err(failure(
12212                NodeFailureCode::UnsupportedSpawnCapability,
12213                "provider runtime does not admit a required spawn capability",
12214            ));
12215        }
12216        if let Some(deadline) = deadline {
12217            spawn_deadline_remaining(deadline)?;
12218        }
12219        self.ensure_binding_capacity()?;
12220        let working_directory = self.workspace_root(&workspace_id)?;
12221        let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
12222        let session = SessionKey {
12223            instance_id,
12224            generation: SessionGeneration(1),
12225        };
12226        let address = SessionAddress {
12227            workspace_id: workspace_id.clone(),
12228            session,
12229        };
12230        // This is the request-accepted checkpoint: admission (managed
12231        // worktree authority, runtime capability, dispatch deadline) already
12232        // passed and a concrete session identity is minted. It is a distinct
12233        // state from the process actually running (`ControlEventKind::Running`,
12234        // logged in `publish_control`) or the terminal outcome (`Exited`/
12235        // `Failed`, logged there too) -- this line exists so that gap is no
12236        // longer silent.
12237        tracing::info!(
12238            node_id = %self.node_id,
12239            workspace_id = %workspace_id,
12240            provider = %provider,
12241            environment_profile = ?environment_profile.as_ref().map(|receipt| &receipt.profile_id),
12242            session = ?address.session,
12243            "session spawn request accepted",
12244        );
12245        let mut prepared_spawn = SpawnPreparer::prepare(
12246            self,
12247            SpawnPrepareRequest {
12248                instance_id,
12249                address: &address,
12250                provider: &provider,
12251                mode,
12252                environment_profile: environment_profile.as_ref(),
12253                bundle: bundle.as_ref(),
12254                context: context.as_ref(),
12255                managed_authority: managed_authority.as_ref(),
12256                harness_mcp,
12257                approval_level,
12258                network_allowlist: network_allowlist.as_ref(),
12259                browser_profile_id,
12260            },
12261        )?;
12262        let materialization_id = prepared_spawn.materialization_id.take();
12263        let dispatch_timeout = spawn_dispatch_timeout(deadline)?;
12264        let record_id = self.bind_spawn_session_with_materialization(
12265            &address,
12266            provider.clone(),
12267            mode,
12268            runtime_policy,
12269            record_policy,
12270            environment_profile,
12271            bundle,
12272            context,
12273            materialization_id,
12274        )?;
12275        prepared_spawn.retain_session_guards();
12276        // `transport` was already computed above, alongside this session's
12277        // `runtime_policy`; reused here for `Register` rather than derived a
12278        // second time.
12279        let agent_id = provider;
12280        let register_command_id = match self.dispatch_bounded(
12281            ControlCommand::Register {
12282                instance_id,
12283                agent_id,
12284                transport,
12285            },
12286            dispatch_timeout,
12287        )
12288        .await {
12289            Ok(command_id) => command_id,
12290            Err(error) => {
12291                if let Some(binding) = self.remove_binding(&address) {
12292                    SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
12293                }
12294                if let Some(record_id) = record_id.as_ref() {
12295                    self.discard_record(record_id)?;
12296                }
12297                return Err(spawn_dispatch_error(error, deadline));
12298            }
12299        };
12300        let start_timeout = match spawn_dispatch_timeout(deadline) {
12301            Ok(timeout) => timeout,
12302            Err(error) => {
12303                let recovery = self
12304                    .rollback_spawn(
12305                        &address,
12306                        Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12307                    )
12308                    .await;
12309                return match recovery {
12310                    Ok(()) => {
12311                        if let Some(record_id) = record_id.as_ref() {
12312                            self.discard_record(record_id)?;
12313                        }
12314                        Err(error)
12315                    }
12316                    Err(recovery_error) => {
12317                        if let Some(record_id) = record_id.as_ref() {
12318                            self.mark_record_error(
12319                                record_id,
12320                                "spawn deadline elapsed and registration recovery failed",
12321                            )?;
12322                        }
12323                        Err(recovery_error)
12324                    }
12325                };
12326            }
12327        };
12328        if let Err(start_error) = self.verify_harness_mcp_before_start(harness_mcp) {
12329            if let Err(recovery_error) = self
12330                .rollback_spawn(&address, Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS))
12331                .await
12332            {
12333                return Err(recovery_error);
12334            }
12335            if let Some(record_id) = record_id.as_ref() {
12336                self.discard_record(record_id)?;
12337            }
12338            return Err(start_error);
12339        }
12340        let start_result = self
12341            .dispatch_bounded(
12342                ControlCommand::Start {
12343                    instance_id,
12344                    runtime_policy,
12345                    request: StartRequest {
12346                        working_directory,
12347                        terminal_size,
12348                        initial_prompt,
12349                        session_options: None,
12350                        approval_level,
12351                    },
12352                },
12353                start_timeout,
12354            )
12355            .await;
12356        let start_command_id = match start_result {
12357            Ok(command_id) => command_id,
12358            Err(start_error) => {
12359                let start_error = spawn_dispatch_error(start_error, deadline);
12360                let recovery = self
12361                    .rollback_spawn(
12362                        &address,
12363                        Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12364                    )
12365                    .await;
12366                return match recovery {
12367                    Ok(()) => {
12368                        if let Some(record_id) = record_id.as_ref() {
12369                            self.discard_record(record_id)?;
12370                        }
12371                        Err(start_error)
12372                    }
12373                    Err(recovery_error) => {
12374                        let diagnostic = format!(
12375                            "start failed and registration recovery failed: {}",
12376                            recovery_error.message,
12377                        );
12378                        if let Some(record_id) = record_id.as_ref() {
12379                            self.mark_record_error(record_id, &diagnostic)?;
12380                        }
12381                        Err(failure(recovery_error.code, &diagnostic))
12382                    }
12383                };
12384            }
12385        };
12386
12387        let commit_deadline = deadline.unwrap_or_else(|| Instant::now() + dispatch_timeout);
12388        loop {
12389            // `Register`/`Start` only fail synchronously for a port-level
12390            // ingress problem (see `dispatch_bounded`); the kernel refusing
12391            // either command's content -- an unsupported transport, an
12392            // unknown agent -- lands one drive-loop tick later, and no
12393            // session ever appears in `self.handle.snapshot()` for it to
12394            // notice below. Check for that named reason before falling
12395            // through to the blind presence poll, so a rejected spawn
12396            // reports why instead of just timing out.
12397            if let Some(reason) = self.take_rejected_command_reason(register_command_id)
12398                .or_else(|| self.take_rejected_command_reason(start_command_id))
12399            {
12400                self.rollback_spawn(
12401                    &address,
12402                    Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12403                ).await.map_err(|error| {
12404                        failure(
12405                            error.code,
12406                            &format!(
12407                                "spawn was rejected ({}) and registration recovery failed: {}",
12408                                reason.message, error.message
12409                            ),
12410                        )
12411                    })?;
12412                if let Some(record_id) = record_id.as_ref() {
12413                    self.discard_record(record_id)?;
12414                }
12415                let code = if reason.unsupported_transport {
12416                    NodeFailureCode::UnsupportedTransport
12417                } else {
12418                    NodeFailureCode::BackendOperationFailed
12419                };
12420                return Err(failure(code, &reason.message));
12421            }
12422            if let Some(current) = self
12423                .handle
12424                .snapshot()
12425                .sessions
12426                .iter()
12427                .find(|current| current.instance_id == instance_id)
12428            {
12429                if current.generation != session.generation {
12430                    let divergence = failure(
12431                        NodeFailureCode::StaleGeneration,
12432                        "spawn generation diverged",
12433                    );
12434                    return match self
12435                        .rollback_spawn(
12436                            &address,
12437                            Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12438                        )
12439                        .await
12440                    {
12441                        Ok(()) => {
12442                            if let Some(record_id) = record_id.as_ref() {
12443                                self.discard_record(record_id)?;
12444                            }
12445                            Err(divergence)
12446                        }
12447                        Err(recovery_error) => {
12448                            if let Some(record_id) = record_id.as_ref() {
12449                                self.mark_record_error(
12450                                    record_id,
12451                                    "spawn generation diverged and recovery failed",
12452                                )?;
12453                            }
12454                            Err(recovery_error)
12455                        }
12456                    };
12457                }
12458                if !matches!(current.status, gate4agent_types::SessionStatus::Registered) {
12459                    prepared_spawn.retain_launch_guards();
12460                    return Ok((address, runtime_policy));
12461                }
12462            }
12463            if Instant::now() >= commit_deadline {
12464                self.rollback_spawn(
12465                    &address,
12466                    Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12467                ).await.map_err(|error| {
12468                        failure(
12469                            error.code,
12470                            &format!(
12471                                "start did not commit and registration recovery failed: {}",
12472                                error.message
12473                            ),
12474                        )
12475                    })?;
12476                if let Some(record_id) = record_id.as_ref() {
12477                    self.discard_record(record_id)?;
12478                }
12479                return Err(failure(
12480                    if deadline.is_some() {
12481                        NodeFailureCode::SpawnDeadlineExceeded
12482                    } else {
12483                        NodeFailureCode::BackendBusy
12484                    },
12485                    "start did not commit before the bounded deadline; registration was removed",
12486                ));
12487            }
12488            sleep(Duration::from_millis(2)).await;
12489        }
12490    }
12491
12492    fn effective_spawn_runtime_policy(
12493        &self,
12494        runtime_policy: ProviderRuntimePolicy,
12495    ) -> ProviderRuntimePolicy {
12496        #[cfg(feature = "fixture")]
12497        let runtime_policy = if self.fixture_semantic_hook_policy {
12498            ProviderRuntimePolicy::new(true, true, true, true, false, true)
12499                .expect("monitoring Hook fixture policy is internally valid")
12500        } else {
12501            runtime_policy
12502        };
12503        runtime_policy
12504    }
12505
12506    fn verify_harness_mcp_before_start(
12507        &self,
12508        prepared: Option<&PreparedHarnessMcpSpawn>,
12509    ) -> Result<(), NodeFailure> {
12510        prepared
12511            .map(PreparedHarnessMcpSpawn::verify_helper_program)
12512            .transpose()
12513            .map(|_| ())
12514            .map_err(harness_mcp_failure)
12515    }
12516
12517    async fn rollback_spawn(
12518        &self,
12519        address: &SessionAddress,
12520        commit_timeout: Duration,
12521    ) -> Result<(), NodeFailure> {
12522        self.dispatch_bounded(
12523            ControlCommand::Remove {
12524                instance_id: address.session.instance_id,
12525            },
12526            commit_timeout,
12527        )
12528        .await?;
12529        self.wait_until_removed(address, commit_timeout).await?;
12530        if let Some(binding) = self.remove_binding(address) {
12531            SessionLifecycleHook::cleanup_session_materialization(self, address, &binding)?;
12532        }
12533        Ok(())
12534    }
12535
12536    async fn remove_session(&self, address: &SessionAddress) -> Result<(), NodeFailure> {
12537        let commit_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
12538        self.dispatch_bounded(
12539            ControlCommand::Remove {
12540                instance_id: address.session.instance_id,
12541            },
12542            commit_timeout,
12543        )
12544            .await?;
12545        self.wait_until_removed(address, commit_timeout).await?;
12546        let removed = self.remove_binding(address);
12547        if let Some(binding) = removed.as_ref() {
12548            SessionLifecycleHook::cleanup_session_materialization(self, address, binding)?;
12549        }
12550        if let Some(lease_id) = removed.and_then(|binding| binding.managed_worktree_lease_id) {
12551            let snapshot = self.managed_worktrees.lock()
12552                .unwrap_or_else(|poisoned| poisoned.into_inner())
12553                .release_session(
12554                    &lease_id,
12555                    self.incarnation_id,
12556                    address.session.instance_id,
12557                    address.session.generation,
12558                    unix_time_ms(),
12559                );
12560            if self.persist_state().is_err() {
12561                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
12562            }
12563            if let Some(snapshot) = snapshot {
12564                self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
12565            }
12566            let _ = self.cleanup_managed_worktree(&lease_id, false).await;
12567        }
12568        Ok(())
12569    }
12570
12571    /// Polls until `address`'s session leaves the engine's own session map,
12572    /// or the bounded deadline expires.
12573    ///
12574    /// A `Remove` sent while the session is not yet in a terminal status
12575    /// (e.g. still `Stopping`, mid-effect right after a `Stop`) is rejected
12576    /// once by the engine (`SessionStatus::allows_remove`) and
12577    /// then discarded -- nothing re-issues it on its own, so the removal
12578    /// that would satisfy this wait never arrives and the caller was
12579    /// guaranteed to time out even though the session settles microseconds
12580    /// later. So this re-dispatches on every poll tick: a session that
12581    /// settles mid-wait -- the common case right after a `Stop` -- still
12582    /// gets removed inside the same bounded deadline.
12583    ///
12584    /// It re-dispatches only once the status will ACCEPT the command. A
12585    /// rejected re-dispatch is not the harmless no-op this loop first
12586    /// assumed: every rejection publishes a `ControlEventKind::Command
12587    /// Rejected` to every subscriber and writes a WARN line. Ungated, one
12588    /// ordinary teardown produced 241 of them in 1.6 seconds -- the whole
12589    /// `Stopping` window at one known-doomed dispatch per 2ms tick, fanned
12590    /// out to the harness and every attached client. Gated, the same
12591    /// teardown sends exactly one `Remove`, at the first tick the engine
12592    /// can act on it.
12593    ///
12594    /// The status is read from the snapshot this loop already takes, so
12595    /// the gate costs nothing extra. It races -- the status can move
12596    /// between the read and the dispatch -- and that is fine: a rare lost
12597    /// race is one rejection, and the next tick retries.
12598    ///
12599    /// Re-dispatch is separately rate-limited to
12600    /// [`REMOVE_REDISPATCH_INTERVAL_MS`], because the status gate alone
12601    /// leaves a second burst behind it. Once the session settles, every
12602    /// 2ms tick sent another `Remove` while the accepted one was still in
12603    /// flight, and each of those landed on an instance the engine had
12604    /// already dropped -- `agent instance … is not registered`, observed
12605    /// live as a tail of rejections immediately after the `session
12606    /// removed` line. The presence poll stays at 2ms so the wait still
12607    /// exits promptly; only the sending slows down.
12608    ///
12609    /// Rate-limited rather than once-only on purpose: the status gate
12610    /// covers the status precondition, but `Gate4AgentEngine::remove` has
12611    /// others (a pending operation, an in-flight capability probe or
12612    /// history request) that clear on their own. A single shot would turn
12613    /// those into a guaranteed `BackendBusy` timeout; at 10Hz they still
12614    /// get their retries, just twenty of them across the deadline instead
12615    /// of a thousand.
12616    async fn wait_until_removed(
12617        &self,
12618        address: &SessionAddress,
12619        commit_timeout: Duration,
12620    ) -> Result<(), NodeFailure> {
12621        let deadline = Instant::now() + commit_timeout;
12622        let mut next_dispatch = Instant::now();
12623        loop {
12624            let snapshot = self.handle.snapshot();
12625            let status = snapshot
12626                .sessions
12627                .iter()
12628                .find(|session| session.instance_id == address.session.instance_id)
12629                .map(|session| session.status.clone());
12630            let Some(status) = status else {
12631                return Ok(());
12632            };
12633            let now = Instant::now();
12634            if now >= deadline {
12635                return Err(failure(
12636                    NodeFailureCode::BackendBusy,
12637                    "remove did not commit before the bounded deadline; session binding was retained",
12638                ));
12639            }
12640            if status.allows_remove() && now >= next_dispatch {
12641                let _ = self.dispatch(ControlCommand::Remove {
12642                    instance_id: address.session.instance_id,
12643                });
12644                next_dispatch = now + Duration::from_millis(REMOVE_REDISPATCH_INTERVAL_MS);
12645            }
12646            sleep(Duration::from_millis(2)).await;
12647        }
12648    }
12649
12650    fn resync(&self, after_sequence: u64) -> NodeResponse {
12651        let history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
12652        let event_sequence = history.last_sequence;
12653        let oldest_available_sequence = history.replay_floor_sequence;
12654        let events = history
12655            .events
12656            .iter()
12657            .filter(|event| event.sequence > after_sequence)
12658            .cloned()
12659            .collect();
12660        drop(history);
12661        NodeResponse::Resync {
12662            event_sequence,
12663            oldest_available_sequence,
12664            snapshot: self.snapshot(),
12665            events,
12666        }
12667    }
12668}
12669
12670/// Coarse label for one `AgentStreamChunkKindV1`, used only by
12671/// `NodeShared::publish_agent_stream_chunk`'s own debug log -- mirrors
12672/// `hatchery-harness-service`'s `agent_stream_event_kind_label` (same
12673/// "name what crossed this boundary" reasoning, one layer upstream of it).
12674fn agent_stream_chunk_kind_label(kind: &AgentStreamChunkKindV1) -> &'static str {
12675    match kind {
12676        AgentStreamChunkKindV1::Text { .. } => "text",
12677        AgentStreamChunkKindV1::Thinking { .. } => "thinking",
12678        AgentStreamChunkKindV1::InteractionPrompt { .. } => "interaction-prompt",
12679        AgentStreamChunkKindV1::ModeCatalog { .. } => "mode-catalog",
12680        AgentStreamChunkKindV1::ConfigOptions { .. } => "config-options",
12681        AgentStreamChunkKindV1::ModelCatalog { .. } => "model-catalog",
12682        AgentStreamChunkKindV1::Blocked { .. } => "blocked",
12683    }
12684}
12685
12686/// Serves this wire on the local endpoint, and -- when `call_home` names a
12687/// relay -- over an outbound connection to it as well.
12688///
12689/// The two live in one future rather than a fourth arm of `run`'s own
12690/// `select!` because they are one concern: this is where the node's wire
12691/// is served, and the direction a given connection was opened in does not
12692/// change what happens on it. It also means the existing graceful drain
12693/// below covers both without being duplicated.
12694///
12695/// Either side finishing ends the pair, which is correct: the listener
12696/// only returns on shutdown or a bind failure, and `call_home_loop` only
12697/// returns on shutdown -- a failed relay connection is retried inside it,
12698/// never propagated out.
12699async fn accept_connections(
12700    endpoint: &str,
12701    call_home: Option<std::net::SocketAddr>,
12702    shared: Arc<NodeShared>,
12703) -> Result<(), NodeServerError> {
12704    let mut connections = JoinSet::new();
12705    let result = match call_home {
12706        None => accept_connections_inner(endpoint, shared, &mut connections).await,
12707        Some(relay) => {
12708            let listener =
12709                accept_connections_inner(endpoint, Arc::clone(&shared), &mut connections);
12710            let dialler = call_home_loop(relay, Arc::clone(&shared));
12711            tokio::pin!(listener);
12712            tokio::pin!(dialler);
12713            tokio::select! {
12714                result = &mut listener => result,
12715                result = &mut dialler => result,
12716            }
12717        }
12718    };
12719    let graceful_deadline = Instant::now() + Duration::from_millis(CONNECTION_SHUTDOWN_GRACE_MS);
12720    while !connections.is_empty() {
12721        let remaining = graceful_deadline.saturating_duration_since(Instant::now());
12722        if remaining.is_zero()
12723            || timeout(remaining, connections.join_next()).await.is_err()
12724        {
12725            break;
12726        }
12727    }
12728    connections.abort_all();
12729    while connections.join_next().await.is_some() {}
12730    result
12731}
12732
12733/// Backoff ladder for a relay that will not answer, capped rather than
12734/// unbounded: a node whose relay is down is not broken and should keep
12735/// trying forever, but it should not spend a core doing it.
12736const CALL_HOME_BACKOFFS_MS: [u64; 5] = [500, 1_000, 2_000, 4_000, 8_000];
12737
12738/// Dials the relay, serves this wire over the connection, and does it
12739/// again when the connection ends.
12740///
12741/// The node is the SERVER on the socket it just opened. That inversion is
12742/// the whole point: `serve_connection` is the same function the local
12743/// listener feeds, and it neither knows nor cares which end placed the
12744/// call. The one thing this direction adds is the preface -- the relay
12745/// accepted a socket and cannot know whose it is, so the node says its
12746/// name before the handshake it would otherwise begin with.
12747///
12748/// Never returns an error. A relay that is down, refusing, or speaking a
12749/// different protocol is a condition to retry, not a reason to take the
12750/// node's local endpoint down with it; the only way out is shutdown.
12751async fn call_home_loop(
12752    relay: std::net::SocketAddr,
12753    shared: Arc<NodeShared>,
12754) -> Result<(), NodeServerError> {
12755    let mut failures = 0_usize;
12756    loop {
12757        if shared.shutdown.load(Ordering::Acquire) {
12758            return Ok(());
12759        }
12760        let permit = tokio::select! {
12761            permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
12762                permit.map_err(|_| NodeServerError::PreauthClosed)?
12763            }
12764            _ = shared.shutdown_notify.notified() => return Ok(()),
12765        };
12766        match dial_relay_and_serve(relay, Arc::clone(&shared), permit).await {
12767            Ok(()) => {
12768                // A clean end is the relay hanging up, which it does on
12769                // its own restart. Reconnect immediately rather than
12770                // serving the backoff a peer that is plainly there.
12771                failures = 0;
12772                tracing::info!(node_id = %shared.node_id, %relay, "call-home connection closed");
12773            }
12774            Err(error) => {
12775                failures = failures.saturating_add(1);
12776                tracing::warn!(
12777                    node_id = %shared.node_id,
12778                    %relay,
12779                    cause = %error,
12780                    attempt = failures,
12781                    "call-home connection failed",
12782                );
12783                let backoff = CALL_HOME_BACKOFFS_MS
12784                    [failures.saturating_sub(1).min(CALL_HOME_BACKOFFS_MS.len() - 1)];
12785                tokio::select! {
12786                    _ = sleep(Duration::from_millis(backoff)) => {}
12787                    _ = shared.shutdown_notify.notified() => return Ok(()),
12788                }
12789            }
12790        }
12791    }
12792}
12793
12794/// One call-home attempt: connect, name this node, then serve.
12795async fn dial_relay_and_serve(
12796    relay: std::net::SocketAddr,
12797    shared: Arc<NodeShared>,
12798    preauth_permit: OwnedSemaphorePermit,
12799) -> Result<(), NodeServerError> {
12800    let mut stream = tokio::net::TcpStream::connect(relay)
12801        .await
12802        .map_err(NodeServerError::CallHomeTransport)?;
12803    // Nagle off for the same reason the rest of this wire wants it off:
12804    // the traffic is small request/response frames, and coalescing them
12805    // adds latency to every one.
12806    let _ = stream.set_nodelay(true);
12807    gate4agent_node_wire::write_call_home_announce(&mut stream, &shared.node_id)
12808        .await
12809        .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
12810    tracing::info!(node_id = %shared.node_id, %relay, "announced this node to the relay");
12811    serve_connection(stream, shared, preauth_permit).await
12812}
12813
12814async fn accept_connections_inner(
12815    endpoint: &str,
12816    shared: Arc<NodeShared>,
12817    connections: &mut JoinSet<Result<(), NodeServerError>>,
12818) -> Result<(), NodeServerError> {
12819    let mut listener = OwnerOnlyLocalListener::bind(endpoint).await?;
12820    loop {
12821        while connections.try_join_next().is_some() {}
12822        if shared.shutdown.load(Ordering::Acquire) {
12823            return Ok(());
12824        }
12825        let preauth_permit = tokio::select! {
12826            permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
12827                permit.map_err(|_| NodeServerError::PreauthClosed)?
12828            }
12829            _ = shared.shutdown_notify.notified() => return Ok(()),
12830        };
12831        let server = tokio::select! {
12832            result = listener.accept() => result?,
12833            _ = shared.shutdown_notify.notified() => return Ok(()),
12834        };
12835        let connection_shared = Arc::clone(&shared);
12836        connections.spawn(async move {
12837            serve_connection(server, connection_shared, preauth_permit).await
12838        });
12839        if shared.shutdown.load(Ordering::Acquire) {
12840            return Ok(());
12841        }
12842    }
12843}
12844
12845/// Serves one connection, whichever end opened it.
12846///
12847/// Generic over the stream rather than typed to `LocalServerStream`
12848/// because a call-home connection arrives on a `TcpStream` the node itself
12849/// dialled. Nothing in the body ever depended on the concrete type -- it
12850/// reads and writes framed JSON and then `tokio::io::split`s -- so this is
12851/// the whole of what welding "who dials" to "who serves" was costing.
12852async fn serve_connection<S>(
12853    mut pipe: S,
12854    shared: Arc<NodeShared>,
12855    preauth_permit: OwnedSemaphorePermit,
12856) -> Result<(), NodeServerError>
12857where
12858    S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
12859{
12860    let hello_frame = timeout(
12861        Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
12862        read_json_frame_limited_body_timeout(
12863            &mut pipe,
12864            MAX_NODE_HELLO_FRAME_BYTES,
12865            Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
12866        ),
12867    )
12868    .await
12869    .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
12870    let ClientFrame::Hello(hello) = hello_frame else {
12871        return Err(NodeServerError::Handshake("first frame must be hello".to_owned()));
12872    };
12873    if hello.build_stamp != BUILD_STAMP {
12874        return Err(NodeServerError::Handshake(format!(
12875            "build stamp mismatch: local={BUILD_STAMP} remote={}",
12876            hello.build_stamp,
12877        )));
12878    }
12879    let compatibility = match hello.compatibility.as_ref() {
12880        Some(offer) => {
12881            let mut selected = node_compatibility_support(&shared)?
12882                .negotiate(offer)
12883                .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
12884            project_negotiated_provider_ids(&mut selected);
12885            Some(selected)
12886        }
12887        None => None,
12888    };
12889    let server_nonce = random_nonce().map_err(NodeServerError::Authentication)?;
12890    let server_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
12891        (Some(offer), Some(selected)) => negotiated_auth_proof(
12892            shared.access_token.as_bytes(),
12893            AuthDirection::Server,
12894            hello.role,
12895            &hello.client_nonce,
12896            &server_nonce,
12897            offer,
12898            selected,
12899        ),
12900        (None, None) => auth_proof(
12901            shared.access_token.as_bytes(),
12902            AuthDirection::Server,
12903            hello.role,
12904            &hello.client_nonce,
12905            &server_nonce,
12906        ),
12907        _ => Err("node compatibility authentication state is inconsistent".to_owned()),
12908    }
12909    .map_err(NodeServerError::Authentication)?;
12910    write_json_frame_limited(
12911        &mut pipe,
12912        &ServerFrame::Challenge(ServerChallenge {
12913            build_stamp: BUILD_STAMP.to_owned(),
12914            server_nonce,
12915            server_proof,
12916            compatibility: compatibility.clone(),
12917        }),
12918        MAX_NODE_HELLO_FRAME_BYTES,
12919    )
12920    .await?;
12921    let authentication = timeout(
12922        Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
12923        read_json_frame_limited_body_timeout(
12924            &mut pipe,
12925            MAX_NODE_HELLO_FRAME_BYTES,
12926            Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
12927        ),
12928    )
12929    .await
12930    .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
12931    let ClientFrame::Authenticate(authentication) = authentication else {
12932        return Err(NodeServerError::Handshake("second frame must authenticate the challenge".to_owned()));
12933    };
12934    let expected_client_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
12935        (Some(offer), Some(selected)) => negotiated_auth_proof(
12936            shared.access_token.as_bytes(),
12937            AuthDirection::Client,
12938            hello.role,
12939            &hello.client_nonce,
12940            &server_nonce,
12941            offer,
12942            selected,
12943        ),
12944        (None, None) => auth_proof(
12945            shared.access_token.as_bytes(),
12946            AuthDirection::Client,
12947            hello.role,
12948            &hello.client_nonce,
12949            &server_nonce,
12950        ),
12951        _ => Err("node compatibility authentication state is inconsistent".to_owned()),
12952    }
12953    .map_err(NodeServerError::Authentication)?;
12954    if !proofs_match(&authentication.client_proof, &expected_client_proof) {
12955        return Err(NodeServerError::Handshake("access denied".to_owned()));
12956    }
12957    let selected_capabilities = compatibility
12958        .as_ref()
12959        .map(|selected| selected.capabilities.clone())
12960        .unwrap_or_default();
12961    let include_provider_runtime_status = selected_capabilities.iter().any(|capability| {
12962        capability.as_str() == NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY
12963    });
12964    let include_open_provider_ids = selected_capabilities.iter().any(|capability| {
12965        capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
12966    });
12967    let include_terminal_frame_events = selected_capabilities.iter().any(|capability| {
12968        capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
12969    });
12970    let include_agent_stream_events = selected_capabilities.iter().any(|capability| {
12971        capability.as_str() == NODE_AGENT_STREAM_EVENTS_CAPABILITY
12972    });
12973    let include_spawn_profiles = selected_capabilities.iter().any(|capability| {
12974        capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
12975    });
12976    let include_managed_worktrees = selected_capabilities.iter().any(|capability| {
12977        capability.as_str() == NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY
12978    }) && selected_capabilities.iter().any(|capability| {
12979        capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
12980    });
12981    let include_child_environment_profiles = selected_capabilities.iter().any(|capability| {
12982        capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
12983    });
12984    let include_session_bundles = selected_capabilities.iter().any(|capability| {
12985        capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
12986    });
12987    let include_history_context_packs = selected_capabilities.iter().any(|capability| {
12988        capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
12989    });
12990    let include_agent_progress = selected_capabilities.iter().any(|capability| {
12991        capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
12992    });
12993    let include_session_task_correlation = selected_capabilities.iter().any(|capability| {
12994        capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
12995    });
12996    let include_harness_mcp_proxy = selected_capabilities.iter().any(|capability| {
12997        capability.as_str() == NODE_HARNESS_MCP_READ_PROXY_CAPABILITY
12998    });
12999    let authenticated_permit = Arc::clone(&shared.authenticated_slots)
13000        .try_acquire_owned()
13001        .map_err(|_| NodeServerError::AuthenticatedConnectionLimit)?;
13002    let connection_id = shared.next_connection_id.fetch_add(1, Ordering::AcqRel);
13003    let _release_guard = ControllerReleaseGuard {
13004        shared: Arc::clone(&shared),
13005        connection_id,
13006    };
13007    drop(preauth_permit);
13008    let _authenticated_permit = authenticated_permit;
13009    let mut event_rx = shared.event_tx.subscribe();
13010    let mut terminal_event_rx = terminal_event_subscription(
13011        &shared,
13012        include_terminal_frame_events,
13013    );
13014    let mut agent_stream_event_rx = agent_stream_event_subscription(
13015        &shared,
13016        include_agent_stream_events,
13017    );
13018    let mut harness_mcp_proxy_event_rx = harness_mcp_proxy_event_subscription(
13019        &shared,
13020        include_harness_mcp_proxy,
13021    );
13022    write_json_frame(
13023        &mut pipe,
13024        &ServerFrame::Hello(NodeHello {
13025            build_stamp: BUILD_STAMP.to_owned(),
13026            incarnation_id: shared.incarnation_id,
13027            connection_id,
13028            role: hello.role,
13029            event_sequence: shared.current_sequence(),
13030            controller: shared.controller_state(),
13031            snapshot: snapshot_for_wire(
13032                &shared,
13033                include_provider_runtime_status,
13034                include_open_provider_ids,
13035                include_spawn_profiles,
13036                include_managed_worktrees,
13037                include_child_environment_profiles,
13038                include_session_bundles,
13039                include_history_context_packs,
13040                include_agent_progress,
13041                include_session_task_correlation,
13042            ),
13043            compatibility,
13044        }),
13045    )
13046    .await?;
13047    let (mut reader, mut writer) = tokio::io::split(pipe);
13048    let (frame_tx, mut frame_rx) = mpsc::channel(16);
13049    let reader_task = tokio::spawn(async move {
13050        loop {
13051            let frame = read_json_frame_limited_body_timeout::<_, ClientFrame>(
13052                &mut reader,
13053                MAX_NODE_CLIENT_FRAME_BYTES,
13054                Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13055            )
13056            .await;
13057            let terminal = frame.is_err();
13058            if frame_tx.send(frame).await.is_err() || terminal {
13059                break;
13060            }
13061        }
13062    });
13063    let _reader_abort = AbortTaskOnDrop(reader_task.abort_handle());
13064    let mut pending_events = Vec::new();
13065    let mut resync_required = false;
13066    let mut discard_events_through = 0_u64;
13067    loop {
13068        // One tick of the serve loop, whether or not it had anything to do.
13069        // Against `connection_events_sent` this separates "busy" from
13070        // "spinning": a loop turning over thousands of times a second while
13071        // sending nothing is not serving, it is burning a core.
13072        shared.connection_loop_iterations.fetch_add(1, Ordering::Relaxed);
13073        let durable_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13074            .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13075        for _ in 0..durable_drain_budget {
13076            match event_rx.try_recv() {
13077                Ok(event) => queue_connection_event(
13078                    &mut pending_events,
13079                    event,
13080                    discard_events_through,
13081                ),
13082                Err(broadcast::error::TryRecvError::Empty) => break,
13083                Err(broadcast::error::TryRecvError::Lagged(_)) => {
13084                    resync_required = true;
13085                    break;
13086                }
13087                Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13088            }
13089        }
13090        if let Some(receiver) = terminal_event_rx.as_mut() {
13091            match receiver.try_recv() {
13092                Ok(events) => queue_connection_event_batch(
13093                    &mut pending_events,
13094                    &events,
13095                    discard_events_through,
13096                ),
13097                Err(broadcast::error::TryRecvError::Empty) => {}
13098                Err(broadcast::error::TryRecvError::Lagged(_)) => {
13099                    resync_required = true;
13100                }
13101                Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13102            }
13103        }
13104        if let Some(receiver) = agent_stream_event_rx.as_mut() {
13105            // Loops up to the same remaining-room budget the durable
13106            // `event_rx` drain above uses, rather than one `try_recv` per
13107            // tick: each successful receive here is itself only ONE
13108            // `NodeEventEnvelope` batch (`publish_agent_stream_chunk` always
13109            // sends `vec![envelope]`, never more), so a single-shot drain
13110            // caps this connection's agent-stream throughput at one chunk
13111            // per outer-loop tick regardless of how many are already queued
13112            // -- exactly the kind of self-inflicted stall this crate's own
13113            // durable-event budget exists to avoid.
13114            let agent_stream_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13115                .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13116            for _ in 0..agent_stream_drain_budget {
13117                match receiver.try_recv() {
13118                    Ok(events) => queue_connection_event_batch(
13119                        &mut pending_events,
13120                        &events,
13121                        discard_events_through,
13122                    ),
13123                    Err(broadcast::error::TryRecvError::Empty) => break,
13124                    Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
13125                        tracing::warn!(
13126                            connection_id,
13127                            dropped,
13128                            "agent stream event subscription lagged; forcing a resync",
13129                        );
13130                        resync_required = true;
13131                        break;
13132                    }
13133                    Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13134                }
13135            }
13136        }
13137        if let Some(receiver) = harness_mcp_proxy_event_rx.as_mut() {
13138            // Mirrors the agent-stream drain immediately above: each
13139            // successful receive is one single-envelope batch
13140            // (`publish_transient` always sends `vec![envelope]`), so this
13141            // loops up to the same remaining-room budget rather than
13142            // stopping after one `try_recv`.
13143            let harness_mcp_proxy_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13144                .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13145            for _ in 0..harness_mcp_proxy_drain_budget {
13146                match receiver.try_recv() {
13147                    Ok(events) => queue_connection_event_batch(
13148                        &mut pending_events,
13149                        &events,
13150                        discard_events_through,
13151                    ),
13152                    Err(broadcast::error::TryRecvError::Empty) => break,
13153                    Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
13154                        tracing::warn!(
13155                            connection_id,
13156                            dropped,
13157                            "harness MCP proxy event subscription lagged; forcing a resync",
13158                        );
13159                        resync_required = true;
13160                        break;
13161                    }
13162                    Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13163                }
13164            }
13165        }
13166        if resync_required {
13167            let event = resync_required_event(&shared);
13168            apply_connection_resync_watermark(
13169                &mut pending_events,
13170                &mut discard_events_through,
13171                event.sequence,
13172            );
13173            write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
13174            resync_required = false;
13175            continue;
13176        }
13177        if !pending_events.is_empty() {
13178            pending_events.sort_unstable_by_key(|event| event.sequence);
13179            let burst_len = connection_event_burst_len(pending_events.len());
13180            shared
13181                .connection_events_sent
13182                .fetch_add(burst_len as u64, Ordering::Relaxed);
13183            for event in pending_events.drain(..burst_len) {
13184                let event = if include_managed_worktrees {
13185                    Some(event)
13186                } else {
13187                    project_event_without_managed_worktrees(event)
13188                };
13189                let event = event.and_then(|event| if include_open_provider_ids {
13190                    Some(event)
13191                } else {
13192                    project_event_legacy_provider_ids(&shared, event)
13193                });
13194                let event = event.map(|event| if include_child_environment_profiles {
13195                    event
13196                } else {
13197                    project_event_without_child_environment_profile(event)
13198                });
13199                let event = event.map(|event| if include_session_bundles {
13200                    event
13201                } else {
13202                    project_event_without_session_bundle(event)
13203                });
13204                let event = event.map(|event| {
13205                    project_event_history_for_wire(event, include_history_context_packs)
13206                });
13207                let event = event.map(|event| if include_history_context_packs {
13208                    event
13209                } else {
13210                    project_event_without_context_pack(event)
13211                });
13212                let event = event.map(|event| if include_session_task_correlation {
13213                    event
13214                } else {
13215                    project_event_without_session_task_binding(event)
13216                });
13217                let exact_harness_controller = hello.role == ClientRole::Operator
13218                    && shared.controller_state().is_some_and(|controller| {
13219                        controller.connection_id == connection_id
13220                    });
13221                let event = event.and_then(|event| {
13222                    if include_harness_mcp_proxy && exact_harness_controller {
13223                        Some(event)
13224                    } else {
13225                        project_event_without_harness_mcp_proxy(event)
13226                    }
13227                });
13228                if let Some(event) = event {
13229                    write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
13230                }
13231            }
13232        }
13233        tokio::select! {
13234            biased;
13235            frame = frame_rx.recv() => {
13236                let frame = match frame {
13237                    Some(frame) => frame,
13238                    None => break,
13239                };
13240                let frame = match frame {
13241                    Ok(frame) => frame,
13242                    Err(FrameError::Io(error)) if matches!(error.kind(), io::ErrorKind::BrokenPipe | io::ErrorKind::UnexpectedEof) => break,
13243                    Err(error) => return Err(error.into()),
13244                };
13245                let ClientFrame::Request(request) = frame else {
13246                    return Err(NodeServerError::Handshake("hello may only be sent once".to_owned()));
13247                };
13248                #[cfg(feature = "fixture")]
13249                let probe_spawn_managed_worktree_v2 = matches!(
13250                    &request.request,
13251                    NodeRequest::SpawnManagedWorktreeV2 { .. }
13252                );
13253                let requires_open_provider_ids =
13254                    request_requires_open_provider_ids(&shared, &request.request);
13255                let requires_child_environment_profile =
13256                    request_requires_child_environment_profile(&shared, &request.request);
13257                let requires_session_bundle =
13258                    request_requires_session_bundle(&shared, &request.request);
13259                let requires_history_context_pack =
13260                    request_requires_history_context_pack(&shared, &request.request);
13261                let mut reply = if requires_open_provider_ids && !include_open_provider_ids {
13262                    ResponseEnvelope {
13263                        request_id: request.request_id,
13264                        result: Err(failure(
13265                            NodeFailureCode::UnsupportedCapability,
13266                            "open provider IDs were not negotiated",
13267                        )),
13268                    }
13269                } else if requires_child_environment_profile
13270                    && !include_child_environment_profiles
13271                {
13272                    ResponseEnvelope {
13273                        request_id: request.request_id,
13274                        result: Err(failure(
13275                            NodeFailureCode::UnsupportedCapability,
13276                            "child environment profiles were not negotiated",
13277                        )),
13278                    }
13279                } else if requires_session_bundle && !include_session_bundles {
13280                    ResponseEnvelope {
13281                        request_id: request.request_id,
13282                        result: Err(failure(
13283                            NodeFailureCode::UnsupportedCapability,
13284                            "session bundles were not negotiated",
13285                        )),
13286                    }
13287                } else if requires_history_context_pack && !include_history_context_packs {
13288                    ResponseEnvelope {
13289                        request_id: request.request_id,
13290                        result: Err(failure(
13291                            NodeFailureCode::UnsupportedCapability,
13292                            "history context packs were not negotiated",
13293                        )),
13294                    }
13295                } else if request_uses_unnegotiated_capability(
13296                    &request.request,
13297                    &selected_capabilities,
13298                )
13299                {
13300                    ResponseEnvelope {
13301                        request_id: request.request_id,
13302                        result: Err(failure(
13303                            NodeFailureCode::UnsupportedCapability,
13304                            "request capability was not negotiated",
13305                        )),
13306                    }
13307                } else {
13308                    process_request(&shared, connection_id, hello.role, request).await
13309                };
13310                #[cfg(feature = "fixture")]
13311                if probe_spawn_managed_worktree_v2 {
13312                    shared
13313                        .fixture_spawn_managed_worktree_v2_failure_probe
13314                        .record(reply.result.as_ref().err().map(|failure| failure.code));
13315                }
13316                if !include_provider_runtime_status {
13317                    clear_response_provider_runtime_status(&mut reply);
13318                }
13319                if !include_managed_worktrees {
13320                    project_response_without_managed_worktrees(&mut reply);
13321                }
13322                if !include_open_provider_ids {
13323                    project_response_legacy_provider_ids(&shared, &mut reply);
13324                }
13325                if !include_child_environment_profiles {
13326                    project_response_without_child_environment_profile(&mut reply);
13327                }
13328                if !include_session_bundles {
13329                    project_response_without_session_bundle(&mut reply);
13330                }
13331                project_response_history_for_wire(
13332                    &mut reply,
13333                    include_history_context_packs,
13334                );
13335                if !include_history_context_packs {
13336                    project_response_without_context_pack(&mut reply);
13337                }
13338                if !include_agent_progress {
13339                    project_response_without_agent_progress(&mut reply);
13340                }
13341                if !include_session_task_correlation {
13342                    project_response_without_session_task_binding(&mut reply);
13343                }
13344                write_json_frame(&mut writer, &ServerFrame::Reply(reply)).await?;
13345            }
13346            _ = tokio::task::yield_now(), if !pending_events.is_empty() => {}
13347            event = event_rx.recv() => {
13348                match event {
13349                    Ok(event) => queue_connection_event(
13350                        &mut pending_events,
13351                        event,
13352                        discard_events_through,
13353                    ),
13354                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13355                    Err(broadcast::error::RecvError::Closed) => break,
13356                }
13357            }
13358            events = receive_terminal_event_batch(&mut terminal_event_rx) => {
13359                match events {
13360                    Ok(events) => queue_connection_event_batch(
13361                        &mut pending_events,
13362                        &events,
13363                        discard_events_through,
13364                    ),
13365                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13366                    Err(broadcast::error::RecvError::Closed) => break,
13367                }
13368            }
13369            events = receive_terminal_event_batch(&mut agent_stream_event_rx) => {
13370                match events {
13371                    Ok(events) => queue_connection_event_batch(
13372                        &mut pending_events,
13373                        &events,
13374                        discard_events_through,
13375                    ),
13376                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13377                    Err(broadcast::error::RecvError::Closed) => break,
13378                }
13379            }
13380            events = receive_terminal_event_batch(&mut harness_mcp_proxy_event_rx) => {
13381                match events {
13382                    Ok(events) => queue_connection_event_batch(
13383                        &mut pending_events,
13384                        &events,
13385                        discard_events_through,
13386                    ),
13387                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13388                    Err(broadcast::error::RecvError::Closed) => break,
13389                }
13390            }
13391            _ = shared.shutdown_notify.notified() => break,
13392        }
13393    }
13394    Ok(())
13395}
13396
13397async fn receive_terminal_event_batch(
13398    receiver: &mut Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>>,
13399) -> Result<Arc<Vec<NodeEventEnvelope>>, broadcast::error::RecvError> {
13400    match receiver {
13401        Some(receiver) => receiver.recv().await,
13402        None => std::future::pending().await,
13403    }
13404}
13405
13406fn terminal_event_subscription(
13407    shared: &NodeShared,
13408    enabled: bool,
13409) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13410    enabled.then(|| shared.terminal_event_tx.subscribe())
13411}
13412
13413/// Mirrors `terminal_event_subscription` exactly, over the agent stream's
13414/// own channel -- see `NodeShared::agent_stream_event_tx`'s own doc for why
13415/// this needs a dedicated channel rather than a flag on the terminal one.
13416fn agent_stream_event_subscription(
13417    shared: &NodeShared,
13418    enabled: bool,
13419) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13420    enabled.then(|| shared.agent_stream_event_tx.subscribe())
13421}
13422
13423/// Mirrors `agent_stream_event_subscription` exactly, over the harness MCP
13424/// proxy's own channel -- see `NodeShared::harness_mcp_proxy_event_tx`'s
13425/// own doc for why this needs a dedicated channel rather than a ride on
13426/// the durable `event_tx`.
13427fn harness_mcp_proxy_event_subscription(
13428    shared: &NodeShared,
13429    enabled: bool,
13430) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13431    enabled.then(|| shared.harness_mcp_proxy_event_tx.subscribe())
13432}
13433
13434fn queue_connection_event(
13435    pending: &mut Vec<NodeEventEnvelope>,
13436    event: NodeEventEnvelope,
13437    discard_through: u64,
13438) {
13439    if event.sequence <= discard_through
13440        && !event.event.requires_harness_mcp_proxy_capability()
13441    {
13442        return;
13443    }
13444    if let NodeEvent::TerminalFrame { address, .. } = &event.event {
13445        if let Some(index) = pending.iter().position(|current| {
13446            matches!(
13447                &current.event,
13448                NodeEvent::TerminalFrame {
13449                    address: current_address,
13450                    ..
13451                } if current_address == address
13452            )
13453        }) {
13454            pending.remove(index);
13455        }
13456    }
13457    pending.push(event);
13458}
13459
13460fn project_event_without_harness_mcp_proxy(
13461    envelope: NodeEventEnvelope,
13462) -> Option<NodeEventEnvelope> {
13463    (!envelope.event.requires_harness_mcp_proxy_capability()).then_some(envelope)
13464}
13465
13466fn queue_connection_event_batch(
13467    pending: &mut Vec<NodeEventEnvelope>,
13468    events: &[NodeEventEnvelope],
13469    discard_through: u64,
13470) {
13471    for event in events {
13472        queue_connection_event(pending, event.clone(), discard_through);
13473    }
13474}
13475
13476fn apply_connection_resync_watermark(
13477    pending: &mut Vec<NodeEventEnvelope>,
13478    discard_through: &mut u64,
13479    marker_sequence: u64,
13480) {
13481    *discard_through = (*discard_through).max(marker_sequence);
13482    pending.retain(|event| event.sequence > *discard_through);
13483}
13484
13485fn connection_event_burst_len(pending_len: usize) -> usize {
13486    pending_len.min(NODE_CONNECTION_EVENT_BURST_MAX)
13487}
13488
13489fn resync_required_event(shared: &NodeShared) -> NodeEventEnvelope {
13490    let history = shared
13491        .history
13492        .lock()
13493        .unwrap_or_else(|poisoned| poisoned.into_inner());
13494    let sequence = history.last_sequence;
13495    let oldest_available_sequence = history.replay_floor_sequence;
13496    NodeEventEnvelope {
13497        sequence,
13498        event: NodeEvent::ResyncRequired {
13499            oldest_available_sequence,
13500        },
13501    }
13502}
13503
13504fn node_compatibility_support(
13505    shared: &NodeShared,
13506) -> Result<NodeCompatibilitySupport, NodeServerError> {
13507    let mut support = node_compatibility_support_for_manifest(
13508        &shared.provider_contracts,
13509        &shared.provider_adapter_contracts,
13510    )?;
13511    if shared
13512        .delivery_store
13513        .lock()
13514        .unwrap_or_else(|poisoned| poisoned.into_inner())
13515        .is_some()
13516    {
13517        support.capabilities.push(
13518            CapabilityId::new(NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY)
13519                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13520        );
13521    }
13522    if shared.harness_mcp_registry.is_some() {
13523        support.capabilities.push(
13524            CapabilityId::new(NODE_HARNESS_MCP_READ_PROXY_CAPABILITY)
13525                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13526        );
13527    }
13528    Ok(support)
13529}
13530
13531fn node_compatibility_support_for_manifest(
13532    provider_contracts: &[ProviderContractSupport],
13533    provider_adapter_contracts: &[ProviderAdapterContractSupport],
13534) -> Result<NodeCompatibilitySupport, NodeServerError> {
13535    Ok(NodeCompatibilitySupport {
13536        build_stamp: BUILD_STAMP.to_owned(),
13537        capabilities: baseline_capabilities()?,
13538        host: platform::host_descriptor().map_err(NodeServerError::Handshake)?,
13539        path_semantics: platform::path_semantics(),
13540        local_transport: platform::local_transport(),
13541        state_schema: StateSchemaSupport {
13542            versions: ProtocolRange::new(NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10)
13543                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13544        },
13545        provider_contracts: provider_contracts.to_vec(),
13546        provider_adapter_contracts: provider_adapter_contracts.to_vec(),
13547    })
13548}
13549
13550fn baseline_capabilities() -> Result<Vec<CapabilityId>, NodeServerError> {
13551    let capabilities = [
13552        NODE_COMPATIBILITY_METADATA_CAPABILITY,
13553        CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
13554        NODE_REPOSITORY_PATH_CAPABILITY,
13555        NODE_WORKSPACE_FILE_READ_CAPABILITY,
13556        NODE_WORKSPACE_FILE_WRITE_CAPABILITY,
13557        NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
13558        NODE_GIT_READ_CAPABILITY,
13559        NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY,
13560        NODE_PROVIDER_ID_OPEN_CAPABILITY,
13561        NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
13562        NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY,
13563        NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
13564        NODE_AGENT_STREAM_EVENTS_CAPABILITY,
13565        NODE_ACP_CONTROL_CAPABILITY,
13566        NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
13567        NODE_SPAWN_PROFILE_REVISION_CAPABILITY,
13568        NODE_WORKTREE_SELECTION_CAPABILITY,
13569        NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
13570        NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
13571        NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
13572        NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
13573        NODE_HISTORY_CONTEXT_PACK_CAPABILITY,
13574        NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
13575        NODE_NATIVE_SESSION_CATALOG_CAPABILITY,
13576        NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
13577        NODE_NATIVE_SESSION_INDEX_CAPABILITY,
13578        NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
13579        NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
13580        NODE_SESSION_TASK_CORRELATION_CAPABILITY,
13581    ]
13582        .into_iter()
13583        .map(|capability| {
13584            CapabilityId::new(capability)
13585                .map_err(|error| NodeServerError::Handshake(error.to_string()))
13586        })
13587        .collect::<Result<Vec<_>, _>>()?;
13588    #[cfg(windows)]
13589    let capabilities = {
13590        let mut capabilities = capabilities;
13591        capabilities.push(
13592            CapabilityId::new(NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY)
13593                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13594        );
13595        capabilities
13596    };
13597    Ok(capabilities)
13598}
13599
13600fn request_uses_unnegotiated_capability(
13601    request: &NodeRequest,
13602    selected_capabilities: &[CapabilityId],
13603) -> bool {
13604    let selected = |required: &str| {
13605        selected_capabilities
13606            .iter()
13607            .any(|capability| capability.as_str() == required)
13608    };
13609    request
13610        .required_capability()
13611        .is_some_and(|required| !selected(required))
13612        || (request.requires_worktree_selection_capability()
13613            && !selected(NODE_WORKTREE_SELECTION_CAPABILITY))
13614        || (request.requires_spawn_spec_defaults_overrides_capability()
13615            && !selected(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY))
13616        || (request.requires_spawn_profile_revision_capability()
13617            && !selected(NODE_SPAWN_PROFILE_REVISION_CAPABILITY))
13618        || (request.requires_child_environment_profile_capability()
13619            && !selected(NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY))
13620        || (request.requires_session_bundle_materialization_capability()
13621            && !selected(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY))
13622        || (request.requires_history_context_pack_capability()
13623            && !selected(NODE_HISTORY_CONTEXT_PACK_CAPABILITY))
13624}
13625
13626fn snapshot_for_wire(
13627    shared: &NodeShared,
13628    include_provider_runtime_status: bool,
13629    include_open_provider_ids: bool,
13630    include_spawn_profiles: bool,
13631    include_managed_worktrees: bool,
13632    include_child_environment_profiles: bool,
13633    include_session_bundles: bool,
13634    include_history_context_packs: bool,
13635    include_agent_progress: bool,
13636    include_session_task_correlation: bool,
13637) -> NodeSnapshot {
13638    let mut snapshot = shared.snapshot();
13639    if !include_provider_runtime_status {
13640        snapshot.provider_runtime_statuses.clear();
13641    }
13642    if !include_open_provider_ids {
13643        project_snapshot_legacy_provider_ids(&mut snapshot);
13644    }
13645    if !include_managed_worktrees {
13646        snapshot.managed_worktrees.clear();
13647        for workspace in &mut snapshot.workspaces {
13648            workspace.managed_worktree_profiles = None;
13649        }
13650    }
13651    if let Some(inventory) = snapshot.launch_inventory.as_mut() {
13652        if !include_spawn_profiles {
13653            inventory.spawn_profiles = None;
13654        }
13655        if !include_session_bundles {
13656            inventory.bundles = None;
13657        }
13658        if inventory.spawn_profiles.is_none()
13659            && inventory.bundles.is_none()
13660            && inventory.network_allowlists.is_none()
13661        {
13662            snapshot.launch_inventory = None;
13663        }
13664    }
13665    if !include_child_environment_profiles {
13666        clear_snapshot_child_environment_profiles(&mut snapshot);
13667    }
13668    if !include_session_bundles {
13669        clear_snapshot_session_bundles(&mut snapshot);
13670    }
13671    project_snapshot_history_for_wire(&mut snapshot, include_history_context_packs);
13672    if !include_history_context_packs {
13673        clear_snapshot_context_packs(&mut snapshot);
13674    }
13675    if !include_agent_progress {
13676        snapshot.agent_progress.clear();
13677    }
13678    if !include_session_task_correlation {
13679        clear_snapshot_session_task_bindings(&mut snapshot);
13680    }
13681    snapshot
13682}
13683
13684fn clear_snapshot_session_task_bindings(snapshot: &mut NodeSnapshot) {
13685    for record in &mut snapshot.session_records {
13686        record.task_binding = None;
13687    }
13688}
13689
13690fn project_event_without_session_task_binding(
13691    mut envelope: NodeEventEnvelope,
13692) -> NodeEventEnvelope {
13693    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
13694        record.task_binding = None;
13695    }
13696    envelope
13697}
13698
13699fn project_response_without_session_task_binding(reply: &mut ResponseEnvelope) {
13700    let Ok(response) = reply.result.as_mut() else { return };
13701    match response {
13702        NodeResponse::Snapshot { snapshot, .. } => {
13703            clear_snapshot_session_task_bindings(snapshot);
13704        }
13705        NodeResponse::Resync { snapshot, events, .. } => {
13706            clear_snapshot_session_task_bindings(snapshot);
13707            for event in events {
13708                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
13709                    record.task_binding = None;
13710                }
13711            }
13712        }
13713        NodeResponse::SessionRecordUpdated { record }
13714        | NodeResponse::ProviderSessionIndexed { record }
13715        | NodeResponse::NativeSessionIndexed { record, .. }
13716        | NodeResponse::SessionRecordResumed { record, .. } => {
13717            record.task_binding = None;
13718        }
13719        _ => {}
13720    }
13721}
13722
13723fn project_response_without_agent_progress(reply: &mut ResponseEnvelope) {
13724    let Ok(response) = reply.result.as_mut() else { return };
13725    match response {
13726        NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
13727            snapshot.agent_progress.clear();
13728        }
13729        _ => {}
13730    }
13731}
13732
13733fn project_event_without_managed_worktrees(
13734    mut envelope: NodeEventEnvelope,
13735) -> Option<NodeEventEnvelope> {
13736    if matches!(
13737        envelope.event,
13738        NodeEvent::ManagedWorktreeUpserted { .. }
13739            | NodeEvent::ManagedWorktreeRemoved { .. }
13740    ) {
13741        return None;
13742    }
13743    if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
13744        workspace.managed_worktree_profiles = None;
13745    }
13746    Some(envelope)
13747}
13748
13749fn project_response_without_managed_worktrees(reply: &mut ResponseEnvelope) {
13750    if matches!(
13751        reply.result,
13752        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { .. })
13753            | Ok(NodeResponse::ManagedWorktreeCleanup { .. })
13754    ) {
13755        reply.result = Err(failure(
13756            NodeFailureCode::UnsupportedCapability,
13757            "managed worktree capability was not negotiated",
13758        ));
13759        return;
13760    }
13761    let Ok(response) = reply.result.as_mut() else { return };
13762    match response {
13763        NodeResponse::Snapshot { snapshot, .. } => {
13764            snapshot.managed_worktrees.clear();
13765            for workspace in &mut snapshot.workspaces {
13766                workspace.managed_worktree_profiles = None;
13767            }
13768        }
13769        NodeResponse::Resync { snapshot, events, .. } => {
13770            snapshot.managed_worktrees.clear();
13771            for workspace in &mut snapshot.workspaces {
13772                workspace.managed_worktree_profiles = None;
13773            }
13774            *events = std::mem::take(events)
13775                .into_iter()
13776                .filter_map(project_event_without_managed_worktrees)
13777                .collect();
13778        }
13779        NodeResponse::WorkspaceRegistered { workspace }
13780        | NodeResponse::StandaloneWorkspaceCreated { workspace }
13781        | NodeResponse::WorktreeCreated { workspace, .. } => {
13782            workspace.managed_worktree_profiles = None;
13783        }
13784        NodeResponse::WorkspaceInspected { inspection } => {
13785            inspection.git.managed_worktree = None;
13786        }
13787        _ => {}
13788    }
13789}
13790
13791fn project_negotiated_provider_ids(compatibility: &mut NegotiatedNodeCompatibility) {
13792    let includes_open_provider_ids = compatibility.capabilities.iter().any(|capability| {
13793        capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
13794    });
13795    if includes_open_provider_ids {
13796        return;
13797    }
13798    compatibility
13799        .provider_contracts
13800        .retain(|contract| provider_id_is_legacy(&contract.provider));
13801    compatibility
13802        .provider_adapter_contracts
13803        .retain(|contract| provider_id_is_legacy(&contract.provider));
13804}
13805
13806fn request_requires_open_provider_ids(shared: &NodeShared, request: &NodeRequest) -> bool {
13807    if let Some(spec) = match request {
13808        NodeRequest::SpawnSpec { spec }
13809        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
13810        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
13811        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
13812        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
13813        _ => None,
13814    } {
13815        return shared
13816            .resolve_spawn_spec(spec)
13817            .is_ok_and(|resolved| !provider_id_is_legacy(&resolved.provider));
13818    }
13819    request_requires_open_provider_ids_with(
13820        request,
13821        |session| shared.validate_address(session).ok(),
13822        |record_id| shared.record(record_id).ok().map(|record| record.provider),
13823    )
13824}
13825
13826fn request_requires_child_environment_profile(
13827    shared: &NodeShared,
13828    request: &NodeRequest,
13829) -> bool {
13830    if let Some(spec) = match request {
13831        NodeRequest::SpawnSpec { spec }
13832        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
13833        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
13834        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
13835        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
13836        _ => None,
13837    } {
13838        return shared
13839            .resolve_spawn_spec(spec)
13840            .is_ok_and(|resolved| resolved.environment_profile_id.is_some());
13841    }
13842    match request {
13843        NodeRequest::Resume { session, .. }
13844        | NodeRequest::Prompt { session, .. }
13845        | NodeRequest::Paste { session, .. }
13846        | NodeRequest::Input { session, .. }
13847        | NodeRequest::TerminalBytes { session, .. }
13848        | NodeRequest::TerminalControl { session, .. }
13849        | NodeRequest::Resize { session, .. }
13850        | NodeRequest::Interrupt { session }
13851        | NodeRequest::Stop { session, .. }
13852        | NodeRequest::Remove { session }
13853        | NodeRequest::ResolveInteraction { session, .. }
13854        | NodeRequest::SetSessionMode { session, .. }
13855        | NodeRequest::SetSessionConfigOption { session, .. }
13856        | NodeRequest::SetSessionModel { session, .. }
13857        | NodeRequest::DiscoverHistory { session, .. }
13858        | NodeRequest::LoadHistory { session, .. }
13859        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
13860        | NodeRequest::ExportContextPack { session } => shared
13861            .session_bindings
13862            .lock()
13863            .unwrap_or_else(|poisoned| poisoned.into_inner())
13864            .get(&session.session.instance_id)
13865            .filter(|binding| {
13866                binding.workspace_id == session.workspace_id
13867                    && binding.generation == session.session.generation
13868            })
13869            .map_or(true, |binding| binding.environment_profile.is_some()),
13870        NodeRequest::RenameSessionRecord { record_id, .. }
13871        | NodeRequest::SetSessionTask { record_id, .. }
13872        | NodeRequest::ResumeSessionRecord { record_id, .. }
13873        | NodeRequest::ForgetSessionRecord { record_id } => shared
13874            .record(record_id)
13875            .map_or(true, |record| record.environment_profile.is_some()),
13876        NodeRequest::ArmHarnessMcpReservation { .. }
13877        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
13878        | NodeRequest::ActivateHarnessMcpReservation { .. }
13879        | NodeRequest::AbortHarnessMcpReservation { .. }
13880        | NodeRequest::PutHarnessMcpReplyChunk { .. }
13881        | NodeRequest::RejectHarnessMcpCall { .. }
13882        | NodeRequest::Snapshot
13883        | NodeRequest::Resync { .. }
13884        | NodeRequest::BeginDeliveryStage { .. }
13885        | NodeRequest::PutDeliveryBlobChunk { .. }
13886        | NodeRequest::CommitDeliveryStage { .. }
13887        | NodeRequest::AbortDeliveryStage { .. }
13888        | NodeRequest::BrowseHostDirectories { .. }
13889        | NodeRequest::InspectWorkspace { .. }
13890        | NodeRequest::ReadWorkspaceFile { .. }
13891        | NodeRequest::WriteWorkspaceFile { .. }
13892        | NodeRequest::CreateWorkspaceFile { .. }
13893        | NodeRequest::CreateWorkspaceDirectory { .. }
13894        | NodeRequest::ReadGitHistory { .. }
13895        | NodeRequest::ReadGitDiff { .. }
13896        | NodeRequest::AcquireController { .. }
13897        | NodeRequest::ReleaseController
13898        | NodeRequest::Spawn { .. }
13899        | NodeRequest::SpawnSpec { .. }
13900        | NodeRequest::RegisterWorkspace { .. }
13901        | NodeRequest::CreateStandaloneWorkspace { .. }
13902        | NodeRequest::UnregisterWorkspace { .. }
13903        | NodeRequest::CreateWorktree { .. }
13904        | NodeRequest::RemoveWorktree { .. }
13905        | NodeRequest::SpawnManagedWorktree { .. }
13906        | NodeRequest::SpawnManagedWorktreeV2 { .. }
13907        | NodeRequest::CleanupManagedWorktree { .. }
13908        | NodeRequest::IndexProviderSession { .. }
13909        | NodeRequest::IndexNativeSession { .. }
13910        | NodeRequest::CatalogNativeSessions { .. }
13911        | NodeRequest::PageNativeSessions { .. }
13912        | NodeRequest::PreviewNativeSession { .. }
13913        | NodeRequest::PreviewSessionRecord { .. }
13914        | NodeRequest::ForgetContextPack { .. }
13915        | NodeRequest::ResolveDurableContextPack { .. }
13916        | NodeRequest::ReadContextPack { .. }
13917        | NodeRequest::Shutdown => false,
13918    }
13919}
13920
13921fn request_requires_session_bundle(shared: &NodeShared, request: &NodeRequest) -> bool {
13922    if let Some(spec) = match request {
13923        NodeRequest::SpawnSpec { spec }
13924        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
13925        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
13926        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
13927        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
13928        _ => None,
13929    } {
13930        return shared
13931            .resolve_spawn_spec(spec)
13932            .is_ok_and(|resolved| resolved.bundle_id.is_some());
13933    }
13934    match request {
13935        NodeRequest::Resume { session, .. }
13936        | NodeRequest::Prompt { session, .. }
13937        | NodeRequest::Paste { session, .. }
13938        | NodeRequest::Input { session, .. }
13939        | NodeRequest::TerminalBytes { session, .. }
13940        | NodeRequest::TerminalControl { session, .. }
13941        | NodeRequest::Resize { session, .. }
13942        | NodeRequest::Interrupt { session }
13943        | NodeRequest::Stop { session, .. }
13944        | NodeRequest::Remove { session }
13945        | NodeRequest::ResolveInteraction { session, .. }
13946        | NodeRequest::SetSessionMode { session, .. }
13947        | NodeRequest::SetSessionConfigOption { session, .. }
13948        | NodeRequest::SetSessionModel { session, .. }
13949        | NodeRequest::DiscoverHistory { session, .. }
13950        | NodeRequest::LoadHistory { session, .. }
13951        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
13952        | NodeRequest::ExportContextPack { session } => shared
13953            .session_bindings
13954            .lock()
13955            .unwrap_or_else(|poisoned| poisoned.into_inner())
13956            .get(&session.session.instance_id)
13957            .filter(|binding| {
13958                binding.workspace_id == session.workspace_id
13959                    && binding.generation == session.session.generation
13960            })
13961            .map_or(true, |binding| binding.bundle.is_some()),
13962        NodeRequest::RenameSessionRecord { record_id, .. }
13963        | NodeRequest::SetSessionTask { record_id, .. }
13964        | NodeRequest::ResumeSessionRecord { record_id, .. }
13965        | NodeRequest::ForgetSessionRecord { record_id } => shared
13966            .record(record_id)
13967            .map_or(true, |record| record.bundle.is_some()),
13968        NodeRequest::ArmHarnessMcpReservation { .. }
13969        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
13970        | NodeRequest::ActivateHarnessMcpReservation { .. }
13971        | NodeRequest::AbortHarnessMcpReservation { .. }
13972        | NodeRequest::PutHarnessMcpReplyChunk { .. }
13973        | NodeRequest::RejectHarnessMcpCall { .. }
13974        | NodeRequest::Snapshot
13975        | NodeRequest::Resync { .. }
13976        | NodeRequest::BeginDeliveryStage { .. }
13977        | NodeRequest::PutDeliveryBlobChunk { .. }
13978        | NodeRequest::CommitDeliveryStage { .. }
13979        | NodeRequest::AbortDeliveryStage { .. }
13980        | NodeRequest::BrowseHostDirectories { .. }
13981        | NodeRequest::InspectWorkspace { .. }
13982        | NodeRequest::ReadWorkspaceFile { .. }
13983        | NodeRequest::WriteWorkspaceFile { .. }
13984        | NodeRequest::CreateWorkspaceFile { .. }
13985        | NodeRequest::CreateWorkspaceDirectory { .. }
13986        | NodeRequest::ReadGitHistory { .. }
13987        | NodeRequest::ReadGitDiff { .. }
13988        | NodeRequest::AcquireController { .. }
13989        | NodeRequest::ReleaseController
13990        | NodeRequest::Spawn { .. }
13991        | NodeRequest::SpawnSpec { .. }
13992        | NodeRequest::RegisterWorkspace { .. }
13993        | NodeRequest::CreateStandaloneWorkspace { .. }
13994        | NodeRequest::UnregisterWorkspace { .. }
13995        | NodeRequest::CreateWorktree { .. }
13996        | NodeRequest::RemoveWorktree { .. }
13997        | NodeRequest::SpawnManagedWorktree { .. }
13998        | NodeRequest::SpawnManagedWorktreeV2 { .. }
13999        | NodeRequest::CleanupManagedWorktree { .. }
14000        | NodeRequest::IndexProviderSession { .. }
14001        | NodeRequest::IndexNativeSession { .. }
14002        | NodeRequest::CatalogNativeSessions { .. }
14003        | NodeRequest::PageNativeSessions { .. }
14004        | NodeRequest::PreviewNativeSession { .. }
14005        | NodeRequest::PreviewSessionRecord { .. }
14006        | NodeRequest::ForgetContextPack { .. }
14007        | NodeRequest::ResolveDurableContextPack { .. }
14008        | NodeRequest::ReadContextPack { .. }
14009        | NodeRequest::Shutdown => false,
14010    }
14011}
14012
14013fn request_requires_history_context_pack(shared: &NodeShared, request: &NodeRequest) -> bool {
14014    if request.requires_history_context_pack_capability() {
14015        return true;
14016    }
14017    if let Some(spec) = match request {
14018        NodeRequest::SpawnSpec { spec }
14019        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14020        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14021        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14022        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14023        _ => None,
14024    } {
14025        return shared
14026            .resolve_spawn_spec(spec)
14027            .map_or(true, |resolved| resolved.context_id.is_some());
14028    }
14029    match request {
14030        NodeRequest::Resume { session, .. }
14031        | NodeRequest::Prompt { session, .. }
14032        | NodeRequest::Paste { session, .. }
14033        | NodeRequest::Input { session, .. }
14034        | NodeRequest::TerminalBytes { session, .. }
14035        | NodeRequest::TerminalControl { session, .. }
14036        | NodeRequest::Resize { session, .. }
14037        | NodeRequest::Interrupt { session }
14038        | NodeRequest::Stop { session, .. }
14039        | NodeRequest::Remove { session }
14040        | NodeRequest::ResolveInteraction { session, .. }
14041        | NodeRequest::SetSessionMode { session, .. }
14042        | NodeRequest::SetSessionConfigOption { session, .. }
14043        | NodeRequest::SetSessionModel { session, .. } => shared
14044            .session_bindings
14045            .lock()
14046            .unwrap_or_else(|poisoned| poisoned.into_inner())
14047            .get(&session.session.instance_id)
14048            .filter(|binding| {
14049                binding.workspace_id == session.workspace_id
14050                    && binding.generation == session.session.generation
14051            })
14052            .map_or(true, |binding| binding.context.is_some()),
14053        NodeRequest::RenameSessionRecord { record_id, .. }
14054        | NodeRequest::SetSessionTask { record_id, .. }
14055        | NodeRequest::ResumeSessionRecord { record_id, .. }
14056        | NodeRequest::ForgetSessionRecord { record_id } => shared
14057            .record(record_id)
14058            .map_or(true, |record| record.context.is_some()),
14059        NodeRequest::ArmHarnessMcpReservation { .. }
14060        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14061        | NodeRequest::ActivateHarnessMcpReservation { .. }
14062        | NodeRequest::AbortHarnessMcpReservation { .. }
14063        | NodeRequest::PutHarnessMcpReplyChunk { .. }
14064        | NodeRequest::RejectHarnessMcpCall { .. }
14065        | NodeRequest::Snapshot
14066        | NodeRequest::Resync { .. }
14067        | NodeRequest::BeginDeliveryStage { .. }
14068        | NodeRequest::PutDeliveryBlobChunk { .. }
14069        | NodeRequest::CommitDeliveryStage { .. }
14070        | NodeRequest::AbortDeliveryStage { .. }
14071        | NodeRequest::BrowseHostDirectories { .. }
14072        | NodeRequest::InspectWorkspace { .. }
14073        | NodeRequest::ReadWorkspaceFile { .. }
14074        | NodeRequest::WriteWorkspaceFile { .. }
14075        | NodeRequest::CreateWorkspaceFile { .. }
14076        | NodeRequest::CreateWorkspaceDirectory { .. }
14077        | NodeRequest::ReadGitHistory { .. }
14078        | NodeRequest::ReadGitDiff { .. }
14079        | NodeRequest::AcquireController { .. }
14080        | NodeRequest::ReleaseController
14081        | NodeRequest::Spawn { .. }
14082        | NodeRequest::SpawnSpec { .. }
14083        | NodeRequest::RegisterWorkspace { .. }
14084        | NodeRequest::CreateStandaloneWorkspace { .. }
14085        | NodeRequest::UnregisterWorkspace { .. }
14086        | NodeRequest::CreateWorktree { .. }
14087        | NodeRequest::RemoveWorktree { .. }
14088        | NodeRequest::SpawnManagedWorktree { .. }
14089        | NodeRequest::SpawnManagedWorktreeV2 { .. }
14090        | NodeRequest::CleanupManagedWorktree { .. }
14091        | NodeRequest::IndexProviderSession { .. }
14092        | NodeRequest::IndexNativeSession { .. }
14093        | NodeRequest::CatalogNativeSessions { .. }
14094        | NodeRequest::PageNativeSessions { .. }
14095        | NodeRequest::PreviewNativeSession { .. }
14096        | NodeRequest::PreviewSessionRecord { .. }
14097        | NodeRequest::DiscoverHistory { .. }
14098        | NodeRequest::LoadHistory { .. }
14099        | NodeRequest::ExportContextPackForSessionRecord { .. }
14100        | NodeRequest::ExportContextPack { .. }
14101        | NodeRequest::ForgetContextPack { .. }
14102        | NodeRequest::ResolveDurableContextPack { .. }
14103        | NodeRequest::ReadContextPack { .. }
14104        | NodeRequest::Shutdown => false,
14105    }
14106}
14107
14108fn request_requires_open_provider_ids_with(
14109    request: &NodeRequest,
14110    provider_for_session: impl Fn(&SessionAddress) -> Option<AgentId>,
14111    provider_for_record: impl Fn(&SessionRecordId) -> Option<AgentId>,
14112) -> bool {
14113    match request {
14114        NodeRequest::Spawn { provider, .. } => !provider_id_is_legacy(provider),
14115        NodeRequest::IndexProviderSession { provider, .. } => !provider_id_is_legacy(provider),
14116        NodeRequest::CatalogNativeSessions { route, .. }
14117        | NodeRequest::PageNativeSessions { route, .. } => {
14118            !provider_id_is_legacy(&route.provider)
14119        }
14120        NodeRequest::PreviewNativeSession { selection, .. }
14121        | NodeRequest::IndexNativeSession { selection, .. } => {
14122            !provider_id_is_legacy(&selection.route.provider)
14123        }
14124        NodeRequest::SpawnSpec { .. }
14125        | NodeRequest::SpawnSpecWithHarnessMcp { .. } => false,
14126        NodeRequest::SpawnManagedWorktree { .. }
14127        | NodeRequest::SpawnManagedWorktreeV2 { .. } => false,
14128        NodeRequest::Resume { session, .. }
14129        | NodeRequest::Prompt { session, .. }
14130        | NodeRequest::Paste { session, .. }
14131        | NodeRequest::Input { session, .. }
14132        | NodeRequest::TerminalBytes { session, .. }
14133        | NodeRequest::TerminalControl { session, .. }
14134        | NodeRequest::Resize { session, .. }
14135        | NodeRequest::Interrupt { session }
14136        | NodeRequest::Stop { session, .. }
14137        | NodeRequest::Remove { session }
14138        | NodeRequest::ResolveInteraction { session, .. }
14139        | NodeRequest::SetSessionMode { session, .. }
14140        | NodeRequest::SetSessionConfigOption { session, .. }
14141        | NodeRequest::SetSessionModel { session, .. }
14142        | NodeRequest::DiscoverHistory { session, .. }
14143        | NodeRequest::LoadHistory { session, .. }
14144        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14145        | NodeRequest::ExportContextPack { session } => provider_for_session(session)
14146            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14147        NodeRequest::RenameSessionRecord { record_id, .. }
14148        | NodeRequest::SetSessionTask { record_id, .. }
14149        | NodeRequest::ResumeSessionRecord { record_id, .. }
14150        | NodeRequest::ForgetSessionRecord { record_id } => provider_for_record(record_id)
14151            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14152        NodeRequest::PreviewSessionRecord { record_id, .. } => provider_for_record(record_id)
14153            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14154        NodeRequest::ArmHarnessMcpReservation { .. }
14155        | NodeRequest::ActivateHarnessMcpReservation { .. }
14156        | NodeRequest::AbortHarnessMcpReservation { .. }
14157        | NodeRequest::PutHarnessMcpReplyChunk { .. }
14158        | NodeRequest::RejectHarnessMcpCall { .. }
14159        | NodeRequest::Snapshot
14160        | NodeRequest::Resync { .. }
14161        | NodeRequest::BeginDeliveryStage { .. }
14162        | NodeRequest::PutDeliveryBlobChunk { .. }
14163        | NodeRequest::CommitDeliveryStage { .. }
14164        | NodeRequest::AbortDeliveryStage { .. }
14165        | NodeRequest::BrowseHostDirectories { .. }
14166        | NodeRequest::InspectWorkspace { .. }
14167        | NodeRequest::ReadWorkspaceFile { .. }
14168        | NodeRequest::WriteWorkspaceFile { .. }
14169        | NodeRequest::CreateWorkspaceFile { .. }
14170        | NodeRequest::CreateWorkspaceDirectory { .. }
14171        | NodeRequest::ReadGitHistory { .. }
14172        | NodeRequest::ReadGitDiff { .. }
14173        | NodeRequest::AcquireController { .. }
14174        | NodeRequest::ReleaseController
14175        | NodeRequest::RegisterWorkspace { .. }
14176        | NodeRequest::CreateStandaloneWorkspace { .. }
14177        | NodeRequest::UnregisterWorkspace { .. }
14178        | NodeRequest::CreateWorktree { .. }
14179        | NodeRequest::RemoveWorktree { .. }
14180        | NodeRequest::CleanupManagedWorktree { .. }
14181        | NodeRequest::Shutdown => false,
14182        NodeRequest::ForgetContextPack { .. } => true,
14183        // No session/record to cheaply resolve a provider from here either;
14184        // the resolved receipt can carry an open-id provider, so require the
14185        // same capability as ForgetContextPack unconditionally. `ReadContextPack`
14186        // is the same digest-keyed lookup one step further into the same
14187        // catalog, so it inherits the same conservative answer.
14188        NodeRequest::ResolveDurableContextPack { .. }
14189        | NodeRequest::ReadContextPack { .. } => true,
14190    }
14191}
14192
14193fn project_snapshot_legacy_provider_ids(snapshot: &mut NodeSnapshot) {
14194    snapshot.enabled_providers.retain(provider_id_is_legacy);
14195    snapshot.provider_runtime_statuses = ProviderRuntimeStatuses::new(
14196        snapshot
14197            .provider_runtime_statuses
14198            .as_slice()
14199            .iter()
14200            .filter(|status| provider_id_is_legacy(status.provider()))
14201            .cloned(),
14202    )
14203    .expect("a filtered provider runtime projection remains valid");
14204    snapshot
14205        .session_records
14206        .retain(|record| provider_id_is_legacy(&record.provider));
14207    for workspace in &mut snapshot.workspaces {
14208        workspace
14209            .sessions
14210            .retain(|session| provider_id_is_legacy(&session.agent_id));
14211    }
14212}
14213
14214fn clear_snapshot_child_environment_profiles(snapshot: &mut NodeSnapshot) {
14215    for record in &mut snapshot.session_records {
14216        record.environment_profile = None;
14217    }
14218    if let Some(profiles) = snapshot
14219        .launch_inventory
14220        .as_mut()
14221        .and_then(|inventory| inventory.spawn_profiles.as_mut())
14222    {
14223        for profile in profiles {
14224            profile.environment_profile = None;
14225        }
14226    }
14227}
14228
14229fn project_event_without_child_environment_profile(
14230    mut envelope: NodeEventEnvelope,
14231) -> NodeEventEnvelope {
14232    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14233        record.environment_profile = None;
14234    }
14235    envelope
14236}
14237
14238fn project_response_without_child_environment_profile(reply: &mut ResponseEnvelope) {
14239    let contains_environment_profile = match reply.result.as_ref() {
14240        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
14241            receipt.environment_profile.is_some()
14242        }
14243        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14244            receipt.spawn.environment_profile.is_some()
14245        }
14246        _ => false,
14247    };
14248    if contains_environment_profile {
14249        reply.result = Err(failure(
14250            NodeFailureCode::UnsupportedCapability,
14251            "child environment profiles were not negotiated",
14252        ));
14253        return;
14254    }
14255    let Ok(response) = reply.result.as_mut() else {
14256        return;
14257    };
14258    match response {
14259        NodeResponse::Snapshot { snapshot, .. } => {
14260            clear_snapshot_child_environment_profiles(snapshot);
14261        }
14262        NodeResponse::Resync { snapshot, events, .. } => {
14263            clear_snapshot_child_environment_profiles(snapshot);
14264            for event in events {
14265                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14266                    record.environment_profile = None;
14267                }
14268            }
14269        }
14270        NodeResponse::SessionRecordUpdated { record }
14271        | NodeResponse::ProviderSessionIndexed { record }
14272        | NodeResponse::NativeSessionIndexed { record, .. }
14273        | NodeResponse::SessionRecordResumed { record, .. } => {
14274            record.environment_profile = None;
14275        }
14276        NodeResponse::Armed { .. }
14277        | NodeResponse::Spawned { .. }
14278        | NodeResponse::Activated { .. }
14279        | NodeResponse::Aborted { .. }
14280        | NodeResponse::ReplyChunkAccepted { .. }
14281        | NodeResponse::CallRejected { .. }
14282        | NodeResponse::DeliveryStageBegun { .. }
14283        | NodeResponse::DeliveryBlobChunkAccepted { .. }
14284        | NodeResponse::DeliveryCommitted { .. }
14285        | NodeResponse::DeliveryStageAborted { .. }
14286        | NodeResponse::WorkspaceInspected { .. }
14287        | NodeResponse::HostDirectoriesBrowsed { .. }
14288        | NodeResponse::WorkspaceFileRead { .. }
14289        | NodeResponse::WorkspaceFileWritten { .. }
14290        | NodeResponse::WorkspaceFileCreated { .. }
14291        | NodeResponse::WorkspaceDirectoryCreated { .. }
14292        | NodeResponse::GitHistoryRead { .. }
14293        | NodeResponse::GitDiffRead { .. }
14294        | NodeResponse::Controller { .. }
14295        | NodeResponse::SpawnAccepted { .. }
14296        | NodeResponse::SpawnSpecAccepted { .. }
14297        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14298        | NodeResponse::ManagedWorktreeCleanup { .. }
14299        | NodeResponse::NativeSessionsCataloged { .. }
14300        | NodeResponse::NativeSessionsPaged { .. }
14301        | NodeResponse::NativeSessionPreviewed { .. }
14302        | NodeResponse::SessionRecordPreviewed { .. }
14303        | NodeResponse::HistoryDiscovered { .. }
14304        | NodeResponse::HistoryLoaded { .. }
14305        | NodeResponse::ContextPackForSessionRecordExported { .. }
14306        | NodeResponse::ContextPackExported { .. }
14307        | NodeResponse::ContextPackForgotten { .. }
14308        | NodeResponse::DurableContextPackResolved { .. }
14309        | NodeResponse::ContextPackBytesRead { .. }
14310        | NodeResponse::SessionRecordForgotten { .. }
14311        | NodeResponse::WorkspaceRegistered { .. }
14312        | NodeResponse::StandaloneWorkspaceCreated { .. }
14313        | NodeResponse::WorkspaceUnregistered { .. }
14314        | NodeResponse::WorktreeCreated { .. }
14315        | NodeResponse::WorktreeRemoved { .. }
14316        | NodeResponse::Accepted
14317        | NodeResponse::ShuttingDown => {}
14318    }
14319}
14320
14321fn clear_snapshot_session_bundles(snapshot: &mut NodeSnapshot) {
14322    for record in &mut snapshot.session_records {
14323        record.bundle = None;
14324    }
14325}
14326
14327fn project_event_without_session_bundle(
14328    mut envelope: NodeEventEnvelope,
14329) -> NodeEventEnvelope {
14330    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14331        record.bundle = None;
14332    }
14333    envelope
14334}
14335
14336fn project_response_without_session_bundle(reply: &mut ResponseEnvelope) {
14337    let contains_bundle = match reply.result.as_ref() {
14338        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.bundle.is_some(),
14339        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14340            receipt.spawn.bundle.is_some()
14341        }
14342        _ => false,
14343    };
14344    if contains_bundle {
14345        reply.result = Err(failure(
14346            NodeFailureCode::UnsupportedCapability,
14347            "session bundles were not negotiated",
14348        ));
14349        return;
14350    }
14351    let Ok(response) = reply.result.as_mut() else {
14352        return;
14353    };
14354    match response {
14355        NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_session_bundles(snapshot),
14356        NodeResponse::Resync { snapshot, events, .. } => {
14357            clear_snapshot_session_bundles(snapshot);
14358            for event in events {
14359                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14360                    record.bundle = None;
14361                }
14362            }
14363        }
14364        NodeResponse::SessionRecordUpdated { record }
14365        | NodeResponse::ProviderSessionIndexed { record }
14366        | NodeResponse::NativeSessionIndexed { record, .. }
14367        | NodeResponse::SessionRecordResumed { record, .. } => record.bundle = None,
14368        NodeResponse::Armed { .. }
14369        | NodeResponse::Spawned { .. }
14370        | NodeResponse::Activated { .. }
14371        | NodeResponse::Aborted { .. }
14372        | NodeResponse::ReplyChunkAccepted { .. }
14373        | NodeResponse::CallRejected { .. }
14374        | NodeResponse::DeliveryStageBegun { .. }
14375        | NodeResponse::DeliveryBlobChunkAccepted { .. }
14376        | NodeResponse::DeliveryCommitted { .. }
14377        | NodeResponse::DeliveryStageAborted { .. }
14378        | NodeResponse::WorkspaceInspected { .. }
14379        | NodeResponse::HostDirectoriesBrowsed { .. }
14380        | NodeResponse::WorkspaceFileRead { .. }
14381        | NodeResponse::WorkspaceFileWritten { .. }
14382        | NodeResponse::WorkspaceFileCreated { .. }
14383        | NodeResponse::WorkspaceDirectoryCreated { .. }
14384        | NodeResponse::GitHistoryRead { .. }
14385        | NodeResponse::GitDiffRead { .. }
14386        | NodeResponse::Controller { .. }
14387        | NodeResponse::SpawnAccepted { .. }
14388        | NodeResponse::SpawnSpecAccepted { .. }
14389        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14390        | NodeResponse::ManagedWorktreeCleanup { .. }
14391        | NodeResponse::NativeSessionsCataloged { .. }
14392        | NodeResponse::NativeSessionsPaged { .. }
14393        | NodeResponse::NativeSessionPreviewed { .. }
14394        | NodeResponse::SessionRecordPreviewed { .. }
14395        | NodeResponse::HistoryDiscovered { .. }
14396        | NodeResponse::HistoryLoaded { .. }
14397        | NodeResponse::ContextPackForSessionRecordExported { .. }
14398        | NodeResponse::ContextPackExported { .. }
14399        | NodeResponse::ContextPackForgotten { .. }
14400        | NodeResponse::DurableContextPackResolved { .. }
14401        | NodeResponse::ContextPackBytesRead { .. }
14402        | NodeResponse::SessionRecordForgotten { .. }
14403        | NodeResponse::WorkspaceRegistered { .. }
14404        | NodeResponse::StandaloneWorkspaceCreated { .. }
14405        | NodeResponse::WorkspaceUnregistered { .. }
14406        | NodeResponse::WorktreeCreated { .. }
14407        | NodeResponse::WorktreeRemoved { .. }
14408        | NodeResponse::Accepted
14409        | NodeResponse::ShuttingDown => {}
14410    }
14411}
14412
14413fn project_snapshot_history_for_wire(
14414    snapshot: &mut NodeSnapshot,
14415    include_history_metadata: bool,
14416) {
14417    for workspace in &mut snapshot.workspaces {
14418        for session in &mut workspace.sessions {
14419            if include_history_metadata {
14420                session.history.loaded = None;
14421            } else {
14422                session.history = Default::default();
14423            }
14424        }
14425    }
14426}
14427
14428fn project_event_history_for_wire(
14429    mut envelope: NodeEventEnvelope,
14430    include_history_metadata: bool,
14431) -> NodeEventEnvelope {
14432    if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
14433        for session in &mut workspace.sessions {
14434            if include_history_metadata {
14435                session.history.loaded = None;
14436            } else {
14437                session.history = Default::default();
14438            }
14439        }
14440    }
14441    envelope
14442}
14443
14444fn project_response_history_for_wire(
14445    reply: &mut ResponseEnvelope,
14446    include_history_metadata: bool,
14447) {
14448    let Ok(response) = reply.result.as_mut() else {
14449        return;
14450    };
14451    match response {
14452        NodeResponse::Snapshot { snapshot, .. } => {
14453            project_snapshot_history_for_wire(snapshot, include_history_metadata);
14454        }
14455        NodeResponse::Resync {
14456            snapshot, events, ..
14457        } => {
14458            project_snapshot_history_for_wire(snapshot, include_history_metadata);
14459            for event in events {
14460                *event = project_event_history_for_wire(
14461                    event.clone(),
14462                    include_history_metadata,
14463                );
14464            }
14465        }
14466        NodeResponse::WorkspaceRegistered { workspace }
14467        | NodeResponse::StandaloneWorkspaceCreated { workspace }
14468        | NodeResponse::WorktreeCreated { workspace, .. } => {
14469            for session in &mut workspace.sessions {
14470                if include_history_metadata {
14471                    session.history.loaded = None;
14472                } else {
14473                    session.history = Default::default();
14474                }
14475            }
14476        }
14477        _ => {}
14478    }
14479}
14480
14481fn clear_snapshot_context_packs(snapshot: &mut NodeSnapshot) {
14482    for record in &mut snapshot.session_records {
14483        record.context_id = None;
14484        record.context = None;
14485        record.exported_context = None;
14486    }
14487}
14488
14489fn project_event_without_context_pack(
14490    mut envelope: NodeEventEnvelope,
14491) -> NodeEventEnvelope {
14492    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14493        record.context_id = None;
14494        record.context = None;
14495        record.exported_context = None;
14496    }
14497    envelope
14498}
14499
14500fn project_response_without_context_pack(reply: &mut ResponseEnvelope) {
14501    let contains_context = match reply.result.as_ref() {
14502        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.context.is_some(),
14503        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14504            receipt.spawn.context.is_some()
14505        }
14506        Ok(NodeResponse::HistoryDiscovered { .. })
14507        | Ok(NodeResponse::HistoryLoaded { .. })
14508        | Ok(NodeResponse::ContextPackForSessionRecordExported { .. })
14509        | Ok(NodeResponse::ContextPackExported { .. })
14510        | Ok(NodeResponse::ContextPackForgotten { .. })
14511        | Ok(NodeResponse::DurableContextPackResolved { .. }) => true,
14512        _ => false,
14513    };
14514    if contains_context {
14515        reply.result = Err(failure(
14516            NodeFailureCode::UnsupportedCapability,
14517            "history context packs were not negotiated",
14518        ));
14519        return;
14520    }
14521    let Ok(response) = reply.result.as_mut() else {
14522        return;
14523    };
14524    match response {
14525        NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_context_packs(snapshot),
14526        NodeResponse::Resync {
14527            snapshot, events, ..
14528        } => {
14529            clear_snapshot_context_packs(snapshot);
14530            for event in events {
14531                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14532                    record.context_id = None;
14533                    record.context = None;
14534                    record.exported_context = None;
14535                }
14536            }
14537        }
14538        NodeResponse::SessionRecordUpdated { record }
14539        | NodeResponse::ProviderSessionIndexed { record }
14540        | NodeResponse::NativeSessionIndexed { record, .. }
14541        | NodeResponse::SessionRecordResumed { record, .. } => {
14542            record.context_id = None;
14543            record.context = None;
14544            record.exported_context = None;
14545        }
14546        _ => {}
14547    }
14548}
14549
14550fn project_response_legacy_provider_ids(shared: &NodeShared, reply: &mut ResponseEnvelope) {
14551    let contains_open_record = match reply.result.as_ref() {
14552        Ok(NodeResponse::SessionRecordUpdated { record })
14553        | Ok(NodeResponse::ProviderSessionIndexed { record })
14554        | Ok(NodeResponse::SessionRecordResumed { record, .. }) => {
14555            !provider_id_is_legacy(&record.provider)
14556        }
14557        Ok(NodeResponse::NativeSessionIndexed { selection, record }) => {
14558            !provider_id_is_legacy(&selection.route.provider)
14559                || !provider_id_is_legacy(&record.provider)
14560        }
14561        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
14562            !provider_id_is_legacy(&receipt.provider)
14563        }
14564        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14565            !provider_id_is_legacy(&receipt.spawn.provider)
14566        }
14567        Ok(NodeResponse::NativeSessionsCataloged { route, .. })
14568        | Ok(NodeResponse::NativeSessionsPaged { route, .. }) => {
14569            !provider_id_is_legacy(&route.provider)
14570        }
14571        Ok(NodeResponse::NativeSessionPreviewed { selection, .. }) => {
14572            !provider_id_is_legacy(&selection.route.provider)
14573        }
14574        _ => false,
14575    };
14576    if contains_open_record {
14577        reply.result = Err(failure(
14578            NodeFailureCode::UnsupportedCapability,
14579            "open provider IDs were not negotiated",
14580        ));
14581        return;
14582    }
14583    let Ok(response) = reply.result.as_mut() else {
14584        return;
14585    };
14586    match response {
14587        NodeResponse::Snapshot { snapshot, .. } => {
14588            project_snapshot_legacy_provider_ids(snapshot);
14589        }
14590        NodeResponse::Resync { snapshot, events, .. } => {
14591            project_snapshot_legacy_provider_ids(snapshot);
14592            *events = std::mem::take(events)
14593                .into_iter()
14594                .filter_map(|event| project_event_legacy_provider_ids(shared, event))
14595                .collect();
14596        }
14597        NodeResponse::WorkspaceRegistered { workspace }
14598        | NodeResponse::StandaloneWorkspaceCreated { workspace }
14599        | NodeResponse::WorktreeCreated { workspace, .. } => {
14600            workspace
14601                .sessions
14602                .retain(|session| provider_id_is_legacy(&session.agent_id));
14603        }
14604        NodeResponse::Armed { .. }
14605        | NodeResponse::Spawned { .. }
14606        | NodeResponse::Activated { .. }
14607        | NodeResponse::Aborted { .. }
14608        | NodeResponse::ReplyChunkAccepted { .. }
14609        | NodeResponse::CallRejected { .. }
14610        | NodeResponse::DeliveryStageBegun { .. }
14611        | NodeResponse::DeliveryBlobChunkAccepted { .. }
14612        | NodeResponse::DeliveryCommitted { .. }
14613        | NodeResponse::DeliveryStageAborted { .. }
14614        | NodeResponse::WorkspaceInspected { .. }
14615        | NodeResponse::HostDirectoriesBrowsed { .. }
14616        | NodeResponse::WorkspaceFileRead { .. }
14617        | NodeResponse::WorkspaceFileWritten { .. }
14618        | NodeResponse::WorkspaceFileCreated { .. }
14619        | NodeResponse::WorkspaceDirectoryCreated { .. }
14620        | NodeResponse::GitHistoryRead { .. }
14621        | NodeResponse::GitDiffRead { .. }
14622        | NodeResponse::Controller { .. }
14623        | NodeResponse::SpawnAccepted { .. }
14624        | NodeResponse::SpawnSpecAccepted { .. }
14625        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14626        | NodeResponse::ManagedWorktreeCleanup { .. }
14627        | NodeResponse::NativeSessionsCataloged { .. }
14628        | NodeResponse::NativeSessionsPaged { .. }
14629        | NodeResponse::NativeSessionPreviewed { .. }
14630        | NodeResponse::SessionRecordPreviewed { .. }
14631        | NodeResponse::HistoryDiscovered { .. }
14632        | NodeResponse::HistoryLoaded { .. }
14633        | NodeResponse::ContextPackForSessionRecordExported { .. }
14634        | NodeResponse::ContextPackExported { .. }
14635        | NodeResponse::ContextPackForgotten { .. }
14636        | NodeResponse::DurableContextPackResolved { .. }
14637        | NodeResponse::ContextPackBytesRead { .. }
14638        | NodeResponse::SessionRecordUpdated { .. }
14639        | NodeResponse::ProviderSessionIndexed { .. }
14640        | NodeResponse::NativeSessionIndexed { .. }
14641        | NodeResponse::SessionRecordResumed { .. }
14642        | NodeResponse::SessionRecordForgotten { .. }
14643        | NodeResponse::WorkspaceUnregistered { .. }
14644        | NodeResponse::WorktreeRemoved { .. }
14645        | NodeResponse::Accepted
14646        | NodeResponse::ShuttingDown => {}
14647    }
14648}
14649
14650fn project_event_legacy_provider_ids(
14651    shared: &NodeShared,
14652    mut envelope: NodeEventEnvelope,
14653) -> Option<NodeEventEnvelope> {
14654    let include = match &mut envelope.event {
14655        NodeEvent::HarnessMcpReadCall { .. } => true,
14656        NodeEvent::Control { address, .. }
14657        | NodeEvent::AgentStream { address, .. }
14658        | NodeEvent::TerminalFrame { address, .. } => shared
14659            .validate_address(address)
14660            .is_ok_and(|provider| provider_id_is_legacy(&provider)),
14661        NodeEvent::WorkspaceAdded { workspace } => {
14662            workspace
14663                .sessions
14664                .retain(|session| provider_id_is_legacy(&session.agent_id));
14665            true
14666        }
14667        NodeEvent::SessionRecordUpserted { record } => {
14668            provider_id_is_legacy(&record.provider)
14669        }
14670        NodeEvent::SessionRecordHistorySummarized { .. } => false,
14671        NodeEvent::SessionRecordRemoved { .. } => shared
14672            .history
14673            .lock()
14674            .unwrap_or_else(|poisoned| poisoned.into_inner())
14675            .removed_record_providers
14676            .get(&envelope.sequence)
14677            .is_some_and(provider_id_is_legacy),
14678        NodeEvent::ControllerChanged { .. }
14679        | NodeEvent::ManagedWorktreeUpserted { .. }
14680        | NodeEvent::ManagedWorktreeRemoved { .. }
14681        | NodeEvent::WorkspaceRemoved { .. }
14682        | NodeEvent::ResyncRequired { .. } => true,
14683    };
14684    include.then_some(envelope)
14685}
14686
14687fn clear_response_provider_runtime_status(reply: &mut ResponseEnvelope) {
14688    let Ok(response) = reply.result.as_mut() else {
14689        return;
14690    };
14691    match response {
14692        NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
14693            snapshot.provider_runtime_statuses.clear();
14694        }
14695        NodeResponse::Armed { .. }
14696        | NodeResponse::Spawned { .. }
14697        | NodeResponse::Activated { .. }
14698        | NodeResponse::Aborted { .. }
14699        | NodeResponse::ReplyChunkAccepted { .. }
14700        | NodeResponse::CallRejected { .. }
14701        | NodeResponse::DeliveryStageBegun { .. }
14702        | NodeResponse::DeliveryBlobChunkAccepted { .. }
14703        | NodeResponse::DeliveryCommitted { .. }
14704        | NodeResponse::DeliveryStageAborted { .. }
14705        | NodeResponse::WorkspaceInspected { .. }
14706        | NodeResponse::HostDirectoriesBrowsed { .. }
14707        | NodeResponse::WorkspaceFileRead { .. }
14708        | NodeResponse::WorkspaceFileWritten { .. }
14709        | NodeResponse::WorkspaceFileCreated { .. }
14710        | NodeResponse::WorkspaceDirectoryCreated { .. }
14711        | NodeResponse::GitHistoryRead { .. }
14712        | NodeResponse::GitDiffRead { .. }
14713        | NodeResponse::Controller { .. }
14714        | NodeResponse::SpawnAccepted { .. }
14715        | NodeResponse::SpawnSpecAccepted { .. }
14716        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14717        | NodeResponse::ManagedWorktreeCleanup { .. }
14718        | NodeResponse::NativeSessionsCataloged { .. }
14719        | NodeResponse::NativeSessionsPaged { .. }
14720        | NodeResponse::NativeSessionPreviewed { .. }
14721        | NodeResponse::SessionRecordPreviewed { .. }
14722        | NodeResponse::HistoryDiscovered { .. }
14723        | NodeResponse::HistoryLoaded { .. }
14724        | NodeResponse::ContextPackForSessionRecordExported { .. }
14725        | NodeResponse::ContextPackExported { .. }
14726        | NodeResponse::ContextPackForgotten { .. }
14727        | NodeResponse::DurableContextPackResolved { .. }
14728        | NodeResponse::ContextPackBytesRead { .. }
14729        | NodeResponse::SessionRecordUpdated { .. }
14730        | NodeResponse::ProviderSessionIndexed { .. }
14731        | NodeResponse::NativeSessionIndexed { .. }
14732        | NodeResponse::SessionRecordResumed { .. }
14733        | NodeResponse::SessionRecordForgotten { .. }
14734        | NodeResponse::WorkspaceRegistered { .. }
14735        | NodeResponse::StandaloneWorkspaceCreated { .. }
14736        | NodeResponse::WorkspaceUnregistered { .. }
14737        | NodeResponse::WorktreeCreated { .. }
14738        | NodeResponse::WorktreeRemoved { .. }
14739        | NodeResponse::Accepted
14740        | NodeResponse::ShuttingDown => {}
14741    }
14742}
14743
14744struct ControllerReleaseGuard {
14745    shared: Arc<NodeShared>,
14746    connection_id: u64,
14747}
14748
14749impl Drop for ControllerReleaseGuard {
14750    fn drop(&mut self) {
14751        self.shared.release_controller(self.connection_id);
14752    }
14753}
14754
14755struct AbortTaskOnDrop(AbortHandle);
14756
14757impl Drop for AbortTaskOnDrop {
14758    fn drop(&mut self) {
14759        self.0.abort();
14760    }
14761}
14762
14763/// Reads an unsigned env-config override, following the pattern of every
14764/// other bounded knob in this module: missing/unparsable/zero falls back to
14765/// `default`, and the resolved value is always clamped to `max` so a bad
14766/// override cannot reopen a bound this budget exists to enforce.
14767fn env_config_bounded(name: &str, default: u64, max: u64) -> u64 {
14768    std::env::var(name)
14769        .ok()
14770        .and_then(|value| value.parse::<u64>().ok())
14771        .filter(|value| *value > 0)
14772        .unwrap_or(default)
14773        .min(max)
14774}
14775
14776fn workspace_inspection_time_budget_ms() -> u64 {
14777    env_config_bounded(
14778        "GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS",
14779        WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT,
14780        WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX,
14781    )
14782}
14783
14784fn workspace_inspection_entry_cap() -> usize {
14785    env_config_bounded(
14786        "GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP",
14787        WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT as u64,
14788        WORKSPACE_INSPECTION_ENTRY_CAP_MAX as u64,
14789    ) as usize
14790}
14791
14792/// Tracks the shared walk-side budget across the whole recursive walk: a
14793/// wall-clock deadline (shared with the git phase that runs after the walk)
14794/// and a cap on directory entries visited, whether or not they were pushed
14795/// to the response. Cheap to check per entry — one `Instant::now()` and one
14796/// integer compare.
14797struct WorkspaceWalkBudget {
14798    deadline: Instant,
14799    entry_cap: usize,
14800    entries_visited: u64,
14801    time_budget_exceeded: bool,
14802    entry_cap_exceeded: bool,
14803}
14804
14805impl WorkspaceWalkBudget {
14806    fn new(deadline: Instant, entry_cap: usize) -> Self {
14807        Self {
14808            deadline,
14809            entry_cap,
14810            entries_visited: 0,
14811            time_budget_exceeded: false,
14812            entry_cap_exceeded: false,
14813        }
14814    }
14815
14816    fn is_exhausted(&self) -> bool {
14817        self.time_budget_exceeded || self.entry_cap_exceeded
14818    }
14819
14820    /// Records one visited directory entry (pushed to the response or not)
14821    /// and reports whether the walk must stop now.
14822    fn record_visit(&mut self) -> bool {
14823        self.entries_visited += 1;
14824        if self.entries_visited as usize >= self.entry_cap {
14825            self.entry_cap_exceeded = true;
14826        }
14827        if Instant::now() >= self.deadline {
14828            self.time_budget_exceeded = true;
14829        }
14830        self.is_exhausted()
14831    }
14832}
14833
14834fn collect_workspace_entries(
14835    root: &Path,
14836    deadline: Instant,
14837    entry_cap: usize,
14838) -> (Vec<WorkspaceEntry>, bool, WorkspaceWalkBudget) {
14839    let mut entries = Vec::new();
14840    let mut truncated = false;
14841    let mut budget = WorkspaceWalkBudget::new(deadline, entry_cap);
14842    walk_workspace_directory(root, None, 0, &mut entries, &mut truncated, &mut budget);
14843    // DFS pre-order emits `x/child` between prefix-named siblings `x` and
14844    // `x-y` ('-' sorts below '/'), while the bounded operator projections
14845    // require strict global byte order over relative paths.
14846    entries.sort_by(|left, right| left.relative_path.as_utf8().cmp(&right.relative_path.as_utf8()));
14847    (entries, truncated, budget)
14848}
14849
14850/// Builds the additive `WorkspaceInspectionTruncationV1` for an
14851/// `inspect_workspace` response — `None` once the walk and git phase both
14852/// completed inside their shared budget.
14853fn workspace_inspection_truncation(
14854    walk_budget: &WorkspaceWalkBudget,
14855    git_time_budget_exceeded: bool,
14856    elapsed: Duration,
14857) -> Option<WorkspaceInspectionTruncationV1> {
14858    if !walk_budget.time_budget_exceeded
14859        && !walk_budget.entry_cap_exceeded
14860        && !git_time_budget_exceeded
14861    {
14862        return None;
14863    }
14864    Some(WorkspaceInspectionTruncationV1 {
14865        walk_time_budget_exceeded: walk_budget.time_budget_exceeded,
14866        walk_entry_cap_exceeded: walk_budget.entry_cap_exceeded,
14867        git_time_budget_exceeded,
14868        entries_visited: walk_budget.entries_visited,
14869        elapsed_ms: u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX),
14870    })
14871}
14872
14873fn walk_workspace_directory(
14874    directory: &Path,
14875    relative_directory: Option<&RepositoryPath>,
14876    depth: usize,
14877    entries: &mut Vec<WorkspaceEntry>,
14878    truncated: &mut bool,
14879    budget: &mut WorkspaceWalkBudget,
14880) {
14881    if budget.is_exhausted() {
14882        *truncated = true;
14883        return;
14884    }
14885    let read_dir = match std::fs::read_dir(directory) {
14886        Ok(read_dir) => read_dir,
14887        Err(_) => {
14888            *truncated = true;
14889            return;
14890        }
14891    };
14892    let mut children = Vec::new();
14893    for child in read_dir {
14894        match child {
14895            Ok(child) => children.push(child),
14896            Err(_) => *truncated = true,
14897        }
14898    }
14899    children.sort_by_key(|child| child.file_name());
14900    for child in children {
14901        if budget.record_visit() {
14902            *truncated = true;
14903            return;
14904        }
14905        if entries.len() >= WORKSPACE_TREE_MAX_ENTRIES {
14906            *truncated = true;
14907            return;
14908        }
14909        let file_type = match child.file_type() {
14910            Ok(file_type) => file_type,
14911            Err(_) => {
14912                *truncated = true;
14913                continue;
14914            }
14915        };
14916        if file_type.is_symlink() {
14917            continue;
14918        }
14919        let name = match child.file_name().into_string() {
14920            Ok(name) => name,
14921            Err(_) => {
14922                *truncated = true;
14923                continue;
14924            }
14925        };
14926        if file_type.is_dir() && is_skipped_workspace_directory(&name, &child.path()) {
14927            continue;
14928        }
14929        let relative_path = match relative_directory {
14930            None => windows_repository_path(name),
14931            Some(parent) => parent
14932                .as_utf8()
14933                .and_then(|parent| windows_repository_path(format!("{parent}/{name}"))),
14934        };
14935        let Some(relative_path) = relative_path else {
14936            *truncated = true;
14937            continue;
14938        };
14939        let kind = if file_type.is_dir() {
14940            WorkspaceEntryKind::Directory
14941        } else if file_type.is_file() {
14942            WorkspaceEntryKind::File
14943        } else {
14944            continue;
14945        };
14946        entries.push(WorkspaceEntry {
14947            relative_path: relative_path.clone(),
14948            kind,
14949        });
14950        if file_type.is_dir() {
14951            if depth + 1 < WORKSPACE_TREE_MAX_DEPTH {
14952                walk_workspace_directory(
14953                    &child.path(),
14954                    Some(&relative_path),
14955                    depth + 1,
14956                    entries,
14957                    truncated,
14958                    budget,
14959                );
14960            } else {
14961                *truncated = true;
14962            }
14963        }
14964    }
14965}
14966
14967fn windows_repository_path(value: String) -> Option<RepositoryPath> {
14968    if value.contains('\\') {
14969        return None;
14970    }
14971    RepositoryPath::utf8(value).ok()
14972}
14973
14974fn windows_repository_path_from_bytes(value: &[u8]) -> Option<RepositoryPath> {
14975    windows_repository_path(std::str::from_utf8(value).ok()?.to_owned())
14976}
14977
14978/// Excludes a directory from the workspace tree walk. The three literal
14979/// names are the common convention-based cases; the `CACHEDIR.TAG` check
14980/// beneath them catches every build-output directory regardless of name
14981/// (cargo writes `CACHEDIR.TAG` into every `--target-dir` it creates, so
14982/// this covers `target-a2`, `target-a2-tui`, and any future renamed build
14983/// directory without maintaining a name heuristic list). The name check
14984/// runs first and returns early so a directory rejected by name never pays
14985/// for the extra `CACHEDIR.TAG` stat.
14986fn is_skipped_workspace_directory(name: &str, path: &Path) -> bool {
14987    if name.eq_ignore_ascii_case(".git")
14988        || name.eq_ignore_ascii_case("target")
14989        || name.eq_ignore_ascii_case("node_modules")
14990    {
14991        return true;
14992    }
14993    path.join("CACHEDIR.TAG").is_file()
14994}
14995
14996fn context_pack_source_status_is_usable(status: &SessionStatus) -> bool {
14997    matches!(
14998        status,
14999        SessionStatus::Running | SessionStatus::Exited { exit_code: Some(0) }
15000    )
15001}
15002
15003#[derive(Clone, Debug, Eq, PartialEq)]
15004enum ContextPackRepositoryHead {
15005    NotRepository,
15006    Unborn,
15007    Commit(String),
15008}
15009
15010#[derive(Clone, Debug, Eq, PartialEq)]
15011struct ContextPackRepositoryObservation {
15012    head: ContextPackRepositoryHead,
15013    git: GitSnapshot,
15014    files: Vec<ContextPackRepositoryFileSource>,
15015}
15016
15017fn stable_context_pack_repository(
15018    first: ContextPackRepositoryObservation,
15019    second: ContextPackRepositoryObservation,
15020) -> Result<ContextPackRepository, NodeFailure> {
15021    if first != second {
15022        return Err(failure(
15023            NodeFailureCode::ContextPackMaterializationFailed,
15024            "context repository changed during bounded capture",
15025        ));
15026    }
15027    Ok(ContextPackRepository::from_sources(
15028        &second.git,
15029        second.files,
15030    ))
15031}
15032
15033/// Appends a "skipped: budget exhausted" diagnostic and returns `true` once
15034/// (and only once) the shared walk+git `deadline` has already passed. Every
15035/// remaining phase in `inspect_git_workspace` checks this immediately
15036/// before issuing its own git call, so a budget spent by the walk (or by an
15037/// earlier git call) skips the rest of the chain instead of paying for it
15038/// anyway.
15039fn git_budget_exhausted(snapshot: &mut GitSnapshot, deadline: Instant, stage: &str) -> bool {
15040    if Instant::now() < deadline {
15041        return false;
15042    }
15043    append_git_diagnostic(
15044        snapshot,
15045        &format!("{stage} skipped: workspace inspection time budget exhausted"),
15046    );
15047    true
15048}
15049
15050/// Deadline-aware sibling of `run_git_bounded`, used only by
15051/// `inspect_git_workspace`'s six-call chain: caps the per-call timeout to
15052/// whatever remains of the shared `deadline` (never more than
15053/// `GIT_COMMAND_TIMEOUT_MS`), so a slow walk phase that already spent most
15054/// of the budget cannot still hand a full per-call timeout to every git
15055/// probe behind it. `inspect_git_workspace` already guards each call site
15056/// with `git_budget_exhausted`, so `deadline` is guaranteed not to have
15057/// passed yet here.
15058async fn run_git_bounded_with_deadline(
15059    root: &str,
15060    arguments: &[&str],
15061    output_limit: usize,
15062    deadline: Instant,
15063) -> io::Result<GitCommandOutput> {
15064    let remaining = deadline.checked_duration_since(Instant::now());
15065    let timeout_ms = remaining
15066        .and_then(|remaining| u64::try_from(remaining.as_millis()).ok())
15067        .unwrap_or(0)
15068        .clamp(1, GIT_COMMAND_TIMEOUT_MS);
15069    run_git_read_bounded(root, arguments, output_limit, timeout_ms).await
15070}
15071
15072/// Runs the bounded six-call git probe chain used by `inspect_workspace`.
15073/// `deadline` is the same absolute instant the preceding directory walk was
15074/// bounded by, so time already spent walking counts against what remains
15075/// for git enrichment. Returns the resulting `GitSnapshot` plus whether the
15076/// shared budget (not an individual per-call timeout) is what cut the git
15077/// phase short.
15078async fn inspect_git_workspace(root: &str, deadline: Instant) -> (GitSnapshot, bool) {
15079    let mut snapshot = GitSnapshot {
15080        is_repository: false,
15081        branch: None,
15082        status: Vec::new(),
15083        recent_commits: Vec::new(),
15084        worktrees: Vec::new(),
15085        managed_worktree: None,
15086        truncated: false,
15087        diagnostic: None,
15088    };
15089    if git_budget_exhausted(&mut snapshot, deadline, "git repository probe") {
15090        return (snapshot, true);
15091    }
15092    let repository = match run_git_bounded_with_deadline(
15093        root,
15094        &["rev-parse", "--is-inside-work-tree"],
15095        4 * 1_024,
15096        deadline,
15097    )
15098    .await
15099    {
15100        Ok(output) => output,
15101        Err(error) => {
15102            append_git_diagnostic(
15103                &mut snapshot,
15104                &format!("git inspection unavailable: {error}"),
15105            );
15106            return (snapshot, false);
15107        }
15108    };
15109    snapshot.truncated |= repository.truncated;
15110    if repository.timed_out {
15111        append_git_diagnostic(&mut snapshot, "git repository probe timed out");
15112        return (snapshot, false);
15113    }
15114    if !repository.success
15115        || String::from_utf8_lossy(&repository.stdout).trim() != "true"
15116    {
15117        let stderr = String::from_utf8_lossy(&repository.stderr);
15118        if !stderr.to_ascii_lowercase().contains("not a git repository") {
15119            append_git_diagnostic(&mut snapshot, stderr.trim());
15120        }
15121        return (snapshot, false);
15122    }
15123    snapshot.is_repository = true;
15124
15125    if git_budget_exhausted(&mut snapshot, deadline, "git branch query") {
15126        return (snapshot, true);
15127    }
15128    match run_git_bounded_with_deadline(root, &["branch", "--show-current"], 4 * 1_024, deadline).await {
15129        Ok(output) => {
15130            snapshot.truncated |= output.truncated;
15131            if output.timed_out {
15132                append_git_diagnostic(&mut snapshot, "git branch query timed out");
15133            } else if output.success {
15134                let branch = String::from_utf8_lossy(&output.stdout).trim().to_owned();
15135                if !branch.is_empty() {
15136                    snapshot.branch = Some(truncate_git_field(
15137                        &branch,
15138                        MAX_REPOSITORY_PATH_BYTES,
15139                        &mut snapshot.truncated,
15140                    ));
15141                }
15142            }
15143        }
15144        Err(error) => append_git_diagnostic(
15145            &mut snapshot,
15146            &format!("git branch query failed: {error}"),
15147        ),
15148    }
15149    if snapshot.branch.is_none() && !git_budget_exhausted(&mut snapshot, deadline, "git detached-HEAD probe") {
15150        if let Ok(output) = run_git_bounded_with_deadline(root, &["rev-parse", "--short", "HEAD"], 4 * 1_024, deadline).await {
15151            snapshot.truncated |= output.truncated;
15152            if output.success && !output.timed_out {
15153                let head = String::from_utf8_lossy(&output.stdout).trim().to_owned();
15154                if !head.is_empty() {
15155                    snapshot.branch = Some(format!("detached:{head}"));
15156                }
15157            }
15158        }
15159    }
15160
15161    if git_budget_exhausted(&mut snapshot, deadline, "git status") {
15162        return (snapshot, true);
15163    }
15164    match run_git_bounded_with_deadline(
15165        root,
15166        &[
15167            "status",
15168            "--porcelain=v1",
15169            "-z",
15170            "--renames",
15171            "--untracked-files=normal",
15172            "--",
15173            ".",
15174        ],
15175        GIT_OUTPUT_MAX_BYTES,
15176        deadline,
15177    )
15178    .await
15179    {
15180        Ok(output) => {
15181            snapshot.truncated |= output.truncated;
15182            if output.timed_out {
15183                append_git_diagnostic(&mut snapshot, "git status timed out");
15184            } else if output.success {
15185                parse_git_status(&output.stdout, &mut snapshot);
15186            } else {
15187                append_git_diagnostic(
15188                    &mut snapshot,
15189                    String::from_utf8_lossy(&output.stderr).trim(),
15190                );
15191            }
15192        }
15193        Err(error) => append_git_diagnostic(
15194            &mut snapshot,
15195            &format!("git status failed: {error}"),
15196        ),
15197    }
15198
15199    if git_budget_exhausted(&mut snapshot, deadline, "git log") {
15200        return (snapshot, true);
15201    }
15202    let log_limit = (GIT_COMMIT_MAX_ENTRIES + 1).to_string();
15203    match run_git_bounded_with_deadline(
15204        root,
15205        &["log", "-n", &log_limit, "--pretty=format:%H%x1f%s", "--", "."],
15206        16 * 1_024,
15207        deadline,
15208    )
15209    .await
15210    {
15211        Ok(output) => {
15212            snapshot.truncated |= output.truncated;
15213            if output.timed_out {
15214                append_git_diagnostic(&mut snapshot, "git log timed out");
15215            } else if output.success {
15216                parse_git_commits(&output.stdout, &mut snapshot);
15217            } else {
15218                let stderr = String::from_utf8_lossy(&output.stderr);
15219                if !stderr.to_ascii_lowercase().contains("does not have any commits") {
15220                    append_git_diagnostic(&mut snapshot, stderr.trim());
15221                }
15222            }
15223        }
15224        Err(error) => append_git_diagnostic(
15225            &mut snapshot,
15226            &format!("git log failed: {error}"),
15227        ),
15228    }
15229
15230    if git_budget_exhausted(&mut snapshot, deadline, "git worktree list") {
15231        return (snapshot, true);
15232    }
15233    match list_git_worktrees_with_deadline(root, deadline).await {
15234        Ok(worktrees) => {
15235            snapshot.worktrees = worktrees.into_iter().map(protocol_worktree).collect()
15236        }
15237        Err(error) => append_git_diagnostic(
15238            &mut snapshot,
15239            &format!("git worktree list failed: {}", error.message),
15240        ),
15241    }
15242    (snapshot, false)
15243}
15244
15245fn parse_git_status(output: &[u8], snapshot: &mut GitSnapshot) {
15246    let mut remaining = output;
15247    while !remaining.is_empty() {
15248        if snapshot.status.len() >= GIT_STATUS_MAX_ENTRIES {
15249            snapshot.truncated = true;
15250            break;
15251        }
15252        let Some(record_end) = remaining.iter().position(|byte| *byte == 0) else {
15253            snapshot.truncated = true;
15254            break;
15255        };
15256        let record = &remaining[..record_end];
15257        remaining = &remaining[record_end + 1..];
15258        let has_previous_path = record
15259            .get(..2)
15260            .is_some_and(|status| status.iter().any(|byte| matches!(*byte, b'R' | b'C')));
15261        let previous_path_bytes = if has_previous_path {
15262            let Some(previous_end) = remaining.iter().position(|byte| *byte == 0) else {
15263                snapshot.truncated = true;
15264                break;
15265            };
15266            let previous = &remaining[..previous_end];
15267            remaining = &remaining[previous_end + 1..];
15268            Some(previous)
15269        } else {
15270            None
15271        };
15272        if record.len() < 4
15273            || record[2] != b' '
15274            || !is_git_status_code(record[0])
15275            || !is_git_status_code(record[1])
15276        {
15277            snapshot.truncated = true;
15278            continue;
15279        }
15280        let Some(path) = windows_repository_path_from_bytes(&record[3..]) else {
15281            snapshot.truncated = true;
15282            continue;
15283        };
15284        let previous_path = match previous_path_bytes {
15285            Some(previous) => {
15286                let Some(previous) = windows_repository_path_from_bytes(previous) else {
15287                    snapshot.truncated = true;
15288                    continue;
15289                };
15290                Some(previous)
15291            }
15292            None => None,
15293        };
15294        snapshot.status.push(GitStatusEntry {
15295            index_status: (record[0] as char).to_string(),
15296            worktree_status: (record[1] as char).to_string(),
15297            path,
15298            previous_path,
15299        });
15300    }
15301}
15302
15303fn is_git_status_code(byte: u8) -> bool {
15304    matches!(byte, b' ' | b'M' | b'T' | b'A' | b'D' | b'R' | b'C' | b'U' | b'?' | b'!')
15305}
15306
15307fn parse_git_commits(output: &[u8], snapshot: &mut GitSnapshot) {
15308    for line in String::from_utf8_lossy(output).lines() {
15309        let Some((id, summary)) = line.split_once('\u{1f}') else {
15310            continue;
15311        };
15312        if snapshot.recent_commits.len() >= GIT_COMMIT_MAX_ENTRIES {
15313            snapshot.truncated = true;
15314            break;
15315        }
15316        let id = truncate_git_field(id, 64, &mut snapshot.truncated);
15317        let summary = truncate_git_field(summary, 512, &mut snapshot.truncated);
15318        snapshot.recent_commits.push(GitCommitSummary { id, summary });
15319    }
15320}
15321
15322fn truncate_git_field(value: &str, max_bytes: usize, truncated: &mut bool) -> String {
15323    if value.len() <= max_bytes {
15324        return value.to_owned();
15325    }
15326    *truncated = true;
15327    let mut boundary = max_bytes;
15328    while boundary > 0 && !value.is_char_boundary(boundary) {
15329        boundary -= 1;
15330    }
15331    value[..boundary].to_owned()
15332}
15333
15334fn append_git_diagnostic(snapshot: &mut GitSnapshot, message: &str) {
15335    if message.is_empty() {
15336        return;
15337    }
15338    let normalized = message
15339        .chars()
15340        .map(|character| if character.is_control() { ' ' } else { character })
15341        .collect::<String>();
15342    let mut diagnostic = snapshot.diagnostic.take().unwrap_or_default();
15343    if !diagnostic.is_empty() {
15344        diagnostic.push_str("; ");
15345    }
15346    diagnostic.push_str(normalized.trim());
15347    snapshot.diagnostic = Some(truncate_git_field(
15348        &diagnostic,
15349        GIT_DIAGNOSTIC_MAX_BYTES,
15350        &mut snapshot.truncated,
15351    ));
15352}
15353
15354async fn run_git_bounded(
15355    root: &str,
15356    arguments: &[&str],
15357    output_limit: usize,
15358) -> io::Result<GitCommandOutput> {
15359    run_git_read_bounded(root, arguments, output_limit, GIT_COMMAND_TIMEOUT_MS).await
15360}
15361
15362async fn read_git_history_bounded(
15363    root: &str,
15364    path: Option<&RepositoryPath>,
15365    before: Option<&GitObjectId>,
15366    limit: u16,
15367) -> Result<GitHistoryPage, NodeFailure> {
15368    let path = path
15369        .map(|path| {
15370            path.as_utf8().ok_or_else(|| {
15371                failure(
15372                    NodeFailureCode::InvalidRepositoryPath,
15373                    "git history path must use UTF-8",
15374                )
15375            })
15376        })
15377        .transpose()?
15378        .unwrap_or(".");
15379    let count = (usize::from(limit) + 1).to_string();
15380    let start = before
15381        .map(|revision| format!("{}^", revision.as_str()))
15382        .unwrap_or_else(|| "HEAD".to_owned());
15383    let arguments = [
15384        "--no-pager",
15385        "log",
15386        "--no-decorate",
15387        "--date=iso-strict",
15388        "--pretty=format:%H%x1f%P%x1f%s%x1f%an%x1f%ae%x1f%aI%x1f%cn%x1f%ce%x1f%cI%x1e",
15389        "-n",
15390        count.as_str(),
15391        start.as_str(),
15392        "--",
15393        path,
15394    ];
15395    let output = run_git_bounded(root, &arguments, 256 * 1_024)
15396        .await
15397        .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git history command failed"))?;
15398    if output.timed_out {
15399        return Err(failure(NodeFailureCode::GitReadTimedOut, "git history exceeded its bounded deadline"));
15400    }
15401    if !output.success {
15402        return Err(failure(NodeFailureCode::GitReadFailed, "git history could not be read"));
15403    }
15404    let mut commits = parse_git_history(&output.stdout);
15405    let has_more = commits.len() > usize::from(limit) || output.truncated;
15406    commits.truncate(usize::from(limit));
15407    let next_before = has_more.then(|| commits.last().map(|commit| commit.id.clone())).flatten();
15408    Ok(GitHistoryPage { commits, next_before, truncated: output.truncated })
15409}
15410
15411fn parse_git_history(output: &[u8]) -> Vec<GitCommitDetails> {
15412    String::from_utf8_lossy(output)
15413        .split('\u{1e}')
15414        .filter_map(|record| {
15415            let fields = record.trim_matches(['\r', '\n']).split('\u{1f}').collect::<Vec<_>>();
15416            if fields.len() != 9 {
15417                return None;
15418            }
15419            let id = GitObjectId::new(fields[0].to_owned()).ok()?;
15420            let parents = if fields[1].is_empty() {
15421                Vec::new()
15422            } else {
15423                fields[1]
15424                    .split(' ')
15425                    .map(|value| GitObjectId::new(value.to_owned()))
15426                    .collect::<Result<Vec<_>, _>>()
15427                    .ok()?
15428            };
15429            Some(GitCommitDetails {
15430                id,
15431                parents,
15432                subject: bounded_git_text(fields[2], 512),
15433                author_name: bounded_git_text(fields[3], 256),
15434                author_email: bounded_git_text(fields[4], 320),
15435                authored_at: bounded_git_text(fields[5], 64),
15436                committer_name: bounded_git_text(fields[6], 256),
15437                committer_email: bounded_git_text(fields[7], 320),
15438                committed_at: bounded_git_text(fields[8], 64),
15439                signature_status: GitSignatureStatus::NoSignature,
15440                signer: None,
15441            })
15442        })
15443        .take(usize::from(MAX_GIT_HISTORY_COMMITS) + 1)
15444        .collect()
15445}
15446
15447fn bounded_git_text(value: &str, max_bytes: usize) -> String {
15448    let normalized = value
15449        .chars()
15450        .map(|character| if character.is_control() { ' ' } else { character })
15451        .collect::<String>();
15452    if normalized.len() <= max_bytes {
15453        return normalized;
15454    }
15455    let mut boundary = max_bytes;
15456    while boundary > 0 && !normalized.is_char_boundary(boundary) {
15457        boundary -= 1;
15458    }
15459    normalized[..boundary].to_owned()
15460}
15461
15462async fn read_git_diff_bounded(root: &str, request: GitDiffRequest) -> Result<GitDiff, NodeFailure> {
15463    let path = request.path.as_ref().map(|path| {
15464        path.as_utf8()
15465            .ok_or_else(|| failure(NodeFailureCode::InvalidRepositoryPath, "git diff path must use UTF-8"))
15466    }).transpose()?;
15467    let mut arguments = match &request.mode {
15468        GitDiffMode::Working => vec!["--no-pager", "-c", "diff.external=", "diff", "--no-ext-diff", "--no-textconv"],
15469        GitDiffMode::Staged => vec!["--no-pager", "-c", "diff.external=", "diff", "--cached", "--no-ext-diff", "--no-textconv"],
15470        GitDiffMode::Commit { .. } => vec!["--no-pager", "-c", "diff.external=", "show", "--format=fuller", "--no-ext-diff", "--no-textconv"],
15471    };
15472    if let GitDiffMode::Commit { revision } = &request.mode {
15473        arguments.push(revision.as_str());
15474    }
15475    arguments.push("--");
15476    if let Some(path) = path {
15477        arguments.push(path);
15478    }
15479    let output = run_git_bounded(root, &arguments, MAX_GIT_DIFF_BYTES)
15480        .await
15481        .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git diff command failed"))?;
15482    if output.timed_out {
15483        return Err(failure(NodeFailureCode::GitReadTimedOut, "git diff exceeded its bounded deadline"));
15484    }
15485    if !output.success {
15486        return Err(failure(NodeFailureCode::GitReadFailed, "git diff could not be read"));
15487    }
15488    Ok(GitDiff {
15489        mode: request.mode,
15490        path: request.path,
15491        text: String::from_utf8_lossy(&output.stdout).into_owned(),
15492        truncated: output.truncated,
15493    })
15494}
15495
15496async fn process_request(shared: &NodeShared, connection_id: u64, role: ClientRole, envelope: RequestEnvelope) -> ResponseEnvelope {
15497    let result = process_request_inner(shared, connection_id, role, envelope.request).await;
15498    ResponseEnvelope { request_id: envelope.request_id, result }
15499}
15500
15501async fn process_request_inner(shared: &NodeShared, connection_id: u64, role: ClientRole, request: NodeRequest) -> Result<NodeResponse, NodeFailure> {
15502    if !request.harness_mcp_contract_is_valid_at(unix_time_ms()) {
15503        return Err(failure(NodeFailureCode::InvalidRequest, "harness MCP request is invalid or stale"));
15504    }
15505    let read_only = matches!(
15506        &request,
15507        NodeRequest::Snapshot
15508            | NodeRequest::Resync { .. }
15509            | NodeRequest::BrowseHostDirectories { .. }
15510            | NodeRequest::InspectWorkspace { .. }
15511            | NodeRequest::ReadWorkspaceFile { .. }
15512            | NodeRequest::ReadGitHistory { .. }
15513            | NodeRequest::ReadGitDiff { .. }
15514            | NodeRequest::CatalogNativeSessions { .. }
15515            | NodeRequest::PageNativeSessions { .. }
15516            | NodeRequest::PreviewNativeSession { .. }
15517            | NodeRequest::PreviewSessionRecord { .. }
15518    );
15519    let _mutation_guard = if read_only {
15520        None
15521    } else {
15522        let guard = shared.mutation_gate.lock().await;
15523        if shared.shutdown.load(Ordering::Acquire) {
15524            return Err(failure(NodeFailureCode::ShuttingDown, "node shutdown has begun"));
15525        }
15526        Some(guard)
15527    };
15528    match request {
15529        NodeRequest::Snapshot => Ok(NodeResponse::Snapshot {
15530            event_sequence: shared.current_sequence(),
15531            controller: shared.controller_state(),
15532            snapshot: shared.snapshot(),
15533        }),
15534        NodeRequest::Resync { after_sequence } => Ok(shared.resync(after_sequence)),
15535        NodeRequest::ArmHarnessMcpReservation {
15536            reservation_id,
15537            activation_digest,
15538            spawn_spec,
15539            launch,
15540            expires_at_unix_ms,
15541        } => {
15542            shared.require_controller(connection_id, role)?;
15543            let registry = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15544                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15545            })?;
15546            let expires_at_unix_ms = registry
15547                .arm(reservation_id.clone(), activation_digest.clone(), spawn_spec, launch, expires_at_unix_ms)
15548                .await
15549                .map_err(harness_mcp_failure)?;
15550            Ok(NodeResponse::Armed { reservation_id, activation_digest, expires_at_unix_ms })
15551        }
15552        NodeRequest::SpawnSpecWithHarnessMcp {
15553            reservation_id,
15554            activation_digest,
15555            spec,
15556            deadline_unix_ms,
15557        } => {
15558            shared.require_controller(connection_id, role)?;
15559            let receipt = shared
15560                .spawn_from_spec_with_harness_mcp(
15561                    reservation_id.clone(), activation_digest.clone(), spec, deadline_unix_ms,
15562                )
15563                .await?;
15564            Ok(NodeResponse::Spawned { reservation_id, activation_digest, receipt })
15565        }
15566        NodeRequest::ActivateHarnessMcpReservation {
15567            reservation_id,
15568            activation_digest,
15569            record_id,
15570            session,
15571        } => {
15572            shared.require_controller(connection_id, role)?;
15573            let provider_root_pid = shared.handle.snapshot().sessions.iter().find(|current| {
15574                current.instance_id == session.session.instance_id
15575                    && current.generation == session.session.generation
15576            }).and_then(|current| current.process_id).ok_or_else(|| {
15577                failure(NodeFailureCode::BindingMismatch, "provider root process is unavailable")
15578            })?;
15579            shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15580                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15581            })?.activate(
15582                &reservation_id, &activation_digest, &record_id, &session, provider_root_pid,
15583            ).map_err(harness_mcp_failure)?;
15584            Ok(NodeResponse::Activated { reservation_id, activation_digest, record_id, session })
15585        }
15586        NodeRequest::AbortHarnessMcpReservation { reservation_id, activation_digest } => {
15587            shared.require_controller(connection_id, role)?;
15588            let instance_id = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15589                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15590            })?.abort(&reservation_id, &activation_digest).map_err(harness_mcp_failure)?;
15591            if let Some(instance_id) = instance_id {
15592                SessionLifecycleHook::on_harness_reservation_aborted(&*shared, instance_id);
15593            }
15594            Ok(NodeResponse::Aborted { reservation_id, activation_digest })
15595        }
15596        NodeRequest::PutHarnessMcpReplyChunk {
15597            reservation_id, activation_digest, record_id, session, call_id,
15598            offset, final_chunk, chunk_hex,
15599        } => {
15600            shared.require_controller(connection_id, role)?;
15601            let (next_offset, completed) = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15602                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15603            })?.put_reply_chunk(
15604                &reservation_id, &activation_digest, &record_id, &session, &call_id,
15605                offset, final_chunk, &chunk_hex,
15606            ).map_err(harness_mcp_failure)?;
15607            Ok(NodeResponse::ReplyChunkAccepted {
15608                reservation_id, activation_digest, record_id, session, call_id,
15609                next_offset, completed,
15610            })
15611        }
15612        NodeRequest::RejectHarnessMcpCall {
15613            reservation_id, activation_digest, record_id, session, call_id, reason,
15614        } => {
15615            shared.require_controller(connection_id, role)?;
15616            shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15617                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15618            })?.reject_call(
15619                &reservation_id, &activation_digest, &record_id, &session, &call_id, reason,
15620            ).map_err(harness_mcp_failure)?;
15621            Ok(NodeResponse::CallRejected {
15622                reservation_id, activation_digest, record_id, session, call_id,
15623            })
15624        }
15625        NodeRequest::BeginDeliveryStage { manifest } => {
15626            shared.require_controller(connection_id, role)?;
15627            shared.begin_delivery_stage(manifest)
15628        }
15629        NodeRequest::PutDeliveryBlobChunk {
15630            stage_id,
15631            blob_digest,
15632            offset,
15633            chunk_hex,
15634        } => {
15635            shared.require_controller(connection_id, role)?;
15636            shared.put_delivery_blob_chunk(stage_id, blob_digest, offset, chunk_hex)
15637        }
15638        NodeRequest::CommitDeliveryStage { stage_id } => {
15639            shared.require_controller(connection_id, role)?;
15640            let receipt = shared.commit_delivery_stage(stage_id)?;
15641            Ok(NodeResponse::DeliveryCommitted { receipt })
15642        }
15643        NodeRequest::AbortDeliveryStage { stage_id } => {
15644            shared.require_controller(connection_id, role)?;
15645            shared.abort_delivery_stage(&stage_id)?;
15646            Ok(NodeResponse::DeliveryStageAborted { stage_id })
15647        }
15648        NodeRequest::BrowseHostDirectories { directory, after } => {
15649            let listing = shared.browse_host_directories(directory, after).await?;
15650            Ok(NodeResponse::HostDirectoriesBrowsed { listing })
15651        }
15652        NodeRequest::InspectWorkspace { workspace_id } => {
15653            let inspection = shared.inspect_workspace(workspace_id).await?;
15654            Ok(NodeResponse::WorkspaceInspected { inspection })
15655        }
15656        NodeRequest::ReadWorkspaceFile { workspace_id, path } => {
15657            let file = shared.read_workspace_file(workspace_id, path).await?;
15658            Ok(NodeResponse::WorkspaceFileRead { file })
15659        }
15660        NodeRequest::WriteWorkspaceFile { workspace_id, path, expected_revision, text } => {
15661            shared.require_controller(connection_id, role)?;
15662            let file = shared
15663                .write_workspace_file(workspace_id, path, expected_revision, text)
15664                .await?;
15665            Ok(NodeResponse::WorkspaceFileWritten { file })
15666        }
15667        NodeRequest::CreateWorkspaceFile { workspace_id, path } => {
15668            shared.require_controller(connection_id, role)?;
15669            let file = shared.create_workspace_file(workspace_id, path).await?;
15670            Ok(NodeResponse::WorkspaceFileCreated { file })
15671        }
15672        NodeRequest::CreateWorkspaceDirectory { workspace_id, path } => {
15673            shared.require_controller(connection_id, role)?;
15674            let entry = shared
15675                .create_workspace_directory(workspace_id.clone(), path)
15676                .await?;
15677            Ok(NodeResponse::WorkspaceDirectoryCreated { workspace_id, entry })
15678        }
15679        NodeRequest::ReadGitHistory { workspace_id, path, before, limit } => {
15680            if role != ClientRole::Operator {
15681                return Err(failure(NodeFailureCode::ObserverReadOnly, "git history requires an operator connection"));
15682            }
15683            if !(1..=MAX_GIT_HISTORY_COMMITS).contains(&limit) {
15684                return Err(failure(NodeFailureCode::InvalidRequest, "git history limit is invalid"));
15685            }
15686            let page = shared
15687                .read_git_history(workspace_id.clone(), path, before, limit)
15688                .await?;
15689            Ok(NodeResponse::GitHistoryRead { workspace_id, page })
15690        }
15691        NodeRequest::ReadGitDiff { workspace_id, request } => {
15692            if role != ClientRole::Operator {
15693                return Err(failure(NodeFailureCode::ObserverReadOnly, "git diff requires an operator connection"));
15694            }
15695            let diff = shared.read_git_diff(workspace_id.clone(), request).await?;
15696            Ok(NodeResponse::GitDiffRead { workspace_id, diff })
15697        }
15698        NodeRequest::CatalogNativeSessions { route, limit } => {
15699            if role != ClientRole::Operator {
15700                return Err(failure(
15701                    NodeFailureCode::ObserverReadOnly,
15702                    "native session catalog requires an operator connection",
15703                ));
15704            }
15705            if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit) {
15706                return Err(failure(
15707                    NodeFailureCode::InvalidRequest,
15708                    "native session catalog limit is invalid",
15709                ));
15710            }
15711            let (entries, summary) = shared
15712                .catalog_native_sessions(route.clone(), limit)
15713                .await?;
15714            Ok(NodeResponse::NativeSessionsCataloged {
15715                route,
15716                entries,
15717                summary: Some(summary),
15718            })
15719        }
15720        NodeRequest::PageNativeSessions {
15721            route,
15722            window,
15723            catalog_revision,
15724            recent_cutoff_unix_ms,
15725            after_selection_id,
15726            limit,
15727        } => {
15728            if role != ClientRole::Operator {
15729                return Err(failure(
15730                    NodeFailureCode::ObserverReadOnly,
15731                    "native session catalog paging requires an operator connection",
15732                ));
15733            }
15734            if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit)
15735                || after_selection_id
15736                    .as_deref()
15737                    .is_some_and(|cursor| validate_candidate_id(cursor).is_err())
15738            {
15739                return Err(failure(
15740                    NodeFailureCode::InvalidRequest,
15741                    "native session catalog page request is invalid",
15742                ));
15743            }
15744            let page = shared
15745                .page_native_sessions(
15746                    route.clone(),
15747                    window,
15748                    catalog_revision,
15749                    recent_cutoff_unix_ms,
15750                    after_selection_id,
15751                    limit,
15752                )
15753                .await?;
15754            Ok(NodeResponse::NativeSessionsPaged {
15755                route,
15756                page,
15757            })
15758        }
15759        NodeRequest::PreviewNativeSession {
15760            selection,
15761            message_limit,
15762        } => {
15763            if role != ClientRole::Operator {
15764                return Err(failure(
15765                    NodeFailureCode::ObserverReadOnly,
15766                    "native session preview requires an operator connection",
15767                ));
15768            }
15769            if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
15770                .contains(&message_limit)
15771                || selection.validate().is_err()
15772            {
15773                return Err(failure(
15774                    NodeFailureCode::InvalidRequest,
15775                    "native session preview request is invalid",
15776                ));
15777            }
15778            let preview = shared
15779                .preview_native_session(selection.clone(), message_limit)
15780                .await?;
15781            Ok(NodeResponse::NativeSessionPreviewed {
15782                selection,
15783                preview,
15784            })
15785        }
15786        NodeRequest::IndexNativeSession {
15787            selection,
15788            display_name,
15789        } => {
15790            shared.require_controller(connection_id, role)?;
15791            let record = shared
15792                .index_native_session(selection.clone(), display_name)
15793                .await?;
15794            Ok(NodeResponse::NativeSessionIndexed { selection, record })
15795        }
15796        NodeRequest::PreviewSessionRecord {
15797            record_id,
15798            message_limit,
15799        } => {
15800            if role != ClientRole::Operator {
15801                return Err(failure(
15802                    NodeFailureCode::ObserverReadOnly,
15803                    "native session preview requires an operator connection",
15804                ));
15805            }
15806            if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
15807                .contains(&message_limit)
15808            {
15809                return Err(failure(
15810                    NodeFailureCode::InvalidRequest,
15811                    "native session preview request is invalid",
15812                ));
15813            }
15814            let record = shared.record(&record_id)?;
15815            let working_directory = shared.workspace_root(&record.workspace_id)?;
15816            if !platform::roots_equal(
15817                &working_directory,
15818                windows_path_text(&record.canonical_root),
15819            ) {
15820                return Err(failure(
15821                    NodeFailureCode::SessionRecordConflict,
15822                    "managed session workspace root changed; refusing to preview another directory",
15823                ));
15824            }
15825            let identity = record.provider_session.clone().ok_or_else(|| {
15826                failure(
15827                    NodeFailureCode::BackendOperationFailed,
15828                    "native session preview is unavailable for this session record",
15829                )
15830            })?;
15831            if identity.key != ProviderSessionKey::SessionId {
15832                return Err(failure(
15833                    NodeFailureCode::BackendOperationFailed,
15834                    "native session preview is unavailable for this session identity",
15835                ));
15836            }
15837            let preview = shared
15838                .preview_native_session_by_session_id(
15839                    record.workspace_id.clone(), record.provider.clone(), identity.id.clone(), message_limit,
15840                )
15841                .await?;
15842            if preview.session_id != identity.id {
15843                return Err(failure(
15844                    NodeFailureCode::SessionRecordConflict,
15845                    "native session preview identity changed while loading",
15846                ));
15847            }
15848            shared.revalidate_session_record_preview(&record, &identity)?;
15849            let preview: gate4agent_types::SessionRecordPreview = preview.into();
15850            shared.publish(NodeEvent::SessionRecordHistorySummarized {
15851                record_id: record_id.clone(),
15852                summary: SessionHistorySummaryV1::from(&preview),
15853            });
15854            Ok(NodeResponse::SessionRecordPreviewed {
15855                record_id,
15856                preview,
15857            })
15858        }
15859        NodeRequest::AcquireController { lease_ms } => {
15860            let controller = shared.acquire_controller(connection_id, role, lease_ms)?;
15861            Ok(NodeResponse::Controller { controller: Some(controller) })
15862        }
15863        NodeRequest::ReleaseController => {
15864            shared.release_controller(connection_id);
15865            Ok(NodeResponse::Controller { controller: shared.controller_state() })
15866        }
15867        NodeRequest::RegisterWorkspace { workspace_id, root } => {
15868            shared.require_controller(connection_id, role)?;
15869            let root = require_windows_path(root)?;
15870            shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
15871            let workspace = shared.register_workspace(workspace_id, root).await?;
15872            Ok(NodeResponse::WorkspaceRegistered { workspace })
15873        }
15874        NodeRequest::CreateStandaloneWorkspace {
15875            workspace_id,
15876            root,
15877            initial_branch,
15878        } => {
15879            shared.require_controller(connection_id, role)?;
15880            let root = require_windows_path(root)?;
15881            shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
15882            let workspace = shared
15883                .create_standalone_workspace(workspace_id, root, initial_branch)
15884                .await?;
15885            Ok(NodeResponse::StandaloneWorkspaceCreated { workspace })
15886        }
15887        NodeRequest::UnregisterWorkspace { workspace_id } => {
15888            shared.require_controller(connection_id, role)?;
15889            shared.reject_managed_reservation(Some(&workspace_id), None)?;
15890            shared.unregister_workspace(&workspace_id)?;
15891            Ok(NodeResponse::WorkspaceUnregistered { workspace_id })
15892        }
15893        NodeRequest::CreateWorktree {
15894            source_workspace_id,
15895            workspace_id,
15896            target_root,
15897            branch,
15898            base,
15899        } => {
15900            shared.require_controller(connection_id, role)?;
15901            let target_root = require_windows_path(target_root)?;
15902            shared.reject_managed_reservation(Some(&workspace_id), Some(&target_root))?;
15903            let (worktree, workspace) = shared
15904                .create_worktree(
15905                    source_workspace_id,
15906                    workspace_id,
15907                    target_root,
15908                    branch,
15909                    base,
15910                )
15911                .await?;
15912            Ok(NodeResponse::WorktreeCreated { worktree, workspace })
15913        }
15914        NodeRequest::RemoveWorktree {
15915            source_workspace_id,
15916            target_root,
15917        } => {
15918            shared.require_controller(connection_id, role)?;
15919            let native_target_root = require_windows_path(target_root.clone())?;
15920            shared.reject_managed_reservation(None, Some(&native_target_root))?;
15921            let workspace_id = shared
15922                .remove_worktree(source_workspace_id, native_target_root)
15923                .await?;
15924            Ok(NodeResponse::WorktreeRemoved {
15925                target_root,
15926                workspace_id,
15927            })
15928        }
15929        NodeRequest::Spawn { workspace_id, provider, mode, terminal_size, initial_prompt } => {
15930            shared.require_controller(connection_id, role)?;
15931            if !terminal_size.is_valid() {
15932                return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
15933            }
15934            if let Some(prompt) = initial_prompt.as_deref() {
15935                validate_node_text("spawn initial prompt", prompt)?;
15936            }
15937            let session = shared
15938                .spawn_session(workspace_id, provider, mode, terminal_size, initial_prompt)
15939                .await?;
15940            Ok(NodeResponse::SpawnAccepted { session })
15941        }
15942        NodeRequest::SpawnSpec { spec } => {
15943            shared.require_controller(connection_id, role)?;
15944            let receipt = shared.spawn_from_spec(spec).await?;
15945            Ok(NodeResponse::SpawnSpecAccepted { receipt })
15946        }
15947        NodeRequest::SpawnManagedWorktree { request } => {
15948            shared.require_controller(connection_id, role)?;
15949            let receipt = shared.spawn_managed_worktree(request).await?;
15950            Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
15951        }
15952        NodeRequest::SpawnManagedWorktreeV2 { request } => {
15953            shared.require_controller(connection_id, role)?;
15954            let receipt = shared.spawn_managed_worktree_v2(request).await?;
15955            Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
15956        }
15957        NodeRequest::CleanupManagedWorktree { lease_id } => {
15958            shared.require_controller(connection_id, role)?;
15959            let lease = shared.cleanup_managed_worktree(&lease_id, true).await?;
15960            Ok(NodeResponse::ManagedWorktreeCleanup { lease })
15961        }
15962        NodeRequest::DiscoverHistory { session, limit } => {
15963            controlled_session(shared, connection_id, role, &session)?;
15964            let candidates = shared.discover_history(&session, limit).await?;
15965            Ok(NodeResponse::HistoryDiscovered {
15966                session,
15967                candidates,
15968            })
15969        }
15970        NodeRequest::LoadHistory {
15971            session,
15972            candidate_id,
15973        } => {
15974            controlled_session(shared, connection_id, role, &session)?;
15975            let loaded = shared
15976                .load_history(&session, candidate_id)
15977                .await?;
15978            Ok(NodeResponse::HistoryLoaded {
15979                session,
15980                session_id: loaded.session_id,
15981                message_count: loaded.message_count,
15982                completed_turn_count: loaded.completed_turn_count,
15983            })
15984        }
15985        NodeRequest::ExportContextPackForSessionRecord { record_id, session } => {
15986            shared.require_controller(connection_id, role)?;
15987            let context = shared
15988                .export_context_pack_for_session_record(&record_id, &session, false)
15989                .await?;
15990            Ok(NodeResponse::ContextPackForSessionRecordExported {
15991                record_id,
15992                session,
15993                context,
15994            })
15995        }
15996        NodeRequest::ExportContextPack { session } => {
15997            controlled_session(shared, connection_id, role, &session)?;
15998            let context = shared.export_context_pack(&session).await?;
15999            Ok(NodeResponse::ContextPackExported { context })
16000        }
16001        NodeRequest::ForgetContextPack { context_id } => {
16002            shared.require_controller(connection_id, role)?;
16003            shared.forget_context_pack(&context_id)?;
16004            Ok(NodeResponse::ContextPackForgotten { context_id })
16005        }
16006        NodeRequest::ResolveDurableContextPack { context_id } => {
16007            // Read-only, idempotent, no controller lease required: this lets
16008            // a caller preflight "is this durably exported pack still here"
16009            // without holding the Node's mutation controller.
16010            let context = shared
16011                .context_catalog
16012                .read()
16013                .unwrap_or_else(|poisoned| poisoned.into_inner())
16014                .get(&context_id)
16015                .map(|pack| pack.receipt().clone())
16016                .ok_or_else(|| {
16017                    failure(
16018                        NodeFailureCode::UnknownContextPack,
16019                        "context pack is not present",
16020                    )
16021                })?;
16022            Ok(NodeResponse::DurableContextPackResolved { context })
16023        }
16024        NodeRequest::ReadContextPack { digest } => {
16025            // Read-only fetch of the pack's own bytes, keyed by content digest
16026            // rather than the caller's `SpawnContextId`: same no-controller-lease
16027            // rationale as `ResolveDurableContextPack` above, one step further
16028            // down the same catalog.
16029            let (id, byte_len, bytes) = shared
16030                .context_catalog
16031                .read()
16032                .unwrap_or_else(|poisoned| poisoned.into_inner())
16033                .get_by_digest(&digest)
16034                .map(|pack| {
16035                    (pack.receipt().id.clone(), pack.receipt().byte_len, pack.bytes().to_vec())
16036                })
16037                .ok_or_else(|| {
16038                    failure(
16039                        NodeFailureCode::UnknownContextPack,
16040                        &format!("context pack {digest} not on this node"),
16041                    )
16042                })?;
16043            tracing::debug!(
16044                digest = digest.as_str(),
16045                byte_len,
16046                "read context pack bytes",
16047            );
16048            Ok(NodeResponse::ContextPackBytesRead {
16049                pack: ContextPackBytesRead {
16050                    digest,
16051                    id,
16052                    byte_len,
16053                    bytes,
16054                },
16055            })
16056        }
16057        NodeRequest::Resume { session, terminal_size, initial_prompt } => {
16058            let provider = controlled_session(shared, connection_id, role, &session)?;
16059            if let Some(prompt) = initial_prompt.as_deref() {
16060                validate_node_text("resume initial prompt", prompt)?;
16061            }
16062            let runtime_requirement = if initial_prompt.is_some() {
16063                ProviderRuntimeRequirement::ResumeWithPrompt
16064            } else {
16065                ProviderRuntimeRequirement::Resume
16066            };
16067            shared.require_session_runtime_policy(&session, runtime_requirement)?;
16068            let runtime_policy = shared
16069                .admit_provider_runtime(&provider, runtime_requirement)
16070                .await?;
16071            let working_directory = shared.bound_workspace_root(&session)?;
16072            let request = ResumeLaunchRequest {
16073                working_directory,
16074                terminal_size,
16075                initial_prompt,
16076            };
16077            request
16078                .validate()
16079                .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
16080            let command = shared.prepare_command(ControlCommand::Resume {
16081                instance_id: session.session.instance_id,
16082                target: ResumeTarget::CurrentProvider,
16083                runtime_policy,
16084                request,
16085            });
16086            shared.arm_resume(&session, command.id, runtime_policy)?;
16087            let dispatch = shared.dispatch_envelope(command);
16088            if let Err(error) = dispatch {
16089                shared.clear_armed_resume(&session);
16090                return Err(error);
16091            }
16092            Ok(NodeResponse::Accepted)
16093        }
16094        NodeRequest::RenameSessionRecord {
16095            record_id,
16096            display_name,
16097        } => {
16098            shared.require_controller(connection_id, role)?;
16099            let record = shared.rename_session_record(&record_id, display_name)?;
16100            Ok(NodeResponse::SessionRecordUpdated { record })
16101        }
16102        NodeRequest::SetSessionTask {
16103            record_id,
16104            expected_revision,
16105            target,
16106        } => {
16107            shared.require_controller(connection_id, role)?;
16108            let record = shared.set_session_task(&record_id, expected_revision, target)?;
16109            Ok(NodeResponse::SessionRecordUpdated { record })
16110        }
16111        NodeRequest::IndexProviderSession {
16112            workspace_id,
16113            provider,
16114            identity,
16115            display_name,
16116        } => {
16117            shared.require_controller(connection_id, role)?;
16118            let record = shared.index_provider_session(
16119                workspace_id,
16120                provider,
16121                identity,
16122                display_name,
16123            )?;
16124            Ok(NodeResponse::ProviderSessionIndexed { record })
16125        }
16126        NodeRequest::ResumeSessionRecord {
16127            record_id,
16128            terminal_size,
16129            initial_prompt,
16130        } => {
16131            shared.require_controller(connection_id, role)?;
16132            if let Some(prompt) = initial_prompt.as_deref() {
16133                validate_node_text("managed resume initial prompt", prompt)?;
16134            }
16135            let (record, session) = shared
16136                .resume_session_record(
16137                    &record_id,
16138                    terminal_size,
16139                    initial_prompt,
16140                )
16141                .await?;
16142            Ok(NodeResponse::SessionRecordResumed { record, session })
16143        }
16144        NodeRequest::ForgetSessionRecord { record_id } => {
16145            shared.require_controller(connection_id, role)?;
16146            shared.forget_session_record(&record_id).await?;
16147            Ok(NodeResponse::SessionRecordForgotten { record_id })
16148        }
16149        NodeRequest::Prompt { session, text } => {
16150            let agent_id = controlled_session(shared, connection_id, role, &session)?;
16151            validate_node_text("prompt", &text)?;
16152            shared.require_session_runtime_policy(
16153                &session,
16154                ProviderRuntimeRequirement::SemanticPrompt,
16155            )?;
16156            let framing = prompt_framing(&agent_id);
16157            shared
16158                .dispatch_input_bounded(
16159                    &session,
16160                    InputAction::SubmitPrompt(PromptPayload { text, framing }),
16161                )
16162                .await?;
16163            Ok(NodeResponse::Accepted)
16164        }
16165        NodeRequest::Paste { session, text } => {
16166            controlled_session(shared, connection_id, role, &session)?;
16167            validate_node_text("paste", &text)?;
16168            shared.require_session_runtime_policy(
16169                &session,
16170                ProviderRuntimeRequirement::SemanticPrompt,
16171            )?;
16172            shared
16173                .dispatch_input_bounded(
16174                    &session,
16175                    InputAction::InsertDraft(PromptPayload {
16176                        text,
16177                        framing: PromptFraming::BracketedPaste,
16178                    }),
16179                )
16180                .await?;
16181            Ok(NodeResponse::Accepted)
16182        }
16183        NodeRequest::Input { session, text } => {
16184            controlled_session(shared, connection_id, role, &session)?;
16185            validate_node_text("terminal input", &text)?;
16186            shared
16187                .dispatch_input_bounded(
16188                    &session,
16189                    InputAction::TerminalText(TerminalText { text }),
16190                )
16191                .await?;
16192            Ok(NodeResponse::Accepted)
16193        }
16194        NodeRequest::TerminalBytes { session, bytes } => {
16195            controlled_session(shared, connection_id, role, &session)?;
16196            let action = terminal_bytes_action(bytes)?;
16197            shared
16198                .dispatch_input_bounded(&session, action)
16199                .await?;
16200            Ok(NodeResponse::Accepted)
16201        }
16202        NodeRequest::TerminalControl { session, control } => {
16203            controlled_session(shared, connection_id, role, &session)?;
16204            shared
16205                .dispatch_input_bounded(&session, InputAction::TerminalControl(control))
16206                .await?;
16207            Ok(NodeResponse::Accepted)
16208        }
16209        NodeRequest::Resize { session, size } => {
16210            controlled_session(shared, connection_id, role, &session)?;
16211            if !size.is_valid() {
16212                return Err(failure(NodeFailureCode::InvalidRequest, "terminal size is invalid"));
16213            }
16214            shared.dispatch_resize_bounded(&session, size).await?;
16215            Ok(NodeResponse::Accepted)
16216        }
16217        NodeRequest::Interrupt { session } => {
16218            controlled_session(shared, connection_id, role, &session)?;
16219            shared
16220                .dispatch_input_bounded(
16221                    &session,
16222                    InputAction::TerminalControl(TerminalControl::Interrupt),
16223                )
16224                .await?;
16225            Ok(NodeResponse::Accepted)
16226        }
16227        NodeRequest::Stop { session, force } => {
16228            controlled_session(shared, connection_id, role, &session)?;
16229            shared.dispatch(ControlCommand::Stop { instance_id: session.session.instance_id, force })?;
16230            Ok(NodeResponse::Accepted)
16231        }
16232        NodeRequest::Remove { session } => {
16233            controlled_session(shared, connection_id, role, &session)?;
16234            shared.remove_session(&session).await?;
16235            Ok(NodeResponse::Accepted)
16236        }
16237        NodeRequest::ResolveInteraction { session, correlation_id, response } => {
16238            shared.require_controller(connection_id, role)?;
16239            let snapshot = shared.require_acp_session(&session)?;
16240            let interaction = snapshot
16241                .provider
16242                .interactions
16243                .iter()
16244                .find(|interaction| {
16245                    interaction_correlation(
16246                        session.session.instance_id,
16247                        session.session.generation,
16248                        interaction.id.0,
16249                    ) == correlation_id
16250                })
16251                .ok_or_else(|| failure(
16252                    NodeFailureCode::InvalidRequest,
16253                    "unknown interaction correlation",
16254                ))?;
16255            if interaction.status != ProviderInteractionStatus::Pending {
16256                return Err(failure(
16257                    NodeFailureCode::BackendOperationFailed,
16258                    "interaction has already been resolved",
16259                ));
16260            }
16261            response
16262                .validate_for(interaction.interaction_kind)
16263                .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
16264            let interaction_id = interaction.id;
16265            let response_kind = response.kind();
16266            let response_outcome = response.outcome();
16267            shared.dispatch(ControlCommand::ResolveInteraction {
16268                instance_id: session.session.instance_id,
16269                generation: session.session.generation,
16270                interaction_id,
16271                response,
16272            })?;
16273            // The one line that lets an operator compare what THIS node
16274            // recorded against what the provider itself reports doing --
16275            // measured live on a kimi run where our side resolved `true`
16276            // while the agent reported "rejected" and neither layer had a
16277            // line to line up against the other.
16278            tracing::info!(
16279                node_id = %shared.node_id,
16280                session = ?session.session,
16281                correlation_id = %correlation_id,
16282                interaction_id = interaction_id.0,
16283                response_kind = ?response_kind,
16284                outcome = ?response_outcome,
16285                "interaction resolved",
16286            );
16287            Ok(NodeResponse::Accepted)
16288        }
16289        NodeRequest::SetSessionMode { session, mode_id } => {
16290            shared.require_controller(connection_id, role)?;
16291            shared.require_acp_session(&session)?;
16292            shared.dispatch(ControlCommand::SetSessionMode {
16293                instance_id: session.session.instance_id,
16294                mode_id,
16295            })?;
16296            Ok(NodeResponse::Accepted)
16297        }
16298        NodeRequest::SetSessionConfigOption {
16299            session,
16300            option_id,
16301            value_json,
16302        } => {
16303            shared.require_controller(connection_id, role)?;
16304            shared.require_acp_session(&session)?;
16305            shared.dispatch(ControlCommand::SetSessionConfigOption {
16306                instance_id: session.session.instance_id,
16307                option_id,
16308                value_json,
16309            })?;
16310            Ok(NodeResponse::Accepted)
16311        }
16312        NodeRequest::SetSessionModel { session, model_id } => {
16313            shared.require_controller(connection_id, role)?;
16314            shared.require_acp_session(&session)?;
16315            shared.dispatch(ControlCommand::SetSessionModel {
16316                instance_id: session.session.instance_id,
16317                model_id,
16318            })?;
16319            Ok(NodeResponse::Accepted)
16320        }
16321        NodeRequest::Shutdown => {
16322            shared.require_controller(connection_id, role)?;
16323            shared.begin_shutdown_locked().await?;
16324            Ok(NodeResponse::ShuttingDown)
16325        }
16326    }
16327}
16328
16329fn controlled_session(shared: &NodeShared, connection_id: u64, role: ClientRole, session: &SessionAddress) -> Result<AgentId, NodeFailure> {
16330    shared.require_controller(connection_id, role)?;
16331    shared.validate_address(session)
16332}
16333
16334/// Selects the sole history candidate matching `provider_session_id` when
16335/// the managed record carries a verified identity, or the sole candidate in
16336/// the discovered set at all when it does not (see
16337/// `export_context_pack_for_session_record_inner`'s identity match). Either
16338/// way, more than one match refuses instead of guessing which one is exact.
16339fn select_history_candidate(
16340    candidates: &[HistoryCandidateSummary],
16341    provider_session_id: Option<&str>,
16342) -> Result<String, NodeFailure> {
16343    let mut matches = candidates.iter().filter(|candidate| match provider_session_id {
16344        Some(id) => candidate.session_id_hint == id,
16345        None => true,
16346    });
16347    let candidate = matches.next().ok_or_else(|| {
16348        failure(
16349            NodeFailureCode::SessionRecordConflict,
16350            if provider_session_id.is_some() {
16351                "no history candidate matches the managed provider session"
16352            } else {
16353                "no history candidate exists for the managed session"
16354            },
16355        )
16356    })?;
16357    if matches.next().is_some() {
16358        return Err(failure(
16359            NodeFailureCode::SessionRecordConflict,
16360            if provider_session_id.is_some() {
16361                "history identity is ambiguous for the managed provider session"
16362            } else {
16363                "history is ambiguous without a verified provider session identity"
16364            },
16365        ));
16366    }
16367    Ok(candidate.id.clone())
16368}
16369
16370fn resolved_spawn_profile_summary(
16371    profile: &SpawnProfileDefaults,
16372    environment_profiles: &BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>,
16373) -> Option<SpawnProfileSummary> {
16374    let environment_profile = match profile.environment_profile_id.as_ref() {
16375        None => None,
16376        Some(profile_id) => {
16377            let binding = environment_profiles.get(profile_id)?;
16378            if binding.provider != profile.provider
16379                || binding.native_profile_id(profile.mode).is_none()
16380            {
16381                return None;
16382            }
16383            Some(ResolvedEnvironmentProfileReceipt {
16384                profile_id: binding.id.clone(),
16385                profile_revision: binding.revision.clone(),
16386                network_allowlist: None,
16387                browser_profile_id: None,
16388            })
16389        }
16390    };
16391    Some(SpawnProfileSummary {
16392        id: profile.profile_id.clone(),
16393        revision: profile.revision.clone(),
16394        environment_profile,
16395    })
16396}
16397
16398/// True if `current` is an acceptable version of `expected` at export
16399/// revalidation/commit time.
16400///
16401/// When `allow_clean_detachment` is `false` (the on-demand,
16402/// `NodeRequest::ExportContextPackForSessionRecord` API path), `current` must
16403/// be byte-identical to `expected` and still actively bound to `session` —
16404/// the original, unchanged invariant.
16405///
16406/// When `true` (the reactive auto-export-at-exit path, `§2.1`), `current` may
16407/// ALSO be the exact Live -> Dormant transition that the same clean exit this
16408/// export reacts to produces on its own: `state` Live -> Dormant,
16409/// `active_session` `Some(session)` -> `None`, `updated_at_unix_ms`
16410/// advancing. Every other field — including `provider_session`, `context`,
16411/// `bundle`, `task_binding` — must still match exactly. A record that changed
16412/// in any other way (rebind, a different active session, an identity
16413/// conflict, capacity eviction) is rejected either way; this widens nothing
16414/// about which sessions are eligible, only how the record's own expected
16415/// detachment is tolerated mid-export.
16416fn session_record_export_target_matches(
16417    expected: &ManagedSessionRecord,
16418    current: &ManagedSessionRecord,
16419    session: &SessionAddress,
16420    allow_clean_detachment: bool,
16421) -> bool {
16422    if current == expected && current.active_session.as_ref() == Some(session) {
16423        return current.state == ManagedSessionState::Live;
16424    }
16425    allow_clean_detachment
16426        && current.state == ManagedSessionState::Dormant
16427        && current.active_session.is_none()
16428        && current.record_id == expected.record_id
16429        && current.display_name == expected.display_name
16430        && current.provider == expected.provider
16431        && current.mode == expected.mode
16432        && current.workspace_id == expected.workspace_id
16433        && current.canonical_root == expected.canonical_root
16434        && current.provider_session == expected.provider_session
16435        && current.environment_profile == expected.environment_profile
16436        && current.bundle == expected.bundle
16437        && current.context_id == expected.context_id
16438        && current.context == expected.context
16439        && current.exported_context == expected.exported_context
16440        && current.task_binding == expected.task_binding
16441        && current.created_at_unix_ms == expected.created_at_unix_ms
16442        && current.last_error == expected.last_error
16443}
16444
16445fn session_record_context_export_binding_is_exact(
16446    expected: &ManagedSessionRecord,
16447    current: &ManagedSessionRecord,
16448    identity: Option<&ProviderSessionIdentity>,
16449    session: &SessionAddress,
16450    binding: &SessionBinding,
16451    provider: &AgentId,
16452    current_root: &str,
16453    allow_clean_detachment: bool,
16454) -> bool {
16455    session_record_export_target_matches(expected, current, session, allow_clean_detachment)
16456        && current.provider_session.as_ref() == identity
16457        && current.workspace_id == session.workspace_id
16458        && provider == &current.provider
16459        && binding.workspace_id == session.workspace_id
16460        && binding.generation == session.session.generation
16461        && binding.record_id.as_ref() == Some(&current.record_id)
16462        && platform::roots_equal(
16463            current_root,
16464            windows_path_text(&current.canonical_root),
16465        )
16466}
16467
16468fn session_record_context_export_source_is_usable(
16469    record: &ManagedSessionRecord,
16470    status: &SessionStatus,
16471    allow_clean_detachment: bool,
16472) -> bool {
16473    (record.state == ManagedSessionState::Live
16474        || (allow_clean_detachment && record.state == ManagedSessionState::Dormant))
16475        && context_pack_source_status_is_usable(status)
16476}
16477
16478fn prompt_framing(_agent_id: &AgentId) -> PromptFraming {
16479    PromptFraming::BracketedPaste
16480}
16481
16482fn validate_node_text(field: &str, text: &str) -> Result<(), NodeFailure> {
16483    if text.len() > MAX_NODE_TEXT_BYTES {
16484        return Err(failure(
16485            NodeFailureCode::InvalidRequest,
16486            &format!(
16487                "{field} length {} exceeds the {MAX_NODE_TEXT_BYTES}-byte node limit",
16488                text.len(),
16489            ),
16490        ));
16491    }
16492    Ok(())
16493}
16494
16495fn validate_terminal_bytes(bytes: &[u8]) -> Result<(), NodeFailure> {
16496    if bytes.is_empty() || bytes.len() > MAX_NODE_TERMINAL_BYTES {
16497        return Err(failure(
16498            NodeFailureCode::InvalidRequest,
16499            &format!(
16500                "terminal byte sequence must contain 1..={MAX_NODE_TERMINAL_BYTES} bytes; received {}",
16501                bytes.len(),
16502            ),
16503        ));
16504    }
16505    Ok(())
16506}
16507
16508fn terminal_bytes_action(bytes: Vec<u8>) -> Result<InputAction, NodeFailure> {
16509    validate_terminal_bytes(&bytes)?;
16510    Ok(InputAction::TerminalBytes(bytes))
16511}
16512
16513fn validate_workspace_request_root(root: &str) -> Result<(), NodeFailure> {
16514    if root.is_empty()
16515        || root.len() > MAX_WORKSPACE_ROOT_BYTES
16516        || root.chars().any(char::is_control)
16517    {
16518        return Err(failure(
16519            NodeFailureCode::InvalidWorkspaceRoot,
16520            &format!(
16521                "workspace root must contain 1..={MAX_WORKSPACE_ROOT_BYTES} bytes and no control characters",
16522            ),
16523        ));
16524    }
16525    Ok(())
16526}
16527
16528fn canonical_standalone_workspace_candidate(
16529    workspace_id: &WorkspaceId,
16530    root: &str,
16531) -> Result<String, NodeServerError> {
16532    let path = Path::new(root);
16533    match std::fs::symlink_metadata(path) {
16534        Ok(_) => WorkspaceConfig::new(workspace_id.clone(), path)
16535            .map(|workspace| workspace.canonical_root),
16536        Err(error) if error.kind() == io::ErrorKind::NotFound => {
16537            if !path.is_absolute() {
16538                return Err(NodeServerError::InvalidWorkspaceRoot {
16539                    workspace_id: workspace_id.clone(),
16540                    path: root.to_owned(),
16541                    message: "path is not absolute".to_owned(),
16542                });
16543            }
16544            let name = path.file_name().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
16545                workspace_id: workspace_id.clone(),
16546                path: root.to_owned(),
16547                message: "path has no standalone directory name".to_owned(),
16548            })?;
16549            let parent = path.parent().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
16550                workspace_id: workspace_id.clone(),
16551                path: root.to_owned(),
16552                message: "path has no parent directory".to_owned(),
16553            })?;
16554            let canonical = std::fs::canonicalize(parent)
16555                .map(|parent| parent.join(name))
16556                .map_err(|error| NodeServerError::InvalidWorkspaceRoot {
16557                    workspace_id: workspace_id.clone(),
16558                    path: root.to_owned(),
16559                    message: error.to_string(),
16560                })?;
16561            let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
16562                NodeServerError::InvalidWorkspaceRoot {
16563                    workspace_id: workspace_id.clone(),
16564                    path: path.to_string_lossy().into_owned(),
16565                    message: "canonical path is not valid Unicode".to_owned(),
16566                }
16567            })?;
16568            let canonical_root = platform::normalize_canonical_root(canonical_root);
16569            validate_workspace_root(workspace_id, &canonical_root)?;
16570            Ok(canonical_root)
16571        }
16572        Err(error) => Err(NodeServerError::InvalidWorkspaceRoot {
16573            workspace_id: workspace_id.clone(),
16574            path: root.to_owned(),
16575            message: error.to_string(),
16576        }),
16577    }
16578}
16579
16580fn validate_git_revision(field: &str, revision: Option<&str>) -> Result<(), NodeFailure> {
16581    let Some(revision) = revision else {
16582        return Ok(());
16583    };
16584    if revision.is_empty()
16585        || revision.len() > MAX_REPOSITORY_PATH_BYTES
16586        || revision.chars().any(char::is_control)
16587    {
16588        return Err(failure(
16589            NodeFailureCode::InvalidRequest,
16590            &format!(
16591                "{field} must contain 1..={MAX_REPOSITORY_PATH_BYTES} bytes and no control characters",
16592            ),
16593        ));
16594    }
16595    Ok(())
16596}
16597
16598fn git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
16599    let code = match error.kind {
16600        GitWorktreeErrorKind::Invalid => NodeFailureCode::InvalidRequest,
16601        GitWorktreeErrorKind::NotRepository => NodeFailureCode::NotGitRepository,
16602        GitWorktreeErrorKind::Conflict => NodeFailureCode::WorktreeConflict,
16603        GitWorktreeErrorKind::Protected => NodeFailureCode::WorktreeProtected,
16604        GitWorktreeErrorKind::Dirty => NodeFailureCode::WorktreeDirty,
16605        GitWorktreeErrorKind::Locked => NodeFailureCode::WorktreeLocked,
16606        GitWorktreeErrorKind::Failed => NodeFailureCode::BackendOperationFailed,
16607    };
16608    failure(code, &error.message)
16609}
16610
16611fn standalone_workspace_failure(error: StandaloneWorkspaceError) -> NodeFailure {
16612    let code = match error.kind {
16613        StandaloneWorkspaceErrorKind::InvalidRoot => NodeFailureCode::InvalidWorkspaceRoot,
16614        StandaloneWorkspaceErrorKind::InvalidInitialBranch => NodeFailureCode::InvalidRequest,
16615        StandaloneWorkspaceErrorKind::GitInitializationFailed => {
16616            NodeFailureCode::BackendOperationFailed
16617        }
16618        StandaloneWorkspaceErrorKind::RecoveryRequired => {
16619            NodeFailureCode::StandaloneWorkspaceRecoveryRequired
16620        }
16621    };
16622    failure(code, error.message)
16623}
16624
16625fn managed_git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
16626    let (code, message) = match error.kind {
16627        GitWorktreeErrorKind::Invalid => (
16628            NodeFailureCode::InvalidRequest,
16629            "managed Git input is invalid",
16630        ),
16631        GitWorktreeErrorKind::NotRepository => (
16632            NodeFailureCode::NotGitRepository,
16633            "managed source is not a Git repository",
16634        ),
16635        GitWorktreeErrorKind::Conflict => (
16636            NodeFailureCode::WorktreeConflict,
16637            "managed Git identity conflicts with existing repository state",
16638        ),
16639        GitWorktreeErrorKind::Protected => (
16640            NodeFailureCode::ManagedWorktreeOwnershipConflict,
16641            "managed worktree ownership validation failed",
16642        ),
16643        GitWorktreeErrorKind::Dirty => (
16644            NodeFailureCode::WorktreeDirty,
16645            "managed worktree contains uncommitted changes",
16646        ),
16647        GitWorktreeErrorKind::Locked => (
16648            NodeFailureCode::WorktreeLocked,
16649            "managed worktree is locked",
16650        ),
16651        GitWorktreeErrorKind::Failed => (
16652            NodeFailureCode::BackendOperationFailed,
16653            "managed Git operation failed",
16654        ),
16655    };
16656    NodeFailure { code, message: message.to_owned() }
16657}
16658
16659fn managed_spawn_receipt(
16660    resolved: &ResolvedSpawnSpec,
16661    incarnation_id: NodeIncarnationId,
16662    session: SessionAddress,
16663    lease: ManagedWorktreeLeaseSnapshot,
16664    environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
16665    bundle: Option<ResolvedBundleReceipt>,
16666    context: Option<ResolvedContextPackReceipt>,
16667) -> ManagedWorktreeSpawnReceipt {
16668    let mut managed = resolved.clone();
16669    managed.target.worktree_id = Some(lease.workspace_id.clone());
16670    ManagedWorktreeSpawnReceipt {
16671        spawn: managed.receipt_with_materialization(
16672            incarnation_id,
16673            session,
16674            environment_profile,
16675            bundle,
16676            context,
16677        ),
16678        lease,
16679    }
16680}
16681
16682fn validate_selected_worktree(
16683    source_root: &str,
16684    selected_root: &str,
16685    worktrees: &[NativeGitWorktreeSnapshot],
16686) -> Result<(), NodeFailure> {
16687    if !worktrees
16688        .iter()
16689        .any(|worktree| worktree_paths_equal(&worktree.path, source_root) && !worktree.is_bare)
16690    {
16691        return Err(failure(
16692            NodeFailureCode::WorktreeProtected,
16693            "source workspace is not an authoritative non-bare Git worktree root",
16694        ));
16695    }
16696    if !worktrees.iter().any(|worktree| {
16697        worktree_paths_equal(&worktree.path, selected_root)
16698            && !worktree.is_main
16699            && !worktree.is_bare
16700    }) {
16701        return Err(failure(
16702            NodeFailureCode::WorktreeProtected,
16703            "selected workspace is not an eligible Git worktree in the source repository",
16704        ));
16705    }
16706    Ok(())
16707}
16708
16709fn workspace_file_failure(error: WorkspaceFileReadError) -> NodeFailure {
16710    let code = match error.kind() {
16711        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
16712        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
16713            NodeFailureCode::RepositoryPathUnsafe
16714        }
16715        WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
16716        WorkspaceFileReadErrorKind::NotRegularFile => {
16717            NodeFailureCode::RepositoryFileNotRegular
16718        }
16719        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
16720            NodeFailureCode::RepositoryFileReadFailed
16721        }
16722        WorkspaceFileReadErrorKind::RevisionConflict => {
16723            NodeFailureCode::RepositoryFileRevisionConflict
16724        }
16725        WorkspaceFileReadErrorKind::AlreadyExists
16726        | WorkspaceFileReadErrorKind::ParentNotFound
16727        | WorkspaceFileReadErrorKind::ParentNotDirectory
16728        | WorkspaceFileReadErrorKind::Canceled => {
16729            NodeFailureCode::RepositoryFileReadFailed
16730        }
16731    };
16732    failure(code, "repository file read failed")
16733}
16734
16735fn workspace_file_write_failure(error: WorkspaceFileReadError) -> NodeFailure {
16736    let code = match error.kind() {
16737        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
16738        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
16739            NodeFailureCode::RepositoryPathUnsafe
16740        }
16741        WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
16742        WorkspaceFileReadErrorKind::NotRegularFile => {
16743            NodeFailureCode::RepositoryFileNotRegular
16744        }
16745        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
16746            NodeFailureCode::RepositoryFileWriteFailed
16747        }
16748        WorkspaceFileReadErrorKind::RevisionConflict => {
16749            NodeFailureCode::RepositoryFileRevisionConflict
16750        }
16751        WorkspaceFileReadErrorKind::AlreadyExists
16752        | WorkspaceFileReadErrorKind::ParentNotFound
16753        | WorkspaceFileReadErrorKind::ParentNotDirectory
16754        | WorkspaceFileReadErrorKind::Canceled => {
16755            NodeFailureCode::RepositoryFileWriteFailed
16756        }
16757    };
16758    failure(code, "repository file write failed")
16759}
16760
16761async fn settle_workspace_entry_create<T>(
16762    mut task: tokio::task::JoinHandle<Result<T, WorkspaceFileReadError>>,
16763    commit_state: Arc<AtomicU8>,
16764    deadline: Duration,
16765    timeout_message: &'static str,
16766    task_failure_message: &'static str,
16767) -> Result<T, NodeFailure> {
16768    let joined = match timeout(deadline, &mut task).await {
16769        Ok(joined) => joined,
16770        Err(_) => {
16771            if commit_state
16772                .compare_exchange(
16773                    WORKSPACE_ENTRY_CREATE_PENDING,
16774                    WORKSPACE_ENTRY_CREATE_CANCELED,
16775                    Ordering::AcqRel,
16776                    Ordering::Acquire,
16777                )
16778                .is_ok()
16779            {
16780                return Err(failure(
16781                    NodeFailureCode::RepositoryEntryCreateTimedOut,
16782                    timeout_message,
16783                ));
16784            }
16785            debug_assert_eq!(
16786                commit_state.load(Ordering::Acquire),
16787                WORKSPACE_ENTRY_CREATE_COMMITTING,
16788            );
16789            task.await
16790        }
16791    };
16792    joined
16793        .map_err(|_| {
16794            failure(
16795                NodeFailureCode::RepositoryEntryCreateFailed,
16796                task_failure_message,
16797            )
16798        })?
16799        .map_err(workspace_entry_create_failure)
16800}
16801
16802fn workspace_entry_create_failure(error: WorkspaceFileReadError) -> NodeFailure {
16803    let code = match error.kind() {
16804        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
16805        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
16806            NodeFailureCode::RepositoryPathUnsafe
16807        }
16808        WorkspaceFileReadErrorKind::AlreadyExists => {
16809            NodeFailureCode::RepositoryEntryAlreadyExists
16810        }
16811        WorkspaceFileReadErrorKind::ParentNotFound | WorkspaceFileReadErrorKind::NotFound => {
16812            NodeFailureCode::RepositoryParentNotFound
16813        }
16814        WorkspaceFileReadErrorKind::ParentNotDirectory
16815        | WorkspaceFileReadErrorKind::NotRegularFile => {
16816            NodeFailureCode::RepositoryParentNotDirectory
16817        }
16818        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
16819            NodeFailureCode::RepositoryEntryCreateFailed
16820        }
16821        WorkspaceFileReadErrorKind::RevisionConflict => {
16822            NodeFailureCode::RepositoryEntryCreateFailed
16823        }
16824        WorkspaceFileReadErrorKind::Canceled => {
16825            NodeFailureCode::RepositoryEntryCreateTimedOut
16826        }
16827    };
16828    failure(code, "repository entry create failed")
16829}
16830
16831fn workspace_file_revision(bytes: &[u8]) -> WorkspaceFileRevision {
16832    let value = digest(&SHA256, bytes)
16833        .as_ref()
16834        .iter()
16835        .map(|byte| format!("{byte:02x}"))
16836        .collect();
16837    WorkspaceFileRevision::new(value).expect("SHA-256 produces a valid workspace revision")
16838}
16839
16840fn managed_spawn_request_digest_v2(
16841    request: &ManagedWorktreeSpawnRequestV2,
16842) -> Result<String, NodeFailure> {
16843    let encoded = serde_json::to_vec(request).map_err(|_| {
16844        failure(
16845            NodeFailureCode::InvalidRequest,
16846            "managed worktree V2 request could not be canonicalized",
16847        )
16848    })?;
16849    let mut value = String::with_capacity(71);
16850    value.push_str("sha256:");
16851    for byte in digest(&SHA256, &encoded).as_ref() {
16852        use std::fmt::Write as _;
16853        write!(&mut value, "{byte:02x}").expect("writing to a String cannot fail");
16854    }
16855    Ok(value)
16856}
16857
16858fn native_session_preview_failure(error: NativeSessionPreviewError) -> NodeFailure {
16859    let code = match error {
16860        NativeSessionPreviewError::InvalidLimit => NodeFailureCode::InvalidRequest,
16861        NativeSessionPreviewError::WorkspaceUnavailable => NodeFailureCode::UnknownWorkspace,
16862        NativeSessionPreviewError::StaleCatalog => NodeFailureCode::StaleNativeSessionCatalog,
16863        NativeSessionPreviewError::UnsupportedProvider
16864        | NativeSessionPreviewError::PreviewUnavailable
16865        | NativeSessionPreviewError::SessionNotFound
16866        | NativeSessionPreviewError::AmbiguousSession => {
16867            NodeFailureCode::BackendOperationFailed
16868        }
16869    };
16870    failure(code, "native session preview failed")
16871}
16872
16873fn host_directory_failure(error: HostDirectoryBrowseError) -> NodeFailure {
16874    let code = match error.kind() {
16875        HostDirectoryBrowseErrorKind::Invalid => NodeFailureCode::HostDirectoryInvalid,
16876        HostDirectoryBrowseErrorKind::ReadFailed => NodeFailureCode::HostDirectoryReadFailed,
16877    };
16878    failure(code, "host directory browse failed")
16879}
16880
16881fn delivery_failure(error: DeliveryStoreError) -> NodeFailure {
16882    let code = match error {
16883        DeliveryStoreError::InvalidManifest => NodeFailureCode::DeliveryManifestInvalid,
16884        DeliveryStoreError::UnknownStage => NodeFailureCode::UnknownDeliveryStage,
16885        DeliveryStoreError::Capacity | DeliveryStoreError::StageConflict => {
16886            NodeFailureCode::DeliveryStageConflict
16887        }
16888        DeliveryStoreError::UnexpectedBlob => NodeFailureCode::DeliveryBlobUnexpected,
16889        DeliveryStoreError::ChunkOutOfOrder | DeliveryStoreError::ChunkOverflow => {
16890            NodeFailureCode::DeliveryChunkOutOfOrder
16891        }
16892        DeliveryStoreError::BlobDigestMismatch => {
16893            NodeFailureCode::DeliveryBlobDigestMismatch
16894        }
16895        DeliveryStoreError::BundleDigestMismatch => {
16896            NodeFailureCode::DeliveryBundleDigestMismatch
16897        }
16898        DeliveryStoreError::StageIncomplete => NodeFailureCode::DeliveryStageIncomplete,
16899        DeliveryStoreError::Unavailable
16900        | DeliveryStoreError::Corrupt
16901        | DeliveryStoreError::Storage(_) => NodeFailureCode::DeliveryStageStorageFailed,
16902    };
16903    failure(code, "delivery operation failed")
16904}
16905
16906fn validate_workspace_root(
16907    workspace_id: &WorkspaceId,
16908    canonical_root: &str,
16909) -> Result<(), NodeServerError> {
16910    #[cfg(windows)]
16911    if canonical_root.starts_with(r"\\") {
16912        return Err(NodeServerError::InvalidWorkspaceRoot {
16913            workspace_id: workspace_id.clone(),
16914            path: canonical_root.to_owned(),
16915            message: "UNC workspace roots are unsupported by Windows PTY providers".to_owned(),
16916        });
16917    }
16918    if !platform::workspace_root_supported(canonical_root)
16919        || canonical_root.len() > WORKING_DIRECTORY_MAX_BYTES
16920    {
16921        return Err(NodeServerError::InvalidWorkspaceRoot {
16922            workspace_id: workspace_id.clone(),
16923            path: canonical_root.to_owned(),
16924            message: "canonical path must be absolute, bounded, and free of control characters"
16925                .to_owned(),
16926        });
16927    }
16928    Ok(())
16929}
16930
16931fn failure(code: NodeFailureCode, _message: &str) -> NodeFailure {
16932    NodeFailure {
16933        code,
16934        message: node_failure_category(code).to_owned(),
16935    }
16936}
16937
16938fn harness_mcp_failure(error: HarnessMcpProxyError) -> NodeFailure {
16939    let code = match error {
16940        HarnessMcpProxyError::Unavailable => NodeFailureCode::HarnessMcpUnavailable,
16941        HarnessMcpProxyError::NotFound => NodeFailureCode::ReservationNotFound,
16942        HarnessMcpProxyError::Conflict => NodeFailureCode::ReservationConflict,
16943        HarnessMcpProxyError::Expired => NodeFailureCode::ReservationExpired,
16944        HarnessMcpProxyError::BindingMismatch => NodeFailureCode::BindingMismatch,
16945        HarnessMcpProxyError::NotActivated => NodeFailureCode::NotActivated,
16946        HarnessMcpProxyError::CallNotFound => NodeFailureCode::CallNotFound,
16947        HarnessMcpProxyError::ChunkOutOfOrder => NodeFailureCode::ChunkOutOfOrder,
16948        HarnessMcpProxyError::ResponseTooLarge => NodeFailureCode::ResponseTooLarge,
16949    };
16950    failure(code, "harness MCP proxy operation failed")
16951}
16952
16953/// Numbers each harness-MCP overlay this process constructs, in
16954/// construction order, so two overlays built back to back in one node process
16955/// still get distinct trace files.
16956static NEXT_HARNESS_MCP_TRACE_SEQUENCE: AtomicU64 = AtomicU64::new(1);
16957
16958/// Builds the optional trace environment entry of one harness-MCP overlay.
16959/// The caller's launch description names both the entry and the node-process
16960/// variable that opts in: when that variable holds a non-empty directory, the
16961/// entry names a trace file under it. Returns `None` when the launch has no
16962/// trace, or the directory is unset, empty, or not valid Unicode. Never creates
16963/// the directory; that is the operator's job.
16964fn harness_mcp_trace_env_entry(launch: &HarnessMcpLaunchV1) -> Option<(OsString, OsString)> {
16965    let trace = launch.trace.as_ref()?;
16966    let trace_dir = std::env::var(&trace.dir_env).ok()?;
16967    if trace_dir.is_empty() {
16968        return None;
16969    }
16970    let sequence = NEXT_HARNESS_MCP_TRACE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
16971    let path = std::path::Path::new(&trace_dir).join(format!("harness-mcp-{sequence}.trace"));
16972    Some((OsString::from(trace.env.as_str()), path.into_os_string()))
16973}
16974
16975fn spawn_deadline_remaining(deadline: Instant) -> Result<Duration, NodeFailure> {
16976    let remaining = deadline.saturating_duration_since(Instant::now());
16977    if remaining.is_zero() {
16978        Err(failure(
16979            NodeFailureCode::SpawnDeadlineExceeded,
16980            "spawn deadline elapsed",
16981        ))
16982    } else {
16983        Ok(remaining)
16984    }
16985}
16986
16987fn spawn_dispatch_timeout(deadline: Option<Instant>) -> Result<Duration, NodeFailure> {
16988    let bounded = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
16989    match deadline {
16990        Some(deadline) => Ok(spawn_deadline_remaining(deadline)?.min(bounded)),
16991        None => Ok(bounded),
16992    }
16993}
16994
16995fn spawn_dispatch_error(error: NodeFailure, deadline: Option<Instant>) -> NodeFailure {
16996    if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
16997        failure(
16998            NodeFailureCode::SpawnDeadlineExceeded,
16999            "spawn dispatch exceeded the spawn deadline",
17000        )
17001    } else {
17002        error
17003    }
17004}
17005
17006fn spawn_runtime_capabilities(
17007    capabilities: &SpawnRequiredCapabilities,
17008) -> Result<Vec<ProviderRuntimeCapability>, NodeFailure> {
17009    capabilities
17010        .iter()
17011        .map(|capability| match capability.as_str() {
17012            SPAWN_RUNTIME_RAW_PTY_LIFECYCLE => Ok(ProviderRuntimeCapability::RawPtyLifecycle),
17013            SPAWN_RUNTIME_SEMANTIC_READINESS => Ok(ProviderRuntimeCapability::SemanticReadiness),
17014            SPAWN_RUNTIME_STRUCTURED_PROMPT => Ok(ProviderRuntimeCapability::StructuredPrompt),
17015            SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY => {
17016                Ok(ProviderRuntimeCapability::ProviderSessionIdentity)
17017            }
17018            SPAWN_RUNTIME_SEMANTIC_RESUME => Ok(ProviderRuntimeCapability::SemanticResume),
17019            _ => Err(failure(
17020                NodeFailureCode::UnsupportedSpawnCapability,
17021                "spawn required capability is unknown",
17022            )),
17023        })
17024        .collect()
17025}
17026
17027fn persistence_failure(_error: io::Error) -> NodeFailure {
17028    NodeFailure {
17029        code: NodeFailureCode::BackendOperationFailed,
17030        message: DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned(),
17031    }
17032}
17033
17034fn node_failure_category(code: NodeFailureCode) -> &'static str {
17035    match code {
17036        NodeFailureCode::InvalidRequest => "invalid-request",
17037        NodeFailureCode::UnsupportedCapability => "unsupported-capability",
17038        NodeFailureCode::HarnessMcpUnavailable => "harness-mcp-unavailable",
17039        NodeFailureCode::ReservationNotFound => "reservation-not-found",
17040        NodeFailureCode::ReservationConflict => "reservation-conflict",
17041        NodeFailureCode::ReservationExpired => "reservation-expired",
17042        NodeFailureCode::BindingMismatch => "binding-mismatch",
17043        NodeFailureCode::NotActivated => "not-activated",
17044        NodeFailureCode::CallNotFound => "call-not-found",
17045        NodeFailureCode::ChunkOutOfOrder => "chunk-out-of-order",
17046        NodeFailureCode::ResponseTooLarge => "response-too-large",
17047        NodeFailureCode::DeliveryManifestInvalid => "delivery-manifest-invalid",
17048        NodeFailureCode::UnknownDeliveryStage => "unknown-delivery-stage",
17049        NodeFailureCode::DeliveryStageConflict => "delivery-stage-conflict",
17050        NodeFailureCode::DeliveryBlobUnexpected => "delivery-blob-unexpected",
17051        NodeFailureCode::DeliveryChunkOutOfOrder => "delivery-chunk-out-of-order",
17052        NodeFailureCode::DeliveryBlobDigestMismatch => "delivery-blob-digest-mismatch",
17053        NodeFailureCode::DeliveryBundleDigestMismatch => "delivery-bundle-digest-mismatch",
17054        NodeFailureCode::DeliveryStageIncomplete => "delivery-stage-incomplete",
17055        NodeFailureCode::DeliveryStageStorageFailed => "delivery-stage-storage-failed",
17056        NodeFailureCode::Unauthorized => "unauthorized",
17057        NodeFailureCode::ObserverReadOnly => "observer-read-only",
17058        NodeFailureCode::ControllerBusy => "controller-busy",
17059        NodeFailureCode::ControllerRequired => "controller-required",
17060        NodeFailureCode::UnknownWorkspace => "unknown-workspace",
17061        NodeFailureCode::HostDirectoryInvalid => "host-directory-invalid",
17062        NodeFailureCode::HostDirectoryReadFailed => "host-directory-read-failed",
17063        NodeFailureCode::HostDirectoryReadTimedOut => "host-directory-read-timed-out",
17064        NodeFailureCode::InvalidRepositoryPath => "invalid-repository-path",
17065        NodeFailureCode::RepositoryFileNotFound => "repository-file-not-found",
17066        NodeFailureCode::RepositoryFileNotRegular => "repository-file-not-regular",
17067        NodeFailureCode::RepositoryPathUnsafe => "repository-path-unsafe",
17068        NodeFailureCode::RepositoryFileReadTimedOut => "repository-file-read-timed-out",
17069        NodeFailureCode::RepositoryFileReadFailed => "repository-file-read-failed",
17070        NodeFailureCode::RepositoryFileWriteTimedOut => "repository-file-write-timed-out",
17071        NodeFailureCode::RepositoryFileWriteFailed => "repository-file-write-failed",
17072        NodeFailureCode::RepositoryFileRevisionConflict => "repository-file-revision-conflict",
17073        NodeFailureCode::RepositoryEntryAlreadyExists => "repository-entry-already-exists",
17074        NodeFailureCode::RepositoryParentNotFound => "repository-parent-not-found",
17075        NodeFailureCode::RepositoryParentNotDirectory => "repository-parent-not-directory",
17076        NodeFailureCode::RepositoryEntryCreateTimedOut => "repository-entry-create-timed-out",
17077        NodeFailureCode::RepositoryEntryCreateFailed => "repository-entry-create-failed",
17078        NodeFailureCode::GitReadTimedOut => "git-read-timed-out",
17079        NodeFailureCode::GitReadFailed => "git-read-failed",
17080        NodeFailureCode::UnknownSpawnProfile => "unknown-spawn-profile",
17081        NodeFailureCode::SpawnProfileRevisionMismatch => "spawn-profile-revision-mismatch",
17082        NodeFailureCode::SpawnTargetMismatch => "spawn-target-mismatch",
17083        NodeFailureCode::SpawnIdempotencyConflict => "spawn-idempotency-conflict",
17084        NodeFailureCode::SpawnIdempotencyCapacity => "spawn-idempotency-capacity",
17085        NodeFailureCode::SpawnDeadlineExceeded => "spawn-deadline-exceeded",
17086        NodeFailureCode::UnsupportedSpawnCapability => "unsupported-spawn-capability",
17087        NodeFailureCode::UnsupportedTransport => "unsupported-transport",
17088        NodeFailureCode::TurnInFlight => "turn-in-flight",
17089        NodeFailureCode::UnknownEnvironmentProfile => "unknown-environment-profile",
17090        NodeFailureCode::UnknownNetworkAllowlist => "unknown-network-allowlist",
17091        NodeFailureCode::UnsupportedNetworkAllowlistMapping => {
17092            "unsupported-network-allowlist-mapping"
17093        }
17094        NodeFailureCode::BrowserStationProbeUnavailable => {
17095            "browser-station-probe-unavailable"
17096        }
17097        NodeFailureCode::BrowserStationUnreachable => "browser-station-unreachable",
17098        NodeFailureCode::BrowserStationProfileBusy => "browser-station-profile-busy",
17099        NodeFailureCode::EnvironmentProfileBindingMismatch => {
17100            "environment-profile-binding-mismatch"
17101        }
17102        NodeFailureCode::UnknownBundle => "unknown-bundle",
17103        NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
17104        NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
17105        NodeFailureCode::UnknownContextPack => "unknown-context-pack",
17106        NodeFailureCode::ContextPackBusy => "context-pack-busy",
17107        NodeFailureCode::ContextPackMaterializationFailed => {
17108            "context-pack-materialization-failed"
17109        }
17110        NodeFailureCode::InvalidWorkspaceRoot => "invalid-workspace-root",
17111        NodeFailureCode::DuplicateWorkspaceId => "duplicate-workspace-id",
17112        NodeFailureCode::DuplicateWorkspaceRoot => "duplicate-workspace-root",
17113        NodeFailureCode::WorkspaceBusy => "workspace-busy",
17114        NodeFailureCode::LastWorkspace => "last-workspace",
17115        NodeFailureCode::NotGitRepository => "not-git-repository",
17116        NodeFailureCode::WorktreeConflict => "worktree-conflict",
17117        NodeFailureCode::WorktreeProtected => "worktree-protected",
17118        NodeFailureCode::WorktreeDirty => "worktree-dirty",
17119        NodeFailureCode::WorktreeLocked => "worktree-locked",
17120        NodeFailureCode::UnknownManagedWorktreeLease => "unknown-managed-worktree-lease",
17121        NodeFailureCode::ManagedWorktreeBusy => "managed-worktree-busy",
17122        NodeFailureCode::ManagedWorktreeOwnershipConflict => "managed-worktree-ownership-conflict",
17123        NodeFailureCode::ManagedWorktreeProfileRevisionMismatch => {
17124            "managed-worktree-profile-revision-mismatch"
17125        }
17126        NodeFailureCode::ManagedWorktreeRecoveryRequired => "managed-worktree-recovery-required",
17127        NodeFailureCode::StandaloneWorkspaceRecoveryRequired => {
17128            "standalone-workspace-recovery-required"
17129        }
17130        NodeFailureCode::UnknownSession => "unknown-session",
17131        NodeFailureCode::UnknownSessionRecord => "unknown-session-record",
17132        NodeFailureCode::SessionRecordNotResumable => "session-record-not-resumable",
17133        NodeFailureCode::SessionRecordBusy => "session-record-busy",
17134        NodeFailureCode::SessionRecordConflict => "session-record-conflict",
17135        NodeFailureCode::SessionWorkspaceMismatch => "session-workspace-mismatch",
17136        NodeFailureCode::WorkspaceRegistrationRequired => {
17137            "workspace-registration-required"
17138        }
17139        NodeFailureCode::StaleNativeSessionCatalog => "stale-native-session-catalog",
17140        NodeFailureCode::StaleGeneration => "stale-generation",
17141        NodeFailureCode::BackendBusy => "backend-busy",
17142        NodeFailureCode::BackendDisconnected => "backend-disconnected",
17143        NodeFailureCode::BackendOperationFailed => "backend-operation-failed",
17144        NodeFailureCode::ShuttingDown => "shutting-down",
17145    }
17146}
17147
17148fn durable_state_server_error(error: io::Error, category: &'static str) -> NodeServerError {
17149    NodeServerError::DurableState(io::Error::new(error.kind(), category))
17150}
17151
17152fn durable_state_load_error(error: io::Error) -> NodeServerError {
17153    let category = match session_registry::state_load_refusal(&error) {
17154        Some(session_registry::StateLoadRefusal::UnsupportedSchema) => {
17155            DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR
17156        }
17157        Some(session_registry::StateLoadRefusal::PathSemanticsUnsupported) => {
17158            DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR
17159        }
17160        Some(session_registry::StateLoadRefusal::NodeIdentityMismatch) => {
17161            DURABLE_STATE_CONFLICT_ERROR
17162        }
17163        _ => DURABLE_STATE_LOAD_FAILED_ERROR,
17164    };
17165    durable_state_server_error(error, category)
17166}
17167
17168fn unix_time_ms() -> u64 {
17169    std::time::SystemTime::now()
17170        .duration_since(std::time::UNIX_EPOCH)
17171        .unwrap_or_default()
17172        .as_millis()
17173        .min(u64::MAX as u128) as u64
17174}
17175
17176fn detached_record_state(record: &ManagedSessionRecord) -> ManagedSessionState {
17177    if record.provider_session.is_some() {
17178        ManagedSessionState::Dormant
17179    } else {
17180        ManagedSessionState::Unavailable
17181    }
17182}
17183
17184fn active_record_state(
17185    identity_admitted: bool,
17186    provider_session_is_known: bool,
17187) -> ManagedSessionState {
17188    if identity_admitted && !provider_session_is_known {
17189        ManagedSessionState::IdentityPending
17190    } else {
17191        ManagedSessionState::Live
17192    }
17193}
17194
17195fn managed_worktree_record_holders(
17196    records: &[ManagedSessionRecord],
17197) -> Vec<(SessionRecordId, WorkspaceId)> {
17198    records
17199        .iter()
17200        .filter(|record| record.provider_session.is_some())
17201        .map(|record| (record.record_id.clone(), record.workspace_id.clone()))
17202        .collect()
17203}
17204
17205fn managed_spawn_record_ownership(
17206    binding: &SessionBinding,
17207    effective_runtime_policy: ProviderRuntimePolicy,
17208) -> (Option<SessionRecordId>, Option<SessionRecordId>) {
17209    debug_assert_eq!(binding.runtime_policy, effective_runtime_policy);
17210    if effective_runtime_policy.provider_session_identity {
17211        (binding.record_id.clone(), None)
17212    } else {
17213        (None, binding.record_id.clone())
17214    }
17215}
17216
17217#[derive(Debug, Error)]
17218pub enum NodeServerError {
17219    #[error("node endpoint must be a bounded absolute local transport path")]
17220    InvalidEndpoint,
17221    #[error("node access token must contain 1..=4096 bytes")]
17222    InvalidAccessToken,
17223    #[error("node HTTP observer must listen on a loopback address: {0}")]
17224    InvalidApiListen(std::net::SocketAddr),
17225    #[error("node browser bridge must listen on a loopback address: {0}")]
17226    InvalidBridgeListen(std::net::SocketAddr),
17227    #[error("node browser bridge token must contain 1..=4096 bytes")]
17228    InvalidBridgeToken,
17229    #[error("bridge underlay requires --bridge-listen (local HTTP+WS door)")]
17230    BridgeUnderlayRequiresBridgeListen,
17231    #[error("bridge underlay requires GATE4AGENT_BRIDGE_TOKEN (application barrier)")]
17232    BridgeUnderlayRequiresBridgeToken,
17233    #[error("mesh underlay auth token must contain 1..=4096 bytes")]
17234    InvalidBridgeUnderlayToken,
17235    #[error("mesh underlay transport key must be 32 bytes (64 hex digits)")]
17236    InvalidBridgeUnderlayKey,
17237    #[error("node call-home relay must be loopback or a concrete C2 tunnel address with a nonzero port: {0}")]
17238    InvalidCallHome(std::net::SocketAddr),
17239    #[error("node could not reach its call-home relay: {0}")]
17240    CallHomeTransport(#[source] std::io::Error),
17241    #[error("node durable state path must be an absolute file path: {0}")]
17242    InvalidStatePath(String),
17243    #[error("harness MCP helper must be an exact reviewed absolute regular file")]
17244    InvalidHarnessMcpHelper,
17245    #[error("network allowlist catalog path must be an absolute regular file: {0}")]
17246    InvalidNetworkAllowlistCatalogPath(String),
17247    #[error("network allowlist catalog could not be read: {0}")]
17248    NetworkAllowlistCatalogIo(#[source] std::io::Error),
17249    #[error("network allowlist catalog line {line}: {message}")]
17250    InvalidNetworkAllowlistCatalogEntry { line: usize, message: String },
17251    #[error("network allowlist catalog: {0}")]
17252    InvalidNetworkAllowlistCatalog(String),
17253    #[error("network allowlist catalog exceeds the {max}-entry limit")]
17254    NetworkAllowlistCatalogCapacity { max: usize },
17255    #[error("the local state directory is unavailable; supply an explicit state path")]
17256    LocalStateDirectoryUnavailable,
17257    #[error("the local runtime directory is unavailable; supply an explicit endpoint")]
17258    LocalRuntimeDirectoryUnavailable,
17259    #[error("node durable state failed: {0}")]
17260    DurableState(io::Error),
17261    #[error("node requires at least one configured workspace")]
17262    NoWorkspaces,
17263    #[error("workspace '{workspace_id}' root '{path}' is invalid: {message}")]
17264    InvalidWorkspaceRoot {
17265        workspace_id: WorkspaceId,
17266        path: String,
17267        message: String,
17268    },
17269    #[error("duplicate workspace ID: {0}")]
17270    DuplicateWorkspaceId(WorkspaceId),
17271    #[error("workspaces '{first}' and '{second}' resolve to the same root '{root}'")]
17272    DuplicateWorkspaceRoot {
17273        first: WorkspaceId,
17274        second: WorkspaceId,
17275        root: String,
17276    },
17277    #[error("workspace '{0}' uses managed worktrees but has no local profile")]
17278    ManagedWorktreeProfileRequired(WorkspaceId),
17279    #[error("workspace '{0}' has managed worktree profiles but is not in managed mode")]
17280    ManagedWorktreeProfileModeMismatch(WorkspaceId),
17281    #[error("workspace '{workspace_id}' has duplicate managed worktree profile '{profile_id}'")]
17282    DuplicateManagedWorktreeProfile {
17283        workspace_id: WorkspaceId,
17284        profile_id: WorktreeProfileId,
17285    },
17286    #[error("workspace '{workspace_id}' exceeds the {max}-profile managed worktree limit")]
17287    ManagedWorktreeProfileCapacity {
17288        workspace_id: WorkspaceId,
17289        max: usize,
17290    },
17291    #[error("workspace '{workspace_id}' managed worktree profile '{profile_id}' is invalid: {message}")]
17292    InvalidManagedWorktreeProfile {
17293        workspace_id: WorkspaceId,
17294        profile_id: WorktreeProfileId,
17295        message: String,
17296    },
17297    #[error("duplicate node environment profile: {0}")]
17298    DuplicateEnvironmentProfile(SpawnEnvironmentProfileId),
17299    #[error("node environment profile provider is unavailable: {0}")]
17300    EnvironmentProfileProviderUnavailable(AgentId),
17301    #[error("node environment profile capacity is {max}")]
17302    EnvironmentProfileCapacity { max: usize },
17303    #[error("node environment profiles reuse native profile ID '{0}'")]
17304    DuplicateNativeEnvironmentProfile(String),
17305    #[error("node native environment profile is invalid: {0}")]
17306    NativeEnvironmentProfile(String),
17307    #[error("node session-environment materialization root must be absolute")]
17308    InvalidSessionEnvironmentRoot,
17309    #[error("node session-environment materializer is unavailable")]
17310    SessionEnvironmentMaterializerRequired,
17311    #[error("node session-environment materializer failed to initialize")]
17312    SessionEnvironmentMaterializer,
17313    #[error("node bundle catalog is invalid: {0}")]
17314    BundleCatalog(String),
17315    #[error("node delivery store failed to initialize: {0}")]
17316    DeliveryStore(String),
17317    #[error("node context pack store failed to initialize")]
17318    ContextPackStore,
17319    #[error("active agent registry failed: {0}")]
17320    Registry(String),
17321    #[error("node provider contract manifest is invalid: {0}")]
17322    ProviderContractManifest(String),
17323    #[error("named pipe I/O failed: {0}")]
17324    Io(#[from] io::Error),
17325    #[error("node HTTP observer failed: {0}")]
17326    HttpApi(io::Error),
17327    #[error("node browser bridge failed: {0}")]
17328    BridgeApi(io::Error),
17329    #[error(transparent)]
17330    Frame(#[from] FrameError),
17331    #[error("node handshake failed: {0}")]
17332    Handshake(String),
17333    #[error("node authentication frame was not received before the bounded deadline")]
17334    AuthenticationTimedOut,
17335    #[error("node authentication primitive failed: {0}")]
17336    Authentication(String),
17337    #[error("node incarnation identity generation failed: {0}")]
17338    IncarnationIdentity(String),
17339    #[error("node pre-authentication limiter is closed")]
17340    PreauthClosed,
17341    #[error("node authenticated connection limit was reached")]
17342    AuthenticatedConnectionLimit,
17343    #[error("node Ctrl+C/CtrlBreak signal stream closed")]
17344    SignalStreamClosed,
17345    #[error("node stop-all dispatch failed: {0:?}")]
17346    ShutdownDispatch(NodeFailure),
17347    #[error("node shutdown timed out with {active_native_sessions} physical sessions retained")]
17348    ShutdownTimedOut { active_native_sessions: usize },
17349}
17350
17351impl From<crate::network_allowlist_catalog::NetworkAllowlistCatalogError> for NodeServerError {
17352    fn from(error: crate::network_allowlist_catalog::NetworkAllowlistCatalogError) -> Self {
17353        use crate::network_allowlist_catalog::NetworkAllowlistCatalogError as E;
17354        match error {
17355            E::InvalidPath(path) => Self::InvalidNetworkAllowlistCatalogPath(path),
17356            E::Io(error) => Self::NetworkAllowlistCatalogIo(error),
17357            E::InvalidEntry { line, message } => {
17358                Self::InvalidNetworkAllowlistCatalogEntry { line, message }
17359            }
17360            E::InvalidCatalog(message) => Self::InvalidNetworkAllowlistCatalog(message),
17361            E::Capacity { max } => Self::NetworkAllowlistCatalogCapacity { max },
17362        }
17363    }
17364}
17365
17366#[cfg(test)]
17367mod agent_stream_blocked_tests {
17368    use super::*;
17369    use gate4agent_types::{
17370        AdapterId, AdapterVerification, HostRequestOutcome as ProviderHostRequestOutcome,
17371        ProviderSource,
17372    };
17373
17374    fn provider_source(family: AdapterFamily, adapter: &str) -> ProviderSource {
17375        ProviderSource {
17376            family,
17377            binding: AdapterBinding::new(
17378                AdapterId::new(adapter).unwrap(),
17379                "fixture/v1",
17380                AdapterVerification::SyntheticFixture,
17381            )
17382            .unwrap(),
17383        }
17384    }
17385
17386    fn provider_control_event_at(
17387        family: AdapterFamily,
17388        adapter: &str,
17389        source_sequence: u64,
17390        provider_event: ProviderEvent,
17391    ) -> ControlEvent {
17392        ControlEvent {
17393            sequence: 41,
17394            command_id: None,
17395            instance_id: AgentInstanceId(7),
17396            generation: SessionGeneration(3),
17397            event: ControlEventKind::ProviderEvent {
17398                sequence: 8,
17399                source: provider_source(family, adapter),
17400                source_sequence,
17401                event: provider_event,
17402            },
17403        }
17404    }
17405
17406    fn provider_control_event(family: AdapterFamily, provider_event: ProviderEvent) -> ControlEvent {
17407        provider_control_event_at(family, "fixture", 9, provider_event)
17408    }
17409
17410    fn denied_host_request(
17411        by: ProviderHostDecisionAuthority,
17412        reason: Option<String>,
17413    ) -> ControlEvent {
17414        provider_control_event_at(
17415            AdapterFamily::Acp,
17416            "claude-code",
17417            2,
17418            ProviderEvent::HostRequestObserved {
17419                method: "terminal/create".to_owned(),
17420                params_json: String::new(),
17421                decision: ProviderHostRequestDecision::Denied { by },
17422                outcome: ProviderHostRequestOutcome::Executed,
17423                reason,
17424            },
17425        )
17426    }
17427
17428    fn blocked(
17429        event: &ControlEvent,
17430    ) -> (
17431        Option<String>,
17432        String,
17433        BlockAuthorityV1,
17434        Option<String>,
17435        String,
17436        Option<String>,
17437    ) {
17438        let chunk = agent_stream_chunk(event).expect("expected a Blocked chunk");
17439        chunk.validate().expect("minted chunk must validate");
17440        let AgentStreamChunkKindV1::Blocked {
17441            correlation_id,
17442            tool_class,
17443            authority,
17444            reason_kind,
17445            reason,
17446            help,
17447        } = chunk.kind
17448        else {
17449            panic!("expected AgentStreamChunkKindV1::Blocked");
17450        };
17451        (correlation_id, tool_class, authority, reason_kind, reason, help)
17452    }
17453
17454    /// Pins the fleet and every adapter each member declares: the fleet is
17455    /// exactly what `active_registry` says, and no member declares a hook
17456    /// adapter (lifecycle hooks are retired).
17457    #[test]
17458    fn the_enabled_provider_matrix_is_exact() {
17459        let registry = active_registry().unwrap();
17460        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
17461        assert_eq!(
17462            providers
17463                .iter()
17464                .map(|contract| contract.provider.as_str())
17465                .collect::<Vec<_>>(),
17466            vec!["claude", "codex", "grok", "kimi"],
17467        );
17468        assert!(adapters.iter().all(|contract| {
17469            !matches!(contract.family, AdapterFamily::Hook | AdapterFamily::ManagedHook)
17470        }));
17471    }
17472
17473    /// A gate-denied host request, WITH its reason text carried all the way
17474    /// from the ACP host, mints a `Blocked` chunk naming the gate.
17475    #[test]
17476    fn gate_denied_host_request_mints_a_blocked_chunk() {
17477        let gate_text =
17478            "blocked by dangerous-command gate: rule=filesystem-wipe, argument=rm -rf /";
17479        let event = denied_host_request(
17480            ProviderHostDecisionAuthority::Gate,
17481            Some(gate_text.to_owned()),
17482        );
17483        let chunk = agent_stream_chunk(&event).expect("expected a Blocked chunk");
17484        assert_eq!(chunk.source_sequence, 2);
17485        let (correlation_id, tool_class, authority, reason_kind, reason, help) = blocked(&event);
17486        assert_eq!(authority, BlockAuthorityV1::HostGate);
17487        assert_eq!(reason, gate_text);
17488        assert_eq!(correlation_id, None);
17489        assert_eq!(reason_kind, None);
17490        assert_eq!(help, None);
17491        assert_eq!(tool_class, "Write");
17492    }
17493
17494    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.";
17495
17496    /// The owner's classifier sample, verbatim, arriving as a
17497    /// `tool_call_update`-derived `ProviderEvent::ToolCompleted` with
17498    /// `non_execution_kind: "permission-rule"` -- mints a `Blocked` chunk with
17499    /// `authority: ProviderPermissionRule`, the `Reason:` sentence as `reason`
17500    /// and the guidance tail as `help`.
17501    #[test]
17502    fn classifier_sample_permission_rule_mints_a_blocked_chunk() {
17503        let event = provider_control_event(
17504            AdapterFamily::Acp,
17505            ProviderEvent::ToolCompleted {
17506                id: "toolu_9".to_owned(),
17507                output: CLASSIFIER_SAMPLE.to_owned(),
17508                is_error: true,
17509                duration_ms: None,
17510                agent_id: None,
17511                non_execution_kind: Some("permission-rule".to_owned()),
17512            },
17513        );
17514        let (correlation_id, _, authority, reason_kind, reason, help) = blocked(&event);
17515        assert_eq!(authority, BlockAuthorityV1::ProviderPermissionRule);
17516        assert_eq!(reason, "Blocked by classifier");
17517        assert_eq!(reason_kind, Some("permission-rule".to_owned()));
17518        assert!(help.as_deref().is_some_and(|help| help.starts_with("If you have other tasks")));
17519        assert!(correlation_id.is_some(), "a tool-correlated block must carry a correlation id");
17520    }
17521
17522    /// A `session/prompt` RPC error with `data.codexErrorInfo:
17523    /// "usageLimitExceeded"` (measured live 2026-09-05) mints a `Blocked` chunk
17524    /// with `authority: ProviderQuota`, the message verbatim as `reason` and the
17525    /// vendor code as `reason_kind`.
17526    #[test]
17527    fn codex_quota_rpc_error_mints_a_blocked_chunk() {
17528        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.";
17529        let event = provider_control_event(
17530            AdapterFamily::Acp,
17531            ProviderEvent::SessionEnded {
17532                result: "JSON-RPC error -32603: Internal error".to_owned(),
17533                cost_usd: None,
17534                is_error: true,
17535                stop_reason: Some(ProviderStopReason::ProviderError {
17536                    code: -32603,
17537                    message: message.to_owned(),
17538                    vendor_code: Some("usageLimitExceeded".to_owned()),
17539                }),
17540            },
17541        );
17542        let (_, tool_class, authority, reason_kind, reason, _) = blocked(&event);
17543        assert_eq!(authority, BlockAuthorityV1::ProviderQuota);
17544        assert_eq!(reason, message);
17545        assert_eq!(reason_kind, Some("usageLimitExceeded".to_owned()));
17546        assert_eq!(tool_class, "Session");
17547    }
17548
17549    /// ACP's `stopReason: "refusal"` mints a `Blocked` chunk with
17550    /// `authority: ProviderRefusal`.
17551    #[test]
17552    fn session_end_stop_reason_refusal_mints_a_blocked_chunk() {
17553        let event = provider_control_event(
17554            AdapterFamily::Acp,
17555            ProviderEvent::SessionEnded {
17556                result: "refusal".to_owned(),
17557                cost_usd: None,
17558                is_error: true,
17559                stop_reason: Some(ProviderStopReason::Refusal),
17560            },
17561        );
17562        let (_, _, authority, _, _, _) = blocked(&event);
17563        assert_eq!(authority, BlockAuthorityV1::ProviderRefusal);
17564    }
17565
17566    /// A failed tool completion with no typed `non_execution_kind` -- or one
17567    /// that only says the turn stopped -- is a plain execution failure, never a
17568    /// block; nor is an agent's own narrative claiming one.
17569    #[test]
17570    fn failures_and_narrative_without_a_typed_block_mint_no_blocked_chunk() {
17571        for non_execution_kind in [None, Some("interrupted".to_owned()), Some("cancelled".to_owned())] {
17572            let event = provider_control_event(
17573                AdapterFamily::Acp,
17574                ProviderEvent::ToolCompleted {
17575                    id: "toolu_1".to_owned(),
17576                    output: "command exited with status 1".to_owned(),
17577                    is_error: true,
17578                    duration_ms: Some(12),
17579                    agent_id: None,
17580                    non_execution_kind,
17581                },
17582            );
17583            assert!(agent_stream_chunk(&event).is_none());
17584        }
17585        let narrative = provider_control_event(
17586            AdapterFamily::Acp,
17587            ProviderEvent::ToolCompleted {
17588                id: "toolu_2".to_owned(),
17589                output: "the harness blocked the write, approval needed".to_owned(),
17590                is_error: false,
17591                duration_ms: Some(3),
17592                agent_id: None,
17593                non_execution_kind: None,
17594            },
17595        );
17596        assert!(agent_stream_chunk(&narrative).is_none());
17597    }
17598
17599    /// `Granted`/`Deferred` decisions never mint a `Blocked` chunk, whatever the
17600    /// execution outcome: a granted request that failed while running was
17601    /// still authorized.
17602    #[test]
17603    fn granted_and_deferred_host_requests_never_mint_a_blocked_chunk() {
17604        for (decision, outcome) in [
17605            (
17606                ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
17607                ProviderHostRequestOutcome::Executed,
17608            ),
17609            (ProviderHostRequestDecision::Deferred, ProviderHostRequestOutcome::Executed),
17610            (
17611                ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
17612                ProviderHostRequestOutcome::Failed {
17613                    error: "terminal/create spawn failed: os error 3".to_owned(),
17614                },
17615            ),
17616        ] {
17617            let event = provider_control_event_at(
17618                AdapterFamily::Acp,
17619                "claude-code",
17620                3,
17621                ProviderEvent::HostRequestObserved {
17622                    method: "fs/read_text_file".to_owned(),
17623                    params_json: String::new(),
17624                    decision: decision.clone(),
17625                    outcome,
17626                    reason: None,
17627                },
17628            );
17629            assert!(agent_stream_chunk(&event).is_none(), "{decision:?}");
17630        }
17631    }
17632
17633    /// A deferred `session/request_permission` resolved by an operator or an
17634    /// expired deadline carries no reason text -- the chunk falls back to a
17635    /// categorical sentence naming the authority rather than inventing detail.
17636    #[test]
17637    fn denied_host_request_with_no_reason_falls_back_to_a_categorical_sentence() {
17638        for (by, expected_authority, expected_reason) in [
17639            (
17640                ProviderHostDecisionAuthority::Operator,
17641                BlockAuthorityV1::Operator,
17642                "declined by the operator",
17643            ),
17644            (
17645                ProviderHostDecisionAuthority::DeadlinePolicy,
17646                BlockAuthorityV1::HostDeadline,
17647                "denied by host policy after the operator deadline expired",
17648            ),
17649        ] {
17650            let (_, _, authority, _, reason, _) = blocked(&denied_host_request(by, None));
17651            assert_eq!(authority, expected_authority);
17652            assert_eq!(reason, expected_reason);
17653        }
17654    }
17655
17656    /// An over-long reason is cut to the wire bound BEFORE the chunk is built,
17657    /// at a safe UTF-8 boundary -- `validate` rejects an over-long value
17658    /// outright rather than truncating.
17659    #[test]
17660    fn overlong_blocked_reason_is_truncated_to_the_wire_bound() {
17661        let oversized = "x".repeat(MAX_ACP_BLOCKED_REASON_BYTES + 500);
17662        let (_, _, _, _, reason, _) = blocked(&denied_host_request(
17663            ProviderHostDecisionAuthority::Policy,
17664            Some(oversized),
17665        ));
17666        assert!(reason.len() <= MAX_ACP_BLOCKED_REASON_BYTES);
17667    }
17668}
17669
17670#[cfg(test)]
17671mod standalone_capability_platform_tests {
17672    use super::*;
17673
17674    #[test]
17675    fn standalone_workspace_capability_requires_windows_identity_guards() {
17676        let capabilities = baseline_capabilities().unwrap();
17677        let advertised = capabilities.iter().any(|capability| {
17678            capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
17679        });
17680        assert_eq!(advertised, cfg!(windows));
17681
17682        let request = NodeRequest::CreateStandaloneWorkspace {
17683            workspace_id: WorkspaceId::new("standalone-platform").unwrap(),
17684            root: OpaqueHostPath::utf8("standalone-platform-root".to_owned()).unwrap(),
17685            initial_branch: None,
17686        };
17687        assert_eq!(
17688            request_uses_unnegotiated_capability(&request, &capabilities),
17689            !cfg!(windows),
17690        );
17691    }
17692}
17693
17694
17695#[cfg(test)]
17696mod station_knobs_resolve_tests {
17697    use super::*;
17698    use gate4agent_catalog::EnvMutation;
17699    use gate4agent_runtime_native::{
17700        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
17701        NativeLaunchProfileId,
17702    };
17703    use std::ffi::OsString;
17704    use std::sync::Arc;
17705
17706    struct EmptyEnvironmentResolver;
17707
17708    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
17709        fn resolve_child_environment(
17710            &self,
17711        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
17712            Ok(vec![EnvMutation {
17713                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
17714                value: None,
17715            }])
17716        }
17717    }
17718
17719    fn agent(value: &str) -> AgentId {
17720        AgentId::new(value).unwrap()
17721    }
17722
17723    fn station_knobs_fixture() -> (NodeShared, crate::protocol::SpawnProfileDefaults) {
17724        let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
17725        let profile_revision =
17726            crate::protocol::SpawnEnvironmentProfileRevision::new("local-claude-r1").unwrap();
17727        let spawn_defaults = crate::protocol::SpawnProfileDefaults {
17728            profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
17729            revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
17730            provider: agent("claude"),
17731            mode: SessionMode::Pty,
17732            terminal_size: gate4agent_types::TerminalSize {
17733                rows: 24,
17734                columns: 80,
17735            },
17736            prompt: None,
17737            bundle_id: None,
17738            context_id: None,
17739            environment_profile_id: Some(profile_id.clone()),
17740        };
17741        let spawn_profiles = SpawnProfileRegistry::new([spawn_defaults.clone()]).unwrap();
17742        let catalog = active_registry().unwrap();
17743        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
17744        let control = runtime.native_launch_profile_control();
17745        let node_profile = NodeEnvironmentProfile::new(
17746            profile_id.clone(),
17747            profile_revision,
17748            agent("claude"),
17749            [NativeLaunchProfile::new(
17750                NativeLaunchProfileId::new("local-claude-pty").unwrap(),
17751                agent("claude"),
17752                TransportKind::Pty,
17753                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
17754                Arc::new(EmptyEnvironmentResolver),
17755            )
17756            .unwrap()],
17757        )
17758        .unwrap();
17759        let (binding, native_profiles, _materialization) = node_profile.into_parts();
17760        for native_profile in native_profiles {
17761            runtime.upsert_native_launch_profile(native_profile).unwrap();
17762        }
17763        let workspace = WorkspaceConfig::new(
17764            WorkspaceId::new("primary").unwrap(),
17765            std::env::current_dir().unwrap(),
17766        )
17767        .unwrap();
17768        let shared = NodeShared::new_with_incarnation(
17769            handle,
17770            "fixture-token".to_owned(),
17771            NodeId::new("node-station-knobs-test").unwrap(),
17772            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
17773            vec![workspace],
17774            vec![agent("claude")],
17775            ProviderRuntimeStatuses::default(),
17776            None,
17777            Vec::new(),
17778            Vec::new(),
17779            spawn_profiles,
17780            Some(control),
17781            None,
17782            None,
17783            None,
17784            Vec::new(),
17785            Vec::new(),
17786            Vec::new(),
17787            Vec::new(),
17788            None,
17789            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
17790        );
17791        shared
17792            .environment_profiles
17793            .write()
17794            .unwrap_or_else(|poisoned| poisoned.into_inner())
17795            .insert(binding.id.clone(), binding);
17796        (shared, spawn_defaults)
17797    }
17798
17799    /// Empty-default catalog refuses unknown network_allowlist ids.
17800    /// Feature-off dig2-station-probe keeps browser_profile stub echo.
17801    #[test]
17802    fn resolve_environment_profile_refuses_unknown_network_allowlist() {
17803        let (shared, defaults) = station_knobs_fixture();
17804        let unknown = crate::protocol::SpawnNetworkAllowlistId::new("not-registered").unwrap();
17805        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
17806        let spec = crate::protocol::SpawnSpec {
17807            target: crate::protocol::SpawnTarget {
17808                node_id: shared.node_id.clone(),
17809                workspace_id: WorkspaceId::new("primary").unwrap(),
17810                worktree_id: None,
17811            },
17812            profile_id: defaults.profile_id.clone(),
17813            expected_profile_revision: defaults.revision.clone(),
17814            overrides: crate::protocol::SpawnOverrides {
17815                network_allowlist: Some(unknown),
17816                browser_profile_id: Some(browser),
17817                ..crate::protocol::SpawnOverrides::default()
17818            },
17819            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
17820            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
17821                "station-knobs-unknown-allowlist",
17822            )
17823            .unwrap(),
17824            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
17825        };
17826        let resolved = spec.resolve(&defaults).unwrap();
17827        let failure = shared
17828            .resolve_environment_profile(&resolved)
17829            .expect_err("unknown allowlist must refuse");
17830        assert_eq!(failure.code, NodeFailureCode::UnknownNetworkAllowlist);
17831    }
17832
17833    /// Registered network_allowlist (+ browser_profile_id when feature off)
17834    /// echo onto the env-profile receipt. Empty catalog still allows omit
17835    /// (None). Empty/whitespace refuse is type-level. Feature-off
17836    /// dig2-station-probe keeps stub browser echo; feature-on refuse is
17837    /// covered by `…_refuses_browser_profile_when_station_probe_fails`.
17838    #[test]
17839    fn resolve_environment_profile_echoes_registered_station_network_and_browser_knobs() {
17840        let (shared, defaults) = station_knobs_fixture();
17841        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
17842        #[cfg(not(feature = "dig2-station-probe"))]
17843        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
17844        shared
17845            .network_allowlist_catalog
17846            .write()
17847            .unwrap_or_else(|poisoned| poisoned.into_inner())
17848            .insert_id(allowlist.clone())
17849            .unwrap();
17850        let spec = crate::protocol::SpawnSpec {
17851            target: crate::protocol::SpawnTarget {
17852                node_id: shared.node_id.clone(),
17853                workspace_id: WorkspaceId::new("primary").unwrap(),
17854                worktree_id: None,
17855            },
17856            profile_id: defaults.profile_id.clone(),
17857            expected_profile_revision: defaults.revision.clone(),
17858            overrides: crate::protocol::SpawnOverrides {
17859                network_allowlist: Some(allowlist.clone()),
17860                #[cfg(not(feature = "dig2-station-probe"))]
17861                browser_profile_id: Some(browser.clone()),
17862                ..crate::protocol::SpawnOverrides::default()
17863            },
17864            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
17865            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-echo")
17866                .unwrap(),
17867            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
17868        };
17869        let resolved = spec.resolve(&defaults).unwrap();
17870        assert_eq!(resolved.network_allowlist.as_ref(), Some(&allowlist));
17871        #[cfg(not(feature = "dig2-station-probe"))]
17872        assert_eq!(resolved.browser_profile_id.as_ref(), Some(&browser));
17873        let receipt = shared
17874            .resolve_environment_profile(&resolved)
17875            .expect("resolve ok")
17876            .expect("env profile present");
17877        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
17878        #[cfg(not(feature = "dig2-station-probe"))]
17879        assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
17880        #[cfg(feature = "dig2-station-probe")]
17881        assert_eq!(receipt.browser_profile_id, None);
17882        assert_eq!(receipt.profile_id.as_str(), "local-claude");
17883
17884        let omitted = crate::protocol::SpawnSpec {
17885            overrides: crate::protocol::SpawnOverrides::default(),
17886            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-omit")
17887                .unwrap(),
17888            ..spec
17889        };
17890        let resolved_omit = omitted.resolve(&defaults).unwrap();
17891        let bare = shared
17892            .resolve_environment_profile(&resolved_omit)
17893            .expect("resolve ok")
17894            .expect("env profile present");
17895        assert_eq!(bare.network_allowlist, None);
17896        assert_eq!(bare.browser_profile_id, None);
17897    }
17898
17899    /// Feature `dig2-station-probe`: browser_profile_id set refuses when the
17900    /// cheap station probe cannot succeed. On non-Windows (this CI host) that
17901    /// is `BrowserStationProbeUnavailable`. Never cookies on C2. Lease map is
17902    /// unit-tested separately (probe refuses before lease on Linux).
17903    #[cfg(feature = "dig2-station-probe")]
17904    #[test]
17905    fn resolve_environment_profile_refuses_browser_profile_when_station_probe_fails() {
17906        let (shared, defaults) = station_knobs_fixture();
17907        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
17908        let spec = crate::protocol::SpawnSpec {
17909            target: crate::protocol::SpawnTarget {
17910                node_id: shared.node_id.clone(),
17911                workspace_id: WorkspaceId::new("primary").unwrap(),
17912                worktree_id: None,
17913            },
17914            profile_id: defaults.profile_id.clone(),
17915            expected_profile_revision: defaults.revision.clone(),
17916            overrides: crate::protocol::SpawnOverrides {
17917                browser_profile_id: Some(browser),
17918                ..crate::protocol::SpawnOverrides::default()
17919            },
17920            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
17921            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
17922                "station-knobs-probe-refuse",
17923            )
17924            .unwrap(),
17925            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
17926        };
17927        let resolved = spec.resolve(&defaults).unwrap();
17928        let failure = shared
17929            .resolve_environment_profile(&resolved)
17930            .expect_err("browser_profile with failing probe must refuse");
17931        #[cfg(windows)]
17932        {
17933            assert!(
17934                matches!(
17935                    failure.code,
17936                    NodeFailureCode::BrowserStationUnreachable
17937                        | NodeFailureCode::BrowserStationProbeUnavailable
17938                ),
17939                "unexpected code {:?}",
17940                failure.code
17941            );
17942        }
17943        #[cfg(not(windows))]
17944        {
17945            assert_eq!(
17946                failure.code,
17947                NodeFailureCode::BrowserStationProbeUnavailable
17948            );
17949        }
17950    }
17951
17952    /// Feature on: omitting browser_profile_id still resolves (no probe).
17953    #[cfg(feature = "dig2-station-probe")]
17954    #[test]
17955    fn resolve_environment_profile_skips_station_probe_when_browser_profile_omitted() {
17956        let (shared, defaults) = station_knobs_fixture();
17957        let spec = crate::protocol::SpawnSpec {
17958            target: crate::protocol::SpawnTarget {
17959                node_id: shared.node_id.clone(),
17960                workspace_id: WorkspaceId::new("primary").unwrap(),
17961                worktree_id: None,
17962            },
17963            profile_id: defaults.profile_id.clone(),
17964            expected_profile_revision: defaults.revision.clone(),
17965            overrides: crate::protocol::SpawnOverrides::default(),
17966            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
17967            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
17968                "station-knobs-probe-omit",
17969            )
17970            .unwrap(),
17971            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
17972        };
17973        let resolved = spec.resolve(&defaults).unwrap();
17974        let receipt = shared
17975            .resolve_environment_profile(&resolved)
17976            .expect("omit browser_profile must not probe")
17977            .expect("env profile present");
17978        assert_eq!(receipt.browser_profile_id, None);
17979    }
17980
17981    /// Feature dig2-station-probe: exclusive BrowserStationLease on NodeShared
17982    /// refuses a second holder for the same opaque profile id; release on
17983    /// remove_binding allows reacquire. Never ImportSession/cookies on C2.
17984    #[cfg(feature = "dig2-station-probe")]
17985    #[test]
17986    fn browser_station_lease_exclusive_and_clears_on_remove_binding() {
17987        let (shared, _defaults) = station_knobs_fixture();
17988        let profile = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
17989        let workspace_id = WorkspaceId::new("primary").unwrap();
17990        let address_a = SessionAddress {
17991            workspace_id: workspace_id.clone(),
17992            session: SessionKey {
17993                instance_id: AgentInstanceId(101),
17994                generation: SessionGeneration(1),
17995            },
17996        };
17997        let address_b = SessionAddress {
17998            workspace_id: workspace_id.clone(),
17999            session: SessionKey {
18000                instance_id: AgentInstanceId(102),
18001                generation: SessionGeneration(1),
18002            },
18003        };
18004        let guard_a = crate::dig2_station_lease::acquire_lease(
18005            &shared.browser_station_leases,
18006            profile.clone(),
18007            address_a.session.instance_id,
18008            1,
18009        )
18010        .expect("first lease");
18011        let busy = crate::dig2_station_lease::acquire_lease(
18012            &shared.browser_station_leases,
18013            profile.clone(),
18014            address_b.session.instance_id,
18015            2,
18016        )
18017        .expect_err("second holder must refuse");
18018        assert!(matches!(
18019            busy,
18020            crate::dig2_station_lease::BrowserStationLeaseError::ProfileBusy { .. }
18021        ));
18022        // Simulate a bound session so remove_binding clears the lease.
18023        shared.bind_session_with_policy(
18024            &address_a,
18025            ProviderRuntimePolicy::raw_pty(),
18026            None,
18027        );
18028        guard_a.retain();
18029        assert!(shared.remove_binding(&address_a).is_some());
18030        crate::dig2_station_lease::acquire_lease(
18031            &shared.browser_station_leases,
18032            profile,
18033            address_b.session.instance_id,
18034            3,
18035        )
18036        .expect("reacquire after clear")
18037        .retain();
18038        assert!(
18039            crate::dig2_station_lease::release_lease_for_holder(
18040                &shared.browser_station_leases,
18041                address_b.session.instance_id,
18042            )
18043        );
18044    }
18045
18046    /// Feature-off: no browser_station_leases field; stub echo documented in
18047    /// resolve_environment_profile_echoes_registered_station_network_and_browser_knobs.
18048    #[cfg(not(feature = "dig2-station-probe"))]
18049    #[test]
18050    fn browser_station_lease_feature_off_keeps_stub_without_lease_table() {
18051        let (shared, defaults) = station_knobs_fixture();
18052        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18053        let spec = crate::protocol::SpawnSpec {
18054            target: crate::protocol::SpawnTarget {
18055                node_id: shared.node_id.clone(),
18056                workspace_id: WorkspaceId::new("primary").unwrap(),
18057                worktree_id: None,
18058            },
18059            profile_id: defaults.profile_id.clone(),
18060            expected_profile_revision: defaults.revision.clone(),
18061            overrides: crate::protocol::SpawnOverrides {
18062                browser_profile_id: Some(browser.clone()),
18063                ..crate::protocol::SpawnOverrides::default()
18064            },
18065            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18066            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("lease-stub-off")
18067                .unwrap(),
18068            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18069        };
18070        let resolved = spec.resolve(&defaults).unwrap();
18071        let receipt = shared
18072            .resolve_environment_profile(&resolved)
18073            .expect("stub resolve")
18074            .expect("env profile");
18075        assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
18076    }
18077
18078
18079    /// Startup file loader registers ids into the empty-default catalog.
18080    /// Feature-off dig2 probe stays stub. Never secrets on C2.
18081    #[test]
18082    fn network_allowlist_catalog_loads_from_temp_file() {
18083        let unique = std::time::SystemTime::now()
18084            .duration_since(std::time::UNIX_EPOCH)
18085            .unwrap()
18086            .as_nanos();
18087        let path = std::env::temp_dir().join(format!(
18088            "gate4agent-node-allowlist-config-{}-{unique}.txt",
18089            std::process::id(),
18090        ));
18091        std::fs::write(
18092            &path,
18093            "# opaque station allowlist ids\negress-default\nlab-egress\n",
18094        )
18095        .unwrap();
18096        let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
18097            .expect("temp catalog file must load");
18098        let _ = std::fs::remove_file(&path);
18099        assert_eq!(loaded.len(), 2);
18100        let (shared, defaults) = station_knobs_fixture();
18101        {
18102            let mut catalog = shared
18103                .network_allowlist_catalog
18104                .write()
18105                .unwrap_or_else(|poisoned| poisoned.into_inner());
18106            *catalog = loaded;
18107        }
18108        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18109        let spec = crate::protocol::SpawnSpec {
18110            target: crate::protocol::SpawnTarget {
18111                node_id: shared.node_id.clone(),
18112                workspace_id: WorkspaceId::new("primary").unwrap(),
18113                worktree_id: None,
18114            },
18115            profile_id: defaults.profile_id.clone(),
18116            expected_profile_revision: defaults.revision.clone(),
18117            overrides: crate::protocol::SpawnOverrides {
18118                network_allowlist: Some(allowlist.clone()),
18119                ..crate::protocol::SpawnOverrides::default()
18120            },
18121            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18122            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18123                "station-knobs-catalog-file",
18124            )
18125            .unwrap(),
18126            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18127        };
18128        let resolved = spec.resolve(&defaults).unwrap();
18129        let receipt = shared
18130            .resolve_environment_profile(&resolved)
18131            .expect("registered via file load")
18132            .expect("env profile present");
18133        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18134    }
18135
18136    /// Snapshot launch inventory echoes registered catalog ids (opaque).
18137    /// Dig2 station probe is orthogonal (feature `dig2-station-probe`). Never secrets on C2.
18138    #[test]
18139    fn snapshot_launch_inventory_lists_network_allowlist_catalog_ids() {
18140        let (shared, _defaults) = station_knobs_fixture();
18141        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18142        shared
18143            .network_allowlist_catalog
18144            .write()
18145            .unwrap_or_else(|poisoned| poisoned.into_inner())
18146            .insert_id(allowlist.clone())
18147            .unwrap();
18148        let snapshot = shared.snapshot();
18149        let inventory = snapshot
18150            .launch_inventory
18151            .expect("launch inventory present");
18152        let ids = inventory
18153            .network_allowlists
18154            .expect("non-empty catalog surfaces ids");
18155        assert_eq!(ids, vec![allowlist]);
18156    }
18157
18158    /// Track B: registered id with provider_native codex_network_access refuses
18159    /// clearly for Claude (fixture provider). Unknown id still UnknownNetworkAllowlist.
18160    /// Never secrets on C2. Codex Moderate maps to `-c` overlay; RO/FullAuto refuse.
18161    #[test]
18162    fn resolve_environment_profile_refuses_unsupported_provider_native_mapping() {
18163        let (shared, defaults) = station_knobs_fixture();
18164        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-only").unwrap();
18165        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18166            id: allowlist.clone(),
18167            permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
18168                protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18169                peer: "127.0.0.1:443".to_owned(),
18170            }],
18171            provider_native: Some(
18172                crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
18173                    codex_network_access: Some(true),
18174                },
18175            ),
18176        };
18177        shared
18178            .network_allowlist_catalog
18179            .write()
18180            .unwrap_or_else(|poisoned| poisoned.into_inner())
18181            .insert(entry)
18182            .unwrap();
18183        let spec = crate::protocol::SpawnSpec {
18184            target: crate::protocol::SpawnTarget {
18185                node_id: shared.node_id.clone(),
18186                workspace_id: WorkspaceId::new("primary").unwrap(),
18187                worktree_id: None,
18188            },
18189            profile_id: defaults.profile_id.clone(),
18190            expected_profile_revision: defaults.revision.clone(),
18191            overrides: crate::protocol::SpawnOverrides {
18192                network_allowlist: Some(allowlist),
18193                ..crate::protocol::SpawnOverrides::default()
18194            },
18195            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18196            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18197                "station-knobs-unsupported-mapping",
18198            )
18199            .unwrap(),
18200            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18201        };
18202        let resolved = spec.resolve(&defaults).unwrap();
18203        assert_eq!(resolved.provider.as_str(), "claude");
18204        let failure = shared
18205            .resolve_environment_profile(&resolved)
18206            .expect_err("claude + codex_network_access must refuse");
18207        assert_eq!(
18208            failure.code,
18209            NodeFailureCode::UnsupportedNetworkAllowlistMapping
18210        );
18211    }
18212
18213    /// Codex Moderate + codex_network_access yields `-c` overlay argv.
18214    /// ReadOnly / FullAuto refuse UnsupportedNetworkAllowlistMapping.
18215    /// Never secrets on C2.
18216    #[test]
18217    fn resolve_codex_network_access_overlay_moderate_only() {
18218        let (shared, defaults) = station_knobs_fixture();
18219        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-net").unwrap();
18220        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18221            id: allowlist.clone(),
18222            permits: Vec::new(),
18223            provider_native: Some(
18224                crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
18225                    codex_network_access: Some(true),
18226                },
18227            ),
18228        };
18229        shared
18230            .network_allowlist_catalog
18231            .write()
18232            .unwrap_or_else(|poisoned| poisoned.into_inner())
18233            .insert(entry)
18234            .unwrap();
18235
18236        let overlay = shared
18237            .resolve_codex_network_access_overlay_args(
18238                &agent("codex"),
18239                SessionMode::Pty,
18240                ApprovalLevel::Moderate,
18241                Some(&allowlist),
18242            )
18243            .expect("Moderate Codex maps network_access");
18244        assert_eq!(
18245            overlay,
18246            vec![
18247                std::ffi::OsString::from("-c"),
18248                std::ffi::OsString::from("sandbox_workspace_write.network_access=true"),
18249            ]
18250        );
18251
18252        let acp = shared
18253            .resolve_codex_network_access_overlay_args(
18254                &agent("codex"),
18255                SessionMode::Acp,
18256                ApprovalLevel::Moderate,
18257                Some(&allowlist),
18258            )
18259            .unwrap();
18260        assert_eq!(acp, overlay);
18261
18262        let inline = shared
18263            .resolve_codex_network_access_overlay_args(
18264                &agent("codex"),
18265                SessionMode::Inline,
18266                ApprovalLevel::Moderate,
18267                Some(&allowlist),
18268            )
18269            .unwrap_err();
18270        assert_eq!(
18271            inline.code,
18272            NodeFailureCode::UnsupportedNetworkAllowlistMapping
18273        );
18274
18275        for level in [ApprovalLevel::ReadOnly, ApprovalLevel::FullAuto] {
18276            let spec = crate::protocol::SpawnSpec {
18277                target: crate::protocol::SpawnTarget {
18278                    node_id: shared.node_id.clone(),
18279                    workspace_id: WorkspaceId::new("primary").unwrap(),
18280                    worktree_id: None,
18281                },
18282                profile_id: defaults.profile_id.clone(),
18283                expected_profile_revision: defaults.revision.clone(),
18284                overrides: crate::protocol::SpawnOverrides {
18285                    provider: crate::protocol::SpawnOverride::Set {
18286                        value: agent("codex"),
18287                    },
18288                    approval_level: Some(level),
18289                    network_allowlist: Some(allowlist.clone()),
18290                    ..crate::protocol::SpawnOverrides::default()
18291                },
18292                deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18293                idempotency_key: crate::protocol::SpawnIdempotencyKey::new(format!(
18294                    "codex-net-refuse-{}",
18295                    match level {
18296                        ApprovalLevel::ReadOnly => "ro",
18297                        ApprovalLevel::FullAuto => "fa",
18298                        _ => "other",
18299                    }
18300                ))
18301                .unwrap(),
18302                required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18303            };
18304            let resolved = spec.resolve(&defaults).unwrap();
18305            let failure = shared
18306                .resolve_environment_profile(&resolved)
18307                .expect_err("RO/FullAuto must refuse");
18308            assert_eq!(
18309                failure.code,
18310                NodeFailureCode::UnsupportedNetworkAllowlistMapping
18311            );
18312        }
18313    }
18314
18315    /// Claude PTY + catalog permits → ProviderHome `--settings` sidecar with
18316    /// sandbox.network.allowedDomains. ACP / missing ProviderHome refuse.
18317    /// Never secrets on C2; does not invent provider_native Claude keys.
18318    #[test]
18319    fn resolve_claude_settings_network_overlay_pty_writes_sidecar() {
18320        let (shared, _defaults) = station_knobs_fixture();
18321        let allowlist =
18322            crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-domains").unwrap();
18323        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18324            id: allowlist.clone(),
18325            permits: vec![
18326                crate::network_allowlist_catalog::NetworkPermitSketch {
18327                    protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18328                    peer: "github.com:443".to_owned(),
18329                },
18330                crate::network_allowlist_catalog::NetworkPermitSketch {
18331                    protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18332                    peer: "*.npmjs.org".to_owned(),
18333                },
18334            ],
18335            provider_native: None,
18336        };
18337        shared
18338            .network_allowlist_catalog
18339            .write()
18340            .unwrap_or_else(|poisoned| poisoned.into_inner())
18341            .insert(entry)
18342            .unwrap();
18343
18344        let unique = std::time::SystemTime::now()
18345            .duration_since(std::time::UNIX_EPOCH)
18346            .unwrap()
18347            .as_nanos();
18348        let provider_home = std::env::temp_dir().join(format!(
18349            "gate4agent-claude-provider-home-{}-{unique}",
18350            std::process::id(),
18351        ));
18352        std::fs::create_dir_all(&provider_home).unwrap();
18353
18354        let overlay = shared
18355            .resolve_network_allowlist_overlay_args(
18356                &agent("claude"),
18357                SessionMode::Pty,
18358                ApprovalLevel::Moderate,
18359                Some(&allowlist),
18360                Some(provider_home.as_path()),
18361            )
18362            .expect("Claude PTY settings overlay on unix");
18363        let settings_path = provider_home.join(
18364            crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
18365        );
18366        assert_eq!(
18367            overlay,
18368            vec![
18369                std::ffi::OsString::from("--settings"),
18370                std::ffi::OsString::from(settings_path.as_os_str()),
18371            ]
18372        );
18373        let body = std::fs::read_to_string(&settings_path).unwrap();
18374        let value: serde_json::Value = serde_json::from_str(&body).unwrap();
18375        assert_eq!(value["sandbox"]["enabled"], true);
18376        assert_eq!(
18377            value["sandbox"]["network"]["allowedDomains"],
18378            serde_json::json!(["github.com", "*.npmjs.org"])
18379        );
18380        assert!(settings_path.starts_with(&provider_home));
18381
18382        let acp = shared
18383            .resolve_network_allowlist_overlay_args(
18384                &agent("claude"),
18385                SessionMode::Acp,
18386                ApprovalLevel::Moderate,
18387                Some(&allowlist),
18388                Some(provider_home.as_path()),
18389            )
18390            .expect_err("Claude ACP must refuse settings overlay");
18391        assert_eq!(
18392            acp.code,
18393            NodeFailureCode::UnsupportedNetworkAllowlistMapping
18394        );
18395
18396        let no_home = shared
18397            .resolve_network_allowlist_overlay_args(
18398                &agent("claude"),
18399                SessionMode::Pty,
18400                ApprovalLevel::Moderate,
18401                Some(&allowlist),
18402                None,
18403            )
18404            .expect_err("Claude overlay requires ProviderHome");
18405        assert_eq!(
18406            no_home.code,
18407            NodeFailureCode::UnsupportedNetworkAllowlistMapping
18408        );
18409
18410        let bare = crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-bare").unwrap();
18411        shared
18412            .network_allowlist_catalog
18413            .write()
18414            .unwrap_or_else(|poisoned| poisoned.into_inner())
18415            .insert_id(bare.clone())
18416            .unwrap();
18417        let empty = shared
18418            .resolve_network_allowlist_overlay_args(
18419                &agent("claude"),
18420                SessionMode::Pty,
18421                ApprovalLevel::Moderate,
18422                Some(&bare),
18423                Some(provider_home.as_path()),
18424            )
18425            .unwrap();
18426        assert!(empty.is_empty());
18427
18428        let _ = std::fs::remove_dir_all(&provider_home);
18429    }
18430
18431    /// Kimi / Grok with permits stay without invented argv overlays.
18432    #[test]
18433    fn resolve_kimi_grok_network_overlay_stays_empty_without_native() {
18434        let (shared, _defaults) = station_knobs_fixture();
18435        let allowlist =
18436            crate::protocol::SpawnNetworkAllowlistId::new("egress-kimi-permits").unwrap();
18437        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18438            id: allowlist.clone(),
18439            permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
18440                protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18441                peer: "example.com:443".to_owned(),
18442            }],
18443            provider_native: None,
18444        };
18445        shared
18446            .network_allowlist_catalog
18447            .write()
18448            .unwrap_or_else(|poisoned| poisoned.into_inner())
18449            .insert(entry)
18450            .unwrap();
18451        for provider in ["kimi", "grok"] {
18452            let overlay = shared
18453                .resolve_network_allowlist_overlay_args(
18454                    &agent(provider),
18455                    SessionMode::Pty,
18456                    ApprovalLevel::Moderate,
18457                    Some(&allowlist),
18458                    None,
18459                )
18460                .unwrap();
18461            assert!(
18462                overlay.is_empty(),
18463                "{provider} must not invent network argv from permits"
18464            );
18465        }
18466    }
18467
18468    /// Track B: JSON v2 catalog file loads permits; inventory still ids only.
18469    #[test]
18470    fn network_allowlist_catalog_loads_json_v2_and_lists_ids_only() {
18471        let unique = std::time::SystemTime::now()
18472            .duration_since(std::time::UNIX_EPOCH)
18473            .unwrap()
18474            .as_nanos();
18475        let path = std::env::temp_dir().join(format!(
18476            "gate4agent-node-allowlist-json-{}-{unique}.json",
18477            std::process::id(),
18478        ));
18479        std::fs::write(
18480            &path,
18481            r#"{
18482              "schema_version": 2,
18483              "entries": [
18484                {
18485                  "id": "egress-default",
18486                  "permits": [{ "protocol": "tcp", "peer": "127.0.0.1:443" }]
18487                },
18488                { "id": "lab-egress" }
18489              ]
18490            }"#,
18491        )
18492        .unwrap();
18493        let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
18494            .expect("json v2 catalog must load");
18495        let _ = std::fs::remove_file(&path);
18496        assert_eq!(loaded.len(), 2);
18497        let entry = loaded
18498            .get(&crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap())
18499            .unwrap();
18500        assert_eq!(entry.permits.len(), 1);
18501        let (shared, defaults) = station_knobs_fixture();
18502        {
18503            let mut catalog = shared
18504                .network_allowlist_catalog
18505                .write()
18506                .unwrap_or_else(|poisoned| poisoned.into_inner());
18507            *catalog = loaded;
18508        }
18509        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18510        let spec = crate::protocol::SpawnSpec {
18511            target: crate::protocol::SpawnTarget {
18512                node_id: shared.node_id.clone(),
18513                workspace_id: WorkspaceId::new("primary").unwrap(),
18514                worktree_id: None,
18515            },
18516            profile_id: defaults.profile_id.clone(),
18517            expected_profile_revision: defaults.revision.clone(),
18518            overrides: crate::protocol::SpawnOverrides {
18519                network_allowlist: Some(allowlist.clone()),
18520                ..crate::protocol::SpawnOverrides::default()
18521            },
18522            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18523            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18524                "station-knobs-catalog-json",
18525            )
18526            .unwrap(),
18527            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18528        };
18529        let resolved = spec.resolve(&defaults).unwrap();
18530        let receipt = shared
18531            .resolve_environment_profile(&resolved)
18532            .expect("permits-only entry ok for claude")
18533            .expect("env profile present");
18534        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18535        let inventory = shared
18536            .snapshot()
18537            .launch_inventory
18538            .expect("launch inventory present");
18539        let ids = inventory.network_allowlists.expect("ids surface");
18540        assert_eq!(
18541            ids,
18542            vec![
18543                crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap(),
18544                crate::protocol::SpawnNetworkAllowlistId::new("lab-egress").unwrap(),
18545            ]
18546        );
18547    }
18548
18549
18550}
18551
18552
18553#[cfg(test)]
18554mod workspace_environment_collision_tests {
18555    use super::*;
18556    use crate::session_environment::{
18557        NodeSessionMaterializationProfile, NodeSessionPathBinding, NodeSessionPathClass,
18558    };
18559    use gate4agent_catalog::EnvMutation;
18560    use gate4agent_runtime_native::{
18561        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
18562        NativeLaunchProfileId,
18563    };
18564    use std::ffi::OsString;
18565    use std::sync::Arc;
18566
18567    struct EmptyEnvironmentResolver;
18568
18569    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
18570        fn resolve_child_environment(
18571            &self,
18572        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
18573            Ok(vec![EnvMutation {
18574                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
18575                value: None,
18576            }])
18577        }
18578    }
18579
18580    fn agent(value: &str) -> AgentId {
18581        AgentId::new(value).unwrap()
18582    }
18583
18584    /// Two Claude ProviderHome profiles (`CLAUDE_CONFIG_DIR` A vs B) on one node.
18585    fn claude_config_dir_collision_fixture() -> (
18586        NodeShared,
18587        ResolvedEnvironmentProfileReceipt,
18588        ResolvedEnvironmentProfileReceipt,
18589    ) {
18590        let catalog = active_registry().unwrap();
18591        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18592        let control = runtime.native_launch_profile_control();
18593        let workspace = WorkspaceConfig::new(
18594            WorkspaceId::new("primary").unwrap(),
18595            std::env::current_dir().unwrap(),
18596        )
18597        .unwrap();
18598        let shared = NodeShared::new_with_incarnation(
18599            handle,
18600            "fixture-token".to_owned(),
18601            NodeId::new("node-env-collision-test").unwrap(),
18602            NodeIncarnationId::from_bytes([3; crate::protocol::NODE_INCARNATION_ID_BYTES]),
18603            vec![workspace],
18604            vec![agent("claude")],
18605            ProviderRuntimeStatuses::default(),
18606            None,
18607            Vec::new(),
18608            Vec::new(),
18609            SpawnProfileRegistry::default(),
18610            Some(control),
18611            None,
18612            None,
18613            None,
18614            Vec::new(),
18615            Vec::new(),
18616            Vec::new(),
18617            Vec::new(),
18618            None,
18619            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
18620        );
18621
18622        let mut receipts = Vec::new();
18623        for (suffix, native_suffix) in [("a", "a-pty"), ("b", "b-pty")] {
18624            let profile_id =
18625                SpawnEnvironmentProfileId::new(format!("claude-config-dir-{suffix}")).unwrap();
18626            let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(format!(
18627                "claude-config-dir-{suffix}-r1"
18628            ))
18629            .unwrap();
18630            let materialization = NodeSessionMaterializationProfile::new(
18631                Vec::new(),
18632                vec![NodeSessionPathBinding::new(
18633                    "CLAUDE_CONFIG_DIR",
18634                    NodeSessionPathClass::ProviderHome,
18635                )
18636                .unwrap()],
18637                Vec::new(),
18638            )
18639            .unwrap();
18640            let node_profile = NodeEnvironmentProfile::new_with_materialization(
18641                profile_id.clone(),
18642                profile_revision.clone(),
18643                agent("claude"),
18644                [NativeLaunchProfile::new(
18645                    NativeLaunchProfileId::new(format!("claude-config-dir-{native_suffix}"))
18646                        .unwrap(),
18647                    agent("claude"),
18648                    TransportKind::Pty,
18649                    vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
18650                    Arc::new(EmptyEnvironmentResolver),
18651                )
18652                .unwrap()],
18653                Some(materialization),
18654            )
18655            .unwrap();
18656            let (binding, native_profiles, materialization) = node_profile.into_parts();
18657            for native_profile in native_profiles {
18658                runtime.upsert_native_launch_profile(native_profile).unwrap();
18659            }
18660            shared
18661                .environment_profiles
18662                .write()
18663                .unwrap_or_else(|poisoned| poisoned.into_inner())
18664                .insert(binding.id.clone(), binding);
18665            shared
18666                .environment_materialization_profiles
18667                .write()
18668                .unwrap_or_else(|poisoned| poisoned.into_inner())
18669                .insert(profile_id.clone(), materialization.unwrap());
18670            receipts.push(ResolvedEnvironmentProfileReceipt {
18671                profile_id,
18672                profile_revision,
18673                network_allowlist: None,
18674                browser_profile_id: None,
18675            });
18676        }
18677        let profile_b = receipts.pop().unwrap();
18678        let profile_a = receipts.pop().unwrap();
18679        (shared, profile_a, profile_b)
18680    }
18681
18682    #[test]
18683    fn refuse_distinct_claude_config_dir_profiles_on_same_workspace_cwd() {
18684        let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
18685        let primary = WorkspaceId::new("primary").unwrap();
18686        let worktree = WorkspaceId::new("worktree-b").unwrap();
18687        let address = SessionAddress {
18688            workspace_id: primary.clone(),
18689            session: SessionKey {
18690                instance_id: AgentInstanceId(41),
18691                generation: SessionGeneration(1),
18692            },
18693        };
18694        shared.bind_session_with_policy(
18695            &address,
18696            ProviderRuntimePolicy::raw_pty(),
18697            Some(profile_a.clone()),
18698        );
18699
18700        let refused = shared
18701            .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_b))
18702            .unwrap_err();
18703        assert_eq!(refused.code, NodeFailureCode::WorkspaceBusy);
18704
18705        // Some vs None on the same cwd is also a collision.
18706        let refused_none = shared
18707            .ensure_workspace_environment_profile_compatible(&primary, None)
18708            .unwrap_err();
18709        assert_eq!(refused_none.code, NodeFailureCode::WorkspaceBusy);
18710
18711        // Same profile on the same workspace remains allowed.
18712        shared
18713            .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_a))
18714            .unwrap();
18715
18716        // Distinct workspace id (managed-worktree parallel path) allows profile B.
18717        shared
18718            .ensure_workspace_environment_profile_compatible(&worktree, Some(&profile_b))
18719            .unwrap();
18720    }
18721
18722    #[tokio::test]
18723    async fn spawn_session_refuses_conflicting_environment_profile_before_mutation() {
18724        let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
18725        let primary = WorkspaceId::new("primary").unwrap();
18726        let address = SessionAddress {
18727            workspace_id: primary.clone(),
18728            session: SessionKey {
18729                instance_id: AgentInstanceId(41),
18730                generation: SessionGeneration(1),
18731            },
18732        };
18733        shared.bind_session_with_policy(
18734            &address,
18735            ProviderRuntimePolicy::raw_pty(),
18736            Some(profile_a),
18737        );
18738
18739        let failure = shared
18740            .spawn_session_with_deadline(
18741                primary,
18742                agent("claude"),
18743                SessionMode::Pty,
18744                gate4agent_types::TerminalSize {
18745                    rows: 24,
18746                    columns: 80,
18747                },
18748                None,
18749                Some(profile_b),
18750                None,
18751                None,
18752                None,
18753                None,
18754                SpawnRecordPolicy::ProviderIdentityOnly,
18755                Some(Instant::now() + Duration::from_secs(30)),
18756                &[],
18757                None,
18758                ApprovalLevel::default(),
18759                None,
18760                None,
18761            )
18762            .await
18763            .unwrap_err();
18764        assert_eq!(failure.code, NodeFailureCode::WorkspaceBusy);
18765        // Guard runs before instance id mint / bind.
18766        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
18767        assert!(shared.handle.snapshot().sessions.is_empty());
18768    }
18769}
18770
18771#[cfg(test)]
18772mod spawn_characterization_tests {
18773    use super::*;
18774    use crate::session_environment::{
18775        NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
18776        NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
18777        NodeSessionPathClass,
18778    };
18779    use gate4agent_catalog::EnvMutation;
18780    use gate4agent_runtime_native::{
18781        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
18782        NativeLaunchProfile, NativeLaunchProfileId,
18783    };
18784    use std::ffi::OsString;
18785    use std::sync::Arc;
18786
18787    struct EmptyEnvironmentResolver;
18788
18789    struct FixtureSecretResolver {
18790        deny: Arc<AtomicBool>,
18791    }
18792
18793    impl NodeSecretResolver for FixtureSecretResolver {
18794        fn resolve(
18795            &self,
18796            _reference: &NodeSecretReference,
18797        ) -> Result<NodeSecretValue, NodeSecretResolveError> {
18798            if self.deny.load(Ordering::Acquire) {
18799                Err(NodeSecretResolveError::Denied)
18800            } else {
18801                NodeSecretValue::text("fixture-secret")
18802                    .map_err(|_| NodeSecretResolveError::Unavailable)
18803            }
18804        }
18805    }
18806
18807    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
18808        fn resolve_child_environment(
18809            &self,
18810        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
18811            Ok(vec![EnvMutation {
18812                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
18813                value: None,
18814            }])
18815        }
18816    }
18817
18818    fn agent(value: &str) -> AgentId {
18819        AgentId::new(value).unwrap()
18820    }
18821
18822    fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
18823        let unique = std::time::SystemTime::now()
18824            .duration_since(std::time::UNIX_EPOCH)
18825            .unwrap()
18826            .as_nanos();
18827        std::env::temp_dir().join(format!(
18828            "gate4agent-node-{label}-{}-{unique}",
18829            std::process::id(),
18830        ))
18831    }
18832
18833    fn materialization_test_shared(
18834        deny_secret: bool,
18835    ) -> (
18836        NodeShared,
18837        NativeRuntime,
18838        ResolvedEnvironmentProfileReceipt,
18839        PathBuf,
18840        Arc<AtomicBool>,
18841    ) {
18842        let workspace = WorkspaceConfig::new(
18843            WorkspaceId::new("primary").unwrap(),
18844            std::env::current_dir().unwrap(),
18845        )
18846        .unwrap();
18847        materialization_test_shared_with_workspace(deny_secret, workspace)
18848    }
18849
18850    fn materialization_test_shared_with_workspace(
18851        deny_secret: bool,
18852        workspace: WorkspaceConfig,
18853    ) -> (
18854        NodeShared,
18855        NativeRuntime,
18856        ResolvedEnvironmentProfileReceipt,
18857        PathBuf,
18858        Arc<AtomicBool>,
18859    ) {
18860        let root = temporary_workspace_root(if deny_secret {
18861            "materialization-denied"
18862        } else {
18863            "materialization-ready"
18864        });
18865        std::fs::create_dir_all(&root).unwrap();
18866        let materialization_root = root.join("session-environments");
18867        let deny = Arc::new(AtomicBool::new(deny_secret));
18868        let resolver = Arc::new(FixtureSecretResolver {
18869            deny: Arc::clone(&deny),
18870        });
18871        let materializer = SessionEnvironmentMaterializer::new(
18872            materialization_root,
18873            resolver,
18874        )
18875        .unwrap();
18876        let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
18877        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
18878            "materialized-claude-r1",
18879        )
18880        .unwrap();
18881        let materialization = NodeSessionMaterializationProfile::new(
18882            vec![NodeSessionEnvironmentMutation::SetSecret {
18883                key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
18884                reference: NodeSecretReference::new("fixture-token").unwrap(),
18885            }],
18886            vec![NodeSessionPathBinding::new(
18887                "GATE4AGENT_PROVIDER_HOME",
18888                NodeSessionPathClass::ProviderHome,
18889            )
18890            .unwrap()],
18891            vec![NodeSessionFile::secret(
18892                NodeSessionPathClass::Config,
18893                "auth/token",
18894                NodeSecretReference::new("fixture-token").unwrap(),
18895            )
18896            .unwrap()],
18897        )
18898        .unwrap();
18899        let catalog = active_registry().unwrap();
18900        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18901        let control = runtime.native_launch_profile_control();
18902        let node_profile = NodeEnvironmentProfile::new_with_materialization(
18903            profile_id.clone(),
18904            profile_revision.clone(),
18905            agent("claude"),
18906            [NativeLaunchProfile::new(
18907                NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
18908                agent("claude"),
18909                TransportKind::Pty,
18910                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
18911                Arc::new(EmptyEnvironmentResolver),
18912            )
18913            .unwrap()],
18914            Some(materialization),
18915        )
18916        .unwrap();
18917        let (binding, native_profiles, materialization) = node_profile.into_parts();
18918        for native_profile in native_profiles {
18919            runtime.upsert_native_launch_profile(native_profile).unwrap();
18920        }
18921        let shared = NodeShared::new_with_incarnation(
18922            handle,
18923            "fixture-token".to_owned(),
18924            NodeId::new("node-materialization-test").unwrap(),
18925            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
18926            vec![workspace],
18927            vec![agent("claude")],
18928            ProviderRuntimeStatuses::default(),
18929            None,
18930            Vec::new(),
18931            Vec::new(),
18932            SpawnProfileRegistry::default(),
18933            Some(control),
18934            None,
18935            Some(materializer),
18936            None,
18937            Vec::new(),
18938            Vec::new(),
18939            Vec::new(),
18940            Vec::new(),
18941            None,
18942            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
18943        );
18944        shared
18945            .environment_profiles
18946            .write()
18947            .unwrap_or_else(|poisoned| poisoned.into_inner())
18948            .insert(binding.id.clone(), binding);
18949        shared
18950            .environment_materialization_profiles
18951            .write()
18952            .unwrap_or_else(|poisoned| poisoned.into_inner())
18953            .insert(profile_id.clone(), materialization.unwrap());
18954        (
18955            shared,
18956            runtime,
18957            ResolvedEnvironmentProfileReceipt {
18958                profile_id,
18959                profile_revision,
18960                network_allowlist: None,
18961                browser_profile_id: None,
18962            },
18963            root,
18964            deny,
18965        )
18966    }
18967
18968
18969    // ---- Characterization of the spawn preparation block (pre-SpawnPreparer) ----
18970
18971    async fn characterization_spawn(
18972        shared: &NodeShared,
18973        mode: SessionMode,
18974        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
18975        required: &[ProviderRuntimeCapability],
18976        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
18977    ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
18978        shared
18979            .spawn_session_with_deadline(
18980                WorkspaceId::new("primary").unwrap(),
18981                agent("claude"),
18982                mode,
18983                gate4agent_types::TerminalSize { rows: 24, columns: 80 },
18984                None,
18985                environment_profile,
18986                None,
18987                None,
18988                None,
18989                Some(ProviderRuntimePolicy::raw_pty()),
18990                SpawnRecordPolicy::ProviderIdentityOnly,
18991                Some(Instant::now() + Duration::from_secs(30)),
18992                required,
18993                harness_mcp,
18994                ApprovalLevel::default(),
18995                None,
18996                None,
18997            )
18998            .await
18999    }
19000
19001    fn assert_no_session_state(shared: &NodeShared) {
19002        assert!(shared.handle.snapshot().sessions.is_empty());
19003        assert!(shared
19004            .session_bindings
19005            .lock()
19006            .unwrap_or_else(|poisoned| poisoned.into_inner())
19007            .is_empty());
19008        assert!(shared
19009            .materializations
19010            .lock()
19011            .unwrap_or_else(|poisoned| poisoned.into_inner())
19012            .is_empty());
19013    }
19014
19015    #[tokio::test]
19016    async fn spawn_unadmitted_required_capability_refuses_before_id_mint() {
19017        let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19018        let error = characterization_spawn(
19019            &shared,
19020            SessionMode::Pty,
19021            None,
19022            &[ProviderRuntimeCapability::SemanticReadiness],
19023            None,
19024        )
19025        .await
19026        .unwrap_err();
19027        assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
19028        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
19029        assert_no_session_state(&shared);
19030        drop(shared);
19031        drop(runtime);
19032        std::fs::remove_dir_all(root).unwrap();
19033    }
19034
19035    #[tokio::test]
19036    async fn spawn_denied_environment_materialization_fails_after_id_mint_with_no_state() {
19037        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
19038        let error = characterization_spawn(&shared, SessionMode::Pty, Some(receipt), &[], None)
19039            .await
19040            .unwrap_err();
19041        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
19042        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 2);
19043        assert_no_session_state(&shared);
19044        drop(shared);
19045        drop(runtime);
19046        std::fs::remove_dir_all(root).unwrap();
19047    }
19048
19049    #[test]
19050    fn remove_binding_returns_binding_and_is_idempotent_for_environment_session() {
19051        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
19052        let address = SessionAddress {
19053            workspace_id: WorkspaceId::new("primary").unwrap(),
19054            session: SessionKey {
19055                instance_id: AgentInstanceId(77),
19056                generation: SessionGeneration(1),
19057            },
19058        };
19059        shared.bind_session_with_policy(&address, ProviderRuntimePolicy::raw_pty(), Some(receipt));
19060        let removed = shared.remove_binding(&address).expect("binding removed");
19061        assert!(removed.environment_profile.is_some());
19062        assert!(shared.remove_binding(&address).is_none());
19063        assert_no_session_state(&shared);
19064        drop(shared);
19065        drop(runtime);
19066        std::fs::remove_dir_all(root).unwrap();
19067    }
19068
19069    async fn characterization_prepared_harness(provider: &str) -> PreparedHarnessMcpSpawn {
19070        use crate::harness_mcp_proxy::{HarnessMcpProxyRegistry, ReviewedHarnessMcpProgram};
19071        use crate::protocol::{HarnessMcpActivationDigest, HarnessMcpLaunchV1, HarnessMcpReservationId};
19072        let helper = ReviewedHarnessMcpProgram::review(std::env::current_exe().unwrap())
19073            .expect("review current test executable as fixture helper");
19074        let registry = HarnessMcpProxyRegistry::new(helper);
19075        let spec = crate::protocol::SpawnSpec {
19076            target: crate::protocol::SpawnTarget {
19077                node_id: NodeId::new("node-characterization").unwrap(),
19078                workspace_id: WorkspaceId::new("primary").unwrap(),
19079                worktree_id: None,
19080            },
19081            profile_id: crate::protocol::SpawnProfileId::new("claude").unwrap(),
19082            expected_profile_revision: crate::protocol::SpawnProfileRevision::new("r1").unwrap(),
19083            overrides: crate::protocol::SpawnOverrides::default(),
19084            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19085            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("characterization-harness").unwrap(),
19086            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19087        };
19088        let reservation = HarnessMcpReservationId::new(format!("hmcpres_{}", "c".repeat(24))).unwrap();
19089        let digest = HarnessMcpActivationDigest::new(format!("sha256:{}", "c".repeat(64))).unwrap();
19090        let launch = HarnessMcpLaunchV1 {
19091            server_name: "fixture-harness".to_owned(),
19092            args: vec!["--session-proxy".to_owned()],
19093            endpoint_env: "FIXTURE_SESSION_ENDPOINT".to_owned(),
19094            token_env: "FIXTURE_SESSION_TOKEN".to_owned(),
19095            program_env: "FIXTURE_MCP_PROGRAM".to_owned(),
19096            trace: None,
19097            scrub_env: Vec::new(),
19098        };
19099        let expires = unix_time_ms().saturating_add(30_000);
19100        registry.arm(reservation.clone(), digest.clone(), spec.clone(), launch, expires).await.unwrap();
19101        registry
19102            .prepare_spawn(&reservation, &digest, &spec, expires, agent(provider))
19103            .unwrap()
19104    }
19105
19106    #[tokio::test]
19107    async fn spawn_harness_overlay_refuses_inline_and_provider_mismatch_with_no_state() {
19108        let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19109        let prepared = characterization_prepared_harness("claude").await;
19110        let error = characterization_spawn(&shared, SessionMode::Inline, None, &[], Some(&prepared))
19111            .await
19112            .unwrap_err();
19113        // Inline is stopped by runtime admission before the harness check runs.
19114        assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
19115        let mismatched = characterization_prepared_harness("codex").await;
19116        let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&mismatched))
19117            .await
19118            .unwrap_err();
19119        assert_eq!(error.code, NodeFailureCode::BindingMismatch);
19120        assert_no_session_state(&shared);
19121        drop(shared);
19122        drop(runtime);
19123        std::fs::remove_dir_all(root).unwrap();
19124    }
19125
19126    #[tokio::test]
19127    async fn spawn_harness_overlay_without_launch_control_is_unavailable_with_no_state() {
19128        let (mut shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19129        shared.native_launch_profile_control = None;
19130        let prepared = characterization_prepared_harness("claude").await;
19131        let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&prepared))
19132            .await
19133            .unwrap_err();
19134        assert_eq!(error.code, NodeFailureCode::HarnessMcpUnavailable);
19135        assert_no_session_state(&shared);
19136        drop(shared);
19137        drop(runtime);
19138        std::fs::remove_dir_all(root).unwrap();
19139    }
19140}
19141
19142#[cfg(all(test, windows))]
19143mod tests {
19144    use super::*;
19145    use gate4agent_types::ProviderActivity;
19146    use crate::session_environment::{
19147        NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
19148        NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
19149        NodeSessionPathClass,
19150    };
19151    use gate4agent_catalog::EnvMutation;
19152    use gate4agent_runtime_native::{
19153        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
19154        NativeLaunchProfile, NativeLaunchProfileId,
19155    };
19156    use std::ffi::OsString;
19157    use std::sync::Arc;
19158
19159    struct EmptyEnvironmentResolver;
19160
19161    struct FixtureSecretResolver {
19162        deny: Arc<AtomicBool>,
19163    }
19164
19165    impl NodeSecretResolver for FixtureSecretResolver {
19166        fn resolve(
19167            &self,
19168            _reference: &NodeSecretReference,
19169        ) -> Result<NodeSecretValue, NodeSecretResolveError> {
19170            if self.deny.load(Ordering::Acquire) {
19171                Err(NodeSecretResolveError::Denied)
19172            } else {
19173                NodeSecretValue::text("fixture-secret")
19174                    .map_err(|_| NodeSecretResolveError::Unavailable)
19175            }
19176        }
19177    }
19178
19179    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19180        fn resolve_child_environment(
19181            &self,
19182        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19183            Ok(vec![EnvMutation {
19184                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19185                value: None,
19186            }])
19187        }
19188    }
19189
19190    fn agent(value: &str) -> AgentId {
19191        AgentId::new(value).unwrap()
19192    }
19193
19194    fn terminal_test_shared_with_mode(mode: WorktreeServiceMode) -> NodeShared {
19195        let catalog = active_registry().unwrap();
19196        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19197        let workspace = WorkspaceConfig::new(
19198            WorkspaceId::new("primary").unwrap(),
19199            std::env::current_dir().unwrap(),
19200        )
19201        .unwrap()
19202        .with_worktree_service_mode(mode);
19203        NodeShared::new(
19204            handle,
19205            "fixture-token".to_owned(),
19206            NodeId::new("node-terminal-test").unwrap(),
19207            vec![workspace],
19208            vec![agent("claude")],
19209        )
19210    }
19211
19212    fn terminal_test_shared() -> NodeShared {
19213        terminal_test_shared_with_mode(WorktreeServiceMode::Manual)
19214    }
19215
19216    #[cfg(feature = "fixture")]
19217    #[test]
19218    fn managed_worktree_v2_failure_probe_keeps_only_the_latest_code() {
19219        let probe = SpawnManagedWorktreeV2FailureProbe::default();
19220        assert_eq!(probe.latest_code(), None);
19221
19222        probe.record(Some(NodeFailureCode::ManagedWorktreeOwnershipConflict));
19223        assert_eq!(
19224            probe.latest_code(),
19225            Some(NodeFailureCode::ManagedWorktreeOwnershipConflict),
19226        );
19227
19228        probe.record(Some(NodeFailureCode::SpawnDeadlineExceeded));
19229        assert_eq!(
19230            probe.latest_code(),
19231            Some(NodeFailureCode::SpawnDeadlineExceeded),
19232        );
19233
19234        probe.record(None);
19235        assert_eq!(probe.latest_code(), None);
19236    }
19237
19238    #[test]
19239    fn launch_snapshot_is_authoritative_bounded_and_path_free() {
19240        let source = temporary_workspace_root("launch-inventory-source");
19241        let allocation = temporary_workspace_root("launch-inventory-allocation");
19242        std::fs::create_dir_all(&source).unwrap();
19243        std::fs::create_dir_all(&allocation).unwrap();
19244        let workspace_id = WorkspaceId::new("primary").unwrap();
19245        let mut workspace = WorkspaceConfig::new(workspace_id.clone(), &source)
19246            .unwrap()
19247            .with_worktree_service_mode(WorktreeServiceMode::Managed);
19248        for index in 0..MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE {
19249            workspace = workspace
19250                .with_managed_worktree_profile(
19251                    ManagedWorktreeProfile::new(
19252                        WorktreeProfileId::new(format!("profile-{index}")).unwrap(),
19253                        crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
19254                        &allocation,
19255                        "gate4agent",
19256                        "HEAD",
19257                        ManagedWorktreeRetention::RemoveWhenReleased,
19258                    )
19259                    .unwrap(),
19260                )
19261                .unwrap();
19262        }
19263        let overflow = workspace.clone().with_managed_worktree_profile(
19264            ManagedWorktreeProfile::new(
19265                WorktreeProfileId::new("overflow").unwrap(),
19266                crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
19267                &allocation,
19268                "gate4agent",
19269                "HEAD",
19270                ManagedWorktreeRetention::Retain,
19271            )
19272            .unwrap(),
19273        );
19274        assert!(matches!(
19275            overflow,
19276            Err(NodeServerError::ManagedWorktreeProfileCapacity {
19277                max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
19278                ..
19279            }),
19280        ));
19281
19282        let catalog = active_registry().unwrap();
19283        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19284        let shared = NodeShared::new(
19285            handle,
19286            "fixture-token".to_owned(),
19287            NodeId::new("node-launch-inventory").unwrap(),
19288            vec![workspace],
19289            vec![agent("claude")],
19290        );
19291        let snapshot = shared.snapshot();
19292        let launch = snapshot.launch_inventory.as_ref().unwrap();
19293        assert_eq!(launch.spawn_profiles.as_ref().unwrap().len(), 1);
19294        assert!(launch.bundles.as_ref().unwrap().is_empty());
19295        let profiles = snapshot.workspaces[0]
19296            .managed_worktree_profiles
19297            .as_ref()
19298            .unwrap();
19299        assert_eq!(
19300            snapshot.workspaces[0].worktree_service_mode,
19301            Some(WorktreeServiceMode::Managed),
19302        );
19303        assert_eq!(
19304            profiles.profiles.len(),
19305            MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
19306        );
19307        let public_inventory = serde_json::to_string(&(launch, profiles)).unwrap();
19308        for forbidden in ["allocation_root", "branch_prefix", "base", "prompt", "environment", "home"] {
19309            assert!(!public_inventory.contains(forbidden), "launch inventory leaked {forbidden}");
19310        }
19311        let legacy = snapshot_for_wire(
19312            &shared,
19313            true,
19314            true,
19315            false,
19316            false,
19317            true,
19318            false,
19319            true,
19320            false,
19321            true,
19322        );
19323        assert!(legacy.launch_inventory.is_none());
19324        assert!(legacy.workspaces[0].managed_worktree_profiles.is_none());
19325
19326        drop(shared);
19327        std::fs::remove_dir_all(source).unwrap();
19328        std::fs::remove_dir_all(allocation).unwrap();
19329    }
19330
19331    #[test]
19332    fn agent_progress_projection_omits_sensitive_provider_payloads_and_is_capability_gated() {
19333        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
19334            capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
19335        }));
19336        let source = gate4agent_types::ProviderSource {
19337            family: AdapterFamily::PtySemantic,
19338            binding: AdapterBinding::new(
19339                gate4agent_types::AdapterId::new("claude").unwrap(),
19340                "fixture/v1",
19341                gate4agent_types::AdapterVerification::SyntheticFixture,
19342            )
19343            .unwrap(),
19344        };
19345        let provider = ProviderSnapshot {
19346            sequence: 17,
19347            session: Some(ProviderSessionIdentity {
19348                key: ProviderSessionKey::SessionId,
19349                id: "PRIVATE_PROVIDER_SESSION".to_owned(),
19350                transcript_path: Some(r"C:\PRIVATE_PROVIDER_PATH".to_owned()),
19351            }),
19352            model: Some("PRIVATE_MODEL".to_owned()),
19353            tools: vec!["PRIVATE_TOOL_INVENTORY".to_owned()],
19354            completed_turns: 3,
19355            usage: gate4agent_types::TokenUsage {
19356                input_tokens: 10,
19357                output_tokens: 20,
19358                cache_read_tokens: 30,
19359                cache_write_tokens: 40,
19360                reasoning_tokens: 50,
19361                context_window: Some(200_000),
19362            },
19363            lead_activity: ProviderActivity::WaitingForInput,
19364            activity: ProviderActivity::WaitingForInput,
19365            current_prompt: Some("PRIVATE_CURRENT_PROMPT".to_owned()),
19366            active_tools: (0..9)
19367                .map(|index| gate4agent_types::ActiveProviderTool {
19368                    id: format!("private-tool-id-{index}"),
19369                    name: if index == 0 {
19370                        format!("  Read\u{0000}{}", "x".repeat(80))
19371                    } else {
19372                        format!("Tool-{index}")
19373                    },
19374                    input_json: "PRIVATE_TOOL_INPUT_JSON".to_owned(),
19375                })
19376                .collect(),
19377            interactions: vec![gate4agent_types::ProviderInteraction {
19378                id: gate4agent_types::ProviderInteractionId(17),
19379                source: source.clone(),
19380                provider_request_id: Some("PRIVATE_PROVIDER_REQUEST".to_owned()),
19381                interaction_kind: ProviderInteractionKind::Approval,
19382                tool_name: " Approve\u{0007}Tool ".to_owned(),
19383                prompt: "PRIVATE_INTERACTION_PROMPT".to_owned(),
19384                agent_id: Some("PRIVATE_INTERACTION_AGENT".to_owned()),
19385                resume_lead_activity: Some(ProviderActivity::Working),
19386                status: ProviderInteractionStatus::Pending,
19387            }],
19388            subagents: vec![gate4agent_types::ProviderSubagent {
19389                source: source.clone(),
19390                provider_agent_id: "PRIVATE_SUBAGENT_ID".to_owned(),
19391                agent_type: Some("PRIVATE_SUBAGENT_TYPE".to_owned()),
19392                description: Some("PRIVATE_SUBAGENT_DESCRIPTION".to_owned()),
19393            }],
19394            sources: vec![gate4agent_types::ProviderSourceCursor {
19395                source,
19396                sequence: 17,
19397                gap_count: 2,
19398                stale: true,
19399            }],
19400            last_event: Some(ProviderEvent::Error {
19401                message: "PRIVATE_PROVIDER_ERROR".to_owned(),
19402            }),
19403            gap_count: 2,
19404            stale: true,
19405        };
19406        let address = terminal_address(3);
19407        let entry = agent_progress_from_provider_snapshot(address.clone(), &provider).unwrap();
19408        assert_eq!(entry.address, address);
19409        assert_eq!(entry.progress.provider_sequence, 17);
19410        assert_eq!(entry.progress.active_tool_count, 9);
19411        assert_eq!(
19412            entry.progress.active_tool_labels.len(),
19413            MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS,
19414        );
19415        assert_eq!(entry.progress.subagent_count, 1);
19416        assert_eq!(entry.progress.last_event_kind, Some(AgentProgressEventKindV1::Error));
19417        assert!(entry.progress.truncated);
19418        let encoded = serde_json::to_string(&entry).unwrap();
19419        for forbidden in [
19420            "PRIVATE_PROVIDER_SESSION",
19421            "PRIVATE_PROVIDER_PATH",
19422            "PRIVATE_MODEL",
19423            "PRIVATE_TOOL_INVENTORY",
19424            "PRIVATE_CURRENT_PROMPT",
19425            "PRIVATE_TOOL_INPUT_JSON",
19426            "PRIVATE_PROVIDER_REQUEST",
19427            "PRIVATE_INTERACTION_PROMPT",
19428            "PRIVATE_INTERACTION_AGENT",
19429            "PRIVATE_SUBAGENT_ID",
19430            "PRIVATE_SUBAGENT_TYPE",
19431            "PRIVATE_SUBAGENT_DESCRIPTION",
19432            "PRIVATE_PROVIDER_ERROR",
19433            "input_json",
19434            "current_prompt",
19435            "session_id",
19436        ] {
19437            assert!(!encoded.contains(forbidden), "agent progress leaked {forbidden}");
19438        }
19439
19440        let snapshot = NodeSnapshot {
19441            node_id: NodeId::new("node-progress-test").unwrap(),
19442            enabled_providers: vec![agent("claude")],
19443            provider_runtime_statuses: ProviderRuntimeStatuses::default(),
19444            workspaces: Vec::new(),
19445            session_records: Vec::new(),
19446            managed_worktrees: Vec::new(),
19447            launch_inventory: None,
19448            agent_progress: vec![entry],
19449        };
19450        let mut legacy_reply = ResponseEnvelope {
19451            request_id: 1,
19452            result: Ok(NodeResponse::Snapshot {
19453                event_sequence: 0,
19454                controller: None,
19455                snapshot: snapshot.clone(),
19456            }),
19457        };
19458        project_response_without_agent_progress(&mut legacy_reply);
19459        let Ok(NodeResponse::Snapshot { snapshot: legacy, .. }) = legacy_reply.result else {
19460            panic!("agent progress projection changed response kind");
19461        };
19462        assert!(legacy.agent_progress.is_empty());
19463        assert_eq!(snapshot.agent_progress.len(), 1);
19464    }
19465
19466    fn terminal_address(generation: u64) -> SessionAddress {
19467        SessionAddress {
19468            workspace_id: WorkspaceId::new("primary").unwrap(),
19469            session: SessionKey {
19470                instance_id: AgentInstanceId(41),
19471                generation: SessionGeneration(generation),
19472            },
19473        }
19474    }
19475
19476    fn spawn_spec_fixture() -> SpawnSpec {
19477        SpawnSpec {
19478            target: crate::protocol::SpawnTarget {
19479                node_id: NodeId::new("node-terminal-test").unwrap(),
19480                workspace_id: WorkspaceId::new("primary").unwrap(),
19481                worktree_id: None,
19482            },
19483            profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
19484            expected_profile_revision: crate::protocol::SpawnProfileRevision::new("builtin-v1")
19485                .unwrap(),
19486            overrides: crate::protocol::SpawnOverrides::default(),
19487            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19488            idempotency_key: SpawnIdempotencyKey::new("spawn-spec-fixture").unwrap(),
19489            required_capabilities: SpawnRequiredCapabilities::default(),
19490        }
19491    }
19492
19493    fn environment_profile_test_shared(
19494    ) -> (NodeShared, NativeRuntime, ResolvedEnvironmentProfileReceipt) {
19495        let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
19496        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19497            "local-claude-r1",
19498        )
19499        .unwrap();
19500        let spawn_profiles = SpawnProfileRegistry::new([
19501            crate::protocol::SpawnProfileDefaults {
19502                profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
19503                revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
19504                provider: agent("claude"),
19505                mode: SessionMode::Pty,
19506                terminal_size: gate4agent_types::TerminalSize {
19507                    rows: 24,
19508                    columns: 80,
19509                },
19510                prompt: None,
19511                bundle_id: None,
19512                context_id: None,
19513                environment_profile_id: Some(profile_id.clone()),
19514            },
19515        ])
19516        .unwrap();
19517        let catalog = active_registry().unwrap();
19518        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19519        let control = runtime.native_launch_profile_control();
19520        let node_profile = NodeEnvironmentProfile::new(
19521            profile_id.clone(),
19522            profile_revision.clone(),
19523            agent("claude"),
19524            [NativeLaunchProfile::new(
19525                NativeLaunchProfileId::new("local-claude-pty").unwrap(),
19526                agent("claude"),
19527                TransportKind::Pty,
19528                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19529                Arc::new(EmptyEnvironmentResolver),
19530            )
19531            .unwrap()],
19532        )
19533        .unwrap();
19534        let (binding, native_profiles, _materialization) = node_profile.into_parts();
19535        for native_profile in native_profiles {
19536            runtime.upsert_native_launch_profile(native_profile).unwrap();
19537        }
19538        let workspace = WorkspaceConfig::new(
19539            WorkspaceId::new("primary").unwrap(),
19540            std::env::current_dir().unwrap(),
19541        )
19542        .unwrap();
19543        let shared = NodeShared::new_with_incarnation(
19544            handle,
19545            "fixture-token".to_owned(),
19546            NodeId::new("node-terminal-test").unwrap(),
19547            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19548            vec![workspace],
19549            vec![agent("claude")],
19550            ProviderRuntimeStatuses::default(),
19551            None,
19552            Vec::new(),
19553            Vec::new(),
19554            spawn_profiles,
19555            Some(control),
19556            None,
19557            None,
19558            None,
19559            Vec::new(),
19560            Vec::new(),
19561            Vec::new(),
19562            Vec::new(),
19563            None,
19564            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19565        );
19566        shared
19567            .environment_profiles
19568            .write()
19569            .unwrap_or_else(|poisoned| poisoned.into_inner())
19570            .insert(binding.id.clone(), binding);
19571        (
19572            shared,
19573            runtime,
19574            ResolvedEnvironmentProfileReceipt {
19575                profile_id,
19576                profile_revision,
19577                network_allowlist: None,
19578                browser_profile_id: None,
19579            },
19580        )
19581    }
19582
19583    fn materialization_test_shared(
19584        deny_secret: bool,
19585    ) -> (
19586        NodeShared,
19587        NativeRuntime,
19588        ResolvedEnvironmentProfileReceipt,
19589        PathBuf,
19590        Arc<AtomicBool>,
19591    ) {
19592        let workspace = WorkspaceConfig::new(
19593            WorkspaceId::new("primary").unwrap(),
19594            std::env::current_dir().unwrap(),
19595        )
19596        .unwrap();
19597        materialization_test_shared_with_workspace(deny_secret, workspace)
19598    }
19599
19600    fn materialization_test_shared_with_workspace(
19601        deny_secret: bool,
19602        workspace: WorkspaceConfig,
19603    ) -> (
19604        NodeShared,
19605        NativeRuntime,
19606        ResolvedEnvironmentProfileReceipt,
19607        PathBuf,
19608        Arc<AtomicBool>,
19609    ) {
19610        let root = temporary_workspace_root(if deny_secret {
19611            "materialization-denied"
19612        } else {
19613            "materialization-ready"
19614        });
19615        std::fs::create_dir_all(&root).unwrap();
19616        let materialization_root = root.join("session-environments");
19617        let deny = Arc::new(AtomicBool::new(deny_secret));
19618        let resolver = Arc::new(FixtureSecretResolver {
19619            deny: Arc::clone(&deny),
19620        });
19621        let materializer = SessionEnvironmentMaterializer::new(
19622            materialization_root,
19623            resolver,
19624        )
19625        .unwrap();
19626        let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
19627        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19628            "materialized-claude-r1",
19629        )
19630        .unwrap();
19631        let materialization = NodeSessionMaterializationProfile::new(
19632            vec![NodeSessionEnvironmentMutation::SetSecret {
19633                key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
19634                reference: NodeSecretReference::new("fixture-token").unwrap(),
19635            }],
19636            vec![NodeSessionPathBinding::new(
19637                "GATE4AGENT_PROVIDER_HOME",
19638                NodeSessionPathClass::ProviderHome,
19639            )
19640            .unwrap()],
19641            vec![NodeSessionFile::secret(
19642                NodeSessionPathClass::Config,
19643                "auth/token",
19644                NodeSecretReference::new("fixture-token").unwrap(),
19645            )
19646            .unwrap()],
19647        )
19648        .unwrap();
19649        let catalog = active_registry().unwrap();
19650        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19651        let control = runtime.native_launch_profile_control();
19652        let node_profile = NodeEnvironmentProfile::new_with_materialization(
19653            profile_id.clone(),
19654            profile_revision.clone(),
19655            agent("claude"),
19656            [NativeLaunchProfile::new(
19657                NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
19658                agent("claude"),
19659                TransportKind::Pty,
19660                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19661                Arc::new(EmptyEnvironmentResolver),
19662            )
19663            .unwrap()],
19664            Some(materialization),
19665        )
19666        .unwrap();
19667        let (binding, native_profiles, materialization) = node_profile.into_parts();
19668        for native_profile in native_profiles {
19669            runtime.upsert_native_launch_profile(native_profile).unwrap();
19670        }
19671        let shared = NodeShared::new_with_incarnation(
19672            handle,
19673            "fixture-token".to_owned(),
19674            NodeId::new("node-materialization-test").unwrap(),
19675            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19676            vec![workspace],
19677            vec![agent("claude")],
19678            ProviderRuntimeStatuses::default(),
19679            None,
19680            Vec::new(),
19681            Vec::new(),
19682            SpawnProfileRegistry::default(),
19683            Some(control),
19684            None,
19685            Some(materializer),
19686            None,
19687            Vec::new(),
19688            Vec::new(),
19689            Vec::new(),
19690            Vec::new(),
19691            None,
19692            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19693        );
19694        shared
19695            .environment_profiles
19696            .write()
19697            .unwrap_or_else(|poisoned| poisoned.into_inner())
19698            .insert(binding.id.clone(), binding);
19699        shared
19700            .environment_materialization_profiles
19701            .write()
19702            .unwrap_or_else(|poisoned| poisoned.into_inner())
19703            .insert(profile_id.clone(), materialization.unwrap());
19704        (
19705            shared,
19706            runtime,
19707            ResolvedEnvironmentProfileReceipt {
19708                profile_id,
19709                profile_revision,
19710                network_allowlist: None,
19711                browser_profile_id: None,
19712            },
19713            root,
19714            deny,
19715        )
19716    }
19717
19718    #[cfg(feature = "fixture")]
19719    #[derive(Clone, Copy)]
19720    enum CodexHomeProfile {
19721        Missing,
19722        WrongClass,
19723        Exact,
19724    }
19725
19726    #[cfg(feature = "fixture")]
19727    fn codex_bundle_test_shared(
19728        home_profile: CodexHomeProfile,
19729    ) -> (
19730        NodeShared,
19731        NativeRuntime,
19732        ResolvedEnvironmentProfileReceipt,
19733        NodeBundle,
19734        PathBuf,
19735    ) {
19736        const ROOT_MANIFEST: &[u8] = br#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"review-tools"}"#;
19737        const CLAUDE_MANIFEST: &[u8] =
19738            br#"{"name":"review-tools","version":"1.0.0","description":"Review helpers"}"#;
19739        const SKILL: &[u8] = b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n";
19740        const DIGEST: &str =
19741            "sha256:941f20868a6ef49b4329bf1bb1368515763f5b64d8279b05cd9700966083d707";
19742
19743        let (shared, mut runtime, _claude_receipt, root, _deny) =
19744            materialization_test_shared(false);
19745        let profile_id = SpawnEnvironmentProfileId::new(match home_profile {
19746            CodexHomeProfile::Missing => "codex-home-missing",
19747            CodexHomeProfile::WrongClass => "codex-home-wrong",
19748            CodexHomeProfile::Exact => "codex-home-exact",
19749        })
19750        .unwrap();
19751        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19752            "codex-home-r1",
19753        )
19754        .unwrap();
19755        let materialization = match home_profile {
19756            CodexHomeProfile::Missing => None,
19757            CodexHomeProfile::WrongClass | CodexHomeProfile::Exact => {
19758                Some(NodeSessionMaterializationProfile::new(
19759                    Vec::new(),
19760                    vec![NodeSessionPathBinding::new(
19761                        "CODEX_HOME",
19762                        if matches!(home_profile, CodexHomeProfile::Exact) {
19763                            NodeSessionPathClass::ProviderHome
19764                        } else {
19765                            NodeSessionPathClass::Config
19766                        },
19767                    )
19768                    .unwrap()],
19769                    Vec::new(),
19770                )
19771                .unwrap())
19772            }
19773        };
19774        let node_profile = NodeEnvironmentProfile::new_with_materialization(
19775            profile_id.clone(),
19776            profile_revision.clone(),
19777            agent("codex"),
19778            [NativeLaunchProfile::new(
19779                NativeLaunchProfileId::new(format!("{profile_id}-pty")).unwrap(),
19780                agent("codex"),
19781                TransportKind::Pty,
19782                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19783                Arc::new(EmptyEnvironmentResolver),
19784            )
19785            .unwrap()],
19786            materialization,
19787        )
19788        .unwrap();
19789        let (binding, native_profiles, materialization) = node_profile.into_parts();
19790        for native_profile in native_profiles {
19791            runtime.upsert_native_launch_profile(native_profile).unwrap();
19792        }
19793        shared
19794            .environment_profiles
19795            .write()
19796            .unwrap_or_else(|poisoned| poisoned.into_inner())
19797            .insert(binding.id.clone(), binding);
19798        if let Some(materialization) = materialization {
19799            shared
19800                .environment_materialization_profiles
19801                .write()
19802                .unwrap_or_else(|poisoned| poisoned.into_inner())
19803                .insert(profile_id.clone(), materialization);
19804        }
19805
19806        let bundle_source = root.join(format!("bundle-source-{profile_id}"));
19807        std::fs::create_dir_all(bundle_source.join(".claude-plugin")).unwrap();
19808        std::fs::create_dir_all(bundle_source.join("skills/review-code")).unwrap();
19809        std::fs::write(bundle_source.join("plugin.json"), ROOT_MANIFEST).unwrap();
19810        std::fs::write(
19811            bundle_source.join(".claude-plugin/plugin.json"),
19812            CLAUDE_MANIFEST,
19813        )
19814        .unwrap();
19815        std::fs::write(bundle_source.join("skills/review-code/SKILL.md"), SKILL).unwrap();
19816        crate::bundle_catalog::protect_bundle_source_tree_fixture(&bundle_source).unwrap();
19817        let bundle = NodeBundle::new(
19818            crate::protocol::SpawnBundleId::new("review-tools").unwrap(),
19819            crate::protocol::SpawnBundleRevision::new("review-tools-r1").unwrap(),
19820            crate::protocol::SpawnBundleDigest::new(DIGEST).unwrap(),
19821            &bundle_source,
19822        )
19823        .unwrap();
19824        *shared
19825            .bundle_catalog
19826            .write()
19827            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
19828            BundleCatalog::new([bundle.clone()]).unwrap();
19829        (
19830            shared,
19831            runtime,
19832            ResolvedEnvironmentProfileReceipt {
19833                profile_id,
19834                profile_revision,
19835                network_allowlist: None,
19836                browser_profile_id: None,
19837            },
19838            bundle,
19839            root,
19840        )
19841    }
19842
19843    #[cfg(feature = "fixture")]
19844    #[test]
19845    fn codex_bundle_preflight_rejects_missing_or_wrong_home_before_materialization() {
19846        for home_profile in [CodexHomeProfile::Missing, CodexHomeProfile::WrongClass] {
19847            let (shared, runtime, receipt, bundle, root) =
19848                codex_bundle_test_shared(home_profile);
19849            let error = shared
19850                .bundle_layout(
19851                    &agent("codex"),
19852                    SessionMode::Pty,
19853                    Some(&receipt),
19854                    &bundle,
19855                )
19856                .unwrap_err();
19857            assert_eq!(error.code, NodeFailureCode::BundleBindingMismatch);
19858            assert!(shared
19859                .materializations
19860                .lock()
19861                .unwrap_or_else(|poisoned| poisoned.into_inner())
19862                .is_empty());
19863            let materialization_root = root.join("session-environments");
19864            assert!(!std::fs::read_dir(&materialization_root)
19865                .unwrap()
19866                .filter_map(Result::ok)
19867                .any(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())));
19868            drop(shared);
19869            drop(runtime);
19870            let _ = std::fs::remove_dir_all(root);
19871        }
19872    }
19873
19874    #[cfg(feature = "fixture")]
19875    #[test]
19876    fn codex_bundle_exact_home_profile_materializes_only_the_skill_tree() {
19877        let (shared, runtime, receipt, bundle, root) =
19878            codex_bundle_test_shared(CodexHomeProfile::Exact);
19879        let address = SessionAddress {
19880            workspace_id: WorkspaceId::new("primary").unwrap(),
19881            session: SessionKey {
19882                instance_id: AgentInstanceId(310),
19883                generation: SessionGeneration(1),
19884            },
19885        };
19886        let (overlay, guard) = shared
19887            .prepare_session_materialization(
19888                &address,
19889                &agent("codex"),
19890                SessionMode::Pty,
19891                Some(&receipt),
19892                Some(&bundle.receipt()),
19893                None,
19894                None,
19895            )
19896            .unwrap()
19897            .unwrap();
19898        assert!(matches!(overlay, Some(PreparedNativeLaunchOverlay::Instance(_))));
19899        let ownership = shared
19900            .materializations
19901            .lock()
19902            .unwrap_or_else(|poisoned| poisoned.into_inner())
19903            .get(guard.id().unwrap())
19904            .unwrap()
19905            .clone();
19906        let (profile, _, _) = shared
19907            .materialization_profile(
19908                &agent("codex"),
19909                SessionMode::Pty,
19910                Some(&receipt),
19911                Some(&bundle.receipt()),
19912                None,
19913            )
19914            .unwrap()
19915            .unwrap();
19916        let environment = shared
19917            .session_environment_materializer
19918            .as_ref()
19919            .unwrap()
19920            .resolve_environment(&ownership, &profile)
19921            .unwrap();
19922        assert!(environment.iter().any(|mutation| {
19923            mutation.key == OsString::from("CODEX_HOME")
19924                && mutation.value.as_deref() == Some(ownership.provider_home().as_os_str())
19925        }));
19926        assert_eq!(
19927            std::fs::read(ownership.provider_home().join("skills/review-code/SKILL.md"))
19928                .unwrap(),
19929            b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n",
19930        );
19931        assert!(std::fs::read_dir(ownership.bundle_root())
19932            .unwrap()
19933            .next()
19934            .is_none());
19935        drop(guard);
19936        drop(shared);
19937        drop(runtime);
19938        let _ = std::fs::remove_dir_all(root);
19939    }
19940
19941    #[cfg(feature = "fixture")]
19942    #[test]
19943    fn codex_cold_record_resume_revalidates_the_isolated_skill_tree() {
19944        let (shared, runtime, receipt, bundle, root) =
19945            codex_bundle_test_shared(CodexHomeProfile::Exact);
19946        let bundle_receipt = bundle.receipt();
19947        let address = SessionAddress {
19948            workspace_id: WorkspaceId::new("primary").unwrap(),
19949            session: SessionKey {
19950                instance_id: AgentInstanceId(311),
19951                generation: SessionGeneration(1),
19952            },
19953        };
19954        let (_overlay, guard) = shared
19955            .prepare_session_materialization(
19956                &address,
19957                &agent("codex"),
19958                SessionMode::Pty,
19959                Some(&receipt),
19960                Some(&bundle_receipt),
19961                None,
19962                None,
19963            )
19964            .unwrap()
19965            .unwrap();
19966        let materialization_id = guard.id().unwrap().clone();
19967        let skill_path = shared
19968            .materializations
19969            .lock()
19970            .unwrap_or_else(|poisoned| poisoned.into_inner())
19971            .get(&materialization_id)
19972            .unwrap()
19973            .provider_home()
19974            .join("skills/review-code/SKILL.md");
19975        let record_id = shared
19976            .bind_spawn_session_with_materialization(
19977                &address,
19978                agent("codex"),
19979                SessionMode::Pty,
19980                ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap(),
19981                SpawnRecordPolicy::ProviderIdentityOnly,
19982                Some(receipt.clone()),
19983                Some(bundle_receipt.clone()),
19984                None,
19985                Some(materialization_id.clone()),
19986            )
19987            .unwrap()
19988            .unwrap();
19989        guard.retain();
19990        std::fs::write(skill_path, b"changed-after-record-bind").unwrap();
19991
19992        let error = match shared.resolve_record_materialization(
19993                &record_id,
19994                &agent("codex"),
19995                SessionMode::Pty,
19996                Some(&receipt),
19997                Some(&bundle_receipt),
19998                None,
19999            ) {
20000                Ok(_) => panic!("changed Codex skill tree unexpectedly revalidated"),
20001                Err(error) => error,
20002            };
20003        assert_eq!(error.code, NodeFailureCode::BundleMaterializationFailed);
20004        assert_eq!(
20005            shared
20006                .materializations
20007                .lock()
20008                .unwrap_or_else(|poisoned| poisoned.into_inner())
20009                .get(&materialization_id)
20010                .unwrap()
20011                .state(),
20012            MaterializationState::RecoveryRequired,
20013        );
20014        drop(shared);
20015        drop(runtime);
20016        let _ = std::fs::remove_dir_all(root);
20017    }
20018
20019    fn bind_record_owned_materialization_fixture(
20020        shared: &NodeShared,
20021        receipt: &ResolvedEnvironmentProfileReceipt,
20022        instance_id: u64,
20023    ) -> (SessionAddress, SessionRecordId, MaterializationId, PathBuf) {
20024        let address = SessionAddress {
20025            workspace_id: WorkspaceId::new("primary").unwrap(),
20026            session: SessionKey {
20027                instance_id: AgentInstanceId(instance_id),
20028                generation: SessionGeneration(1),
20029            },
20030        };
20031        let (overlay, materialization_guard) = shared
20032            .prepare_session_materialization(
20033                &address,
20034                &agent("claude"),
20035                SessionMode::Pty,
20036                Some(receipt),
20037                None,
20038                None,
20039                None,
20040            )
20041            .unwrap()
20042            .unwrap();
20043        let materialization_id = materialization_guard.id().unwrap().clone();
20044        let materialization_root = shared
20045            .materializations
20046            .lock()
20047            .unwrap_or_else(|poisoned| poisoned.into_inner())
20048            .get(&materialization_id)
20049            .unwrap()
20050            .root()
20051            .to_path_buf();
20052        let selection = shared
20053            .select_environment_profile(
20054                address.session.instance_id,
20055                &agent("claude"),
20056                SessionMode::Pty,
20057                Some(receipt),
20058            )
20059            .unwrap()
20060            .unwrap();
20061        assert!(shared
20062            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
20063            .unwrap()
20064            .is_none());
20065        let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
20066        let record_id = shared
20067            .bind_spawn_session_with_materialization(
20068                &address,
20069                agent("claude"),
20070                SessionMode::Pty,
20071                policy,
20072                SpawnRecordPolicy::ProviderIdentityOnly,
20073                Some(receipt.clone()),
20074                None,
20075                None,
20076                Some(materialization_id.clone()),
20077            )
20078            .unwrap()
20079            .unwrap();
20080        selection.retain();
20081        materialization_guard.retain();
20082        (address, record_id, materialization_id, materialization_root)
20083    }
20084
20085    #[test]
20086    fn child_environment_profile_capability_is_state_aware_and_legacy_safe() {
20087        let (shared, _runtime, environment_profile) = environment_profile_test_shared();
20088        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20089            capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
20090        }));
20091        let support = node_compatibility_support_for_manifest(&[], &[]).unwrap();
20092        assert_eq!(support.state_schema.versions.maximum(), NODE_STATE_SCHEMA_V10);
20093        let mut spec = spawn_spec_fixture();
20094        spec.expected_profile_revision =
20095            crate::protocol::SpawnProfileRevision::new("test-r1").unwrap();
20096        assert!(matches!(
20097            &spec.overrides.environment_profile_id,
20098            crate::protocol::SpawnOverride::Inherit,
20099        ));
20100        assert!(!NodeRequest::SpawnSpec { spec: spec.clone() }
20101            .requires_child_environment_profile_capability());
20102        assert!(request_requires_child_environment_profile(
20103            &shared,
20104            &NodeRequest::SpawnSpec { spec: spec.clone() },
20105        ));
20106
20107        let address = terminal_address(1);
20108        shared.bind_session_with_policy(
20109            &address,
20110            ProviderRuntimePolicy::raw_pty(),
20111            Some(environment_profile.clone()),
20112        );
20113        assert!(request_requires_child_environment_profile(
20114            &shared,
20115            &NodeRequest::Input {
20116                session: address.clone(),
20117                text: "x".to_owned(),
20118            },
20119        ));
20120
20121        let mut record = record("claude", "profile-record");
20122        record.active_session = Some(address.clone());
20123        record.environment_profile = Some(environment_profile.clone());
20124        let record_id = record.record_id.clone();
20125        shared.insert_record(record.clone()).unwrap();
20126        assert!(request_requires_child_environment_profile(
20127            &shared,
20128            &NodeRequest::ResumeSessionRecord {
20129                record_id,
20130                terminal_size: gate4agent_types::TerminalSize {
20131                    rows: 24,
20132                    columns: 80,
20133                },
20134                initial_prompt: None,
20135            },
20136        ));
20137
20138        let snapshot = snapshot_for_wire(
20139            &shared, false, true, true, true, false, false, false, false, true,
20140        );
20141        assert_eq!(snapshot.session_records.len(), 1);
20142        assert!(snapshot.session_records[0].environment_profile.is_none());
20143        let event = project_event_without_child_environment_profile(NodeEventEnvelope {
20144            sequence: 1,
20145            event: NodeEvent::SessionRecordUpserted { record },
20146        });
20147        let NodeEvent::SessionRecordUpserted { record } = event.event else {
20148            panic!("record projection changed the event kind");
20149        };
20150        assert!(record.environment_profile.is_none());
20151
20152        let resolved = shared.resolve_spawn_spec(&spec).unwrap();
20153        let mut reply = ResponseEnvelope {
20154            request_id: 1,
20155            result: Ok(NodeResponse::SpawnSpecAccepted {
20156                receipt: resolved.receipt_with_environment(
20157                    shared.incarnation_id,
20158                    address,
20159                    Some(environment_profile),
20160                ),
20161            }),
20162        };
20163        project_response_without_child_environment_profile(&mut reply);
20164        assert_eq!(
20165            reply.result.unwrap_err().code,
20166            NodeFailureCode::UnsupportedCapability,
20167        );
20168    }
20169
20170    #[test]
20171    fn spawn_profile_environment_authority_is_exact_and_legacy_safe() {
20172        let (shared, _runtime, environment_profile) = environment_profile_test_shared();
20173        let snapshot = shared.snapshot();
20174        let profiles = snapshot
20175            .launch_inventory
20176            .as_ref()
20177            .and_then(|inventory| inventory.spawn_profiles.as_ref())
20178            .unwrap();
20179        assert_eq!(profiles.len(), 1);
20180        assert_eq!(profiles[0].environment_profile, Some(environment_profile));
20181        assert!(snapshot.requires_child_environment_profile_capability());
20182
20183        let mut legacy = snapshot.clone();
20184        clear_snapshot_child_environment_profiles(&mut legacy);
20185        assert!(legacy
20186            .launch_inventory
20187            .as_ref()
20188            .unwrap()
20189            .spawn_profiles
20190            .as_ref()
20191            .unwrap()[0]
20192            .environment_profile
20193            .is_none());
20194        assert!(!legacy.requires_child_environment_profile_capability());
20195
20196        let profile = shared.spawn_profiles.iter().next().unwrap().clone();
20197        let bindings = shared
20198            .environment_profiles
20199            .read()
20200            .unwrap_or_else(|poisoned| poisoned.into_inner())
20201            .clone();
20202        let mut provider_mismatch = bindings.clone();
20203        provider_mismatch
20204            .values_mut()
20205            .next()
20206            .unwrap()
20207            .provider = agent("codex");
20208        assert!(resolved_spawn_profile_summary(&profile, &provider_mismatch).is_none());
20209        let mut mode_mismatch = profile;
20210        mode_mismatch.mode = SessionMode::Inline;
20211        assert!(resolved_spawn_profile_summary(&mode_mismatch, &bindings).is_none());
20212
20213        shared
20214            .environment_profiles
20215            .write()
20216            .unwrap_or_else(|poisoned| poisoned.into_inner())
20217            .clear();
20218        assert!(shared
20219            .snapshot()
20220            .launch_inventory
20221            .unwrap()
20222            .spawn_profiles
20223            .unwrap()
20224            .is_empty());
20225    }
20226
20227    #[test]
20228    fn session_bundle_capability_is_state_aware_and_legacy_safe() {
20229        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20230            capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
20231        }));
20232        let shared = terminal_test_shared();
20233        let bundle = ResolvedBundleReceipt {
20234            id: crate::protocol::SpawnBundleId::new("review-bundle").unwrap(),
20235            revision: crate::protocol::SpawnBundleRevision::new("r1").unwrap(),
20236            digest: crate::protocol::SpawnBundleDigest::new(format!(
20237                "sha256:{}",
20238                "0".repeat(64),
20239            ))
20240            .unwrap(),
20241        };
20242        let mut bundled = record("claude", "bundle-record");
20243        bundled.bundle = Some(bundle.clone());
20244        let record_id = bundled.record_id.clone();
20245        shared.insert_record(bundled.clone()).unwrap();
20246        assert!(request_requires_session_bundle(
20247            &shared,
20248            &NodeRequest::ForgetSessionRecord {
20249                record_id: record_id.clone(),
20250            },
20251        ));
20252
20253        let snapshot = snapshot_for_wire(
20254            &shared, false, true, true, true, true, false, false, false, true,
20255        );
20256        assert!(snapshot.session_records[0].bundle.is_none());
20257        let launch = snapshot.launch_inventory.as_ref().unwrap();
20258        assert!(launch.spawn_profiles.is_some());
20259        assert!(launch.bundles.is_none());
20260        let event = project_event_without_session_bundle(NodeEventEnvelope {
20261            sequence: 1,
20262            event: NodeEvent::SessionRecordUpserted { record: bundled },
20263        });
20264        let NodeEvent::SessionRecordUpserted { record } = event.event else {
20265            panic!("record projection changed the event kind");
20266        };
20267        assert!(record.bundle.is_none());
20268
20269        let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
20270        let mut reply = ResponseEnvelope {
20271            request_id: 1,
20272            result: Ok(NodeResponse::SpawnSpecAccepted {
20273                receipt: resolved.receipt_with_materialization(
20274                    shared.incarnation_id,
20275                    terminal_address(17),
20276                    None,
20277                    Some(bundle),
20278                    None,
20279                ),
20280            }),
20281        };
20282        project_response_without_session_bundle(&mut reply);
20283        assert_eq!(
20284            reply.result.unwrap_err().code,
20285            NodeFailureCode::UnsupportedCapability,
20286        );
20287    }
20288
20289    #[test]
20290    fn history_context_pack_capability_redacts_loaded_messages_and_legacy_metadata() {
20291        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20292            capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
20293        }));
20294        let context_id = SpawnContextId::new("context-review").unwrap();
20295        let context = ResolvedContextPackReceipt {
20296            id: context_id.clone(),
20297            digest: crate::protocol::SpawnContextDigest::new(format!(
20298                "sha256:{}",
20299                "c".repeat(64),
20300            ))
20301            .unwrap(),
20302            lineage: ContextPackLineageReceipt {
20303                source_node_id: NodeId::new("node-source").unwrap(),
20304                source_session: terminal_address(1),
20305                source_provider: agent("kimi"),
20306            },
20307            source_message_count: 2,
20308            retained_message_count: 2,
20309            byte_len: 256,
20310            truncated: false,
20311        };
20312        assert!(context.is_valid());
20313        let mut bound_record = record("codex", "context-record");
20314        bound_record.context_id = Some(context_id);
20315        bound_record.context = Some(context.clone());
20316        let session = gate4agent_types::SessionSnapshot {
20317            instance_id: AgentInstanceId(91),
20318            agent_id: agent("kimi"),
20319            transport: TransportKind::Pty,
20320            generation: SessionGeneration(3),
20321            status: SessionStatus::Running,
20322            pending_operation: None,
20323            pending_input: None,
20324            process_id: Some(9001),
20325            terminal_size: None,
20326            terminal_frame: None,
20327            terminal_stale: None,
20328            session_options: None,
20329            capabilities: gate4agent_types::CapabilitySnapshot::default(),
20330            history: gate4agent_types::HistorySnapshot {
20331                pending: None,
20332                candidates: vec![HistoryCandidateSummary {
20333                    id: "candidate-opaque".to_owned(),
20334                    session_id_hint: "session-hint".to_owned(),
20335                    modified_at_unix_ms: Some(1),
20336                }],
20337                loaded_candidate_id: Some("candidate-opaque".to_owned()),
20338                loaded: Some(HistorySessionRecord {
20339                    session_id: "private-vendor-session".to_owned(),
20340                    title: Some("private title".to_owned()),
20341                    cwd: Some(r"C:\private\history-root".to_owned()),
20342                    model: Some("private-model".to_owned()),
20343                    message_count: 2,
20344                    completed_turn_count: None,
20345                    total_tokens: 19,
20346                    messages: vec![
20347                        gate4agent_types::HistoryMessageRecord {
20348                            role: gate4agent_types::HistoryMessageRole::User,
20349                            text: "F7_PRIVATE_HISTORY_MESSAGE".to_owned(),
20350                        },
20351                        gate4agent_types::HistoryMessageRecord {
20352                            role: gate4agent_types::HistoryMessageRole::Assistant,
20353                            text: "private answer".to_owned(),
20354                        },
20355                    ],
20356                }),
20357                last_error: None,
20358            },
20359            resume: gate4agent_types::ResumeSnapshot::default(),
20360            foreground: gate4agent_types::ForegroundSnapshot::default(),
20361            provider: gate4agent_types::ProviderSnapshot::default(),
20362            screen_state: gate4agent_types::PtyScreenState::default(),
20363        };
20364        let original = NodeSnapshot {
20365            node_id: NodeId::new("node-terminal-test").unwrap(),
20366            enabled_providers: vec![agent("kimi"), agent("codex")],
20367            provider_runtime_statuses: ProviderRuntimeStatuses::default(),
20368            workspaces: vec![WorkspaceSnapshot {
20369                workspace_id: WorkspaceId::new("primary").unwrap(),
20370                canonical_root: opaque_windows_path(
20371                    std::env::current_dir().unwrap().to_string_lossy().into_owned(),
20372                ),
20373                sessions: vec![session],
20374                worktree_service_mode: None,
20375                managed_worktree_profiles: None,
20376            }],
20377            session_records: vec![bound_record],
20378            managed_worktrees: Vec::new(),
20379            launch_inventory: None,
20380            agent_progress: Vec::new(),
20381        };
20382
20383        let mut negotiated = original.clone();
20384        project_snapshot_history_for_wire(&mut negotiated, true);
20385        assert_eq!(negotiated.workspaces[0].sessions[0].history.candidates.len(), 1);
20386        assert!(negotiated.workspaces[0].sessions[0].history.loaded.is_none());
20387        assert_eq!(negotiated.session_records[0].context.as_ref(), Some(&context));
20388        assert!(!serde_json::to_string(&negotiated)
20389            .unwrap()
20390            .contains("F7_PRIVATE_HISTORY_MESSAGE"));
20391
20392        let mut legacy = original;
20393        project_snapshot_history_for_wire(&mut legacy, false);
20394        clear_snapshot_context_packs(&mut legacy);
20395        assert_eq!(
20396            legacy.workspaces[0].sessions[0].history,
20397            gate4agent_types::HistorySnapshot::default(),
20398        );
20399        assert!(legacy.session_records[0].context_id.is_none());
20400        assert!(legacy.session_records[0].context.is_none());
20401        let encoded = serde_json::to_string(&legacy).unwrap();
20402        assert!(!encoded.contains("F7_PRIVATE_HISTORY_MESSAGE"));
20403        assert!(!encoded.contains("context-review"));
20404
20405        let mut reply = ResponseEnvelope {
20406            request_id: 7,
20407            result: Ok(NodeResponse::ContextPackExported { context }),
20408        };
20409        project_response_without_context_pack(&mut reply);
20410        assert_eq!(
20411            reply.result.unwrap_err().code,
20412            NodeFailureCode::UnsupportedCapability,
20413        );
20414    }
20415
20416    #[test]
20417    fn forgetting_unbound_context_pack_evicts_replay_but_retained_record_is_busy() {
20418        let shared = terminal_test_shared();
20419        let history = HistorySessionRecord {
20420            session_id: "fixture-history".to_owned(),
20421            title: None,
20422            cwd: None,
20423            model: None,
20424            message_count: 1,
20425            completed_turn_count: None,
20426            total_tokens: 0,
20427            messages: vec![gate4agent_types::HistoryMessageRecord {
20428                role: gate4agent_types::HistoryMessageRole::User,
20429                text: "bounded handoff".to_owned(),
20430            }],
20431        };
20432        let pack = NodeContextPack::export(
20433            ContextPackLineageReceipt {
20434                source_node_id: shared.node_id.clone(),
20435                source_session: terminal_address(1),
20436                source_provider: agent("claude"),
20437            },
20438            &history,
20439        )
20440        .unwrap();
20441        let context = shared
20442            .context_catalog
20443            .write()
20444            .unwrap_or_else(|poisoned| poisoned.into_inner())
20445            .insert(pack)
20446            .unwrap();
20447        let mut retained = record("codex", "context-retained-record");
20448        retained.context_id = Some(context.id.clone());
20449        retained.context = Some(context.clone());
20450        let record_id = retained.record_id.clone();
20451        shared.insert_record(retained).unwrap();
20452        assert_eq!(
20453            shared.forget_context_pack(&context.id).unwrap_err().code,
20454            NodeFailureCode::ContextPackBusy,
20455        );
20456        assert!(shared
20457            .context_catalog
20458            .read()
20459            .unwrap_or_else(|poisoned| poisoned.into_inner())
20460            .get(&context.id)
20461            .is_some());
20462        shared
20463            .session_records
20464            .lock()
20465            .unwrap_or_else(|poisoned| poisoned.into_inner())
20466            .records
20467            .remove(&record_id);
20468
20469        let mut spec = spawn_spec_fixture();
20470        spec.overrides.context_id = crate::protocol::SpawnOverride::Set {
20471            value: context.id.clone(),
20472        };
20473        let resolved = shared.resolve_spawn_spec(&spec).unwrap();
20474        let receipt = resolved.receipt_with_materialization(
20475            shared.incarnation_id,
20476            terminal_address(2),
20477            None,
20478            None,
20479            Some(context.clone()),
20480        );
20481        assert!(receipt.context_binding_is_valid());
20482        shared.remember_spawn_spec(spec.clone(), Ok(receipt), Instant::now());
20483
20484        shared.forget_context_pack(&context.id).unwrap();
20485        assert!(shared
20486            .context_catalog
20487            .read()
20488            .unwrap_or_else(|poisoned| poisoned.into_inner())
20489            .get(&context.id)
20490            .is_none());
20491        assert!(shared
20492            .replay_spawn_spec(&spec, Instant::now())
20493            .unwrap()
20494            .is_none());
20495    }
20496
20497    #[test]
20498    fn environment_profile_selection_is_owned_by_the_exact_session_binding() {
20499        let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
20500        let address = terminal_address(1);
20501        let selection = shared
20502            .select_environment_profile(
20503                address.session.instance_id,
20504                &agent("claude"),
20505                SessionMode::Pty,
20506                Some(&environment_profile),
20507            )
20508            .unwrap()
20509            .unwrap();
20510        shared.bind_session_with_policy(
20511            &address,
20512            ProviderRuntimePolicy::raw_pty(),
20513            Some(environment_profile.clone()),
20514        );
20515        selection.retain();
20516        let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
20517        assert!(matches!(
20518            runtime.remove_native_launch_profile(&native_id),
20519            Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
20520        ));
20521        assert!(shared.remove_binding(&address).is_some());
20522        assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
20523
20524        let mismatched = ResolvedEnvironmentProfileReceipt {
20525            profile_id: environment_profile.profile_id,
20526            profile_revision: crate::protocol::SpawnEnvironmentProfileRevision::new(
20527                "local-claude-r2",
20528            )
20529            .unwrap(),
20530            network_allowlist: None,
20531            browser_profile_id: None,
20532        };
20533        let error = match shared.select_environment_profile(
20534                AgentInstanceId(42),
20535                &agent("claude"),
20536                SessionMode::Pty,
20537                Some(&mismatched),
20538            ) {
20539            Ok(_) => panic!("mismatched environment profile revision was selected"),
20540            Err(error) => error,
20541        };
20542        assert_eq!(
20543            error.code,
20544            NodeFailureCode::EnvironmentProfileBindingMismatch,
20545        );
20546        assert!(!shared
20547            .session_bindings
20548            .lock()
20549            .unwrap_or_else(|poisoned| poisoned.into_inner())
20550            .contains_key(&AgentInstanceId(42)));
20551    }
20552
20553    #[test]
20554    fn combined_environment_and_bundle_overlay_clears_with_its_binding() {
20555        let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
20556        let address = terminal_address(1);
20557        let selection = shared
20558            .select_environment_profile(
20559                address.session.instance_id,
20560                &agent("claude"),
20561                SessionMode::Pty,
20562                Some(&environment_profile),
20563            )
20564            .unwrap()
20565            .unwrap();
20566        let overlay = NativeInstanceLaunchOverlay::new(
20567            agent("claude"),
20568            TransportKind::Pty,
20569            vec![EnvMutation {
20570                key: OsString::from("GATE4AGENT_SESSION_TOKEN"),
20571                value: Some(OsString::from("fixture-secret")),
20572            }],
20573            vec![
20574                OsString::from("--plugin-dir"),
20575                std::env::current_dir().unwrap().into_os_string(),
20576            ],
20577        )
20578        .unwrap();
20579        let instance_overlay = shared
20580            .install_prepared_launch_overlay(
20581                address.session.instance_id,
20582                PreparedNativeLaunchOverlay::Instance(overlay),
20583            )
20584            .unwrap()
20585            .unwrap();
20586        let bundle = ResolvedBundleReceipt {
20587            id: crate::protocol::SpawnBundleId::new("combined-bundle").unwrap(),
20588            revision: crate::protocol::SpawnBundleRevision::new("combined-r1").unwrap(),
20589            digest: crate::protocol::SpawnBundleDigest::new(format!(
20590                "sha256:{}",
20591                "0".repeat(64),
20592            ))
20593            .unwrap(),
20594        };
20595        shared.bind_session_with_materialization(
20596            &address,
20597            ProviderRuntimePolicy::raw_pty(),
20598            Some(environment_profile),
20599            Some(bundle),
20600            None,
20601            None,
20602        );
20603        selection.retain();
20604        instance_overlay.retain();
20605
20606        let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
20607        assert!(matches!(
20608            runtime.remove_native_launch_profile(&native_id),
20609            Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
20610        ));
20611        assert!(shared.remove_binding(&address).is_some());
20612        assert!(!shared
20613            .native_launch_profile_control
20614            .as_ref()
20615            .unwrap()
20616            .clear_native_instance_launch_overlay(address.session.instance_id));
20617        assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
20618    }
20619
20620    #[test]
20621    fn session_environment_materialization_failure_is_pre_child_and_durable() {
20622        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
20623        let address = SessionAddress {
20624            workspace_id: WorkspaceId::new("primary").unwrap(),
20625            session: SessionKey {
20626                instance_id: AgentInstanceId(201),
20627                generation: SessionGeneration(1),
20628            },
20629        };
20630        let error = match shared.prepare_session_materialization(
20631            &address,
20632            &agent("claude"),
20633            SessionMode::Pty,
20634            Some(&receipt),
20635            None,
20636            None,
20637            None,
20638        ) {
20639            Ok(_) => panic!("denied local reference unexpectedly materialized"),
20640            Err(error) => error,
20641        };
20642        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
20643        assert!(shared.handle.snapshot().sessions.is_empty());
20644        assert!(shared
20645            .session_bindings
20646            .lock()
20647            .unwrap_or_else(|poisoned| poisoned.into_inner())
20648            .is_empty());
20649        assert!(shared
20650            .materializations
20651            .lock()
20652            .unwrap_or_else(|poisoned| poisoned.into_inner())
20653            .is_empty());
20654        drop(shared);
20655        drop(runtime);
20656        std::fs::remove_dir_all(root).unwrap();
20657    }
20658
20659    #[test]
20660    fn session_environment_binding_retains_on_stop_and_cleans_after_remove_reap() {
20661        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20662        let address = SessionAddress {
20663            workspace_id: WorkspaceId::new("primary").unwrap(),
20664            session: SessionKey {
20665                instance_id: AgentInstanceId(202),
20666                generation: SessionGeneration(1),
20667            },
20668        };
20669        let (overlay, materialization_guard) = shared
20670            .prepare_session_materialization(
20671                &address,
20672                &agent("claude"),
20673                SessionMode::Pty,
20674                Some(&receipt),
20675                None,
20676                None,
20677                None,
20678            )
20679            .unwrap()
20680            .unwrap();
20681        let materialization_id = materialization_guard.id().unwrap().clone();
20682        let materialization_root = shared
20683            .materializations
20684            .lock()
20685            .unwrap_or_else(|poisoned| poisoned.into_inner())
20686            .get(&materialization_id)
20687            .unwrap()
20688            .root()
20689            .to_path_buf();
20690        let selection = shared
20691            .select_environment_profile(
20692                address.session.instance_id,
20693                &agent("claude"),
20694                SessionMode::Pty,
20695                Some(&receipt),
20696            )
20697            .unwrap()
20698            .unwrap();
20699        assert!(shared
20700            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
20701            .unwrap()
20702            .is_none());
20703        shared.bind_session_with_materialization(
20704            &address,
20705            ProviderRuntimePolicy::raw_pty(),
20706            Some(receipt),
20707            None,
20708            None,
20709            Some(materialization_id),
20710        );
20711        selection.retain();
20712        materialization_guard.retain();
20713
20714        assert!(materialization_root.is_dir());
20715        assert!(shared
20716            .session_bindings
20717            .lock()
20718            .unwrap_or_else(|poisoned| poisoned.into_inner())
20719            .contains_key(&address.session.instance_id));
20720
20721        let removed = shared.remove_binding(&address).unwrap();
20722        shared
20723            .cleanup_session_owned_materialization(&address, &removed)
20724            .unwrap();
20725        assert!(!materialization_root.exists());
20726        assert!(shared
20727            .materializations
20728            .lock()
20729            .unwrap_or_else(|poisoned| poisoned.into_inner())
20730            .is_empty());
20731        drop(shared);
20732        drop(runtime);
20733        std::fs::remove_dir_all(root).unwrap();
20734    }
20735
20736    #[tokio::test]
20737    async fn record_owned_session_environment_reuses_on_resume_and_forget_cleans() {
20738        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20739        let address = SessionAddress {
20740            workspace_id: WorkspaceId::new("primary").unwrap(),
20741            session: SessionKey {
20742                instance_id: AgentInstanceId(203),
20743                generation: SessionGeneration(1),
20744            },
20745        };
20746        let (overlay, materialization_guard) = shared
20747            .prepare_session_materialization(
20748                &address,
20749                &agent("claude"),
20750                SessionMode::Pty,
20751                Some(&receipt),
20752                None,
20753                None,
20754                None,
20755            )
20756            .unwrap()
20757            .unwrap();
20758        let materialization_id = materialization_guard.id().unwrap().clone();
20759        let materialization_root = shared
20760            .materializations
20761            .lock()
20762            .unwrap_or_else(|poisoned| poisoned.into_inner())
20763            .get(&materialization_id)
20764            .unwrap()
20765            .root()
20766            .to_path_buf();
20767        let selection = shared
20768            .select_environment_profile(
20769                address.session.instance_id,
20770                &agent("claude"),
20771                SessionMode::Pty,
20772                Some(&receipt),
20773            )
20774            .unwrap()
20775            .unwrap();
20776        assert!(shared
20777            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
20778            .unwrap()
20779            .is_none());
20780        let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
20781        let record_id = shared
20782            .bind_spawn_session_with_materialization(
20783                &address,
20784                agent("claude"),
20785                SessionMode::Pty,
20786                policy,
20787                SpawnRecordPolicy::ProviderIdentityOnly,
20788                Some(receipt.clone()),
20789                None,
20790                None,
20791                Some(materialization_id.clone()),
20792            )
20793            .unwrap()
20794            .unwrap();
20795        selection.retain();
20796        materialization_guard.retain();
20797        assert_eq!(
20798            shared
20799                .materializations
20800                .lock()
20801                .unwrap_or_else(|poisoned| poisoned.into_inner())
20802                .get(&materialization_id)
20803                .unwrap()
20804                .owner(),
20805            &MaterializationOwner::Record {
20806                record_id: record_id.clone(),
20807            },
20808        );
20809
20810        let removed = shared.remove_binding(&address).unwrap();
20811        shared
20812            .cleanup_session_owned_materialization(&address, &removed)
20813            .unwrap();
20814        assert!(materialization_root.is_dir());
20815        assert!(shared
20816            .resolve_record_materialization(
20817                &record_id,
20818                &agent("claude"),
20819                SessionMode::Pty,
20820                Some(&receipt),
20821                None,
20822                None,
20823            )
20824            .unwrap()
20825            .is_some());
20826        {
20827            let mut records = shared
20828                .session_records
20829                .lock()
20830                .unwrap_or_else(|poisoned| poisoned.into_inner());
20831            let record = records.records.get_mut(&record_id).unwrap();
20832            record.active_session = None;
20833            record.state = ManagedSessionState::Unavailable;
20834        }
20835        shared.forget_session_record(&record_id).await.unwrap();
20836        assert!(!materialization_root.exists());
20837        assert!(shared
20838            .materializations
20839            .lock()
20840            .unwrap_or_else(|poisoned| poisoned.into_inner())
20841            .is_empty());
20842        drop(shared);
20843        drop(runtime);
20844        std::fs::remove_dir_all(root).unwrap();
20845    }
20846
20847    #[test]
20848    fn record_materialization_revalidation_failure_persists_recovery_required() {
20849        let (mut shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20850        let state_path = root.join("node-state-v6.json");
20851        shared.state_path = Some(state_path.clone());
20852        let (_address, _record_id, materialization_id, materialization_root) =
20853            bind_record_owned_materialization_fixture(&shared, &receipt, 205);
20854        std::fs::write(
20855            materialization_root.join(".gate4agent-materialization-owner"),
20856            b"tampered",
20857        )
20858        .unwrap();
20859
20860        let error = match shared.resolve_record_materialization(
20861            &_record_id,
20862            &agent("claude"),
20863            SessionMode::Pty,
20864            Some(&receipt),
20865            None,
20866            None,
20867        ) {
20868            Ok(_) => panic!("tampered owner marker unexpectedly revalidated"),
20869            Err(error) => error,
20870        };
20871        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
20872        assert_eq!(
20873            shared
20874                .materializations
20875                .lock()
20876                .unwrap_or_else(|poisoned| poisoned.into_inner())
20877                .get(&materialization_id)
20878                .unwrap()
20879                .state(),
20880            MaterializationState::RecoveryRequired,
20881        );
20882        let loaded = session_registry::load(Some(&state_path), &shared.node_id).unwrap();
20883        assert_eq!(loaded.materializations.len(), 1);
20884        assert_eq!(
20885            loaded.materializations[0].state(),
20886            MaterializationState::RecoveryRequired,
20887        );
20888        drop(shared);
20889        drop(runtime);
20890        std::fs::remove_dir_all(root).unwrap();
20891    }
20892
20893    #[test]
20894    fn transient_record_materialization_resolution_failure_remains_ready() {
20895        let (shared, runtime, receipt, root, deny) = materialization_test_shared(false);
20896        let (_address, record_id, materialization_id, _materialization_root) =
20897            bind_record_owned_materialization_fixture(&shared, &receipt, 206);
20898        deny.store(true, Ordering::Release);
20899
20900        let error = match shared.resolve_record_materialization(
20901            &record_id,
20902            &agent("claude"),
20903            SessionMode::Pty,
20904            Some(&receipt),
20905            None,
20906            None,
20907        ) {
20908            Ok(_) => panic!("denied transient resolver unexpectedly succeeded"),
20909            Err(error) => error,
20910        };
20911        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
20912        assert_eq!(
20913            shared
20914                .materializations
20915                .lock()
20916                .unwrap_or_else(|poisoned| poisoned.into_inner())
20917                .get(&materialization_id)
20918                .unwrap()
20919                .state(),
20920            MaterializationState::Ready,
20921        );
20922        drop(shared);
20923        drop(runtime);
20924        std::fs::remove_dir_all(root).unwrap();
20925    }
20926
20927    #[test]
20928    fn startup_missing_record_materialization_is_not_resumable() {
20929        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20930        let record_id = SessionRecordId::new("sr-missing-materialization").unwrap();
20931        shared
20932            .insert_record(ManagedSessionRecord {
20933                record_id: record_id.clone(),
20934                display_name: "missing materialization".to_owned(),
20935                provider: agent("claude"),
20936                mode: SessionMode::Pty,
20937                state: ManagedSessionState::Dormant,
20938                workspace_id: WorkspaceId::new("primary").unwrap(),
20939                canonical_root: opaque_windows_path(
20940                    std::env::current_dir().unwrap().to_string_lossy().into_owned(),
20941                ),
20942                provider_session: Some(gate4agent_types::ProviderSessionIdentity {
20943                    key: gate4agent_types::ProviderSessionKey::SessionId,
20944                    id: "provider-session-missing-materialization".to_owned(),
20945                    transcript_path: None,
20946                }),
20947                active_session: None,
20948                environment_profile: Some(receipt),
20949                bundle: None,
20950                context_id: None,
20951                context: None,
20952                exported_context: None,
20953                task_binding: None,
20954                created_at_unix_ms: 1,
20955                updated_at_unix_ms: 1,
20956                last_error: None,
20957            })
20958            .unwrap();
20959
20960        shared.reconcile_materializations();
20961        let record = shared.record(&record_id).unwrap();
20962        assert_eq!(record.state, ManagedSessionState::Unavailable);
20963        assert!(record.provider_session.is_none());
20964        assert_eq!(
20965            record.last_error.as_deref(),
20966            Some("environment-profile-unavailable"),
20967        );
20968        drop(shared);
20969        drop(runtime);
20970        std::fs::remove_dir_all(root).unwrap();
20971    }
20972
20973    #[tokio::test]
20974    async fn managed_worktree_cleanup_waits_for_session_environment_cleanup() {
20975        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20976        let lease = install_managed_lease(&shared);
20977        let address = SessionAddress {
20978            workspace_id: WorkspaceId::new("primary").unwrap(),
20979            session: SessionKey {
20980                instance_id: AgentInstanceId(204),
20981                generation: SessionGeneration(1),
20982            },
20983        };
20984        let (overlay, materialization_guard) = shared
20985            .prepare_session_materialization(
20986                &address,
20987                &agent("claude"),
20988                SessionMode::Pty,
20989                Some(&receipt),
20990                None,
20991                None,
20992                Some(lease.lease_id.clone()),
20993            )
20994            .unwrap()
20995            .unwrap();
20996        let materialization_id = materialization_guard.id().unwrap().clone();
20997        let materialization_root = shared
20998            .materializations
20999            .lock()
21000            .unwrap_or_else(|poisoned| poisoned.into_inner())
21001            .get(&materialization_id)
21002            .unwrap()
21003            .root()
21004            .to_path_buf();
21005        let selection = shared
21006            .select_environment_profile(
21007                address.session.instance_id,
21008                &agent("claude"),
21009                SessionMode::Pty,
21010                Some(&receipt),
21011            )
21012            .unwrap()
21013            .unwrap();
21014        assert!(shared
21015            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21016            .unwrap()
21017            .is_none());
21018        shared.bind_session_with_materialization(
21019            &address,
21020            ProviderRuntimePolicy::raw_pty(),
21021            Some(receipt),
21022            None,
21023            None,
21024            Some(materialization_id),
21025        );
21026        selection.retain();
21027        materialization_guard.retain();
21028
21029        let blocked = shared
21030            .cleanup_managed_worktree(&lease.lease_id, false)
21031            .await
21032            .unwrap_err();
21033        assert_eq!(blocked.code, NodeFailureCode::ManagedWorktreeRecoveryRequired);
21034        assert!(materialization_root.is_dir());
21035        let removed = shared.remove_binding(&address).unwrap();
21036        shared
21037            .cleanup_session_owned_materialization(&address, &removed)
21038            .unwrap();
21039        assert!(!materialization_root.exists());
21040        drop(shared);
21041        drop(runtime);
21042        std::fs::remove_dir_all(root).unwrap();
21043    }
21044
21045    #[tokio::test]
21046    async fn raw_managed_record_is_inventory_only_and_does_not_block_session_cleanup() {
21047        let git_root = temporary_workspace_root("raw-managed-inventory");
21048        let source = git_root.join("source");
21049        let allocation = git_root.join("allocation");
21050        std::fs::create_dir_all(&source).unwrap();
21051        std::fs::create_dir_all(&allocation).unwrap();
21052        run_test_git(&source, &["init"]);
21053        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
21054        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
21055        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
21056        run_test_git(&source, &["add", "seed.txt"]);
21057        run_test_git(&source, &["commit", "-m", "seed"]);
21058        let profile = ManagedWorktreeProfile::new(
21059            WorktreeProfileId::new("default").unwrap(),
21060            crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
21061            &allocation,
21062            "gate4agent",
21063            "HEAD",
21064            ManagedWorktreeRetention::RemoveWhenReleased,
21065        )
21066        .unwrap();
21067        let workspace = WorkspaceConfig::new(
21068            WorkspaceId::new("primary").unwrap(),
21069            &source,
21070        )
21071        .unwrap()
21072        .with_worktree_service_mode(WorktreeServiceMode::Managed)
21073        .with_managed_worktree_profile(profile.clone())
21074        .unwrap();
21075        let (shared, mut runtime, receipt, materialization_fixture_root, _deny) =
21076            materialization_test_shared_with_workspace(false, workspace);
21077        let target = PathBuf::from(profile.allocation_root()).join("mw-raw-inventory");
21078        let source_text = source.to_string_lossy().into_owned();
21079        let target_text = target.to_string_lossy().into_owned();
21080        let created = create_git_worktree(
21081            &source_text,
21082            &target_text,
21083            "gate4agent/mw-raw-inventory",
21084            Some("HEAD"),
21085        )
21086        .await
21087        .unwrap();
21088        let lease = ManagedWorktreeLeaseRecord {
21089            lease_id: ManagedWorktreeLeaseId::new("mw-raw-inventory").unwrap(),
21090            source_workspace_id: WorkspaceId::new("primary").unwrap(),
21091            workspace_id: WorkspaceId::new("managed-raw-inventory").unwrap(),
21092            profile_id: profile.profile_id().clone(),
21093            profile_revision: profile.revision().clone(),
21094            target_root: created.path.clone(),
21095            branch: "gate4agent/mw-raw-inventory".to_owned(),
21096            base_commit: created.head.clone(),
21097            expected_head: Some(created.head.clone()),
21098            retention: ManagedWorktreeRetention::RemoveWhenReleased,
21099            state: ManagedWorktreeLeaseState::Ready,
21100            session_holders: Vec::new(),
21101            record_holders: Vec::new(),
21102            cleanup_failure: None,
21103            created_at_unix_ms: 1,
21104            updated_at_unix_ms: 1,
21105        };
21106        shared
21107            .workspaces
21108            .write()
21109            .unwrap_or_else(|poisoned| poisoned.into_inner())
21110            .insert(lease.workspace_id.clone(), created.path.clone());
21111        *shared
21112            .managed_worktrees
21113            .lock()
21114            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21115            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
21116        let address = SessionAddress {
21117            workspace_id: lease.workspace_id.clone(),
21118            session: SessionKey {
21119                instance_id: AgentInstanceId(205),
21120                generation: SessionGeneration(1),
21121            },
21122        };
21123        let (overlay, materialization_guard) = shared
21124            .prepare_session_materialization(
21125                &address,
21126                &agent("claude"),
21127                SessionMode::Pty,
21128                Some(&receipt),
21129                None,
21130                None,
21131                Some(lease.lease_id.clone()),
21132            )
21133            .unwrap()
21134            .unwrap();
21135        let materialization_id = materialization_guard.id().unwrap().clone();
21136        let materialization_root = shared
21137            .materializations
21138            .lock()
21139            .unwrap_or_else(|poisoned| poisoned.into_inner())
21140            .get(&materialization_id)
21141            .unwrap()
21142            .root()
21143            .to_path_buf();
21144        let selection = shared
21145            .select_environment_profile(
21146                address.session.instance_id,
21147                &agent("claude"),
21148                SessionMode::Pty,
21149                Some(&receipt),
21150            )
21151            .unwrap()
21152            .unwrap();
21153        assert!(shared
21154            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21155            .unwrap()
21156            .is_none());
21157        let record_id = shared
21158            .bind_spawn_session_with_materialization(
21159                &address,
21160                agent("claude"),
21161                SessionMode::Pty,
21162                ProviderRuntimePolicy::raw_pty(),
21163                SpawnRecordPolicy::Always,
21164                Some(receipt.clone()),
21165                None,
21166                None,
21167                Some(materialization_id.clone()),
21168            )
21169            .unwrap()
21170            .unwrap();
21171        selection.retain();
21172        materialization_guard.retain();
21173        let record = shared.record(&record_id).unwrap();
21174        assert_eq!(record.state, ManagedSessionState::Live);
21175        assert!(record.provider_session.is_none());
21176        assert!(record.environment_profile.is_none());
21177        assert!(record.bundle.is_none());
21178        assert!(record.context_id.is_none());
21179        assert!(record.context.is_none());
21180        assert_eq!(record.active_session.as_ref(), Some(&address));
21181        assert_eq!(
21182            shared
21183                .materializations
21184                .lock()
21185                .unwrap_or_else(|poisoned| poisoned.into_inner())
21186                .get(&materialization_id)
21187                .unwrap()
21188                .owner(),
21189            &MaterializationOwner::Session {
21190                incarnation_id: shared.incarnation_id,
21191                instance_id: address.session.instance_id,
21192                generation: address.session.generation,
21193            },
21194        );
21195        {
21196            let mut bindings = shared
21197                .session_bindings
21198                .lock()
21199                .unwrap_or_else(|poisoned| poisoned.into_inner());
21200            bindings
21201                .get_mut(&address.session.instance_id)
21202                .unwrap()
21203                .managed_worktree_lease_id = Some(lease.lease_id.clone());
21204        }
21205        let lease_snapshot = shared
21206            .managed_worktrees
21207            .lock()
21208            .unwrap_or_else(|poisoned| poisoned.into_inner())
21209            .bind_session(
21210                &lease.lease_id,
21211                ManagedWorktreeSessionHolder {
21212                    incarnation_id: shared.incarnation_id,
21213                    instance_id: address.session.instance_id,
21214                    generation: address.session.generation,
21215                },
21216                None,
21217                unix_time_ms(),
21218            )
21219            .unwrap();
21220        assert_eq!(lease_snapshot.active_session_count, 1);
21221        assert_eq!(lease_snapshot.managed_record_count, 0);
21222        shared.persist_state().unwrap();
21223        assert!(shared
21224            .managed_worktrees
21225            .lock()
21226            .unwrap_or_else(|poisoned| poisoned.into_inner())
21227            .get(&lease.lease_id)
21228            .unwrap()
21229            .record_holders
21230            .is_empty());
21231        shared
21232            .handle
21233            .dispatch(shared.prepare_command(ControlCommand::Register {
21234                instance_id: address.session.instance_id,
21235                agent_id: agent("claude"),
21236                transport: TransportKind::Pty,
21237            }))
21238            .unwrap();
21239        runtime.tick().await;
21240        shared.reconcile_managed_record(
21241            &record_id,
21242            &address,
21243            &ControlEvent {
21244                sequence: 1,
21245                command_id: None,
21246                instance_id: address.session.instance_id,
21247                generation: address.session.generation,
21248                event: ControlEventKind::Removed,
21249            },
21250            false,
21251            false,
21252        );
21253        let remove = shared.remove_session(&address);
21254        let drive_runtime = async {
21255            runtime.tick().await;
21256        };
21257        let (removed, ()) = tokio::join!(remove, drive_runtime);
21258        removed.unwrap();
21259
21260        assert!(!materialization_root.exists());
21261        assert!(!Path::new(&created.path).exists());
21262        assert_eq!(
21263            shared
21264                .managed_worktrees
21265                .lock()
21266                .unwrap_or_else(|poisoned| poisoned.into_inner())
21267                .get(&lease.lease_id)
21268                .unwrap()
21269                .state,
21270            ManagedWorktreeLeaseState::Removed,
21271        );
21272        let renamed = shared
21273            .rename_session_record(&record_id, "raw managed inventory".to_owned())
21274            .unwrap();
21275        assert_eq!(renamed.record_id, record_id);
21276        assert_eq!(renamed.display_name, "raw managed inventory");
21277        assert_eq!(renamed.state, ManagedSessionState::Unavailable);
21278        assert!(renamed.active_session.is_none());
21279
21280        drop(shared);
21281        drop(runtime);
21282        std::fs::remove_dir_all(materialization_fixture_root).unwrap();
21283        std::fs::remove_dir_all(git_root).unwrap();
21284    }
21285
21286    fn install_managed_lease(shared: &NodeShared) -> ManagedWorktreeLeaseRecord {
21287        let root = std::env::current_dir().unwrap()
21288            .join("managed-test-target")
21289            .to_string_lossy()
21290            .into_owned();
21291        let lease = ManagedWorktreeLeaseRecord {
21292            lease_id: ManagedWorktreeLeaseId::new("mw-test").unwrap(),
21293            source_workspace_id: WorkspaceId::new("primary").unwrap(),
21294            workspace_id: WorkspaceId::new("managed-test").unwrap(),
21295            profile_id: WorktreeProfileId::new("default").unwrap(),
21296            profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
21297            target_root: root.clone(),
21298            branch: "gate4agent/mw-test".to_owned(),
21299            base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
21300            expected_head: Some("0123456789abcdef0123456789abcdef01234567".to_owned()),
21301            retention: ManagedWorktreeRetention::RemoveWhenReleased,
21302            state: ManagedWorktreeLeaseState::Ready,
21303            session_holders: Vec::new(),
21304            record_holders: Vec::new(),
21305            cleanup_failure: None,
21306            created_at_unix_ms: 1,
21307            updated_at_unix_ms: 1,
21308        };
21309        shared.workspaces.write()
21310            .unwrap_or_else(|poisoned| poisoned.into_inner())
21311            .insert(lease.workspace_id.clone(), root);
21312        *shared.managed_worktrees.lock()
21313            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21314            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
21315        lease
21316    }
21317
21318    #[cfg(feature = "fixture")]
21319    #[test]
21320    fn monitoring_fixture_managed_spawn_retains_effective_record_holder_and_converges_live() {
21321        let mut shared = terminal_test_shared();
21322        shared.fixture_semantic_hook_policy = true;
21323        let lease = install_managed_lease(&shared);
21324        let address = SessionAddress {
21325            workspace_id: lease.workspace_id.clone(),
21326            session: SessionKey {
21327                instance_id: AgentInstanceId(206),
21328                generation: SessionGeneration(1),
21329            },
21330        };
21331        let pre_admission_policy = ProviderRuntimePolicy::raw_pty();
21332        let effective_runtime_policy =
21333            shared.effective_spawn_runtime_policy(pre_admission_policy);
21334        assert_ne!(effective_runtime_policy, pre_admission_policy);
21335        assert!(effective_runtime_policy.provider_session_identity);
21336
21337        let record_id = shared
21338            .bind_spawn_session(
21339                &address,
21340                agent("claude"),
21341                SessionMode::Pty,
21342                effective_runtime_policy,
21343                SpawnRecordPolicy::Always,
21344                None,
21345            )
21346            .unwrap()
21347            .unwrap();
21348        assert_eq!(
21349            shared.record(&record_id).unwrap().state,
21350            ManagedSessionState::IdentityPending,
21351        );
21352        let (record_holder, raw_inventory_record_id) = {
21353            let mut bindings = shared
21354                .session_bindings
21355                .lock()
21356                .unwrap_or_else(|poisoned| poisoned.into_inner());
21357            let binding = bindings.get_mut(&address.session.instance_id).unwrap();
21358            binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
21359            managed_spawn_record_ownership(binding, effective_runtime_policy)
21360        };
21361        assert_eq!(record_holder.as_ref(), Some(&record_id));
21362        assert!(raw_inventory_record_id.is_none());
21363        let snapshot = shared
21364            .managed_worktrees
21365            .lock()
21366            .unwrap_or_else(|poisoned| poisoned.into_inner())
21367            .bind_session(
21368                &lease.lease_id,
21369                ManagedWorktreeSessionHolder {
21370                    incarnation_id: shared.incarnation_id,
21371                    instance_id: address.session.instance_id,
21372                    generation: address.session.generation,
21373                },
21374                record_holder,
21375                unix_time_ms(),
21376            )
21377            .unwrap();
21378        assert_eq!(snapshot.managed_record_count, 1);
21379        assert_eq!(
21380            shared
21381                .managed_worktrees
21382                .lock()
21383                .unwrap_or_else(|poisoned| poisoned.into_inner())
21384                .get(&lease.lease_id)
21385                .unwrap()
21386                .record_holders,
21387            vec![record_id.clone()],
21388        );
21389
21390        let identity = ProviderSessionIdentity {
21391            key: ProviderSessionKey::SessionId,
21392            id: "monitoring-fixture-managed-session".to_owned(),
21393            transcript_path: None,
21394        };
21395        shared.publish_control(ControlEvent {
21396            sequence: 1,
21397            command_id: None,
21398            instance_id: address.session.instance_id,
21399            generation: address.session.generation,
21400            event: ControlEventKind::ProviderEvent {
21401                sequence: 1,
21402                source: gate4agent_types::ProviderSource {
21403                    family: AdapterFamily::Hook,
21404                    binding: AdapterBinding::new(
21405                        gate4agent_types::AdapterId::new("claude-code").unwrap(),
21406                        "fixture/v1",
21407                        gate4agent_types::AdapterVerification::SyntheticFixture,
21408                    )
21409                    .unwrap(),
21410                },
21411                source_sequence: 1,
21412                event: ProviderEvent::SessionIdentityObserved {
21413                    identity: identity.clone(),
21414                },
21415            },
21416        });
21417
21418        let live = shared.record(&record_id).unwrap();
21419        assert_eq!(live.state, ManagedSessionState::Live);
21420        assert_eq!(live.provider_session.as_ref(), Some(&identity));
21421        assert_eq!(live.active_session.as_ref(), Some(&address));
21422        assert_eq!(
21423            shared
21424                .managed_worktrees
21425                .lock()
21426                .unwrap_or_else(|poisoned| poisoned.into_inner())
21427                .get(&lease.lease_id)
21428                .unwrap()
21429                .record_holders,
21430            vec![record_id],
21431        );
21432    }
21433
21434    fn managed_v2_test_shared(
21435        allocation_root: &Path,
21436        state_path: Option<PathBuf>,
21437        profile_revision: &str,
21438    ) -> NodeShared {
21439        let profile = ManagedWorktreeProfile::new(
21440            WorktreeProfileId::new("default").unwrap(),
21441            WorktreeProfileRevision::new(profile_revision).unwrap(),
21442            allocation_root,
21443            "gate4agent",
21444            "HEAD",
21445            ManagedWorktreeRetention::RemoveWhenReleased,
21446        )
21447        .unwrap();
21448        let workspace = WorkspaceConfig::new(
21449            WorkspaceId::new("primary").unwrap(),
21450            std::env::current_dir().unwrap(),
21451        )
21452        .unwrap()
21453        .with_worktree_service_mode(WorktreeServiceMode::Managed)
21454        .with_managed_worktree_profile(profile)
21455        .unwrap();
21456        let catalog = active_registry().unwrap();
21457        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
21458        let mut shared = NodeShared::new(
21459            handle,
21460            "fixture-token".to_owned(),
21461            NodeId::new("node-terminal-test").unwrap(),
21462            vec![workspace],
21463            vec![agent("claude")],
21464        );
21465        shared.state_path = state_path;
21466        shared
21467    }
21468
21469    fn managed_v2_request(idempotency_key: &str) -> ManagedWorktreeSpawnRequestV2 {
21470        let mut spawn_spec = spawn_spec_fixture();
21471        spawn_spec.idempotency_key = SpawnIdempotencyKey::new(idempotency_key).unwrap();
21472        ManagedWorktreeSpawnRequestV2 {
21473            spawn_spec,
21474            worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
21475            expected_profile_revision: WorktreeProfileRevision::new("v1").unwrap(),
21476        }
21477    }
21478
21479    #[test]
21480    fn managed_spawn_receipt_rebinds_allocated_worktree_and_roundtrips_wire() {
21481        let shared = terminal_test_shared();
21482        let mut lease = install_managed_lease(&shared);
21483        let session = SessionAddress {
21484            workspace_id: lease.workspace_id.clone(),
21485            session: SessionKey {
21486                instance_id: AgentInstanceId(73),
21487                generation: SessionGeneration(4),
21488            },
21489        };
21490        lease.state = ManagedWorktreeLeaseState::InUse;
21491        lease.session_holders.push(ManagedWorktreeSessionHolder {
21492            incarnation_id: shared.incarnation_id,
21493            instance_id: session.session.instance_id,
21494            generation: session.session.generation,
21495        });
21496        let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
21497        assert!(resolved.target.worktree_id.is_none());
21498
21499        let receipt = managed_spawn_receipt(
21500            &resolved,
21501            shared.incarnation_id,
21502            session.clone(),
21503            lease.snapshot(),
21504            None,
21505            None,
21506            None,
21507        );
21508        assert!(resolved.target.worktree_id.is_none());
21509        assert_eq!(receipt.spawn.target.workspace_id, lease.source_workspace_id);
21510        assert_eq!(receipt.spawn.target.worktree_id.as_ref(), Some(&lease.workspace_id));
21511        assert_eq!(receipt.spawn.session, session);
21512        assert_eq!(receipt.lease.workspace_id, lease.workspace_id);
21513        let encoded = serde_json::to_vec(&receipt).unwrap();
21514        let decoded: ManagedWorktreeSpawnReceipt = serde_json::from_slice(&encoded).unwrap();
21515        assert_eq!(decoded, receipt);
21516    }
21517
21518    #[tokio::test]
21519    async fn managed_spawn_v2_rejects_profile_revision_before_allocation() {
21520        let root = temporary_workspace_root("managed-v2-revision");
21521        let allocation = root.join("allocation");
21522        std::fs::create_dir_all(&allocation).unwrap();
21523        let shared = managed_v2_test_shared(
21524            &allocation,
21525            Some(root.join("node-state.json")),
21526            "v1",
21527        );
21528        let mut request = managed_v2_request("managed-v2-revision");
21529        request.expected_profile_revision = WorktreeProfileRevision::new("v2").unwrap();
21530
21531        let failure = shared.spawn_managed_worktree_v2(request).await.unwrap_err();
21532
21533        assert_eq!(
21534            failure.code,
21535            NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
21536        );
21537        assert!(shared
21538            .managed_worktrees
21539            .lock()
21540            .unwrap_or_else(|poisoned| poisoned.into_inner())
21541            .records()
21542            .is_empty());
21543        assert!(shared
21544            .managed_spawn_replays
21545            .lock()
21546            .unwrap_or_else(|poisoned| poisoned.into_inner())
21547            .is_empty());
21548        std::fs::remove_dir_all(root).unwrap();
21549    }
21550
21551    #[test]
21552    fn managed_spawn_v2_concurrent_same_key_has_one_durable_reservation() {
21553        let root = temporary_workspace_root("managed-v2-concurrent");
21554        let allocation = root.join("allocation");
21555        std::fs::create_dir_all(&allocation).unwrap();
21556        let shared = Arc::new(managed_v2_test_shared(
21557            &allocation,
21558            Some(root.join("node-state.json")),
21559            "v1",
21560        ));
21561        let request = managed_v2_request("managed-v2-concurrent");
21562        let barrier = Arc::new(std::sync::Barrier::new(3));
21563        let mut joins = Vec::new();
21564        for _ in 0..2 {
21565            let shared = Arc::clone(&shared);
21566            let request = request.clone();
21567            let barrier = Arc::clone(&barrier);
21568            joins.push(std::thread::spawn(move || {
21569                let digest = managed_spawn_request_digest_v2(&request).unwrap();
21570                barrier.wait();
21571                shared.acquire_durable_managed_spawn_v2(&request, digest)
21572            }));
21573        }
21574        barrier.wait();
21575        let results = joins
21576            .into_iter()
21577            .map(|join| join.join().unwrap())
21578            .collect::<Vec<_>>();
21579        assert_eq!(
21580            results
21581                .iter()
21582                .filter(|result| {
21583                    matches!(result, Ok(DurableManagedSpawnReplayDecision::Reserved))
21584                })
21585                .count(),
21586            1,
21587        );
21588        assert_eq!(
21589            results
21590                .iter()
21591                .filter(|result| {
21592                    matches!(result, Err(error) if error.code == NodeFailureCode::BackendBusy)
21593                })
21594                .count(),
21595            1,
21596        );
21597        assert_eq!(
21598            shared
21599                .managed_spawn_replays
21600                .lock()
21601                .unwrap_or_else(|poisoned| poisoned.into_inner())
21602                .len(),
21603            1,
21604        );
21605        assert!(shared
21606            .managed_worktrees
21607            .lock()
21608            .unwrap_or_else(|poisoned| poisoned.into_inner())
21609            .records()
21610            .is_empty());
21611        drop(shared);
21612        std::fs::remove_dir_all(root).unwrap();
21613    }
21614
21615    #[tokio::test]
21616    async fn managed_spawn_v2_linked_pending_reopen_fails_closed_without_reallocation() {
21617        let root = temporary_workspace_root("managed-v2-linked-pending");
21618        let allocation = root.join("allocation");
21619        std::fs::create_dir_all(&allocation).unwrap();
21620        let state_path = root.join("node-state.json");
21621        let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
21622        let lease = install_managed_lease(&shared);
21623        let request = managed_v2_request("managed-v2-linked-pending");
21624        assert!(matches!(
21625            shared
21626                .acquire_durable_managed_spawn_v2(
21627                    &request,
21628                    managed_spawn_request_digest_v2(&request).unwrap(),
21629                )
21630                .unwrap(),
21631            DurableManagedSpawnReplayDecision::Reserved,
21632        ));
21633        {
21634            let _transaction = shared
21635                .state_transaction
21636                .lock()
21637                .unwrap_or_else(|poisoned| poisoned.into_inner());
21638            shared
21639                .link_durable_managed_spawn_v2_lease_locked(
21640                    &request.spawn_spec.idempotency_key,
21641                    &lease.lease_id,
21642                )
21643                .unwrap();
21644            shared.persist_state_locked().unwrap();
21645        }
21646        drop(shared);
21647
21648        let mut loaded = session_registry::load(
21649            Some(&state_path),
21650            &NodeId::new("node-terminal-test").unwrap(),
21651        )
21652        .unwrap();
21653        let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
21654        *reopened
21655            .managed_worktrees
21656            .lock()
21657            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21658            ManagedWorktreeRegistry::from_records(
21659                std::mem::take(&mut loaded.managed_worktrees),
21660                std::mem::take(&mut loaded.managed_worktree_tombstones),
21661            )
21662            .unwrap();
21663        *reopened
21664            .managed_spawn_replays
21665            .lock()
21666            .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
21667            .managed_spawn_replays
21668            .into_iter()
21669            .map(|record| (record.idempotency_key.clone(), record))
21670            .collect();
21671        let lease_count = reopened
21672            .managed_worktrees
21673            .lock()
21674            .unwrap_or_else(|poisoned| poisoned.into_inner())
21675            .records()
21676            .len();
21677
21678        let failure = reopened
21679            .spawn_managed_worktree_v2(request)
21680            .await
21681            .unwrap_err();
21682
21683        assert_eq!(
21684            failure.code,
21685            NodeFailureCode::ManagedWorktreeRecoveryRequired,
21686        );
21687        assert_eq!(
21688            reopened
21689                .managed_worktrees
21690                .lock()
21691                .unwrap_or_else(|poisoned| poisoned.into_inner())
21692                .records()
21693                .len(),
21694            lease_count,
21695        );
21696        std::fs::remove_dir_all(root).unwrap();
21697    }
21698
21699    #[tokio::test]
21700    async fn managed_spawn_v2_replays_exact_receipt_after_durable_reopen_and_conflicts() {
21701        let root = temporary_workspace_root("managed-v2-reopen");
21702        let allocation = root.join("allocation");
21703        std::fs::create_dir_all(&allocation).unwrap();
21704        let state_path = root.join("node-state.json");
21705        let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
21706        let lease = install_managed_lease(&shared);
21707        let request = managed_v2_request("managed-v2-reopen");
21708        let session = SessionAddress {
21709            workspace_id: lease.workspace_id.clone(),
21710            session: SessionKey {
21711                instance_id: AgentInstanceId(91),
21712                generation: SessionGeneration(1),
21713            },
21714        };
21715        let snapshot = {
21716            let mut registry = shared
21717                .managed_worktrees
21718                .lock()
21719                .unwrap_or_else(|poisoned| poisoned.into_inner());
21720            let current = registry.get_mut(&lease.lease_id).unwrap();
21721            current.state = ManagedWorktreeLeaseState::InUse;
21722            current.session_holders.push(ManagedWorktreeSessionHolder {
21723                incarnation_id: shared.incarnation_id,
21724                instance_id: session.session.instance_id,
21725                generation: session.session.generation,
21726            });
21727            current.snapshot()
21728        };
21729        let resolved = shared.resolve_spawn_spec(&request.spawn_spec).unwrap();
21730        let receipt = managed_spawn_receipt(
21731            &resolved,
21732            shared.incarnation_id,
21733            session,
21734            snapshot,
21735            None,
21736            None,
21737            None,
21738        );
21739        assert!(matches!(
21740            shared
21741                .acquire_durable_managed_spawn_v2(
21742                    &request,
21743                    managed_spawn_request_digest_v2(&request).unwrap(),
21744                )
21745                .unwrap(),
21746            DurableManagedSpawnReplayDecision::Reserved,
21747        ));
21748        {
21749            let _transaction = shared
21750                .state_transaction
21751                .lock()
21752                .unwrap_or_else(|poisoned| poisoned.into_inner());
21753            shared
21754                .link_durable_managed_spawn_v2_lease_locked(
21755                    &request.spawn_spec.idempotency_key,
21756                    &lease.lease_id,
21757                )
21758                .unwrap();
21759            shared.persist_state_locked().unwrap();
21760        }
21761        shared
21762            .commit_durable_managed_spawn_v2(&request, receipt.clone())
21763            .unwrap();
21764        let legacy_same_key = ManagedWorktreeSpawnRequest {
21765            spawn_spec: request.spawn_spec.clone(),
21766            worktree_profile_id: request.worktree_profile_id.clone(),
21767        };
21768        shared.remember_managed_spawn_attempt(
21769            Some(&request.spawn_spec.idempotency_key),
21770            legacy_same_key.clone(),
21771            Ok(receipt.clone()),
21772            Instant::now(),
21773        );
21774        assert!(shared
21775            .replay_managed_spawn(&legacy_same_key, Instant::now())
21776            .unwrap()
21777            .is_none());
21778        drop(shared);
21779
21780        let mut loaded = session_registry::load(
21781            Some(&state_path),
21782            &NodeId::new("node-terminal-test").unwrap(),
21783        )
21784        .unwrap();
21785        assert_eq!(loaded.managed_spawn_replays.len(), 1);
21786        let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
21787        *reopened
21788            .managed_worktrees
21789            .lock()
21790            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21791            ManagedWorktreeRegistry::from_records(
21792                std::mem::take(&mut loaded.managed_worktrees),
21793                std::mem::take(&mut loaded.managed_worktree_tombstones),
21794            )
21795            .unwrap();
21796        *reopened
21797            .managed_spawn_replays
21798            .lock()
21799            .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
21800            .managed_spawn_replays
21801            .into_iter()
21802            .map(|record| (record.idempotency_key.clone(), record))
21803            .collect();
21804        let lease_count_before = reopened
21805            .managed_worktrees
21806            .lock()
21807            .unwrap_or_else(|poisoned| poisoned.into_inner())
21808            .records()
21809            .len();
21810
21811        assert_eq!(
21812            reopened
21813                .spawn_managed_worktree_v2(request.clone())
21814                .await
21815                .unwrap(),
21816            receipt,
21817        );
21818        assert_eq!(
21819            reopened
21820                .managed_worktrees
21821                .lock()
21822                .unwrap_or_else(|poisoned| poisoned.into_inner())
21823                .records()
21824                .len(),
21825            lease_count_before,
21826        );
21827        let mut changed = request;
21828        changed.spawn_spec.deadline_ms = crate::protocol::SpawnDeadlineMs::new(29_000).unwrap();
21829        let failure = reopened
21830            .spawn_managed_worktree_v2(changed)
21831            .await
21832            .unwrap_err();
21833        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
21834        assert_eq!(
21835            reopened
21836                .managed_worktrees
21837                .lock()
21838                .unwrap_or_else(|poisoned| poisoned.into_inner())
21839                .records()
21840                .len(),
21841            lease_count_before,
21842        );
21843        let mut new_key = managed_v2_request("managed-v2-new-after-profile-change");
21844        new_key.expected_profile_revision = WorktreeProfileRevision::new("v1").unwrap();
21845        let failure = reopened
21846            .spawn_managed_worktree_v2(new_key)
21847            .await
21848            .unwrap_err();
21849        assert_eq!(
21850            failure.code,
21851            NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
21852        );
21853        assert_eq!(
21854            reopened
21855                .managed_worktrees
21856                .lock()
21857                .unwrap_or_else(|poisoned| poisoned.into_inner())
21858                .records()
21859                .len(),
21860            lease_count_before,
21861        );
21862        assert_eq!(
21863            reopened
21864                .managed_spawn_replays
21865                .lock()
21866                .unwrap_or_else(|poisoned| poisoned.into_inner())
21867                .len(),
21868            1,
21869        );
21870        std::fs::remove_dir_all(root).unwrap();
21871    }
21872
21873    #[test]
21874    fn legacy_managed_spawn_idempotency_remains_transient_and_exact() {
21875        let shared = terminal_test_shared();
21876        let request = ManagedWorktreeSpawnRequest {
21877            spawn_spec: spawn_spec_fixture(),
21878            worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
21879        };
21880        let expected = failure(NodeFailureCode::BackendBusy, "legacy fixture failure");
21881        shared.remember_managed_spawn(
21882            request.clone(),
21883            Err(expected.clone()),
21884            Instant::now(),
21885        );
21886
21887        assert_eq!(
21888            shared
21889                .replay_managed_spawn(&request, Instant::now())
21890                .unwrap()
21891                .unwrap(),
21892            Err(expected),
21893        );
21894        assert!(shared
21895            .managed_spawn_replays
21896            .lock()
21897            .unwrap_or_else(|poisoned| poisoned.into_inner())
21898            .is_empty());
21899    }
21900
21901    #[tokio::test]
21902    async fn ordinary_spawn_and_spawn_spec_cannot_bypass_managed_lease_holder_fence() {
21903        let shared = terminal_test_shared();
21904        let lease = install_managed_lease(&shared);
21905        let raw = shared.spawn_session(
21906            lease.workspace_id.clone(),
21907            agent("claude"),
21908            SessionMode::Pty,
21909            gate4agent_types::TerminalSize { rows: 24, columns: 80 },
21910            None,
21911        ).await.unwrap_err();
21912        assert_eq!(raw.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
21913        let mut spec = spawn_spec_fixture();
21914        spec.target.workspace_id = lease.workspace_id.clone();
21915        let specified = shared.spawn_from_spec(spec).await.unwrap_err();
21916        assert_eq!(specified.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
21917        let mut selected = spawn_spec_fixture();
21918        selected.idempotency_key = SpawnIdempotencyKey::new("managed-selected-bypass").unwrap();
21919        selected.target.worktree_id = Some(lease.workspace_id);
21920        let selected = shared.spawn_from_spec(selected).await.unwrap_err();
21921        assert_eq!(selected.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
21922        assert!(shared.handle.snapshot().sessions.is_empty());
21923    }
21924
21925    #[test]
21926    fn legacy_projection_omits_managed_snapshot_events_and_path_bearing_diagnostics() {
21927        let shared = terminal_test_shared();
21928        let lease = install_managed_lease(&shared);
21929        let snapshot = snapshot_for_wire(
21930            &shared, true, true, true, false, true, true, false, false, true,
21931        );
21932        assert!(snapshot.managed_worktrees.is_empty());
21933        assert!(snapshot.workspaces.iter().all(|workspace| {
21934            workspace.managed_worktree_profiles.is_none()
21935        }));
21936        let mut reply = ResponseEnvelope {
21937            request_id: 1,
21938            result: Ok(NodeResponse::Resync {
21939                event_sequence: 1,
21940                oldest_available_sequence: 1,
21941                snapshot: shared.snapshot(),
21942                events: vec![NodeEventEnvelope {
21943                    sequence: 1,
21944                    event: NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() },
21945                }],
21946            }),
21947        };
21948        project_response_without_managed_worktrees(&mut reply);
21949        let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
21950            panic!("projection changed the response kind");
21951        };
21952        assert!(snapshot.managed_worktrees.is_empty());
21953        assert!(events.is_empty());
21954        let mut git = git_snapshot_for_parser();
21955        git.branch = Some(lease.branch.clone());
21956        git.managed_worktree = Some(crate::protocol::ManagedWorktreeGitScope {
21957            lease_id: lease.lease_id.clone(),
21958            source_workspace_id: lease.source_workspace_id.clone(),
21959            branch: lease.branch.clone(),
21960            base_commit: GitObjectId::new(lease.base_commit.clone()).unwrap(),
21961            active_session_count: 1,
21962            managed_record_count: 0,
21963        });
21964        let mut inspection_reply = ResponseEnvelope {
21965            request_id: 2,
21966            result: Ok(NodeResponse::WorkspaceInspected {
21967                inspection: WorkspaceInspection {
21968                    workspace_id: lease.workspace_id.clone(),
21969                    entries: Vec::new(),
21970                    tree_truncated: false,
21971                    git,
21972                    truncation: None,
21973                },
21974            }),
21975        };
21976        project_response_without_managed_worktrees(&mut inspection_reply);
21977        let Ok(NodeResponse::WorkspaceInspected { inspection }) = inspection_reply.result else {
21978            panic!("projection changed the inspection response kind");
21979        };
21980        assert!(inspection.git.managed_worktree.is_none());
21981        let failure = managed_git_worktree_failure(GitWorktreeError {
21982            kind: GitWorktreeErrorKind::Failed,
21983            message: r"git failed at C:\private\managed-target".to_owned(),
21984        });
21985        assert!(!failure.message.contains("private"));
21986        assert!(!failure.message.contains("managed-target"));
21987    }
21988
21989    #[tokio::test]
21990    async fn restart_reconciliation_retains_exact_owned_worktree_and_explicit_cleanup_removes_it() {
21991        let root = temporary_workspace_root("managed-restart");
21992        let source = root.join("source");
21993        let allocation = root.join("allocation");
21994        std::fs::create_dir_all(&source).unwrap();
21995        std::fs::create_dir_all(&allocation).unwrap();
21996        run_test_git(&source, &["init"]);
21997        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
21998        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
21999        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22000        run_test_git(&source, &["add", "seed.txt"]);
22001        run_test_git(&source, &["commit", "-m", "seed"]);
22002        let profile = ManagedWorktreeProfile::new(
22003            WorktreeProfileId::new("default").unwrap(),
22004            crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22005            &allocation,
22006            "gate4agent",
22007            "HEAD",
22008            ManagedWorktreeRetention::Retain,
22009        ).unwrap();
22010        let workspace = WorkspaceConfig::new(
22011            WorkspaceId::new("primary").unwrap(),
22012            &source,
22013        ).unwrap()
22014            .with_worktree_service_mode(WorktreeServiceMode::Managed)
22015            .with_managed_worktree_profile(profile.clone()).unwrap();
22016        let target = PathBuf::from(profile.allocation_root()).join("mw-restart");
22017        let source_text = workspace.canonical_root().to_owned();
22018        let target_text = target.to_string_lossy().into_owned();
22019        let created = create_git_worktree(
22020            &source_text,
22021            &target_text,
22022            "gate4agent/mw-restart",
22023            Some("HEAD"),
22024        ).await.unwrap();
22025        let lease = ManagedWorktreeLeaseRecord {
22026            lease_id: ManagedWorktreeLeaseId::new("mw-restart").unwrap(),
22027            source_workspace_id: WorkspaceId::new("primary").unwrap(),
22028            workspace_id: WorkspaceId::new("managed-restart").unwrap(),
22029            profile_id: profile.profile_id().clone(),
22030            profile_revision: profile.revision().clone(),
22031            target_root: created.path.clone(),
22032            branch: "gate4agent/mw-restart".to_owned(),
22033            base_commit: created.head.clone(),
22034            expected_head: None,
22035            retention: ManagedWorktreeRetention::Retain,
22036            state: ManagedWorktreeLeaseState::Allocating,
22037            session_holders: Vec::new(),
22038            record_holders: Vec::new(),
22039            cleanup_failure: None,
22040            created_at_unix_ms: 1,
22041            updated_at_unix_ms: 1,
22042        };
22043        let catalog = active_registry().unwrap();
22044        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22045        let shared = NodeShared::new_with_incarnation(
22046            handle,
22047            "fixture-token".to_owned(),
22048            NodeId::new("node-managed-restart").unwrap(),
22049            NodeIncarnationId::from_bytes([7; crate::protocol::NODE_INCARNATION_ID_BYTES]),
22050            vec![workspace],
22051            vec![agent("claude")],
22052            ProviderRuntimeStatuses::default(),
22053            None,
22054            Vec::new(),
22055            Vec::new(),
22056            SpawnProfileRegistry::default(),
22057            None,
22058            None,
22059            None,
22060            None,
22061            Vec::new(),
22062            vec![lease.clone()],
22063            Vec::new(),
22064            Vec::new(),
22065            None,
22066            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
22067        );
22068        shared.reconcile_managed_worktrees().await;
22069        let retained = shared.managed_worktrees.lock()
22070            .unwrap_or_else(|poisoned| poisoned.into_inner())
22071            .get(&lease.lease_id).unwrap().clone();
22072        assert_eq!(retained.state, ManagedWorktreeLeaseState::Retained);
22073        assert!(shared.workspaces.read()
22074            .unwrap_or_else(|poisoned| poisoned.into_inner())
22075            .contains_key(&lease.workspace_id));
22076        let removed = shared.cleanup_managed_worktree(&lease.lease_id, true).await.unwrap();
22077        assert_eq!(removed.state, ManagedWorktreeLeaseState::Removed);
22078        assert!(!Path::new(&created.path).exists());
22079        std::fs::remove_dir_all(root).unwrap();
22080    }
22081
22082    fn run_test_git(root: &Path, arguments: &[&str]) {
22083        let output = std::process::Command::new("git")
22084            .arg("-C").arg(root).args(arguments).output().unwrap();
22085        assert!(
22086            output.status.success(),
22087            "git {:?} failed: {}",
22088            arguments,
22089            String::from_utf8_lossy(&output.stderr),
22090        );
22091    }
22092
22093    #[tokio::test]
22094    async fn spawn_spec_validation_fails_before_session_mutation() {
22095        let shared = terminal_test_shared();
22096        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22097            capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
22098        }));
22099        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22100            capability.as_str() == NODE_SPAWN_PROFILE_REVISION_CAPABILITY
22101        }));
22102        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22103            capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
22104        }));
22105
22106        let mut target_mismatch = spawn_spec_fixture();
22107        target_mismatch.target.node_id = NodeId::new("other-node").unwrap();
22108        let failure = shared.spawn_from_spec(target_mismatch).await.unwrap_err();
22109        assert_eq!(failure.code, NodeFailureCode::SpawnTargetMismatch);
22110
22111        let mut unknown_profile = spawn_spec_fixture();
22112        unknown_profile.idempotency_key = SpawnIdempotencyKey::new("unknown-profile").unwrap();
22113        unknown_profile.profile_id = crate::protocol::SpawnProfileId::new("missing").unwrap();
22114        let failure = shared.spawn_from_spec(unknown_profile).await.unwrap_err();
22115        assert_eq!(failure.code, NodeFailureCode::UnknownSpawnProfile);
22116
22117        let mut revision_mismatch = spawn_spec_fixture();
22118        revision_mismatch.idempotency_key =
22119            SpawnIdempotencyKey::new("profile-revision-mismatch").unwrap();
22120        revision_mismatch.expected_profile_revision =
22121            crate::protocol::SpawnProfileRevision::new("test-r2").unwrap();
22122        let failure = shared.spawn_from_spec(revision_mismatch).await.unwrap_err();
22123        assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
22124
22125        let mut unsupported_materializer = spawn_spec_fixture();
22126        unsupported_materializer.idempotency_key =
22127            SpawnIdempotencyKey::new("unsupported-materializer").unwrap();
22128        unsupported_materializer.overrides.bundle_id = crate::protocol::SpawnOverride::Set {
22129            value: crate::protocol::SpawnBundleId::new("bundle-v1").unwrap(),
22130        };
22131        let failure = shared
22132            .spawn_from_spec(unsupported_materializer)
22133            .await
22134            .unwrap_err();
22135        assert_eq!(failure.code, NodeFailureCode::UnknownBundle);
22136
22137        let failure = shared
22138            .spawn_session_with_deadline(
22139                WorkspaceId::new("primary").unwrap(),
22140                agent("claude"),
22141                SessionMode::Pty,
22142                gate4agent_types::TerminalSize {
22143                    rows: 24,
22144                    columns: 80,
22145                },
22146                None,
22147                None,
22148                None,
22149                None,
22150                None,
22151                None,
22152                SpawnRecordPolicy::ProviderIdentityOnly,
22153                Some(Instant::now()),
22154                &[],
22155                None,
22156                ApprovalLevel::default(),
22157                None,
22158                None,
22159            )
22160            .await
22161            .unwrap_err();
22162        assert_eq!(failure.code, NodeFailureCode::SpawnDeadlineExceeded);
22163        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22164        assert!(shared.handle.snapshot().sessions.is_empty());
22165    }
22166
22167    #[test]
22168    fn worktree_selection_requires_its_separate_negotiated_capability() {
22169        let mut spec = spawn_spec_fixture();
22170        spec.target.worktree_id = Some(WorkspaceId::new("selected-worktree").unwrap());
22171        let request = NodeRequest::SpawnSpec { spec };
22172        let spawn_spec_capability =
22173            CapabilityId::new(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY).unwrap();
22174        assert!(request_uses_unnegotiated_capability(
22175            &request,
22176            &[spawn_spec_capability.clone()],
22177        ));
22178        let worktree_capability =
22179            CapabilityId::new(NODE_WORKTREE_SELECTION_CAPABILITY).unwrap();
22180        assert!(request_uses_unnegotiated_capability(
22181            &request,
22182            &[spawn_spec_capability.clone(), worktree_capability.clone()],
22183        ));
22184        let profile_revision_capability =
22185            CapabilityId::new(NODE_SPAWN_PROFILE_REVISION_CAPABILITY).unwrap();
22186        assert!(request_uses_unnegotiated_capability(
22187            &request,
22188            &[
22189                spawn_spec_capability.clone(),
22190                worktree_capability.clone(),
22191                profile_revision_capability.clone(),
22192            ],
22193        ));
22194        // Default `SpawnOverrides` leaves `bundle_id`/`context_id` at
22195        // `Inherit`, not `Clear` — the profile's own defaults might still
22196        // materialize a bundle or a context pack, so both capabilities stay
22197        // conservatively required until negotiated (see
22198        // `requires_session_bundle_materialization_capability` /
22199        // `requires_history_context_pack_capability`).
22200        let bundle_materialization_capability =
22201            CapabilityId::new(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY).unwrap();
22202        assert!(request_uses_unnegotiated_capability(
22203            &request,
22204            &[
22205                spawn_spec_capability.clone(),
22206                worktree_capability.clone(),
22207                profile_revision_capability.clone(),
22208                bundle_materialization_capability.clone(),
22209            ],
22210        ));
22211        let history_context_pack_capability =
22212            CapabilityId::new(NODE_HISTORY_CONTEXT_PACK_CAPABILITY).unwrap();
22213        assert!(!request_uses_unnegotiated_capability(
22214            &request,
22215            &[
22216                spawn_spec_capability,
22217                worktree_capability,
22218                profile_revision_capability,
22219                bundle_materialization_capability,
22220                history_context_pack_capability,
22221            ],
22222        ));
22223    }
22224
22225    #[tokio::test]
22226    async fn spawn_spec_idempotency_replays_receipt_and_conflicts_before_session_mutation() {
22227        let mut shared = terminal_test_shared();
22228        let spec = spawn_spec_fixture();
22229        let receipt = shared
22230            .resolve_spawn_spec(&spec)
22231            .unwrap()
22232            .receipt(shared.incarnation_id, terminal_address(1));
22233        shared.remember_spawn_spec(spec.clone(), Ok(receipt.clone()), Instant::now());
22234
22235        let mut revised = shared.spawn_profiles.get(&spec.profile_id).unwrap().clone();
22236        revised.revision = crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
22237        shared.spawn_profiles = SpawnProfileRegistry::new([revised]).unwrap();
22238
22239        assert_eq!(shared.spawn_from_spec(spec.clone()).await.unwrap(), receipt);
22240
22241        let mut revision_conflicting = spec.clone();
22242        revision_conflicting.expected_profile_revision =
22243            crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
22244        let failure = shared.spawn_from_spec(revision_conflicting).await.unwrap_err();
22245        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22246
22247        let mut stale_cache_miss = spec.clone();
22248        stale_cache_miss.idempotency_key =
22249            SpawnIdempotencyKey::new("stale-profile-cache-miss").unwrap();
22250        let failure = shared.spawn_from_spec(stale_cache_miss).await.unwrap_err();
22251        assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
22252
22253        let mut conflicting = spec;
22254        conflicting.deadline_ms = crate::protocol::SpawnDeadlineMs::new(31_000).unwrap();
22255        let failure = shared.spawn_from_spec(conflicting).await.unwrap_err();
22256        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22257        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22258        assert!(shared.handle.snapshot().sessions.is_empty());
22259    }
22260
22261    #[tokio::test]
22262    async fn off_worktree_service_rejects_selection_and_mutation_before_git() {
22263        let shared = terminal_test_shared_with_mode(WorktreeServiceMode::Off);
22264        let mut spec = spawn_spec_fixture();
22265        spec.target.worktree_id = Some(WorkspaceId::new("unregistered-worktree").unwrap());
22266        let failure = shared.spawn_from_spec(spec).await.unwrap_err();
22267        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
22268
22269        let failure = shared
22270            .create_worktree(
22271                WorkspaceId::new("primary").unwrap(),
22272                WorkspaceId::new("new-worktree").unwrap(),
22273                "not-an-absolute-path".to_owned(),
22274                "invalid branch".to_owned(),
22275                None,
22276            )
22277            .await
22278            .unwrap_err();
22279        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
22280
22281        let failure = shared
22282            .remove_worktree(
22283                WorkspaceId::new("primary").unwrap(),
22284                "not-an-absolute-path".to_owned(),
22285            )
22286            .await
22287            .unwrap_err();
22288        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
22289        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22290        assert!(shared.handle.snapshot().sessions.is_empty());
22291    }
22292
22293    #[tokio::test]
22294    async fn manual_worktree_registration_failure_removes_the_created_worktree() {
22295        let root = temporary_workspace_root("manual-worktree-compensation");
22296        let source = root.join("source");
22297        let target = root.join("target");
22298        let invalid_state_parent = root.join("state-parent-is-a-file");
22299        let invalid_state_path = invalid_state_parent.join("state.json");
22300        std::fs::create_dir_all(&source).unwrap();
22301        std::fs::write(&invalid_state_parent, "not a directory").unwrap();
22302        run_test_git(&source, &["init"]);
22303        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
22304        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
22305        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22306        run_test_git(&source, &["add", "seed.txt"]);
22307        run_test_git(&source, &["commit", "-m", "seed"]);
22308
22309        let catalog = active_registry().unwrap();
22310        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22311        let workspace = WorkspaceConfig::new(
22312            WorkspaceId::new("primary").unwrap(),
22313            &source,
22314        ).unwrap();
22315        let mut shared = NodeShared::new(
22316            handle,
22317            "fixture-token".to_owned(),
22318            NodeId::new("node-worktree-compensation").unwrap(),
22319            vec![workspace],
22320            vec![agent("claude")],
22321        );
22322        shared.state_path = Some(invalid_state_path);
22323
22324        let error = shared.create_worktree(
22325            WorkspaceId::new("primary").unwrap(),
22326            WorkspaceId::new("compensated").unwrap(),
22327            target.to_string_lossy().into_owned(),
22328            "gate4agent/compensated".to_owned(),
22329            Some("HEAD".to_owned()),
22330        ).await.unwrap_err();
22331
22332        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
22333        assert!(!target.exists());
22334        assert!(list_git_worktrees(source.to_str().unwrap()).await.unwrap()
22335            .iter()
22336            .all(|worktree| !worktree_paths_equal(&worktree.path, &target.to_string_lossy())));
22337        std::fs::remove_dir_all(root).unwrap();
22338    }
22339
22340    #[test]
22341    fn selected_worktree_requires_authoritative_non_main_non_bare_entry() {
22342        fn worktree(path: &str, is_main: bool, is_bare: bool) -> NativeGitWorktreeSnapshot {
22343            NativeGitWorktreeSnapshot {
22344                path: path.to_owned(),
22345                head: "0123456789abcdef".to_owned(),
22346                branch: Some("refs/heads/test".to_owned()),
22347                is_bare,
22348                is_main,
22349                locked: false,
22350                lock_reason: None,
22351                prunable: false,
22352                prunable_reason: None,
22353                workspace_id: None,
22354            }
22355        }
22356
22357        let source = r"C:\repo";
22358        let selected = r"C:\repo-worktree";
22359        let listed = vec![
22360            worktree(source, true, false),
22361            worktree(selected, false, false),
22362        ];
22363        assert!(validate_selected_worktree(source, selected, &listed).is_ok());
22364
22365        let main_failure = validate_selected_worktree(source, source, &listed).unwrap_err();
22366        assert_eq!(main_failure.code, NodeFailureCode::WorktreeProtected);
22367
22368        let bare = vec![
22369            worktree(source, true, false),
22370            worktree(selected, false, true),
22371        ];
22372        let bare_failure = validate_selected_worktree(source, selected, &bare).unwrap_err();
22373        assert_eq!(bare_failure.code, NodeFailureCode::WorktreeProtected);
22374
22375        let foreign_failure =
22376            validate_selected_worktree(source, r"C:\foreign", &listed).unwrap_err();
22377        assert_eq!(foreign_failure.code, NodeFailureCode::WorktreeProtected);
22378    }
22379
22380    fn terminal_frame(sequence: u64) -> TerminalFrame {
22381        TerminalFrame {
22382            sequence,
22383            size: gate4agent_types::TerminalSize {
22384                rows: 24,
22385                columns: 80,
22386            },
22387            cursor_row: 2,
22388            cursor_column: 3,
22389            contents: format!("frame-{sequence}"),
22390            formatted: vec![1, 2, 3],
22391            scrollback_formatted: Vec::new(),
22392            alternate_screen: false,
22393            mouse_protocol_enabled: false,
22394            mouse_protocol_encoding: Default::default(),
22395            produced_at_unix_ms: 0,
22396            screen_state: gate4agent_types::PtyScreenState::default(),
22397            bracketed_paste: None,
22398        }
22399    }
22400
22401    #[test]
22402    fn terminal_frame_watermark_coalesces_and_resets_for_exact_session_address() {
22403        let shared = terminal_test_shared();
22404        let mut receiver = shared.terminal_event_tx.subscribe();
22405        let original = terminal_address(1);
22406
22407        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
22408        let first = receiver.try_recv().unwrap();
22409        assert_eq!(first.len(), 1);
22410        assert!(matches!(
22411            &first[0].event,
22412            NodeEvent::TerminalFrame { address, frame }
22413                if address == &original && frame.sequence == 7
22414        ));
22415
22416        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
22417        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(6))]);
22418        assert!(matches!(
22419            receiver.try_recv(),
22420            Err(broadcast::error::TryRecvError::Empty)
22421        ));
22422
22423        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(8))]);
22424        let advanced = receiver.try_recv().unwrap();
22425        assert_eq!(advanced[0].sequence, 2);
22426
22427        let resumed = terminal_address(2);
22428        shared.publish_terminal_frame_candidates(vec![(resumed.clone(), terminal_frame(1))]);
22429        let reset = receiver.try_recv().unwrap();
22430        assert_eq!(reset[0].sequence, 3);
22431        assert!(matches!(
22432            &reset[0].event,
22433            NodeEvent::TerminalFrame { address, frame }
22434                if address == &resumed && frame.sequence == 1
22435        ));
22436        shared.clear_terminal_frame_watermark(&resumed);
22437        assert!(!shared
22438            .terminal_frame_watermarks
22439            .lock()
22440            .unwrap_or_else(|poisoned| poisoned.into_inner())
22441            .contains_key(&resumed.session.instance_id));
22442    }
22443
22444    #[test]
22445    fn terminal_frame_batches_replace_without_entering_node_event_history() {
22446        let shared = terminal_test_shared();
22447        let mut receiver = shared.terminal_event_tx.subscribe();
22448        let address = terminal_address(1);
22449        let count = NODE_EVENT_HISTORY_MAX as u64 + 17;
22450
22451        for sequence in 1..=count {
22452            shared.publish_terminal_frame_candidates(vec![(
22453                address.clone(),
22454                terminal_frame(sequence),
22455            )]);
22456        }
22457
22458        let history = shared
22459            .history
22460            .lock()
22461            .unwrap_or_else(|poisoned| poisoned.into_inner());
22462        assert!(history.events.is_empty());
22463        assert_eq!(history.last_sequence, count);
22464        assert_eq!(history.replay_floor_sequence, 1);
22465        drop(history);
22466        let NodeResponse::Resync {
22467            event_sequence,
22468            oldest_available_sequence,
22469            events,
22470            ..
22471        } = shared.resync(0) else {
22472            unreachable!("resync helper returned another response")
22473        };
22474        assert_eq!(event_sequence, count);
22475        assert_eq!(oldest_available_sequence, 1);
22476        assert!(events.is_empty());
22477        assert!(matches!(
22478            resync_required_event(&shared).event,
22479            NodeEvent::ResyncRequired {
22480                oldest_available_sequence: 1,
22481            }
22482        ));
22483        assert!(matches!(
22484            receiver.try_recv(),
22485            Err(broadcast::error::TryRecvError::Lagged(skipped)) if skipped == count - 1
22486        ));
22487        let latest = receiver.try_recv().unwrap();
22488        assert_eq!(latest.len(), 1);
22489        assert_eq!(latest[0].sequence, count);
22490    }
22491
22492    #[test]
22493    fn real_durable_eviction_advances_authoritative_replay_floor() {
22494        let shared = terminal_test_shared();
22495        for _ in 0..=NODE_EVENT_HISTORY_MAX {
22496            shared.publish(NodeEvent::ControllerChanged { controller: None });
22497        }
22498
22499        let NodeResponse::Resync {
22500            event_sequence,
22501            oldest_available_sequence,
22502            events,
22503            ..
22504        } = shared.resync(0) else {
22505            unreachable!("resync helper returned another response")
22506        };
22507        assert_eq!(event_sequence, NODE_EVENT_HISTORY_MAX as u64 + 1);
22508        assert_eq!(oldest_available_sequence, 2);
22509        assert_eq!(events.first().map(|event| event.sequence), Some(2));
22510        assert_eq!(events.len(), NODE_EVENT_HISTORY_MAX);
22511        assert!(matches!(
22512            resync_required_event(&shared).event,
22513            NodeEvent::ResyncRequired {
22514                oldest_available_sequence: 2,
22515            }
22516        ));
22517    }
22518
22519    #[test]
22520    fn legacy_terminal_frame_connection_has_no_live_subscription() {
22521        let shared = terminal_test_shared();
22522        let without_capability = terminal_event_subscription(&shared, false);
22523        let with_capability = terminal_event_subscription(&shared, true);
22524        assert!(without_capability.is_none());
22525        assert!(with_capability.is_some());
22526        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22527            capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
22528        }));
22529    }
22530
22531    #[test]
22532    fn continuous_terminal_output_leaves_request_service_after_bounded_event_burst() {
22533        let mut pending = Vec::new();
22534        let mut event_sequence = 0_u64;
22535        for frame_sequence in 1..=4 {
22536            for instance in 1..=(NODE_CONNECTION_EVENT_BURST_MAX as u64 * 2) {
22537                event_sequence += 1;
22538                let mut address = terminal_address(1);
22539                address.session.instance_id = AgentInstanceId(instance);
22540                queue_connection_event(
22541                    &mut pending,
22542                    NodeEventEnvelope {
22543                        sequence: event_sequence,
22544                        event: NodeEvent::TerminalFrame {
22545                            address,
22546                            frame: terminal_frame(frame_sequence),
22547                        },
22548                    },
22549                    0,
22550                );
22551            }
22552        }
22553        assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX * 2);
22554        pending.sort_unstable_by_key(|event| event.sequence);
22555
22556        let mut uninterrupted = pending.clone();
22557        let first_burst = connection_event_burst_len(uninterrupted.len());
22558        uninterrupted.drain(..first_burst);
22559        let second_burst = connection_event_burst_len(uninterrupted.len());
22560        uninterrupted.drain(..second_burst);
22561        assert!(uninterrupted.is_empty());
22562
22563        let (frame_tx, mut frame_rx) =
22564            mpsc::channel::<Result<ClientFrame, FrameError>>(1);
22565        frame_tx
22566            .try_send(Ok(ClientFrame::Request(RequestEnvelope {
22567                request_id: 77,
22568                request: NodeRequest::Snapshot,
22569            })))
22570            .unwrap();
22571        let burst_len = connection_event_burst_len(pending.len());
22572        assert_eq!(burst_len, NODE_CONNECTION_EVENT_BURST_MAX);
22573        pending.drain(..burst_len);
22574        assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX);
22575        assert!(matches!(
22576            frame_rx.try_recv(),
22577            Ok(Ok(ClientFrame::Request(RequestEnvelope {
22578                request_id: 77,
22579                request: NodeRequest::Snapshot,
22580            })))
22581        ));
22582    }
22583
22584    #[test]
22585    fn terminal_lag_watermark_discards_retained_and_replayed_batches() {
22586        let mut pending = Vec::new();
22587        for sequence in [7_u64, 8] {
22588            let mut address = terminal_address(1);
22589            address.session.instance_id = AgentInstanceId(sequence);
22590            queue_connection_event(
22591                &mut pending,
22592                NodeEventEnvelope {
22593                    sequence,
22594                    event: NodeEvent::TerminalFrame {
22595                        address,
22596                        frame: terminal_frame(sequence),
22597                    },
22598                },
22599                0,
22600            );
22601        }
22602        let mut discard_through = 0;
22603        apply_connection_resync_watermark(&mut pending, &mut discard_through, 8);
22604        assert!(pending.is_empty());
22605
22606        let replayed = NodeEventEnvelope {
22607            sequence: 8,
22608            event: NodeEvent::TerminalFrame {
22609                address: terminal_address(1),
22610                frame: terminal_frame(8),
22611            },
22612        };
22613        let advanced = NodeEventEnvelope {
22614            sequence: 9,
22615            event: NodeEvent::TerminalFrame {
22616                address: terminal_address(2),
22617                frame: terminal_frame(1),
22618            },
22619        };
22620        queue_connection_event_batch(
22621            &mut pending,
22622            &[replayed, advanced.clone()],
22623            discard_through,
22624        );
22625        assert_eq!(pending, vec![advanced]);
22626    }
22627
22628    fn record(provider: &str, record_id: &str) -> ManagedSessionRecord {
22629        ManagedSessionRecord {
22630            record_id: SessionRecordId::new(record_id).unwrap(),
22631            display_name: format!("{provider} record"),
22632            provider: agent(provider),
22633            mode: SessionMode::Pty,
22634            state: ManagedSessionState::Unavailable,
22635            workspace_id: WorkspaceId::new("primary").unwrap(),
22636            canonical_root: opaque_windows_path(
22637                std::env::current_dir().unwrap().to_string_lossy().into_owned(),
22638            ),
22639            provider_session: None,
22640            active_session: None,
22641            environment_profile: None,
22642            bundle: None,
22643            context_id: None,
22644            context: None,
22645            exported_context: None,
22646            task_binding: None,
22647            created_at_unix_ms: 1,
22648            updated_at_unix_ms: 1,
22649            last_error: None,
22650        }
22651    }
22652
22653    fn retention_test_record(
22654        workspace_id: &str,
22655        record_id: &str,
22656        state: ManagedSessionState,
22657        updated_at_unix_ms: u64,
22658    ) -> ManagedSessionRecord {
22659        ManagedSessionRecord {
22660            record_id: SessionRecordId::new(record_id).unwrap(),
22661            display_name: format!("{record_id} display"),
22662            provider: agent("claude"),
22663            mode: SessionMode::Pty,
22664            state,
22665            workspace_id: WorkspaceId::new(workspace_id).unwrap(),
22666            canonical_root: opaque_windows_path(
22667                std::env::current_dir().unwrap().to_string_lossy().into_owned(),
22668            ),
22669            provider_session: None,
22670            active_session: None,
22671            environment_profile: None,
22672            bundle: None,
22673            context_id: None,
22674            context: None,
22675            exported_context: None,
22676            task_binding: None,
22677            created_at_unix_ms: updated_at_unix_ms,
22678            updated_at_unix_ms,
22679            last_error: None,
22680        }
22681    }
22682
22683    /// Two workspaces of three `Unavailable` records each, staggered by
22684    /// age, plus one never-touchable decoy per non-`Unavailable` state in
22685    /// `workspace-a` (all as old as that workspace's oldest real record --
22686    /// if the sweep's state gate ever leaked, these would be the first
22687    /// thing it wrongly retired). Ages, oldest to newest:
22688    /// `workspace-a`: a1 (500s), a2 (300s), a3 (100s).
22689    /// `workspace-b`: b1 (400s), b2 (200s), b3 (50s).
22690    fn retention_fixture_records(now: u64) -> Vec<ManagedSessionRecord> {
22691        vec![
22692            retention_test_record("workspace-a", "a1", ManagedSessionState::Unavailable, now - 500_000),
22693            retention_test_record("workspace-a", "a2", ManagedSessionState::Unavailable, now - 300_000),
22694            retention_test_record("workspace-a", "a3", ManagedSessionState::Unavailable, now - 100_000),
22695            retention_test_record("workspace-b", "b1", ManagedSessionState::Unavailable, now - 400_000),
22696            retention_test_record("workspace-b", "b2", ManagedSessionState::Unavailable, now - 200_000),
22697            retention_test_record("workspace-b", "b3", ManagedSessionState::Unavailable, now - 50_000),
22698            retention_test_record("workspace-a", "decoy-live", ManagedSessionState::Live, now - 500_000),
22699            retention_test_record(
22700                "workspace-a",
22701                "decoy-pending",
22702                ManagedSessionState::IdentityPending,
22703                now - 500_000,
22704            ),
22705            retention_test_record(
22706                "workspace-a",
22707                "decoy-dormant",
22708                ManagedSessionState::Dormant,
22709                now - 500_000,
22710            ),
22711        ]
22712    }
22713
22714    fn retention_fixture_shared() -> (NodeShared, NativeRuntime, PathBuf, u64) {
22715        let root = temporary_workspace_root("session-record-retention");
22716        std::fs::create_dir_all(&root).unwrap();
22717        let catalog = active_registry().unwrap();
22718        let (handle, runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22719        let workspace_a =
22720            WorkspaceConfig::new(WorkspaceId::new("workspace-a").unwrap(), &root).unwrap();
22721        let workspace_b =
22722            WorkspaceConfig::new(WorkspaceId::new("workspace-b").unwrap(), &root).unwrap();
22723        let mut shared = NodeShared::new(
22724            handle,
22725            "fixture-token".to_owned(),
22726            NodeId::new("node-retention-test").unwrap(),
22727            vec![workspace_a, workspace_b],
22728            vec![agent("claude")],
22729        );
22730        shared.state_path = Some(root.join("node-state-v10.json"));
22731        let now = unix_time_ms();
22732        for record in retention_fixture_records(now) {
22733            shared.insert_record(record).unwrap();
22734        }
22735        shared
22736            .persist_state_locked()
22737            .expect("fixture-only, empty-history persist against a fresh temp state path");
22738        (shared, runtime, root, now)
22739    }
22740
22741    fn retention_removed_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
22742        shared
22743            .history
22744            .lock()
22745            .unwrap_or_else(|poisoned| poisoned.into_inner())
22746            .events
22747            .iter()
22748            .filter_map(|envelope| match &envelope.event {
22749                NodeEvent::SessionRecordRemoved { record_id } => {
22750                    Some(record_id.as_str().to_owned())
22751                }
22752                _ => None,
22753            })
22754            .collect()
22755    }
22756
22757    fn retention_remaining_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
22758        shared
22759            .session_records
22760            .lock()
22761            .unwrap_or_else(|poisoned| poisoned.into_inner())
22762            .records
22763            .keys()
22764            .map(|record_id| record_id.as_str().to_owned())
22765            .collect()
22766    }
22767
22768    fn owned(values: &[&str]) -> std::collections::BTreeSet<String> {
22769        values.iter().map(|value| (*value).to_owned()).collect()
22770    }
22771
22772    #[tokio::test]
22773    async fn session_record_retention_keep_n_only_keeps_the_newest_per_workspace() {
22774        let (mut shared, runtime, root, _now) = retention_fixture_shared();
22775        shared.session_record_retention = SessionRecordRetentionConfig {
22776            age_ms: 0,
22777            keep_per_workspace: 1,
22778        };
22779        shared.retire_unavailable_session_records().await;
22780
22781        assert_eq!(
22782            retention_remaining_record_ids(&shared),
22783            owned(&["a3", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
22784        );
22785        assert_eq!(
22786            retention_removed_record_ids(&shared),
22787            owned(&["a1", "a2", "b1", "b2"]),
22788        );
22789        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 4);
22790
22791        drop(shared);
22792        drop(runtime);
22793        std::fs::remove_dir_all(root).unwrap();
22794    }
22795
22796    /// Age-only would retire {a1, a2, b1}; keep-N-only would retire
22797    /// {a1, a2, b1, b2}. With both flags set the sweep must retire exactly
22798    /// the intersection {a1, a2, b1} -- `b2` fails the age test (it is only
22799    /// 200s old against a 250s threshold) even though it fails the keep-N
22800    /// test too, proving retirement needs BOTH, not either.
22801    #[tokio::test]
22802    async fn session_record_retention_both_set_retires_only_the_intersection() {
22803        let (mut shared, runtime, root, _now) = retention_fixture_shared();
22804        shared.session_record_retention = SessionRecordRetentionConfig {
22805            age_ms: 250_000,
22806            keep_per_workspace: 1,
22807        };
22808        shared.retire_unavailable_session_records().await;
22809
22810        assert_eq!(
22811            retention_remaining_record_ids(&shared),
22812            owned(&["a3", "b2", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
22813        );
22814        assert_eq!(retention_removed_record_ids(&shared), owned(&["a1", "a2", "b1"]));
22815        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 3);
22816
22817        drop(shared);
22818        drop(runtime);
22819        std::fs::remove_dir_all(root).unwrap();
22820    }
22821
22822    #[tokio::test]
22823    async fn session_record_retention_both_disabled_is_a_no_op() {
22824        let (mut shared, runtime, root, _now) = retention_fixture_shared();
22825        shared.session_record_retention = SessionRecordRetentionConfig::default();
22826        shared.retire_unavailable_session_records().await;
22827
22828        assert_eq!(
22829            retention_remaining_record_ids(&shared),
22830            owned(&[
22831                "a1", "a2", "a3", "b1", "b2", "b3",
22832                "decoy-live", "decoy-pending", "decoy-dormant",
22833            ]),
22834        );
22835        assert!(retention_removed_record_ids(&shared).is_empty());
22836        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 0);
22837
22838        drop(shared);
22839        drop(runtime);
22840        std::fs::remove_dir_all(root).unwrap();
22841    }
22842
22843    #[test]
22844    fn session_record_context_export_selects_one_exact_history_identity() {
22845        let candidates = vec![
22846            HistoryCandidateSummary {
22847                id: "candidate-other".to_owned(),
22848                session_id_hint: "provider-other".to_owned(),
22849                modified_at_unix_ms: Some(1),
22850            },
22851            HistoryCandidateSummary {
22852                id: "candidate-exact".to_owned(),
22853                session_id_hint: "provider-exact".to_owned(),
22854                modified_at_unix_ms: Some(2),
22855            },
22856        ];
22857        assert_eq!(
22858            select_history_candidate(&candidates, Some("provider-exact")).unwrap(),
22859            "candidate-exact",
22860        );
22861    }
22862
22863    #[test]
22864    fn session_record_context_export_rejects_missing_and_ambiguous_history_identity() {
22865        let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
22866            id: id.to_owned(),
22867            session_id_hint: hint.to_owned(),
22868            modified_at_unix_ms: None,
22869        };
22870        let missing = select_history_candidate(
22871            &[candidate("candidate-other", "provider-other")],
22872            Some("provider-exact"),
22873        )
22874        .unwrap_err();
22875        assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
22876        let ambiguous = select_history_candidate(
22877            &[
22878                candidate("candidate-a", "provider-exact"),
22879                candidate("candidate-b", "provider-exact"),
22880            ],
22881            Some("provider-exact"),
22882        )
22883        .unwrap_err();
22884        assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
22885    }
22886
22887    /// The PTY-sidecar-observation shape (no fleet provider today, but
22888    /// capability-keyed rather than vendor-locked) never carries a verified
22889    /// `provider_session`; its record-bound export instead falls back to
22890    /// "exactly one discovered candidate" (see
22891    /// `export_context_pack_for_session_record_inner`). This is the same
22892    /// ambiguity refusal as the identity-filtered path, just without an
22893    /// identity to filter by.
22894    #[test]
22895    fn session_record_context_export_selects_sole_history_candidate_without_identity() {
22896        let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
22897            id: id.to_owned(),
22898            session_id_hint: hint.to_owned(),
22899            modified_at_unix_ms: None,
22900        };
22901        assert_eq!(
22902            select_history_candidate(&[candidate("candidate-sole", "sidecar-session")], None)
22903                .unwrap(),
22904            "candidate-sole",
22905        );
22906        let missing = select_history_candidate(&[], None).unwrap_err();
22907        assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
22908        let ambiguous = select_history_candidate(
22909            &[
22910                candidate("candidate-a", "sidecar-session-a"),
22911                candidate("candidate-b", "sidecar-session-b"),
22912            ],
22913            None,
22914        )
22915        .unwrap_err();
22916        assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
22917    }
22918
22919    #[test]
22920    fn session_record_context_export_revalidation_rejects_record_change() {
22921        let session = terminal_address(1);
22922        let identity = ProviderSessionIdentity {
22923            key: ProviderSessionKey::SessionId,
22924            id: "provider-exact".to_owned(),
22925            transcript_path: Some(r"C:\private\provider.jsonl".to_owned()),
22926        };
22927        let mut expected = record("claude", "record-context-export");
22928        expected.state = ManagedSessionState::Live;
22929        expected.provider_session = Some(identity.clone());
22930        expected.active_session = Some(session.clone());
22931        let binding = SessionBinding {
22932            workspace_id: session.workspace_id.clone(),
22933            generation: session.session.generation,
22934            runtime_policy: ProviderRuntimePolicy::raw_pty(),
22935            pending_resume: None,
22936            record_id: Some(expected.record_id.clone()),
22937            managed_worktree_lease_id: None,
22938            environment_profile: None,
22939            bundle: None,
22940            context: None,
22941            materialization_id: None,
22942        };
22943        let current_root = windows_path_text(&expected.canonical_root).to_owned();
22944        assert!(session_record_context_export_binding_is_exact(
22945            &expected,
22946            &expected,
22947            Some(&identity),
22948            &session,
22949            &binding,
22950            &expected.provider,
22951            &current_root,
22952            false,
22953        ));
22954        let mut changed = expected.clone();
22955        changed.updated_at_unix_ms += 1;
22956        assert!(!session_record_context_export_binding_is_exact(
22957            &expected,
22958            &changed,
22959            Some(&identity),
22960            &session,
22961            &binding,
22962            &expected.provider,
22963            &current_root,
22964            false,
22965        ));
22966        // A same-instant timestamp bump alone (state and active_session
22967        // otherwise unchanged) is not the clean-detachment transition
22968        // either, even with the reactive flag on: only the exact Live ->
22969        // Dormant + active_session cleared shape qualifies.
22970        assert!(!session_record_context_export_binding_is_exact(
22971            &expected,
22972            &changed,
22973            Some(&identity),
22974            &session,
22975            &binding,
22976            &expected.provider,
22977            &current_root,
22978            true,
22979        ));
22980        let mut detached = expected.clone();
22981        detached.state = ManagedSessionState::Dormant;
22982        detached.active_session = None;
22983        detached.updated_at_unix_ms += 1;
22984        assert!(!session_record_context_export_binding_is_exact(
22985            &expected,
22986            &detached,
22987            Some(&identity),
22988            &session,
22989            &binding,
22990            &expected.provider,
22991            &current_root,
22992            false,
22993        ));
22994        assert!(session_record_context_export_binding_is_exact(
22995            &expected,
22996            &detached,
22997            Some(&identity),
22998            &session,
22999            &binding,
23000            &expected.provider,
23001            &current_root,
23002            true,
23003        ));
23004    }
23005
23006    /// Mirrors `session_record_context_export_revalidation_rejects_record_change`
23007    /// for a record whose provider never carries a verified identity (the
23008    /// PTY-sidecar-observation shape -- capability-keyed, no fleet provider
23009    /// declares one today): `identity` is `None` throughout, and the
23010    /// exactness check falls back to comparing `provider_session` itself,
23011    /// which must still be `None` on both sides.
23012    #[test]
23013    fn session_record_context_export_binding_is_exact_without_identity() {
23014        let session = terminal_address(1);
23015        let mut expected = record("pty-sidecar-fixture", "record-context-export-no-identity");
23016        expected.state = ManagedSessionState::Live;
23017        expected.active_session = Some(session.clone());
23018        let binding = SessionBinding {
23019            workspace_id: session.workspace_id.clone(),
23020            generation: session.session.generation,
23021            runtime_policy: ProviderRuntimePolicy::raw_pty(),
23022            pending_resume: None,
23023            record_id: Some(expected.record_id.clone()),
23024            managed_worktree_lease_id: None,
23025            environment_profile: None,
23026            bundle: None,
23027            context: None,
23028            materialization_id: None,
23029        };
23030        let current_root = windows_path_text(&expected.canonical_root).to_owned();
23031        assert!(session_record_context_export_binding_is_exact(
23032            &expected,
23033            &expected,
23034            None,
23035            &session,
23036            &binding,
23037            &expected.provider,
23038            &current_root,
23039            false,
23040        ));
23041        // A record that gained an identity between the initial lookup and
23042        // this recheck no longer matches the `None` this shape expects to
23043        // keep seeing -- treated the same as any other mid-export drift.
23044        let mut drifted = expected.clone();
23045        drifted.provider_session = Some(ProviderSessionIdentity {
23046            key: ProviderSessionKey::SessionId,
23047            id: "unexpected-identity".to_owned(),
23048            transcript_path: None,
23049        });
23050        assert!(!session_record_context_export_binding_is_exact(
23051            &expected,
23052            &drifted,
23053            None,
23054            &session,
23055            &binding,
23056            &expected.provider,
23057            &current_root,
23058            false,
23059        ));
23060    }
23061
23062    #[test]
23063    fn session_record_context_export_preflight_requires_live_usable_source() {
23064        let mut source = record("claude", "record-context-export-preflight");
23065        source.state = ManagedSessionState::Live;
23066        assert!(session_record_context_export_source_is_usable(
23067            &source,
23068            &SessionStatus::Running,
23069            false,
23070        ));
23071        assert!(session_record_context_export_source_is_usable(
23072            &source,
23073            &SessionStatus::Exited { exit_code: Some(0) },
23074            false,
23075        ));
23076
23077        source.state = ManagedSessionState::Dormant;
23078        assert!(!session_record_context_export_source_is_usable(
23079            &source,
23080            &SessionStatus::Running,
23081            false,
23082        ));
23083        // The reactive auto-export-at-exit path (allow_clean_detachment) is
23084        // the only caller that may treat a Dormant, cleanly-exited record as
23085        // still usable — the on-demand path above never does.
23086        assert!(session_record_context_export_source_is_usable(
23087            &source,
23088            &SessionStatus::Exited { exit_code: Some(0) },
23089            true,
23090        ));
23091        assert!(!session_record_context_export_source_is_usable(
23092            &source,
23093            &SessionStatus::Exited { exit_code: Some(0) },
23094            false,
23095        ));
23096        source.state = ManagedSessionState::Live;
23097        assert!(!session_record_context_export_source_is_usable(
23098            &source,
23099            &SessionStatus::Starting,
23100            false,
23101        ));
23102    }
23103
23104    #[test]
23105    fn session_record_context_export_final_conflict_leaves_catalog_empty() {
23106        let catalog = active_registry().unwrap();
23107        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23108        let workspace = WorkspaceConfig::new(
23109            WorkspaceId::new("primary").unwrap(),
23110            std::env::current_dir().unwrap(),
23111        )
23112        .unwrap();
23113        let shared = NodeShared::new(
23114            handle,
23115            "fixture-token".to_owned(),
23116            NodeId::new("node-context-export-conflict").unwrap(),
23117            vec![workspace],
23118            vec![agent("claude")],
23119        );
23120        let history = HistorySessionRecord {
23121            session_id: "provider-exact".to_owned(),
23122            title: None,
23123            cwd: None,
23124            model: None,
23125            message_count: 1,
23126            completed_turn_count: None,
23127            total_tokens: 0,
23128            messages: vec![gate4agent_types::HistoryMessageRecord {
23129                role: gate4agent_types::HistoryMessageRole::User,
23130                text: "bounded context".to_owned(),
23131            }],
23132        };
23133        let pack = NodeContextPack::export(
23134            ContextPackLineageReceipt {
23135                source_node_id: shared.node_id.clone(),
23136                source_session: terminal_address(1),
23137                source_provider: agent("claude"),
23138            },
23139            &history,
23140        )
23141        .unwrap();
23142        let context_id = pack.receipt().id.clone();
23143        let session = terminal_address(1);
23144        let identity = ProviderSessionIdentity {
23145            key: ProviderSessionKey::SessionId,
23146            id: history.session_id.clone(),
23147            transcript_path: None,
23148        };
23149        let mut expected = record("claude", "record-context-export-final-conflict");
23150        expected.state = ManagedSessionState::Live;
23151        expected.provider_session = Some(identity.clone());
23152        expected.active_session = Some(session.clone());
23153        let mut changed = expected.clone();
23154        changed.updated_at_unix_ms += 1;
23155        shared.insert_record(changed).unwrap();
23156
23157        let error = shared
23158            .commit_context_pack_for_session_record(
23159                &expected,
23160                Some(&identity),
23161                &session,
23162                "candidate-exact",
23163                &history,
23164                pack,
23165                false,
23166            )
23167            .unwrap_err();
23168
23169        assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
23170        assert!(shared
23171            .context_catalog
23172            .read()
23173            .unwrap_or_else(|poisoned| poisoned.into_inner())
23174            .get(&context_id)
23175            .is_none());
23176    }
23177
23178    #[tokio::test]
23179    async fn session_task_binding_mints_assigns_clears_with_cas_and_persists_v9() {
23180        let root = temporary_workspace_root("session-task-binding");
23181        std::fs::create_dir_all(&root).unwrap();
23182        let state_path = root.join("node-state.json");
23183        let catalog = active_registry().unwrap();
23184        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23185        let workspace = WorkspaceConfig::new(WorkspaceId::new("primary").unwrap(), &root).unwrap();
23186        let node_id = NodeId::new("node-1").unwrap();
23187        let mut shared = NodeShared::new(
23188            handle,
23189            "fixture-token".to_owned(),
23190            node_id.clone(),
23191            vec![workspace],
23192            vec![agent("claude")],
23193        );
23194        shared.state_path = Some(state_path.clone());
23195        let record = record("claude", "sr-task");
23196        let record_id = record.record_id.clone();
23197        shared.insert_record(record).unwrap();
23198        shared.persist_state().unwrap();
23199        shared
23200            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
23201            .unwrap();
23202
23203        let minted = process_request_inner(
23204            &shared,
23205            77,
23206            ClientRole::Operator,
23207            NodeRequest::SetSessionTask {
23208                record_id: record_id.clone(),
23209                expected_revision: 0,
23210                target: SessionTaskTargetV1::New,
23211            },
23212        )
23213        .await
23214        .unwrap();
23215        let NodeResponse::SessionRecordUpdated { record: minted } = minted else {
23216            panic!("session task mutation returned another response");
23217        };
23218        let minted_binding = minted.task_binding.clone().unwrap();
23219        let minted_task_id = minted_binding.task_id.clone().unwrap();
23220        assert_eq!(minted_binding.revision, 1);
23221        assert!(minted_task_id.as_str().starts_with("task-"));
23222        assert_eq!(minted_task_id.as_str().len(), 29);
23223
23224        let event_count = shared.current_sequence();
23225        let repeated = process_request_inner(
23226            &shared,
23227            77,
23228            ClientRole::Operator,
23229            NodeRequest::SetSessionTask {
23230                record_id: record_id.clone(),
23231                expected_revision: 1,
23232                target: SessionTaskTargetV1::Existing {
23233                    task_id: minted_task_id.clone(),
23234                },
23235            },
23236        )
23237        .await
23238        .unwrap();
23239        assert_eq!(repeated, NodeResponse::SessionRecordUpdated { record: minted.clone() });
23240        assert_eq!(shared.current_sequence(), event_count);
23241
23242        let stale = process_request_inner(
23243            &shared,
23244            77,
23245            ClientRole::Operator,
23246            NodeRequest::SetSessionTask {
23247                record_id: record_id.clone(),
23248                expected_revision: 0,
23249                target: SessionTaskTargetV1::Existing {
23250                    task_id: minted_task_id,
23251                },
23252            },
23253        )
23254        .await
23255        .unwrap_err();
23256        assert_eq!(stale.code, NodeFailureCode::SessionRecordConflict);
23257
23258        let assigned_id: TaskId = "task-ffeeddccbbaa998877665544".parse().unwrap();
23259        let assigned = process_request_inner(
23260            &shared,
23261            77,
23262            ClientRole::Operator,
23263            NodeRequest::SetSessionTask {
23264                record_id: record_id.clone(),
23265                expected_revision: 1,
23266                target: SessionTaskTargetV1::Existing {
23267                    task_id: assigned_id.clone(),
23268                },
23269            },
23270        )
23271        .await
23272        .unwrap();
23273        let NodeResponse::SessionRecordUpdated { record: assigned } = assigned else {
23274            panic!("session task assignment returned another response");
23275        };
23276        assert_eq!(assigned.task_binding.as_ref().unwrap().revision, 2);
23277        assert_eq!(assigned.task_binding.as_ref().unwrap().task_id.as_ref(), Some(&assigned_id));
23278
23279        let cleared = process_request_inner(
23280            &shared,
23281            77,
23282            ClientRole::Operator,
23283            NodeRequest::SetSessionTask {
23284                record_id: record_id.clone(),
23285                expected_revision: 2,
23286                target: SessionTaskTargetV1::Clear,
23287            },
23288        )
23289        .await
23290        .unwrap();
23291        let NodeResponse::SessionRecordUpdated { record: cleared } = cleared else {
23292            panic!("session task clear returned another response");
23293        };
23294        assert_eq!(cleared.task_binding.as_ref().unwrap().revision, 3);
23295        assert!(cleared.task_binding.as_ref().unwrap().task_id.is_none());
23296        assert!(cleared.task_binding.as_ref().unwrap().changed_at_unix_ms > 0);
23297        assert_eq!(
23298            cleared.task_binding.as_ref().unwrap().changed_at_unix_ms,
23299            cleared.updated_at_unix_ms,
23300        );
23301
23302        let persisted = std::fs::read(&state_path).unwrap();
23303        assert_eq!(serde_json::from_slice::<serde_json::Value>(&persisted).unwrap()["version"], NODE_STATE_SCHEMA_V10);
23304        let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
23305        assert_eq!(loaded.records[0].task_binding, cleared.task_binding);
23306        std::fs::remove_dir_all(root).unwrap();
23307    }
23308
23309    #[test]
23310    fn session_task_capability_strips_snapshots_events_and_replies() {
23311        let mut bound = record("claude", "sr-task-projection");
23312        bound.updated_at_unix_ms = 2;
23313        bound.task_binding = Some(SessionTaskBindingV1 {
23314            revision: 1,
23315            task_id: Some("task-00112233445566778899aaff".parse().unwrap()),
23316            changed_at_unix_ms: 2,
23317        });
23318        let request = NodeRequest::SetSessionTask {
23319            record_id: bound.record_id.clone(),
23320            expected_revision: 1,
23321            target: SessionTaskTargetV1::Clear,
23322        };
23323        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23324            capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
23325        }));
23326        assert!(!request_uses_unnegotiated_capability(
23327            &request,
23328            &baseline_capabilities().unwrap(),
23329        ));
23330        assert!(request_uses_unnegotiated_capability(&request, &[]));
23331
23332        let mut snapshot = terminal_test_shared().snapshot();
23333        snapshot.session_records.push(bound.clone());
23334        let mut reply = ResponseEnvelope {
23335            request_id: 1,
23336            result: Ok(NodeResponse::Resync {
23337                event_sequence: 1,
23338                oldest_available_sequence: 1,
23339                snapshot,
23340                events: vec![NodeEventEnvelope {
23341                    sequence: 1,
23342                    event: NodeEvent::SessionRecordUpserted {
23343                        record: bound.clone(),
23344                    },
23345                }],
23346            }),
23347        };
23348        project_response_without_session_task_binding(&mut reply);
23349        let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
23350            panic!("task projection changed the response kind");
23351        };
23352        assert!(snapshot.session_records[0].task_binding.is_none());
23353        let NodeEvent::SessionRecordUpserted { record } = &events[0].event else {
23354            panic!("task projection changed the event kind");
23355        };
23356        assert!(record.task_binding.is_none());
23357
23358        let projected = project_event_without_session_task_binding(NodeEventEnvelope {
23359            sequence: 2,
23360            event: NodeEvent::SessionRecordUpserted { record: bound.clone() },
23361        });
23362        let NodeEvent::SessionRecordUpserted { record } = projected.event else {
23363            panic!("task event projection changed the event kind");
23364        };
23365        assert!(record.task_binding.is_none());
23366
23367        let mut direct = ResponseEnvelope {
23368            request_id: 2,
23369            result: Ok(NodeResponse::SessionRecordUpdated { record: bound }),
23370        };
23371        project_response_without_session_task_binding(&mut direct);
23372        let Ok(NodeResponse::SessionRecordUpdated { record }) = direct.result else {
23373            panic!("task reply projection changed the response kind");
23374        };
23375        assert!(record.task_binding.is_none());
23376    }
23377
23378    #[test]
23379    fn windows_runtime_default_node_endpoint_is_exact_and_valid() {
23380        assert_eq!(DEFAULT_NODE_ENDPOINT, r"\\.\pipe\gate4agent-node");
23381        let workspace = WorkspaceConfig::new(
23382            WorkspaceId::new("primary").unwrap(),
23383            std::env::current_dir().unwrap(),
23384        )
23385        .unwrap();
23386        let config = NodeServerConfig::new(
23387            DEFAULT_NODE_ENDPOINT,
23388            "fixture-token",
23389            NodeId::new("node-1").unwrap(),
23390            [workspace],
23391        )
23392        .unwrap();
23393        assert_eq!(config.endpoint, DEFAULT_NODE_ENDPOINT);
23394    }
23395
23396    #[cfg(all(feature = "fixture", windows))]
23397    #[test]
23398    fn provider_bundle_argv_hold_fixture_is_parent_bounded_and_waits_after_proof() {
23399        let root = temporary_workspace_root("provider-bundle-argv-hold");
23400        let outside = temporary_workspace_root("provider-bundle-argv-hold-outside");
23401        std::fs::create_dir_all(&root).unwrap();
23402        std::fs::create_dir_all(&outside).unwrap();
23403        let proof_path = root.join("provider.proof");
23404        let release_signal = root.join("release.signal");
23405        let config = NodeServerConfig::new(
23406            format!(
23407                r"\\.\pipe\gate4agent-provider-bundle-hold-{}",
23408                std::process::id(),
23409            ),
23410            "fixture-token",
23411            NodeId::new("provider-bundle-hold-node").unwrap(),
23412            [WorkspaceConfig::new(
23413                WorkspaceId::new("primary").unwrap(),
23414                std::env::current_dir().unwrap(),
23415            )
23416            .unwrap()],
23417        )
23418        .unwrap()
23419        .with_spawn_profiles(SpawnProfileRegistry::new([
23420            crate::protocol::SpawnProfileDefaults {
23421                profile_id: crate::protocol::SpawnProfileId::new("kimi-bundle-hold").unwrap(),
23422                revision: crate::protocol::SpawnProfileRevision::new("kimi-bundle-hold-r1")
23423                    .unwrap(),
23424                provider: agent("kimi"),
23425                mode: SessionMode::Pty,
23426                terminal_size: gate4agent_types::TerminalSize {
23427                    rows: 24,
23428                    columns: 80,
23429                },
23430                prompt: None,
23431                bundle_id: None,
23432                context_id: None,
23433                environment_profile_id: None,
23434            },
23435        ])
23436        .unwrap());
23437        let server = NodeServer::new_provider_bundle_argv_hold_fixture(
23438            config,
23439            agent("kimi"),
23440            proof_path.clone(),
23441            release_signal.clone(),
23442        )
23443        .unwrap();
23444        assert_eq!(server.shared.enabled_providers, [agent("kimi")]);
23445
23446        let spec = NodeServer::provider_bundle_argv_fixture_spec(
23447            agent("kimi"),
23448            proof_path.clone(),
23449            Some(release_signal.clone()),
23450        )
23451        .unwrap();
23452        let wrapper = &spec.launch.fixed_args[spec.launch.fixed_args.len() - 3];
23453        let proof_write = wrapper.find("WriteAllLines($proofPath").unwrap();
23454        let release_wait = wrapper
23455            .find("while (-not (Test-Path -LiteralPath $releaseSignal -PathType Leaf))")
23456            .unwrap();
23457        let fixture_ready = wrapper.find("fixture-ready>").unwrap();
23458        assert!(proof_write < release_wait);
23459        assert!(release_wait < fixture_ready);
23460        assert!(wrapper.contains("[DateTime]::UtcNow.AddSeconds(45)"));
23461        assert!(wrapper.contains("Start-Sleep -Milliseconds 20"));
23462        assert_eq!(
23463            &spec.launch.fixed_args[spec.launch.fixed_args.len() - 2..],
23464            &[
23465                proof_path.to_string_lossy().into_owned(),
23466                release_signal.to_string_lossy().into_owned(),
23467            ],
23468        );
23469
23470        let error = NodeServer::provider_bundle_argv_fixture_spec(
23471            agent("kimi"),
23472            proof_path,
23473            Some(outside.join("release.signal")),
23474        )
23475        .unwrap_err();
23476        assert!(matches!(
23477            error,
23478            NodeServerError::Registry(message)
23479                if message == "provider bundle argv release path must share the proof parent"
23480        ));
23481        drop(server);
23482        std::fs::remove_dir_all(root).unwrap();
23483        std::fs::remove_dir_all(outside).unwrap();
23484    }
23485
23486    #[cfg(feature = "fixture")]
23487    #[test]
23488    fn four_provider_context_fixture_registry_is_unambiguous_and_history_bound() {
23489        let root = temporary_workspace_root("context-fixture-registry");
23490        std::fs::create_dir_all(&root).unwrap();
23491        let proof_path = root.join("context.proof");
23492        let config = NodeServerConfig::new(
23493            format!(
23494                r"\\.\pipe\gate4agent-context-fixture-registry-{}",
23495                std::process::id(),
23496            ),
23497            "fixture-token",
23498            NodeId::new("context-fixture-node").unwrap(),
23499            [WorkspaceConfig::new(
23500                WorkspaceId::new("primary").unwrap(),
23501                std::env::current_dir().unwrap(),
23502            )
23503            .unwrap()],
23504        )
23505        .unwrap();
23506        let server = NodeServer::new_context_pack_fixture(config, proof_path).unwrap();
23507        let mut enabled = server
23508            .shared
23509            .enabled_providers
23510            .iter()
23511            .map(AgentId::as_str)
23512            .collect::<Vec<_>>();
23513        enabled.sort_unstable();
23514        assert_eq!(enabled, ["claude", "codex", "grok", "kimi"]);
23515        for provider in &server.shared.enabled_providers {
23516            assert!(server
23517                .shared
23518                .provider_adapter_contracts
23519                .iter()
23520                .any(|contract| {
23521                    &contract.provider == provider
23522                        && contract.family == AdapterFamily::History
23523                }));
23524        }
23525        drop(server);
23526        std::fs::remove_dir_all(root).unwrap();
23527    }
23528
23529    #[cfg(feature = "fixture")]
23530    #[test]
23531    fn monitoring_context_fixture_combines_claude_and_codex_hooks_with_history() {
23532        let root = temporary_workspace_root("monitoring-context-fixture");
23533        std::fs::create_dir_all(&root).unwrap();
23534        let proof_path = root.join("context.proof");
23535        let standard = NodeServer::context_pack_fixture_catalog(
23536            proof_path.clone(),
23537            None,
23538            false,
23539        ).unwrap();
23540        let monitoring = NodeServer::context_pack_fixture_catalog(
23541            proof_path.clone(),
23542            None,
23543            true,
23544        ).unwrap();
23545        let claude = AgentId::new("claude").unwrap();
23546        let standard_claude = standard.get(&claude).unwrap();
23547        let monitoring_claude = monitoring.get(&claude).unwrap();
23548        // Lifecycle hooks are retired (owner ruling 2026-09-25): neither the
23549        // standard nor the monitoring fixture declares a hook adapter
23550        // anymore.
23551        assert!(monitoring_claude.capabilities.adapters.hook.is_none());
23552        assert_eq!(
23553            monitoring_claude.capabilities.adapters.history,
23554            standard_claude.capabilities.adapters.history,
23555        );
23556        assert_ne!(monitoring_claude.launch, standard_claude.launch);
23557        let codex = AgentId::new("codex").unwrap();
23558        let standard_codex = standard.get(&codex).unwrap();
23559        let monitoring_codex = monitoring.get(&codex).unwrap();
23560        assert!(monitoring_codex.capabilities.adapters.hook.is_none());
23561        assert_eq!(
23562            monitoring_codex.capabilities.adapters.history,
23563            standard_codex.capabilities.adapters.history,
23564        );
23565        assert_ne!(monitoring_codex.launch, standard_codex.launch);
23566        let codex_validation = monitoring_codex.launch.fixed_args.iter()
23567            .find(|argument| argument.contains("F7_CODEX_BUNDLE_CONTEXT_VALIDATED"))
23568            .unwrap();
23569        for required in [
23570            "$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')",
23571            "$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')",
23572            "@('role', 'text')",
23573            "g4a-private-provider-session-canary",
23574            "private-provider-login-identity",
23575            "$rawProof",
23576            "$env:GATE4AGENT_HOOK_URL",
23577            "g4a-private-provider-session-canary",
23578        ] {
23579            assert!(codex_validation.contains(required), "missing privacy validation: {required}");
23580        }
23581        for provider in ["grok", "kimi"] {
23582            let provider = AgentId::new(provider).unwrap();
23583            assert_eq!(monitoring.get(&provider), standard.get(&provider));
23584        }
23585
23586        let config = NodeServerConfig::new(
23587            format!(
23588                r"\\.\pipe\gate4agent-monitoring-context-fixture-{}",
23589                std::process::id(),
23590            ),
23591            "fixture-token",
23592            NodeId::new("monitoring-context-fixture-node").unwrap(),
23593            [WorkspaceConfig::new(
23594                WorkspaceId::new("primary").unwrap(),
23595                std::env::current_dir().unwrap(),
23596            )
23597            .unwrap()],
23598        )
23599        .unwrap();
23600        let server = NodeServer::new_monitoring_context_pack_fixture(config, proof_path).unwrap();
23601        assert!(server.shared.fixture_semantic_hook_policy);
23602        drop(server);
23603        std::fs::remove_dir_all(root).unwrap();
23604    }
23605
23606    #[cfg(feature = "fixture")]
23607    #[test]
23608    fn kimi_context_only_fixture_is_bounded_and_preserves_codex_validation() {
23609        let root = temporary_workspace_root("kimi-context-only-fixture");
23610        std::fs::create_dir_all(&root).unwrap();
23611        let proof_path = root.join("context.proof");
23612        let standard = NodeServer::context_pack_fixture_catalog(
23613            proof_path.clone(),
23614            None,
23615            false,
23616        ).unwrap();
23617        let kimi = AgentId::new("kimi").unwrap();
23618        let context_only = NodeServer::context_pack_fixture_catalog(
23619            proof_path,
23620            Some(&kimi),
23621            false,
23622        )
23623        .unwrap();
23624
23625        let codex = AgentId::new("codex").unwrap();
23626        assert_eq!(
23627            standard.get(&codex).unwrap().launch.fixed_args,
23628            context_only.get(&codex).unwrap().launch.fixed_args,
23629        );
23630        let script = context_only
23631            .get(&kimi)
23632            .unwrap()
23633            .launch
23634            .fixed_args
23635            .iter()
23636            .find(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED"))
23637            .expect("Kimi context-only validation script is missing");
23638        for required in [
23639            "$bundleArgs.Count -ne 0",
23640            "GATE4AGENT_CONTEXT_ROOT",
23641            "context-pack.json",
23642            "$entries.Count -ne 1",
23643            "g4a-context-pack-v1",
23644            "$document.PSObject.Properties['cwd']",
23645            "'claude', 'codex', 'grok', 'kimi'",
23646            "$hasUser",
23647            "$hasAssistant",
23648            "SHA256",
23649            "context-only",
23650        ] {
23651            assert!(script.contains(required), "missing validation: {required}");
23652        }
23653        for forbidden in ["CODEX_HOME", "USERPROFILE", "auth", "token", "global config"] {
23654            assert!(!script.contains(forbidden), "forbidden home/auth access: {forbidden}");
23655        }
23656        assert!(!standard
23657            .get(&kimi)
23658            .unwrap()
23659            .launch
23660            .fixed_args
23661            .iter()
23662            .any(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED")));
23663
23664        std::fs::remove_dir_all(root).unwrap();
23665    }
23666
23667    #[cfg(all(feature = "fixture", windows))]
23668    #[tokio::test]
23669    async fn kimi_context_only_fixture_spawns_catalogued_context_pack_through_spawn_spec() {
23670        let root = temporary_workspace_root("kimi-context-only-spawn");
23671        let workspace_root = root.join("workspace");
23672        std::fs::create_dir_all(&workspace_root).unwrap();
23673        let materialization_root = root.join("private-materializations");
23674        let proof_path = root.join("context.proof");
23675        let node_id = NodeId::new("node-kimi-context-only-spawn").unwrap();
23676        let workspace_id = WorkspaceId::new("primary").unwrap();
23677        let profile_id = crate::protocol::SpawnProfileId::new("target-kimi").unwrap();
23678        let endpoint = format!(
23679            r"\\.\pipe\gate4agent-kimi-context-only-spawn-{}-{}",
23680            std::process::id(),
23681            unix_time_ms(),
23682        );
23683        let spawn_profiles = SpawnProfileRegistry::new([
23684            crate::protocol::SpawnProfileDefaults {
23685                profile_id: profile_id.clone(),
23686                revision: crate::protocol::SpawnProfileRevision::new("target-kimi-r1").unwrap(),
23687                provider: agent("kimi"),
23688                mode: SessionMode::Pty,
23689                terminal_size: gate4agent_types::TerminalSize {
23690                    rows: 30,
23691                    columns: 120,
23692                },
23693                prompt: None,
23694                bundle_id: None,
23695                context_id: None,
23696                environment_profile_id: None,
23697            },
23698        ])
23699        .unwrap();
23700        let mut config = NodeServerConfig::new(
23701            &endpoint,
23702            "fixture-token",
23703            node_id.clone(),
23704            [WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap()],
23705        )
23706        .unwrap()
23707        .with_spawn_profiles(spawn_profiles)
23708        .with_session_environment_materialization(
23709            materialization_root,
23710            Arc::new(FixtureSecretResolver {
23711                deny: Arc::new(AtomicBool::new(false)),
23712            }),
23713        )
23714        .unwrap();
23715        config.fixture_raw_pty_runtime = true;
23716        let server = NodeServer::new_context_only_proof_fixture(
23717            config,
23718            agent("kimi"),
23719            proof_path.clone(),
23720        )
23721        .unwrap();
23722        let shared = Arc::clone(&server.shared);
23723        let shutdown = server.shutdown_handle();
23724
23725        let history = HistorySessionRecord {
23726            session_id: "context-only-source-history".to_owned(),
23727            title: None,
23728            cwd: None,
23729            model: None,
23730            message_count: 2,
23731            completed_turn_count: None,
23732            total_tokens: 0,
23733            messages: vec![
23734                gate4agent_types::HistoryMessageRecord {
23735                    role: gate4agent_types::HistoryMessageRole::User,
23736                    text: "continue the bounded parent run through native C2".to_owned(),
23737                },
23738                gate4agent_types::HistoryMessageRecord {
23739                    role: gate4agent_types::HistoryMessageRole::Assistant,
23740                    text: "the exact parent context is ready for the child run".to_owned(),
23741                },
23742            ],
23743        };
23744        let pack = NodeContextPack::export(
23745            ContextPackLineageReceipt {
23746                source_node_id: node_id.clone(),
23747                source_session: SessionAddress {
23748                    workspace_id: workspace_id.clone(),
23749                    session: SessionKey {
23750                        instance_id: AgentInstanceId(71),
23751                        generation: SessionGeneration(1),
23752                    },
23753                },
23754                source_provider: agent("codex"),
23755            },
23756            &history,
23757        )
23758        .unwrap();
23759        let expected_context_hash = digest(&SHA256, pack.bytes())
23760            .as_ref()
23761            .iter()
23762            .map(|byte| format!("{byte:02x}"))
23763            .collect::<String>();
23764        let context = shared
23765            .context_catalog
23766            .write()
23767            .unwrap_or_else(|poisoned| poisoned.into_inner())
23768            .insert(pack)
23769            .unwrap();
23770        let spec = SpawnSpec {
23771            target: crate::protocol::SpawnTarget {
23772                node_id,
23773                workspace_id: workspace_id.clone(),
23774                worktree_id: None,
23775            },
23776            profile_id,
23777            expected_profile_revision: crate::protocol::SpawnProfileRevision::new(
23778                "target-kimi-r1",
23779            ).unwrap(),
23780            overrides: crate::protocol::SpawnOverrides {
23781                context_id: crate::protocol::SpawnOverride::Set {
23782                    value: context.id.clone(),
23783                },
23784                ..crate::protocol::SpawnOverrides::default()
23785            },
23786            deadline_ms: crate::protocol::SpawnDeadlineMs::new(10_000).unwrap(),
23787            idempotency_key: SpawnIdempotencyKey::new("kimi-context-only-spawn").unwrap(),
23788            required_capabilities: SpawnRequiredCapabilities::default(),
23789        };
23790        let resolved = match shared.resolve_spawn_spec(&spec) {
23791            Ok(resolved) => resolved,
23792            Err(error) => panic!(
23793                "Kimi context-only resolve rejected: exact_code={:?} message={}",
23794                error.code,
23795                error.message,
23796            ),
23797        };
23798        let resolved_context = match shared.resolve_context(&resolved) {
23799            Ok(Some(resolved_context)) => resolved_context,
23800            Ok(None) => panic!("Kimi context-only resolve omitted the exact ContextPack"),
23801            Err(error) => panic!(
23802                "Kimi context catalog resolve rejected: exact_code={:?} message={}",
23803                error.code,
23804                error.message,
23805            ),
23806        };
23807        assert_eq!(resolved_context, context);
23808        let runtime_policy = match shared
23809            .admit_provider_runtime(&agent("kimi"), ProviderRuntimeRequirement::RawPty)
23810            .await
23811        {
23812            Ok(runtime_policy) => runtime_policy,
23813            Err(error) => panic!(
23814                "Kimi RawPty admission rejected: exact_code={:?} message={}",
23815                error.code,
23816                error.message,
23817            ),
23818        };
23819        assert!(runtime_policy.raw_pty_lifecycle);
23820
23821        let launch_control = shared.native_launch_profile_control.as_ref().unwrap();
23822        let generic_overlay = NativeLaunchEnvironmentOverlay::new(
23823            agent("kimi"),
23824            TransportKind::Pty,
23825            vec![EnvMutation {
23826                key: OsString::from("GATE4AGENT_UNPROFILED_GENERIC"),
23827                value: Some(OsString::from("must-remain-rejected")),
23828            }],
23829        )
23830        .unwrap();
23831        assert_eq!(
23832            launch_control
23833                .install_native_launch_environment_overlay(
23834                    AgentInstanceId(72),
23835                    generic_overlay,
23836                )
23837                .unwrap_err(),
23838            gate4agent_runtime_native::NativeLaunchProfileError::EnvironmentOverlaySelectionMissing,
23839        );
23840
23841        let diagnostic_address = SessionAddress {
23842            workspace_id: workspace_id.clone(),
23843            session: SessionKey {
23844                instance_id: AgentInstanceId(73),
23845                generation: SessionGeneration(1),
23846            },
23847        };
23848        let (diagnostic_overlay, diagnostic_materialization) = match shared
23849            .prepare_session_materialization(
23850                &diagnostic_address,
23851                &agent("kimi"),
23852                SessionMode::Pty,
23853                None,
23854                None,
23855                Some(&resolved_context),
23856                None,
23857            )
23858        {
23859            Ok(Some(prepared)) => prepared,
23860            Ok(None) => panic!("Kimi context-only materialization produced no launch overlay"),
23861            Err(error) => panic!(
23862                "Kimi context-only materialization rejected: exact_code={:?} message={}",
23863                error.code,
23864                error.message,
23865            ),
23866        };
23867        let diagnostic_overlay = diagnostic_overlay
23868            .expect("Kimi context-only materialization omitted its environment overlay");
23869        assert!(matches!(
23870            &diagnostic_overlay,
23871            PreparedNativeLaunchOverlay::Environment(_),
23872        ));
23873        match shared.install_prepared_launch_overlay(
23874            diagnostic_address.session.instance_id,
23875            diagnostic_overlay,
23876        ) {
23877            Ok(None) => {}
23878            Ok(Some(_)) => panic!("Kimi context-only overlay unexpectedly retained argv authority"),
23879            Err(error) => panic!(
23880                "Kimi context-only overlay installation rejected: exact_code={:?} message={}",
23881                error.code,
23882                error.message,
23883            ),
23884        }
23885        assert!(launch_control.clear_native_instance_launch_overlay(
23886            diagnostic_address.session.instance_id,
23887        ));
23888        drop(diagnostic_materialization);
23889        assert!(shared
23890            .materializations
23891            .lock()
23892            .unwrap_or_else(|poisoned| poisoned.into_inner())
23893            .is_empty());
23894
23895        let node_task = tokio::spawn(server.run());
23896        let ready_client = timeout(Duration::from_secs(5), async {
23897            loop {
23898                match gate4agent_node_wire::LocalNodeClient::connect(
23899                    &endpoint,
23900                    &shared.node_id,
23901                    ClientRole::Observer,
23902                    "fixture-token",
23903                )
23904                .await
23905                {
23906                    Ok(client) => break client,
23907                    Err(_) if !node_task.is_finished() => sleep(Duration::from_millis(10)).await,
23908                    Err(error) => panic!("Kimi context-only Node stopped before readiness: {error}"),
23909                }
23910            }
23911        })
23912        .await
23913        .expect("Kimi context-only Node did not become ready");
23914        assert_eq!(
23915            ready_client
23916                .hello()
23917                .snapshot
23918                .provider_runtime_statuses
23919                .iter()
23920                .find(|status| status.provider().as_str() == "kimi")
23921                .expect("Kimi runtime status is missing")
23922                .mode(),
23923            crate::protocol::ProviderRuntimeMode::RawPassthrough,
23924        );
23925        drop(ready_client);
23926
23927        let receipt = match shared.spawn_from_spec(spec).await {
23928            Ok(receipt) => receipt,
23929            Err(error) => panic!(
23930                "Kimi context-only SpawnSpec rejected: exact_code={:?} message={}",
23931                error.code,
23932                error.message,
23933            ),
23934        };
23935        assert_eq!(receipt.provider, agent("kimi"));
23936        assert_eq!(receipt.context.as_ref(), Some(&context));
23937        assert!(receipt.context_binding_is_valid());
23938        timeout(Duration::from_secs(5), async {
23939            while !proof_path.is_file() {
23940                sleep(Duration::from_millis(10)).await;
23941            }
23942        })
23943        .await
23944        .expect("Kimi context-only fixture did not write its proof");
23945
23946        let proof = std::fs::read_to_string(&proof_path).unwrap();
23947        let lines = proof.lines().collect::<Vec<_>>();
23948        assert_eq!(lines.len(), 7);
23949        assert_eq!(lines[0], "context-only");
23950        assert!(Path::new(lines[1]).is_absolute());
23951        assert_ne!(
23952            std::fs::canonicalize(Path::new(lines[1])).unwrap(),
23953            std::fs::canonicalize(&workspace_root).unwrap(),
23954        );
23955        assert_eq!(
23956            std::fs::canonicalize(Path::new(lines[2])).unwrap(),
23957            std::fs::canonicalize(&workspace_root).unwrap(),
23958        );
23959        assert_eq!(lines[3], expected_context_hash);
23960        assert_eq!(lines[4], "g4a-context-pack-v1");
23961        assert_eq!(lines[5], "codex");
23962        assert_eq!(lines[6], "2");
23963        assert!(shared.handle.snapshot().sessions.iter().any(|session| {
23964            session.instance_id == receipt.session.session.instance_id
23965                && session.agent_id.as_str() == "kimi"
23966        }));
23967
23968        shutdown.request_shutdown().await.unwrap();
23969        timeout(Duration::from_secs(5), node_task)
23970            .await
23971            .unwrap()
23972            .unwrap()
23973            .unwrap();
23974        drop(shutdown);
23975        drop(shared);
23976        std::fs::remove_dir_all(root).unwrap();
23977    }
23978
23979    #[cfg(feature = "fixture")]
23980    #[test]
23981    fn clean_exit_fixture_constructor_is_fixture_scoped_and_path_bounded() {
23982        let root = temporary_workspace_root("clean-exit-constructor");
23983        std::fs::create_dir_all(&root).unwrap();
23984        let config = construction_test_config("clean-exit-constructor")
23985            .with_state_path(root.join("node-state.json"))
23986            .unwrap();
23987        let server = NodeServer::new_clean_exit_fixture(
23988            config,
23989            root.clone(),
23990            root.join("started.marker"),
23991            root.join("release.signal"),
23992        )
23993        .unwrap();
23994        assert_eq!(
23995            server
23996                .shared
23997                .enabled_providers
23998                .iter()
23999                .map(AgentId::as_str)
24000                .collect::<Vec<_>>(),
24001            ["claude"],
24002        );
24003        drop(server);
24004        std::fs::remove_dir_all(root).unwrap();
24005    }
24006
24007    #[test]
24008    fn production_registry_provider_contract_manifest_is_exact() {
24009        let registry = active_registry().unwrap();
24010        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
24011        assert_eq!(
24012            providers
24013                .iter()
24014                .map(|contract| (contract.provider.as_str(), contract.revision.as_str()))
24015                .collect::<Vec<_>>(),
24016            vec![
24017                ("claude", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24018                ("codex", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24019                ("grok", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24020                ("kimi", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24021            ]
24022        );
24023        assert_eq!(
24024            adapters
24025                .iter()
24026                .map(|contract| (
24027                    contract.provider.as_str(),
24028                    contract.family,
24029                    contract.adapter_id.as_str(),
24030                    contract.revision.as_str(),
24031                ))
24032                .collect::<Vec<_>>(),
24033            vec![
24034                ("claude", AdapterFamily::PtySemantic, "claude-code", "gate4agent-adapter/v1"),
24035                ("claude", AdapterFamily::Pipe, "claude-code", "gate4agent-adapter/v1"),
24036                ("claude", AdapterFamily::Acp, "claude-code", "gate4agent-adapter/v1"),
24037                ("claude", AdapterFamily::OneShot, "claude", "gate4agent-inline/claude-code-2.1/v1"),
24038                ("claude", AdapterFamily::History, "claude-code", "gate4agent-adapter/v1"),
24039                ("claude", AdapterFamily::Resume, "claude-code", "gate4agent-adapter/v1"),
24040                ("claude", AdapterFamily::SessionOptions, "claude-code", "gate4agent-session-options/orca-d8629c4/v1"),
24041                ("codex", AdapterFamily::PtySemantic, "codex", "gate4agent-adapter/v1"),
24042                ("codex", AdapterFamily::Pipe, "codex", "gate4agent-adapter/v1"),
24043                ("codex", AdapterFamily::Acp, "codex", "gate4agent-adapter/v1"),
24044                ("codex", AdapterFamily::OneShot, "codex", "gate4agent-inline/codex-cli-0.144/v1"),
24045                ("codex", AdapterFamily::History, "codex", "gate4agent-adapter/v1"),
24046                ("codex", AdapterFamily::Resume, "codex", "gate4agent-adapter/v1"),
24047                ("codex", AdapterFamily::SessionOptions, "codex", "gate4agent-session-options/orca-d8629c4/v1"),
24048                ("grok", AdapterFamily::Acp, "grok", "gate4agent-adapter/v1"),
24049                ("grok", AdapterFamily::History, "grok", "gate4agent-adapter/v1"),
24050                ("grok", AdapterFamily::Resume, "grok", "gate4agent-adapter/v1"),
24051                ("kimi", AdapterFamily::PtySemantic, "kimi", "gate4agent-adapter/v1"),
24052                ("kimi", AdapterFamily::Pipe, "kimi", "gate4agent-adapter/v1"),
24053                ("kimi", AdapterFamily::Acp, "kimi", "gate4agent-adapter/v1"),
24054                ("kimi", AdapterFamily::OneShot, "kimi", "gate4agent-inline/kimi-code-0.31/v1"),
24055                ("kimi", AdapterFamily::History, "kimi", "gate4agent-adapter/v1"),
24056                ("kimi", AdapterFamily::Resume, "kimi", "gate4agent-adapter/v1"),
24057            ]
24058        );
24059    }
24060
24061    fn construction_test_config(name: &str) -> NodeServerConfig {
24062        let workspace = WorkspaceConfig::new(
24063            WorkspaceId::new("primary").unwrap(),
24064            std::env::current_dir().unwrap(),
24065        )
24066        .unwrap();
24067        NodeServerConfig::new(
24068            format!(r"\\.\pipe\gate4agent-{name}"),
24069            "fixture-token",
24070            NodeId::new(name).unwrap(),
24071            [workspace],
24072        )
24073        .unwrap()
24074    }
24075
24076    fn maximum_size_provider_manifest_registry() -> AgentRegistry {
24077        let adapter = AdapterBinding::new(
24078            gate4agent_types::AdapterId::new("a".repeat(96)).unwrap(),
24079            "r".repeat(crate::protocol::MAX_ADAPTER_CONTRACT_REVISION_BYTES),
24080            gate4agent_types::AdapterVerification::SyntheticFixture,
24081        )
24082        .unwrap();
24083        let agent_ids = ["claude", "codex", "kimi"]
24084            .into_iter()
24085            .map(|agent_id| AgentId::new(agent_id).unwrap())
24086            .collect::<Vec<_>>();
24087        let adapters = gate4agent_catalog::AdapterRegistry::new(
24088            [
24089                AdapterFamily::PtySemantic,
24090                AdapterFamily::Pipe,
24091                AdapterFamily::OneShot,
24092                AdapterFamily::Acp,
24093                AdapterFamily::Hook,
24094                AdapterFamily::ManagedHook,
24095                AdapterFamily::History,
24096                AdapterFamily::Resume,
24097                AdapterFamily::SessionOptions,
24098                AdapterFamily::CapabilityProbe,
24099            ]
24100            .into_iter()
24101            .map(|family| gate4agent_catalog::AdapterDescriptor {
24102                family,
24103                binding: adapter.clone(),
24104                agents: agent_ids.clone(),
24105            }),
24106        )
24107        .unwrap();
24108        let specs = agent_ids
24109            .iter()
24110            .map(|agent_id| {
24111                let agent_id = agent_id.as_str();
24112                let mut spec = builtin_registry().get_by_id(agent_id).unwrap().clone();
24113                spec.revision =
24114                    "r".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES);
24115                spec.capabilities.transports.pty_adapter = Some(adapter.clone());
24116                spec.capabilities.transports.pipe = Some(gate4agent_types::PipeTransportSpec {
24117                    adapter: adapter.clone(),
24118                    protocol: gate4agent_types::PipeProtocol::SemanticNdjson,
24119                    launch_override: None,
24120                    prompt_delivery: gate4agent_types::PipePromptDelivery::None,
24121                });
24122                spec.capabilities.transports.acp = Some(gate4agent_types::AcpTransportSpec {
24123                    adapter: adapter.clone(),
24124                    launch_override: None,
24125                });
24126                spec.capabilities.adapters.hook = Some(adapter.clone());
24127                spec.capabilities.adapters.managed_hook = Some(adapter.clone());
24128                spec.capabilities.adapters.one_shot = Some(adapter.clone());
24129                spec.capabilities.adapters.history = Some(adapter.clone());
24130                spec.capabilities.adapters.resume = Some(adapter.clone());
24131                spec.capabilities.adapters.session_options = Some(adapter.clone());
24132                spec.capabilities.adapters.capability_probe = Some(adapter.clone());
24133                spec
24134            })
24135            .collect::<Vec<_>>();
24136        AgentRegistry::new_with_adapters(specs, &adapters).unwrap()
24137    }
24138
24139    /// The real fleet's manifest is accepted by a real server construction.
24140    ///
24141    /// The count is asserted against `active_registry` itself rather than a
24142    /// literal, so this stays a test that construction succeeds for whatever
24143    /// the fleet is -- which is the property worth guarding -- instead of a
24144    /// second place the fleet size is written down and has to be kept in
24145    /// step with the first.
24146    #[test]
24147    fn node_server_construction_accepts_the_production_manifest() {
24148        let registry = active_registry().unwrap();
24149        let (providers, _) = provider_contract_manifest(&registry).unwrap();
24150        assert_eq!(providers.len(), registry.iter().count());
24151        assert!(!providers.is_empty());
24152        let server = NodeServer::new_with_registry(
24153            construction_test_config("manifest-production-bound"),
24154            registry,
24155        )
24156        .unwrap();
24157        drop(server);
24158    }
24159
24160    #[test]
24161    fn node_server_construction_rejects_count_valid_manifest_over_handshake_capacity() {
24162        let registry = maximum_size_provider_manifest_registry();
24163        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
24164        assert_eq!(providers.len(), 3);
24165        assert_eq!(adapters.len(), 30);
24166        assert!(adapters.len() <= crate::protocol::MAX_PROVIDER_ADAPTER_CONTRACTS);
24167        let error = match NodeServer::new_with_registry(
24168            construction_test_config("manifest-handshake-overflow"),
24169            registry,
24170        ) {
24171            Ok(_) => panic!("oversized negotiated manifest was accepted"),
24172            Err(error) => error,
24173        };
24174        assert!(matches!(
24175            error,
24176            NodeServerError::ProviderContractManifest(message)
24177                if message.contains("authentication binding length")
24178                    && message.contains("8192-byte limit")
24179        ));
24180    }
24181
24182    #[test]
24183    fn provider_contract_manifest_rejects_wire_revision_overflow_before_startup() {
24184        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24185        spec.revision = "x".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES + 1);
24186        let registry = AgentRegistry::new([spec]).unwrap();
24187        assert!(matches!(
24188            provider_contract_manifest(&registry),
24189            Err(NodeServerError::ProviderContractManifest(message))
24190                if message.contains("exceeds the 128-byte limit")
24191        ));
24192    }
24193
24194    #[test]
24195    fn provider_contract_manifest_rejects_invalid_wire_revision_before_startup() {
24196        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24197        spec.revision = "invalid revision".to_owned();
24198        let registry = AgentRegistry::new([spec]).unwrap();
24199        assert!(matches!(
24200            provider_contract_manifest(&registry),
24201            Err(NodeServerError::ProviderContractManifest(message))
24202                if message.contains("bounded lowercase ASCII")
24203        ));
24204    }
24205
24206    #[test]
24207    fn provider_contract_manifest_rejects_conflicting_duplicate_family_before_startup() {
24208        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24209        let transport_binding = spec.capabilities.adapters.one_shot.clone().unwrap();
24210        let conflicting_binding = builtin_registry()
24211            .get_by_id("codex")
24212            .unwrap()
24213            .capabilities
24214            .adapters
24215            .one_shot
24216            .clone()
24217            .unwrap();
24218        let pipe = spec.capabilities.transports.pipe.as_mut().unwrap();
24219        pipe.protocol = gate4agent_types::PipeProtocol::OneShotText;
24220        pipe.adapter = transport_binding;
24221        spec.capabilities.adapters.one_shot = Some(conflicting_binding);
24222        let registry = AgentRegistry::new([spec]).unwrap();
24223        assert!(matches!(
24224            provider_contract_manifest(&registry),
24225            Err(NodeServerError::ProviderContractManifest(message))
24226                if message.contains("conflicting OneShot adapter bindings")
24227        ));
24228    }
24229
24230    #[test]
24231    fn public_node_failures_expose_only_fixed_categories() {
24232        let provider = failure(
24233            NodeFailureCode::BackendOperationFailed,
24234            r"provider bearer-secret failed at C:\private\session.jsonl",
24235        );
24236        assert_eq!(provider.message, "backend-operation-failed");
24237        assert!(!provider.message.contains("bearer-secret"));
24238        assert!(!provider.message.contains(r"C:\private"));
24239        let persistence = persistence_failure(io::Error::new(
24240            io::ErrorKind::Other,
24241            r"state write failed at C:\private\state-v1.json",
24242        ));
24243        assert_eq!(persistence.message, DURABLE_STATE_COMMIT_FAILED_ERROR);
24244        assert!(!persistence.message.contains(r"C:\private"));
24245
24246        for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::Unsupported] {
24247            let generic = durable_state_load_error(io::Error::new(kind, "host I/O failure"));
24248            assert_eq!(
24249                generic.to_string(),
24250                "node durable state failed: durable-state-load-failed",
24251            );
24252        }
24253    }
24254
24255    #[test]
24256    fn terminal_byte_sequences_are_bounded_before_dispatch() {
24257        let sequence = b"\x1b[1;5D".to_vec();
24258        assert_eq!(
24259            terminal_bytes_action(sequence.clone()).unwrap(),
24260            InputAction::TerminalBytes(sequence),
24261        );
24262        assert_eq!(
24263            terminal_bytes_action(Vec::new()).unwrap_err().code,
24264            NodeFailureCode::InvalidRequest,
24265        );
24266        assert_eq!(
24267            terminal_bytes_action(vec![0; MAX_NODE_TERMINAL_BYTES + 1])
24268                .unwrap_err()
24269                .code,
24270            NodeFailureCode::InvalidRequest,
24271        );
24272    }
24273
24274    #[test]
24275    fn node_server_rejects_a_second_owner_of_the_same_durable_state_path() {
24276        let root = temporary_workspace_root("exclusive-state-owner");
24277        std::fs::create_dir_all(&root).unwrap();
24278        let workspace = WorkspaceConfig::new(
24279            WorkspaceId::new("primary").unwrap(),
24280            &root,
24281        )
24282        .unwrap();
24283        let state_path = root.join("state-v1.json");
24284        let endpoint = format!(
24285            r"\\.\pipe\gate4agent-node-state-lock-{}-{}",
24286            std::process::id(),
24287            std::time::SystemTime::now()
24288                .duration_since(std::time::UNIX_EPOCH)
24289                .unwrap()
24290                .as_nanos(),
24291        );
24292        let config = NodeServerConfig::new(
24293            endpoint,
24294            "fixture-token",
24295            NodeId::new("node-1").unwrap(),
24296            [workspace],
24297        )
24298        .unwrap()
24299        .with_state_path(&state_path)
24300        .unwrap();
24301
24302        let first = NodeServer::new(config.clone()).unwrap();
24303        let error = match NodeServer::new(config.clone()) {
24304            Ok(_) => panic!("a second node server acquired the same durable state path"),
24305            Err(error) => error,
24306        };
24307        assert!(matches!(
24308            &error,
24309            NodeServerError::DurableState(error)
24310                if error.kind() == io::ErrorKind::WouldBlock
24311        ));
24312        assert_eq!(
24313            error.to_string(),
24314            "node durable state failed: durable-state-lock-failed",
24315        );
24316        assert!(!error.to_string().contains(&state_path.to_string_lossy().into_owned()));
24317        drop(first);
24318        let second = NodeServer::new(config).unwrap();
24319        drop(second);
24320        std::fs::remove_dir_all(root).unwrap();
24321    }
24322
24323    #[test]
24324    fn session_binding_is_workspace_scoped_and_removed_fail_closed() {
24325        let catalog = active_registry().unwrap();
24326        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24327        let workspace_id = WorkspaceId::new("primary").unwrap();
24328        let workspace = WorkspaceConfig::new(
24329            workspace_id.clone(),
24330            std::env::current_dir().unwrap(),
24331        )
24332        .unwrap();
24333        let shared = NodeShared::new(
24334            handle,
24335            "fixture-token".to_owned(),
24336            NodeId::new("node-1").unwrap(),
24337            vec![workspace.clone()],
24338            vec![agent("claude"), agent("codex"), agent("kimi")],
24339        );
24340        let instance_id = AgentInstanceId(17);
24341        let generation = SessionGeneration(3);
24342        assert!(shared.address_for(instance_id, generation).is_none());
24343        shared.bind_session(instance_id, workspace_id.clone(), generation);
24344        let address = SessionAddress {
24345            workspace_id,
24346            session: SessionKey { instance_id, generation },
24347        };
24348        assert_eq!(
24349            shared.workspace_root(&address.workspace_id).unwrap(),
24350            workspace.canonical_root,
24351        );
24352        assert_eq!(shared.address_for(instance_id, generation), Some(address.clone()));
24353        shared.remove_binding(&address);
24354        assert!(shared.address_for(instance_id, generation).is_none());
24355    }
24356
24357    #[test]
24358    fn open_provider_request_gate_is_state_aware_and_fail_closed() {
24359        let address = SessionAddress {
24360            workspace_id: WorkspaceId::new("primary").unwrap(),
24361            session: SessionKey {
24362                instance_id: AgentInstanceId(7),
24363                generation: SessionGeneration(2),
24364            },
24365        };
24366        let record_id = SessionRecordId::new("record-a").unwrap();
24367        let size = gate4agent_types::TerminalSize {
24368            rows: 24,
24369            columns: 80,
24370        };
24371        let address_requests = [
24372            NodeRequest::Resume {
24373                session: address.clone(),
24374                terminal_size: size,
24375                initial_prompt: None,
24376            },
24377            NodeRequest::Prompt { session: address.clone(), text: "p".to_owned() },
24378            NodeRequest::Paste { session: address.clone(), text: "p".to_owned() },
24379            NodeRequest::Input { session: address.clone(), text: "p".to_owned() },
24380            NodeRequest::TerminalBytes { session: address.clone(), bytes: vec![1] },
24381            NodeRequest::TerminalControl {
24382                session: address.clone(),
24383                control: TerminalControl::Enter,
24384            },
24385            NodeRequest::Resize { session: address.clone(), size },
24386            NodeRequest::Interrupt { session: address.clone() },
24387            NodeRequest::Stop { session: address.clone(), force: false },
24388            NodeRequest::Remove { session: address },
24389        ];
24390        for request in &address_requests {
24391            assert!(request_requires_open_provider_ids_with(
24392                request,
24393                |_| Some(agent("third-party-agent")),
24394                |_| Some(agent("claude")),
24395            ));
24396            assert!(!request_requires_open_provider_ids_with(
24397                request,
24398                |_| Some(agent("claude")),
24399                |_| Some(agent("claude")),
24400            ));
24401            assert!(request_requires_open_provider_ids_with(
24402                request,
24403                |_| None,
24404                |_| Some(agent("claude")),
24405            ));
24406        }
24407
24408        let record_requests = [
24409            NodeRequest::RenameSessionRecord {
24410                record_id: record_id.clone(),
24411                display_name: "renamed".to_owned(),
24412            },
24413            NodeRequest::ResumeSessionRecord {
24414                record_id: record_id.clone(),
24415                terminal_size: size,
24416                initial_prompt: None,
24417            },
24418            NodeRequest::ForgetSessionRecord { record_id },
24419        ];
24420        for request in &record_requests {
24421            assert!(request_requires_open_provider_ids_with(
24422                request,
24423                |_| Some(agent("claude")),
24424                |_| Some(agent("grok")),
24425            ));
24426            assert!(!request_requires_open_provider_ids_with(
24427                request,
24428                |_| Some(agent("claude")),
24429                |_| Some(agent("kimi")),
24430            ));
24431            assert!(request_requires_open_provider_ids_with(
24432                request,
24433                |_| Some(agent("claude")),
24434                |_| None,
24435            ));
24436        }
24437
24438        assert!(request_requires_open_provider_ids_with(
24439            &NodeRequest::Spawn {
24440                workspace_id: WorkspaceId::new("primary").unwrap(),
24441                provider: agent("third-party-agent"),
24442                mode: SessionMode::Pty,
24443                terminal_size: size,
24444                initial_prompt: None,
24445            },
24446            |_| None,
24447            |_| None,
24448        ));
24449        assert!(!request_requires_open_provider_ids_with(
24450            &NodeRequest::Snapshot,
24451            |_| None,
24452            |_| None,
24453        ));
24454        assert!(request_requires_open_provider_ids_with(
24455            &NodeRequest::ForgetContextPack {
24456                context_id: SpawnContextId::new("ctx-forget").unwrap(),
24457            },
24458            |_| None,
24459            |_| None,
24460        ));
24461    }
24462
24463    #[test]
24464    fn legacy_projection_filters_open_record_replies_and_event_history() {
24465        let catalog = active_registry().unwrap();
24466        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24467        let workspace = WorkspaceConfig::new(
24468            WorkspaceId::new("primary").unwrap(),
24469            std::env::current_dir().unwrap(),
24470        )
24471        .unwrap();
24472        let shared = NodeShared::new(
24473            handle,
24474            "fixture-token".to_owned(),
24475            NodeId::new("node-1").unwrap(),
24476            vec![workspace],
24477            vec![agent("claude"), agent("grok")],
24478        );
24479        let legacy = record("claude", "legacy-record");
24480        let open = record("grok", "open-record");
24481        let legacy_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
24482            record: legacy.clone(),
24483        });
24484        let open_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
24485            record: open.clone(),
24486        });
24487        let legacy_removed = shared.publish(NodeEvent::SessionRecordRemoved {
24488            record_id: legacy.record_id.clone(),
24489        });
24490        let open_removed = shared.publish(NodeEvent::SessionRecordRemoved {
24491            record_id: open.record_id.clone(),
24492        });
24493        assert!(project_event_legacy_provider_ids(&shared, legacy_upsert.clone()).is_some());
24494        assert!(project_event_legacy_provider_ids(&shared, open_upsert.clone()).is_none());
24495        assert!(project_event_legacy_provider_ids(&shared, legacy_removed.clone()).is_some());
24496        assert!(project_event_legacy_provider_ids(&shared, open_removed.clone()).is_none());
24497
24498        let mut record_reply = ResponseEnvelope {
24499            request_id: 1,
24500            result: Ok(NodeResponse::SessionRecordUpdated { record: open }),
24501        };
24502        project_response_legacy_provider_ids(&shared, &mut record_reply);
24503        assert!(matches!(
24504            record_reply.result,
24505            Err(NodeFailure { code: NodeFailureCode::UnsupportedCapability, .. })
24506        ));
24507
24508        let mut resync = ResponseEnvelope {
24509            request_id: 2,
24510            result: Ok(NodeResponse::Resync {
24511                event_sequence: open_removed.sequence,
24512                oldest_available_sequence: 1,
24513                snapshot: shared.snapshot(),
24514                events: vec![legacy_upsert, open_upsert, legacy_removed, open_removed],
24515            }),
24516        };
24517        project_response_legacy_provider_ids(&shared, &mut resync);
24518        let Ok(NodeResponse::Resync { snapshot, events, .. }) = resync.result else {
24519            panic!("legacy projection changed the resync response kind");
24520        };
24521        assert!(snapshot.session_records.iter().all(|item| {
24522            provider_id_is_legacy(&item.provider)
24523        }));
24524        assert_eq!(events.len(), 2);
24525        assert!(events.iter().all(|event| matches!(
24526            &event.event,
24527            NodeEvent::SessionRecordUpserted { record }
24528                if provider_id_is_legacy(&record.provider)
24529        ) || matches!(&event.event, NodeEvent::SessionRecordRemoved { .. })));
24530    }
24531
24532    #[test]
24533    fn session_binding_capacity_is_bounded_by_the_control_session_limit() {
24534        let catalog = active_registry().unwrap();
24535        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24536        let workspace_id = WorkspaceId::new("primary").unwrap();
24537        let workspace = WorkspaceConfig::new(
24538            workspace_id.clone(),
24539            std::env::current_dir().unwrap(),
24540        )
24541        .unwrap();
24542        let shared = NodeShared::new(
24543            handle,
24544            "fixture-token".to_owned(),
24545            NodeId::new("node-1").unwrap(),
24546            vec![workspace],
24547            vec![agent("claude")],
24548        );
24549        for index in 0..CONTROL_SESSIONS_MAX {
24550            shared.bind_session(
24551                AgentInstanceId((index + 1) as u64),
24552                workspace_id.clone(),
24553                SessionGeneration(1),
24554            );
24555        }
24556        let error = shared.ensure_binding_capacity().unwrap_err();
24557        assert_eq!(error.code, NodeFailureCode::BackendBusy);
24558    }
24559
24560    #[tokio::test]
24561    async fn provider_spawn_admission_rejects_before_session_mutation() {
24562        let catalog = active_registry().unwrap();
24563        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24564        let workspace = WorkspaceConfig::new(
24565            WorkspaceId::new("primary").unwrap(),
24566            std::env::current_dir().unwrap(),
24567        )
24568        .unwrap();
24569        let mut shared = NodeShared::new(
24570            handle,
24571            "fixture-token".to_owned(),
24572            NodeId::new("node-provider-admission").unwrap(),
24573            vec![workspace],
24574            vec![agent("claude")],
24575        );
24576        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
24577            crate::protocol::ProviderRuntimeStatus::unavailable(agent("claude")),
24578        ])
24579        .unwrap();
24580        let terminal_size = gate4agent_types::TerminalSize {
24581            rows: 24,
24582            columns: 80,
24583        };
24584
24585        let unavailable = shared
24586            .spawn_session(
24587                WorkspaceId::new("primary").unwrap(),
24588                agent("claude"),
24589                SessionMode::Pty,
24590                terminal_size,
24591                None,
24592            )
24593            .await
24594            .unwrap_err();
24595        assert_eq!(unavailable.code, NodeFailureCode::BackendOperationFailed);
24596        assert_eq!(unavailable.message, "backend-operation-failed");
24597
24598        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
24599            crate::protocol::ProviderRuntimeStatus::raw_passthrough(
24600                agent("claude"),
24601                Some(crate::protocol::ProviderRuntimeVersion::new("999.0.0").unwrap()),
24602            ),
24603        ])
24604        .unwrap();
24605        let semantic = shared
24606            .spawn_session(
24607                WorkspaceId::new("primary").unwrap(),
24608                agent("claude"),
24609                SessionMode::Inline,
24610                terminal_size,
24611                None,
24612            )
24613            .await
24614            .unwrap_err();
24615        assert_eq!(semantic.code, NodeFailureCode::UnsupportedCapability);
24616        assert_eq!(semantic.message, "unsupported-capability");
24617
24618        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
24619        assert!(shared
24620            .session_bindings
24621            .lock()
24622            .unwrap_or_else(|poisoned| poisoned.into_inner())
24623            .is_empty());
24624        assert!(shared
24625            .session_records
24626            .lock()
24627            .unwrap_or_else(|poisoned| poisoned.into_inner())
24628            .records
24629            .is_empty());
24630        assert!(shared.handle.snapshot().sessions.is_empty());
24631    }
24632
24633    #[tokio::test]
24634    async fn raw_spawn_binding_is_visible_with_a_durable_live_record() {
24635        let catalog = active_registry().unwrap();
24636        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24637        let workspace_id = WorkspaceId::new("primary").unwrap();
24638        let workspace = WorkspaceConfig::new(
24639            workspace_id.clone(),
24640            std::env::current_dir().unwrap(),
24641        )
24642        .unwrap();
24643        let shared = NodeShared::new(
24644            handle,
24645            "fixture-token".to_owned(),
24646            NodeId::new("node-raw-spawn-record").unwrap(),
24647            vec![workspace],
24648            vec![agent("claude")],
24649        );
24650        let address = SessionAddress {
24651            workspace_id,
24652            session: SessionKey {
24653                instance_id: AgentInstanceId(71),
24654                generation: SessionGeneration::default(),
24655            },
24656        };
24657
24658        let record_id = shared
24659            .bind_spawn_session(
24660                &address,
24661                agent("claude"),
24662                SessionMode::Pty,
24663                ProviderRuntimePolicy::raw_pty(),
24664                SpawnRecordPolicy::Always,
24665                None,
24666            )
24667            .unwrap()
24668            .unwrap();
24669        shared
24670            .handle
24671            .dispatch(shared.prepare_command(ControlCommand::Register {
24672                instance_id: address.session.instance_id,
24673                agent_id: agent("claude"),
24674                transport: TransportKind::Pty,
24675            }))
24676            .unwrap();
24677        runtime.tick().await;
24678
24679        assert!(shared.handle.snapshot().sessions.iter().any(|session| {
24680            session.instance_id == address.session.instance_id
24681                && session.generation == address.session.generation
24682        }));
24683        let record = shared.record(&record_id).unwrap();
24684        assert_eq!(record.display_name, "claude #1");
24685        assert_eq!(record.state, ManagedSessionState::Live);
24686        assert_eq!(record.active_session.as_ref(), Some(&address));
24687        assert!(record.provider_session.is_none());
24688        let binding_record_id = shared
24689            .session_bindings
24690            .lock()
24691            .unwrap_or_else(|poisoned| poisoned.into_inner())
24692            .get(&address.session.instance_id)
24693            .and_then(|binding| binding.record_id.clone());
24694        assert_eq!(binding_record_id.as_ref(), Some(&record_id));
24695
24696        let renamed = shared
24697            .rename_session_record(&record_id, "raw review".to_owned())
24698            .unwrap();
24699        assert_eq!(renamed.record_id, record_id);
24700        assert_eq!(renamed.display_name, "raw review");
24701        shared.reconcile_managed_record(
24702            &record_id,
24703            &address,
24704            &ControlEvent {
24705                sequence: 1,
24706                command_id: None,
24707                instance_id: address.session.instance_id,
24708                generation: address.session.generation,
24709                event: ControlEventKind::Running { process_id: None },
24710            },
24711            false,
24712            false,
24713        );
24714        let running = shared.record(&record_id).unwrap();
24715        assert_eq!(running.state, ManagedSessionState::Live);
24716        assert_eq!(running.active_session.as_ref(), Some(&address));
24717        assert_eq!(running.display_name, "raw review");
24718
24719        shared.reconcile_managed_record(
24720            &record_id,
24721            &address,
24722            &ControlEvent {
24723                sequence: 2,
24724                command_id: None,
24725                instance_id: address.session.instance_id,
24726                generation: address.session.generation,
24727                event: ControlEventKind::Exited {
24728                    exit_code: Some(0),
24729                    forced: false,
24730                },
24731            },
24732            false,
24733            false,
24734        );
24735        let detached = shared.record(&record_id).unwrap();
24736        assert_eq!(detached.state, ManagedSessionState::Unavailable);
24737        assert!(detached.active_session.is_none());
24738        assert_eq!(detached.display_name, "raw review");
24739        let renamed_detached = shared
24740            .rename_session_record(&record_id, "raw review detached".to_owned())
24741            .unwrap();
24742        assert_eq!(renamed_detached.record_id, record_id);
24743        assert_eq!(renamed_detached.state, ManagedSessionState::Unavailable);
24744        assert!(renamed_detached.active_session.is_none());
24745        assert_eq!(renamed_detached.display_name, "raw review detached");
24746        assert_eq!(shared.snapshot().session_records, vec![renamed_detached]);
24747    }
24748
24749    #[test]
24750    fn legacy_raw_spawn_binding_remains_untracked() {
24751        let catalog = active_registry().unwrap();
24752        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24753        let workspace_id = WorkspaceId::new("primary").unwrap();
24754        let workspace = WorkspaceConfig::new(
24755            workspace_id.clone(),
24756            std::env::current_dir().unwrap(),
24757        )
24758        .unwrap();
24759        let shared = NodeShared::new(
24760            handle,
24761            "fixture-token".to_owned(),
24762            NodeId::new("node-legacy-raw-spawn").unwrap(),
24763            vec![workspace],
24764            vec![agent("claude")],
24765        );
24766        let address = SessionAddress {
24767            workspace_id,
24768            session: SessionKey {
24769                instance_id: AgentInstanceId(72),
24770                generation: SessionGeneration::default(),
24771            },
24772        };
24773
24774        let record_id = shared
24775            .bind_spawn_session(
24776                &address,
24777                agent("claude"),
24778                SessionMode::Pty,
24779                ProviderRuntimePolicy::raw_pty(),
24780                SpawnRecordPolicy::ProviderIdentityOnly,
24781                None,
24782            )
24783            .unwrap();
24784
24785        assert!(record_id.is_none());
24786        let binding_record_id = shared
24787            .session_bindings
24788            .lock()
24789            .unwrap_or_else(|poisoned| poisoned.into_inner())
24790            .get(&address.session.instance_id)
24791            .and_then(|binding| binding.record_id.clone());
24792        assert!(binding_record_id.is_none());
24793        assert!(shared.snapshot().session_records.is_empty());
24794    }
24795
24796    #[tokio::test]
24797    async fn raw_runtime_policy_rejects_prompt_paste_and_resume_before_mutation() {
24798        let catalog = active_registry().unwrap();
24799        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24800        let workspace_id = WorkspaceId::new("primary").unwrap();
24801        let workspace = WorkspaceConfig::new(
24802            workspace_id.clone(),
24803            std::env::current_dir().unwrap(),
24804        )
24805        .unwrap();
24806        let mut shared = NodeShared::new(
24807            handle,
24808            "fixture-token".to_owned(),
24809            NodeId::new("node-raw-semantic-gate").unwrap(),
24810            vec![workspace],
24811            vec![agent("claude")],
24812        );
24813        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
24814            crate::protocol::ProviderRuntimeStatus::raw_passthrough(agent("claude"), None),
24815        ])
24816        .unwrap();
24817        shared
24818            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
24819            .unwrap();
24820        let address = SessionAddress {
24821            workspace_id,
24822            session: SessionKey {
24823                instance_id: AgentInstanceId(72),
24824                generation: SessionGeneration::default(),
24825            },
24826        };
24827        shared.bind_session_with_policy(
24828            &address,
24829            ProviderRuntimePolicy::raw_pty(),
24830            None,
24831        );
24832        shared
24833            .handle
24834            .dispatch(shared.prepare_command(ControlCommand::Register {
24835                instance_id: address.session.instance_id,
24836                agent_id: agent("claude"),
24837                transport: TransportKind::Pty,
24838            }))
24839            .unwrap();
24840        runtime.tick().await;
24841        let command_id_before = shared.next_command_id.load(Ordering::Acquire);
24842        let revision_before = shared.handle.snapshot().revision;
24843        let history_sequence_before = shared.current_sequence();
24844        let terminal_size = gate4agent_types::TerminalSize {
24845            rows: 24,
24846            columns: 80,
24847        };
24848
24849        for request in [
24850            NodeRequest::Prompt {
24851                session: address.clone(),
24852                text: "prompt".to_owned(),
24853            },
24854            NodeRequest::Paste {
24855                session: address.clone(),
24856                text: "paste".to_owned(),
24857            },
24858            // `initial_prompt: Some(_)` selects `ResumeWithPrompt`, which needs
24859            // the same semantic capabilities as `Prompt`/`Paste`. A bare
24860            // (promptless) resume is a raw PTY relaunch and IS admitted under
24861            // `raw_pty()` — see `raw_pty_admits_provider_native_resume_without_prompt_only`
24862            // in `provider_runtime.rs`; it is deliberately not exercised here.
24863            NodeRequest::Resume {
24864                session: address.clone(),
24865                terminal_size,
24866                initial_prompt: Some("resume".to_owned()),
24867            },
24868        ] {
24869            let error = process_request_inner(
24870                &shared,
24871                77,
24872                ClientRole::Operator,
24873                request,
24874            )
24875            .await
24876            .unwrap_err();
24877            assert_eq!(error.code, NodeFailureCode::UnsupportedCapability);
24878        }
24879
24880        assert_eq!(shared.next_command_id.load(Ordering::Acquire), command_id_before);
24881        assert_eq!(shared.handle.snapshot().revision, revision_before);
24882        assert_eq!(shared.current_sequence(), history_sequence_before);
24883        let bindings = shared
24884            .session_bindings
24885            .lock()
24886            .unwrap_or_else(|poisoned| poisoned.into_inner());
24887        let binding = bindings.get(&address.session.instance_id).unwrap();
24888        assert_eq!(binding.generation, address.session.generation);
24889        assert!(binding.pending_resume.is_none());
24890        assert!(binding.record_id.is_none());
24891    }
24892
24893    #[tokio::test]
24894    async fn windows_rejects_unix_bytes_workspace_path_before_filesystem_access() {
24895        let catalog = active_registry().unwrap();
24896        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24897        let primary = WorkspaceConfig::new(
24898            WorkspaceId::new("primary").unwrap(),
24899            std::env::current_dir().unwrap(),
24900        )
24901        .unwrap();
24902        let shared = NodeShared::new(
24903            handle,
24904            "fixture-token".to_owned(),
24905            NodeId::new("node-1").unwrap(),
24906            vec![primary],
24907            vec![agent("claude")],
24908        );
24909        shared
24910            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
24911            .unwrap();
24912        let before = shared.snapshot();
24913        let error = process_request_inner(
24914            &shared,
24915            77,
24916            ClientRole::Operator,
24917            NodeRequest::RegisterWorkspace {
24918                workspace_id: WorkspaceId::new("foreign").unwrap(),
24919                root: OpaqueHostPath::unix_bytes(b"/srv/repo".to_vec()).unwrap(),
24920            },
24921        )
24922        .await
24923        .unwrap_err();
24924        assert_eq!(error.code, NodeFailureCode::InvalidRequest);
24925        assert_eq!(error.message, "invalid-request");
24926        assert_eq!(shared.snapshot().workspaces, before.workspaces);
24927    }
24928
24929    #[tokio::test]
24930    async fn dynamic_workspaces_reject_duplicates_busy_and_last_then_publish_ordered_events() {
24931        let catalog = active_registry().unwrap();
24932        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24933        let primary_id = WorkspaceId::new("primary").unwrap();
24934        let primary = WorkspaceConfig::new(
24935            primary_id.clone(),
24936            std::env::current_dir().unwrap(),
24937        )
24938        .unwrap();
24939        let shared = NodeShared::new(
24940            handle,
24941            "fixture-token".to_owned(),
24942            NodeId::new("node-1").unwrap(),
24943            vec![primary.clone()],
24944            vec![agent("claude")],
24945        );
24946        let secondary_id = WorkspaceId::new("secondary").unwrap();
24947        let secondary = shared
24948            .register_workspace(
24949                secondary_id.clone(),
24950                std::env::temp_dir().to_string_lossy().into_owned(),
24951            )
24952            .await
24953            .unwrap();
24954        assert_eq!(secondary.workspace_id, secondary_id);
24955        assert!(secondary.sessions.is_empty());
24956        assert_eq!(shared.snapshot().workspaces.len(), 2);
24957
24958        let duplicate_id = shared
24959            .register_workspace(
24960                secondary_id.clone(),
24961                primary.canonical_root().to_owned(),
24962            )
24963            .await
24964            .unwrap_err();
24965        assert_eq!(duplicate_id.code, NodeFailureCode::DuplicateWorkspaceId);
24966        let duplicate_root = shared
24967            .register_workspace(
24968                WorkspaceId::new("duplicate-root").unwrap(),
24969                windows_path_text(&secondary.canonical_root).to_owned(),
24970            )
24971            .await
24972            .unwrap_err();
24973        assert_eq!(duplicate_root.code, NodeFailureCode::DuplicateWorkspaceRoot);
24974
24975        let address = SessionAddress {
24976            workspace_id: secondary_id.clone(),
24977            session: SessionKey {
24978                instance_id: AgentInstanceId(41),
24979                generation: SessionGeneration(1),
24980            },
24981        };
24982        shared.bind_session(
24983            address.session.instance_id,
24984            secondary_id.clone(),
24985            address.session.generation,
24986        );
24987        let busy = shared.unregister_workspace(&secondary_id).unwrap_err();
24988        assert_eq!(busy.code, NodeFailureCode::WorkspaceBusy);
24989        shared.remove_binding(&address);
24990        shared.unregister_workspace(&secondary_id).unwrap();
24991        assert_eq!(shared.snapshot().workspaces.len(), 1);
24992
24993        let last = shared.unregister_workspace(&primary_id).unwrap_err();
24994        assert_eq!(last.code, NodeFailureCode::LastWorkspace);
24995        let history = shared
24996            .history
24997            .lock()
24998            .unwrap_or_else(|poisoned| poisoned.into_inner());
24999        assert!(matches!(
25000            &history.events[0],
25001            NodeEventEnvelope {
25002                sequence: 1,
25003                event: NodeEvent::WorkspaceAdded { workspace },
25004            } if workspace.workspace_id == secondary_id
25005        ));
25006        assert!(matches!(
25007            &history.events[1],
25008            NodeEventEnvelope {
25009                sequence: 2,
25010                event: NodeEvent::WorkspaceRemoved { workspace_id },
25011            } if workspace_id == &secondary_id
25012        ));
25013    }
25014
25015    #[tokio::test]
25016    async fn provider_session_reference_index_is_controller_gated_and_idempotent() {
25017        let secondary_root = temporary_workspace_root("provider-session-reference-index");
25018        std::fs::create_dir_all(&secondary_root).unwrap();
25019        let catalog = active_registry().unwrap();
25020        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25021        let primary_id = WorkspaceId::new("primary").unwrap();
25022        let secondary_id = WorkspaceId::new("secondary").unwrap();
25023        let shared = NodeShared::new(
25024            handle,
25025            "fixture-token".to_owned(),
25026            NodeId::new("node-session-reference-index").unwrap(),
25027            vec![
25028                WorkspaceConfig::new(primary_id.clone(), std::env::current_dir().unwrap())
25029                    .unwrap(),
25030                WorkspaceConfig::new(secondary_id.clone(), &secondary_root).unwrap(),
25031            ],
25032            vec![agent("claude"), agent("codex")],
25033        );
25034        let identity = ProviderSessionIdentity {
25035            key: gate4agent_types::ProviderSessionKey::SessionId,
25036            id: "provider-session-42".to_owned(),
25037            transcript_path: None,
25038        };
25039        let index_request = |workspace_id: WorkspaceId,
25040                             provider: AgentId,
25041                             identity: ProviderSessionIdentity,
25042                             display_name: &str| {
25043            NodeRequest::IndexProviderSession {
25044                workspace_id,
25045                provider,
25046                identity,
25047                display_name: display_name.to_owned(),
25048            }
25049        };
25050        let request = index_request(
25051            primary_id.clone(),
25052            agent("claude"),
25053            identity.clone(),
25054            "release shepherd",
25055        );
25056        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
25057            capability.as_str() == NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY
25058        }));
25059        let observer = process_request_inner(
25060            &shared,
25061            77,
25062            ClientRole::Observer,
25063            request.clone(),
25064        )
25065        .await
25066        .unwrap_err();
25067        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
25068        let uncontrolled = process_request_inner(
25069            &shared,
25070            77,
25071            ClientRole::Operator,
25072            request.clone(),
25073        )
25074        .await
25075        .unwrap_err();
25076        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
25077        shared
25078            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25079            .unwrap();
25080
25081        let indexed = process_request_inner(
25082            &shared,
25083            77,
25084            ClientRole::Operator,
25085            request.clone(),
25086        )
25087        .await
25088        .unwrap();
25089        let NodeResponse::ProviderSessionIndexed { record } = indexed else {
25090            panic!("provider session reference index returned another response");
25091        };
25092        assert_eq!(record.state, ManagedSessionState::Dormant);
25093        assert_eq!(record.mode, SessionMode::Pty);
25094        assert_eq!(record.workspace_id, primary_id);
25095        assert_eq!(record.provider_session.as_ref(), Some(&identity));
25096        assert!(record.active_session.is_none());
25097        assert!(record.environment_profile.is_none());
25098        assert!(record.bundle.is_none());
25099        assert!(record.context.is_none());
25100
25101        let repeated = process_request_inner(
25102            &shared,
25103            77,
25104            ClientRole::Operator,
25105            index_request(
25106                primary_id.clone(),
25107                agent("claude"),
25108                identity.clone(),
25109                "ignored replacement",
25110            ),
25111        )
25112        .await
25113        .unwrap();
25114        assert!(matches!(
25115            repeated,
25116            NodeResponse::ProviderSessionIndexed { record: ref existing }
25117                if existing.record_id == record.record_id
25118                    && existing.display_name == "release shepherd"
25119        ));
25120        let conflicting_workspace = process_request_inner(
25121            &shared,
25122            77,
25123            ClientRole::Operator,
25124            index_request(
25125                secondary_id,
25126                agent("claude"),
25127                identity.clone(),
25128                "release shepherd",
25129            ),
25130        )
25131        .await
25132        .unwrap_err();
25133        assert_eq!(conflicting_workspace.code, NodeFailureCode::SessionRecordConflict);
25134        let conflicting_provider = process_request_inner(
25135            &shared,
25136            77,
25137            ClientRole::Operator,
25138            index_request(
25139                primary_id.clone(),
25140                agent("codex"),
25141                identity.clone(),
25142                "release shepherd",
25143            ),
25144        )
25145        .await
25146        .unwrap_err();
25147        assert_eq!(conflicting_provider.code, NodeFailureCode::SessionRecordConflict);
25148        let transcript_rejected = process_request_inner(
25149            &shared,
25150            77,
25151            ClientRole::Operator,
25152            index_request(
25153                primary_id,
25154                agent("claude"),
25155                ProviderSessionIdentity {
25156                    transcript_path: Some("C:/provider/transcript.jsonl".to_owned()),
25157                    ..identity
25158                },
25159                "release shepherd",
25160            ),
25161        )
25162        .await
25163        .unwrap_err();
25164        assert_eq!(transcript_rejected.code, NodeFailureCode::InvalidRequest);
25165        let upserts = shared
25166            .history
25167            .lock()
25168            .unwrap_or_else(|poisoned| poisoned.into_inner())
25169            .events
25170            .iter()
25171            .filter(|event| matches!(
25172                &event.event,
25173                NodeEvent::SessionRecordUpserted { record: published }
25174                    if published.record_id == record.record_id
25175            ))
25176            .count();
25177        assert_eq!(upserts, 1);
25178        std::fs::remove_dir_all(secondary_root).unwrap();
25179    }
25180
25181    #[tokio::test]
25182    async fn standalone_workspace_is_independent_registered_and_controller_gated() {
25183        let fixture_root = temporary_workspace_root("standalone-independent");
25184        let target = fixture_root.join("independent");
25185        std::fs::create_dir_all(&fixture_root).unwrap();
25186        let catalog = active_registry().unwrap();
25187        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25188        let selected = WorkspaceConfig::new(
25189            WorkspaceId::new("primary").unwrap(),
25190            std::env::current_dir().unwrap(),
25191        )
25192        .unwrap();
25193        let selected_root = selected.canonical_root().to_owned();
25194        let shared = NodeShared::new(
25195            handle,
25196            "fixture-token".to_owned(),
25197            NodeId::new("node-standalone").unwrap(),
25198            vec![selected],
25199            vec![agent("claude")],
25200        );
25201        let workspace_id = WorkspaceId::new("independent").unwrap();
25202        let request = NodeRequest::CreateStandaloneWorkspace {
25203            workspace_id: workspace_id.clone(),
25204            root: opaque_windows_path(target.to_string_lossy().into_owned()),
25205            initial_branch: Some("main".to_owned()),
25206        };
25207        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
25208            capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
25209        }));
25210        assert!(!request_uses_unnegotiated_capability(
25211            &request,
25212            &baseline_capabilities().unwrap(),
25213        ));
25214        assert!(request_uses_unnegotiated_capability(&request, &[]));
25215
25216        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
25217            .await
25218            .unwrap_err();
25219        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
25220        assert!(!target.exists());
25221        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
25222            .await
25223            .unwrap_err();
25224        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
25225        assert!(!target.exists());
25226        shared
25227            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25228            .unwrap();
25229
25230        let response = process_request_inner(&shared, 77, ClientRole::Operator, request)
25231            .await
25232            .unwrap();
25233        let NodeResponse::StandaloneWorkspaceCreated { workspace } = response else {
25234            panic!("standalone workspace creation returned another response");
25235        };
25236        assert_eq!(workspace.workspace_id, workspace_id);
25237        assert!(target.join(".git").is_dir());
25238        let branch = run_git_bounded(
25239            target.to_str().unwrap(),
25240            &["branch", "--show-current"],
25241            GIT_OUTPUT_MAX_BYTES,
25242        )
25243        .await
25244        .unwrap();
25245        assert!(branch.success);
25246        assert_eq!(String::from_utf8(branch.stdout).unwrap().trim(), "main");
25247        let worktrees = list_git_worktrees(target.to_str().unwrap()).await.unwrap();
25248        assert_eq!(worktrees.len(), 1);
25249        let listed_root = WorkspaceConfig::new(
25250            WorkspaceId::new("listed-independent").unwrap(),
25251            &worktrees[0].path,
25252        )
25253        .unwrap();
25254        assert!(platform::roots_equal(
25255            listed_root.canonical_root(),
25256            windows_path_text(&workspace.canonical_root),
25257        ));
25258        assert!(!platform::roots_equal(listed_root.canonical_root(), &selected_root));
25259        assert!(shared.snapshot().workspaces.iter().any(|snapshot| {
25260            snapshot.workspace_id == workspace_id
25261                && snapshot.canonical_root == workspace.canonical_root
25262        }));
25263        assert!(shared
25264            .history
25265            .lock()
25266            .unwrap_or_else(|poisoned| poisoned.into_inner())
25267            .events
25268            .iter()
25269            .any(|event| matches!(
25270                &event.event,
25271                NodeEvent::WorkspaceAdded { workspace }
25272                    if workspace.workspace_id == workspace_id
25273            )));
25274        std::fs::remove_dir_all(fixture_root).unwrap();
25275    }
25276
25277    #[tokio::test]
25278    async fn standalone_workspace_registration_failure_preserves_guarded_repository_for_recovery() {
25279        let fixture_root = temporary_workspace_root("standalone-compensation");
25280        let created_target = fixture_root.join("created-target");
25281        let existing_target = fixture_root.join("existing-target");
25282        let invalid_state_parent = fixture_root.join("state-parent-is-a-file");
25283        let invalid_state_path = invalid_state_parent.join("state.json");
25284        std::fs::create_dir_all(&existing_target).unwrap();
25285        std::fs::write(&invalid_state_parent, "not a directory").unwrap();
25286        let catalog = active_registry().unwrap();
25287        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25288        let primary = WorkspaceConfig::new(
25289            WorkspaceId::new("primary").unwrap(),
25290            std::env::current_dir().unwrap(),
25291        )
25292        .unwrap();
25293        let mut shared = NodeShared::new(
25294            handle,
25295            "fixture-token".to_owned(),
25296            NodeId::new("node-standalone-compensation").unwrap(),
25297            vec![primary],
25298            vec![agent("claude")],
25299        );
25300        shared.state_path = Some(invalid_state_path);
25301
25302        let created_recovery = shared
25303            .create_standalone_workspace(
25304                WorkspaceId::new("compensated").unwrap(),
25305                created_target.to_string_lossy().into_owned(),
25306                Some("main".to_owned()),
25307            )
25308            .await
25309            .unwrap_err();
25310        assert_eq!(
25311            created_recovery.code,
25312            NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
25313        );
25314        assert!(created_target.join(".git").is_dir());
25315        assert!(shared.snapshot().workspaces.iter().all(|workspace| {
25316            workspace.workspace_id.as_str() != "compensated"
25317        }));
25318
25319        let existing_recovery = shared
25320            .create_standalone_workspace(
25321                WorkspaceId::new("restored").unwrap(),
25322                existing_target.to_string_lossy().into_owned(),
25323                None,
25324            )
25325            .await
25326            .unwrap_err();
25327        assert_eq!(
25328            existing_recovery.code,
25329            NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
25330        );
25331        assert!(existing_target.is_dir());
25332        assert!(existing_target.join(".git").is_dir());
25333        assert!(shared.snapshot().workspaces.iter().all(|workspace| {
25334            workspace.workspace_id.as_str() != "restored"
25335        }));
25336
25337        let registered_empty = fixture_root.join("registered-empty");
25338        std::fs::create_dir(&registered_empty).unwrap();
25339        let catalog = active_registry().unwrap();
25340        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25341        let registered = WorkspaceConfig::new(
25342            WorkspaceId::new("registered-empty").unwrap(),
25343            &registered_empty,
25344        )
25345        .unwrap();
25346        let duplicate_shared = NodeShared::new(
25347            handle,
25348            "fixture-token".to_owned(),
25349            NodeId::new("node-standalone-duplicate-root").unwrap(),
25350            vec![registered],
25351            vec![agent("claude")],
25352        );
25353        let duplicate = duplicate_shared
25354            .create_standalone_workspace(
25355                WorkspaceId::new("duplicate-root").unwrap(),
25356                registered_empty.to_string_lossy().into_owned(),
25357                None,
25358            )
25359            .await
25360            .unwrap_err();
25361        assert_eq!(duplicate.code, NodeFailureCode::DuplicateWorkspaceRoot);
25362        assert!(std::fs::read_dir(&registered_empty).unwrap().next().is_none());
25363        std::fs::remove_dir_all(fixture_root).unwrap();
25364    }
25365
25366    #[test]
25367    fn managed_record_id_collision_preserves_the_existing_record() {
25368        let catalog = active_registry().unwrap();
25369        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25370        let workspace = WorkspaceConfig::new(
25371            WorkspaceId::new("primary").unwrap(),
25372            std::env::current_dir().unwrap(),
25373        )
25374        .unwrap();
25375        let shared = NodeShared::new(
25376            handle,
25377            "fixture-token".to_owned(),
25378            NodeId::new("node-1").unwrap(),
25379            vec![workspace.clone()],
25380            vec![agent("claude")],
25381        );
25382        let record_id = SessionRecordId::new("sr-collision").unwrap();
25383        let original = ManagedSessionRecord {
25384            record_id: record_id.clone(),
25385            display_name: "original".to_owned(),
25386            provider: agent("claude"),
25387            mode: SessionMode::Pty,
25388            state: ManagedSessionState::Dormant,
25389            workspace_id: workspace.workspace_id,
25390            canonical_root: opaque_windows_path(workspace.canonical_root),
25391            provider_session: None,
25392            active_session: None,
25393            environment_profile: None,
25394            bundle: None,
25395            context_id: None,
25396            context: None,
25397            exported_context: None,
25398            task_binding: None,
25399            created_at_unix_ms: 1,
25400            updated_at_unix_ms: 1,
25401            last_error: None,
25402        };
25403        shared.insert_record(original.clone()).unwrap();
25404        let mut collision = original.clone();
25405        collision.display_name = "replacement".to_owned();
25406        let error = shared.insert_record(collision).unwrap_err();
25407        assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
25408        assert_eq!(shared.record(&record_id).unwrap(), original);
25409    }
25410
25411    #[test]
25412    fn managed_record_runtime_errors_are_exposed_only_as_allowlisted_categories() {
25413        let catalog = active_registry().unwrap();
25414        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25415        let workspace = WorkspaceConfig::new(
25416            WorkspaceId::new("primary").unwrap(),
25417            std::env::current_dir().unwrap(),
25418        )
25419        .unwrap();
25420        let shared = NodeShared::new(
25421            handle,
25422            "fixture-token".to_owned(),
25423            NodeId::new("node-1").unwrap(),
25424            vec![workspace.clone()],
25425            vec![agent("claude")],
25426        );
25427        let record_id = SessionRecordId::new("sr-runtime-error").unwrap();
25428        shared
25429            .insert_record(ManagedSessionRecord {
25430                record_id: record_id.clone(),
25431                display_name: "runtime error".to_owned(),
25432                provider: agent("claude"),
25433                mode: SessionMode::Pty,
25434                state: ManagedSessionState::IdentityPending,
25435                workspace_id: workspace.workspace_id,
25436                canonical_root: opaque_windows_path(workspace.canonical_root),
25437                provider_session: None,
25438                active_session: None,
25439                environment_profile: None,
25440                bundle: None,
25441                context_id: None,
25442                context: None,
25443                exported_context: None,
25444                task_binding: None,
25445                created_at_unix_ms: 1,
25446                updated_at_unix_ms: 1,
25447                last_error: None,
25448            })
25449            .unwrap();
25450
25451        shared
25452            .mark_record_error(
25453                &record_id,
25454                r"provider failed with bearer-secret-123 at C:\private\session.jsonl",
25455            )
25456            .unwrap();
25457        let record = shared.record(&record_id).unwrap();
25458        assert_eq!(record.state, ManagedSessionState::Unavailable);
25459        assert_eq!(
25460            record.last_error.as_deref(),
25461            Some("provider-runtime-failed"),
25462        );
25463        let snapshot = shared.snapshot();
25464        assert_eq!(snapshot.session_records, vec![record]);
25465    }
25466
25467    /// Regression coverage for the "operator sees `deadline` instead of the
25468    /// kernel's own named rejection" defect: a spawn-dispatch waiter must be
25469    /// able to learn a command's asynchronous kernel rejection (here,
25470    /// specifically classified as `UnsupportedTransport`) exactly once, and
25471    /// must not still find it after that -- proving the fast path this
25472    /// mechanism exists for (`spawn_session_with_deadline`'s commit-wait
25473    /// loop) drains the reason instead of leaving it to be rediscovered (or
25474    /// missed) on a later poll.
25475    #[test]
25476    fn rejected_command_reason_is_classified_and_consumed_exactly_once() {
25477        let catalog = active_registry().unwrap();
25478        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25479        let workspace = WorkspaceConfig::new(
25480            WorkspaceId::new("primary").unwrap(),
25481            std::env::current_dir().unwrap(),
25482        )
25483        .unwrap();
25484        let shared = NodeShared::new(
25485            handle,
25486            "fixture-token".to_owned(),
25487            NodeId::new("node-1").unwrap(),
25488            vec![workspace],
25489            vec![agent("claude")],
25490        );
25491
25492        assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
25493
25494        shared.record_rejected_command(
25495            CommandId(1),
25496            "agent 'claude' does not support transport Acp".to_owned(),
25497            true,
25498        );
25499        // An unrelated command_id must not see another command's reason.
25500        assert!(shared.take_rejected_command_reason(CommandId(2)).is_none());
25501
25502        let reason = shared.take_rejected_command_reason(CommandId(1)).unwrap();
25503        assert_eq!(reason.message, "agent 'claude' does not support transport Acp");
25504        assert!(reason.unsupported_transport);
25505
25506        // Consumed: a second read for the same command_id finds nothing.
25507        assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
25508
25509        // A rejection unrelated to transport support keeps its own
25510        // classification distinct, so the reader can choose the generic
25511        // `BackendOperationFailed` code instead of misreporting
25512        // `UnsupportedTransport`.
25513        shared.record_rejected_command(
25514            CommandId(3),
25515            "backend busy".to_owned(),
25516            false,
25517        );
25518        let unclassified = shared.take_rejected_command_reason(CommandId(3)).unwrap();
25519        assert!(!unclassified.unsupported_transport);
25520    }
25521
25522    #[test]
25523    fn provider_identity_update_is_ignored_without_exact_session_policy() {
25524        let catalog = active_registry().unwrap();
25525        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25526        let workspace = WorkspaceConfig::new(
25527            WorkspaceId::new("primary").unwrap(),
25528            std::env::current_dir().unwrap(),
25529        )
25530        .unwrap();
25531        let shared = NodeShared::new(
25532            handle,
25533            "fixture-token".to_owned(),
25534            NodeId::new("node-identity-policy").unwrap(),
25535            vec![workspace.clone()],
25536            vec![agent("claude")],
25537        );
25538        let address = SessionAddress {
25539            workspace_id: workspace.workspace_id.clone(),
25540            session: SessionKey {
25541                instance_id: AgentInstanceId(90),
25542                generation: SessionGeneration(1),
25543            },
25544        };
25545        let record_id = SessionRecordId::new("sr-policy-denied").unwrap();
25546        shared
25547            .insert_record(ManagedSessionRecord {
25548                record_id: record_id.clone(),
25549                display_name: "policy denied".to_owned(),
25550                provider: agent("claude"),
25551                mode: SessionMode::Pty,
25552                state: ManagedSessionState::IdentityPending,
25553                workspace_id: workspace.workspace_id,
25554                canonical_root: opaque_windows_path(workspace.canonical_root),
25555                provider_session: None,
25556                active_session: Some(address.clone()),
25557                environment_profile: None,
25558                bundle: None,
25559                context_id: None,
25560                context: None,
25561                exported_context: None,
25562                task_binding: None,
25563                created_at_unix_ms: 1,
25564                updated_at_unix_ms: 1,
25565                last_error: None,
25566            })
25567            .unwrap();
25568        let event = ControlEvent {
25569            sequence: 1,
25570            command_id: None,
25571            instance_id: address.session.instance_id,
25572            generation: address.session.generation,
25573            event: ControlEventKind::ProviderEvent {
25574                sequence: 1,
25575                source: gate4agent_types::ProviderSource {
25576                    family: AdapterFamily::PtySemantic,
25577                    binding: AdapterBinding::new(
25578                        gate4agent_types::AdapterId::new("claude").unwrap(),
25579                        "fixture/v1",
25580                        gate4agent_types::AdapterVerification::SyntheticFixture,
25581                    )
25582                    .unwrap(),
25583                },
25584                source_sequence: 1,
25585                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
25586                    identity: gate4agent_types::ProviderSessionIdentity {
25587                        key: gate4agent_types::ProviderSessionKey::SessionId,
25588                        id: "provider-session".to_owned(),
25589                        transcript_path: None,
25590                    },
25591                },
25592            },
25593        };
25594
25595        shared.reconcile_managed_record(&record_id, &address, &event, false, false);
25596
25597        let record = shared.record(&record_id).unwrap();
25598        assert!(record.provider_session.is_none());
25599        assert_eq!(record.state, ManagedSessionState::IdentityPending);
25600        assert_eq!(record.active_session.as_ref(), Some(&address));
25601    }
25602
25603    #[test]
25604    fn provider_identity_cannot_rebind_a_record_across_workspaces() {
25605        let secondary_root = temporary_workspace_root("identity-scope");
25606        std::fs::create_dir_all(&secondary_root).unwrap();
25607        let catalog = active_registry().unwrap();
25608        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25609        let primary = WorkspaceConfig::new(
25610            WorkspaceId::new("primary").unwrap(),
25611            std::env::current_dir().unwrap(),
25612        )
25613        .unwrap();
25614        let secondary = WorkspaceConfig::new(
25615            WorkspaceId::new("secondary").unwrap(),
25616            &secondary_root,
25617        )
25618        .unwrap();
25619        let shared = NodeShared::new(
25620            handle,
25621            "fixture-token".to_owned(),
25622            NodeId::new("node-1").unwrap(),
25623            vec![primary.clone(), secondary.clone()],
25624            vec![agent("claude")],
25625        );
25626        let identity = gate4agent_types::ProviderSessionIdentity {
25627            key: gate4agent_types::ProviderSessionKey::SessionId,
25628            id: "provider-session".to_owned(),
25629            transcript_path: None,
25630        };
25631        let dormant_id = SessionRecordId::new("sr-dormant").unwrap();
25632        shared
25633            .insert_record(ManagedSessionRecord {
25634                record_id: dormant_id.clone(),
25635                display_name: "dormant".to_owned(),
25636                provider: agent("claude"),
25637                mode: SessionMode::Pty,
25638                state: ManagedSessionState::Dormant,
25639                workspace_id: primary.workspace_id,
25640                canonical_root: opaque_windows_path(primary.canonical_root),
25641                provider_session: Some(identity.clone()),
25642                active_session: None,
25643                environment_profile: None,
25644                bundle: None,
25645                context_id: None,
25646                context: None,
25647                exported_context: None,
25648                task_binding: None,
25649                created_at_unix_ms: 1,
25650                updated_at_unix_ms: 1,
25651                last_error: None,
25652            })
25653            .unwrap();
25654        let address = SessionAddress {
25655            workspace_id: secondary.workspace_id.clone(),
25656            session: SessionKey {
25657                instance_id: AgentInstanceId(91),
25658                generation: SessionGeneration(1),
25659            },
25660        };
25661        let current_id = SessionRecordId::new("sr-current").unwrap();
25662        shared
25663            .insert_record(ManagedSessionRecord {
25664                record_id: current_id.clone(),
25665                display_name: "current".to_owned(),
25666                provider: agent("claude"),
25667                mode: SessionMode::Pty,
25668                state: ManagedSessionState::IdentityPending,
25669                workspace_id: secondary.workspace_id,
25670                canonical_root: opaque_windows_path(secondary.canonical_root),
25671                provider_session: None,
25672                active_session: Some(address.clone()),
25673                environment_profile: None,
25674                bundle: None,
25675                context_id: None,
25676                context: None,
25677                exported_context: None,
25678                task_binding: None,
25679                created_at_unix_ms: 2,
25680                updated_at_unix_ms: 2,
25681                last_error: None,
25682            })
25683            .unwrap();
25684        shared.bind_managed_session(
25685            &address,
25686            current_id.clone(),
25687            ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
25688            None,
25689        );
25690        let event = ControlEvent {
25691            sequence: 1,
25692            command_id: None,
25693            instance_id: address.session.instance_id,
25694            generation: address.session.generation,
25695            event: ControlEventKind::ProviderEvent {
25696                sequence: 1,
25697                source: gate4agent_types::ProviderSource {
25698                    family: gate4agent_types::AdapterFamily::PtySemantic,
25699                    binding: gate4agent_types::AdapterBinding::new(
25700                        gate4agent_types::AdapterId::new("claude").unwrap(),
25701                        "fixture/v1",
25702                        gate4agent_types::AdapterVerification::SyntheticFixture,
25703                    )
25704                    .unwrap(),
25705                },
25706                source_sequence: 1,
25707                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
25708                    identity,
25709                },
25710            },
25711        };
25712        shared.reconcile_managed_record(&current_id, &address, &event, true, false);
25713
25714        let binding = shared
25715            .session_bindings
25716            .lock()
25717            .unwrap_or_else(|poisoned| poisoned.into_inner())
25718            .get(&address.session.instance_id)
25719            .cloned()
25720            .unwrap();
25721        assert_eq!(binding.record_id.as_ref(), Some(&current_id));
25722        assert_eq!(shared.record(&dormant_id).unwrap().state, ManagedSessionState::Dormant);
25723        let current = shared.record(&current_id).unwrap();
25724        assert_eq!(current.state, ManagedSessionState::Unavailable);
25725        assert_eq!(current.active_session.as_ref(), Some(&address));
25726        assert_eq!(
25727            current.last_error.as_deref(),
25728            Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR),
25729        );
25730        std::fs::remove_dir_all(secondary_root).unwrap();
25731    }
25732
25733    #[tokio::test]
25734    async fn semantic_identity_with_transcript_path_reuses_record_and_survives_reload() {
25735        let root = temporary_workspace_root("semantic-provider-session");
25736        std::fs::create_dir_all(&root).unwrap();
25737        let state_path = root.join("state-v1.json");
25738        let catalog = active_registry().unwrap();
25739        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25740        let workspace = WorkspaceConfig::new(
25741            WorkspaceId::new("primary").unwrap(),
25742            &root,
25743        )
25744        .unwrap();
25745        let node_id = NodeId::new("node-semantic-identity").unwrap();
25746        let shared = NodeShared::new_with_incarnation(
25747            handle,
25748            "fixture-token".to_owned(),
25749            node_id.clone(),
25750            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
25751            vec![workspace.clone()],
25752            vec![agent("claude")],
25753            ProviderRuntimeStatuses::default(),
25754            None,
25755            Vec::new(),
25756            Vec::new(),
25757            SpawnProfileRegistry::default(),
25758            None,
25759            None,
25760            None,
25761            Some(state_path.clone()),
25762            Vec::new(),
25763            Vec::new(),
25764            Vec::new(),
25765            Vec::new(),
25766            None,
25767            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
25768        );
25769        let persisted_identity = gate4agent_types::ProviderSessionIdentity {
25770            key: gate4agent_types::ProviderSessionKey::SessionId,
25771            id: "provider-session".to_owned(),
25772            transcript_path: None,
25773        };
25774        let durable_id = SessionRecordId::new("sr-durable").unwrap();
25775        shared
25776            .insert_record(ManagedSessionRecord {
25777                record_id: durable_id.clone(),
25778                display_name: "durable".to_owned(),
25779                provider: agent("claude"),
25780                mode: SessionMode::Pty,
25781                state: ManagedSessionState::Dormant,
25782                workspace_id: workspace.workspace_id.clone(),
25783                canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
25784                provider_session: Some(persisted_identity.clone()),
25785                active_session: None,
25786                environment_profile: None,
25787                bundle: None,
25788                context_id: None,
25789                context: None,
25790                exported_context: None,
25791                task_binding: None,
25792                created_at_unix_ms: 1,
25793                updated_at_unix_ms: 1,
25794                last_error: None,
25795            })
25796            .unwrap();
25797        let address = SessionAddress {
25798            workspace_id: workspace.workspace_id.clone(),
25799            session: SessionKey {
25800                instance_id: AgentInstanceId(92),
25801                generation: SessionGeneration(2),
25802            },
25803        };
25804        let pending_id = SessionRecordId::new("sr-pending").unwrap();
25805        shared
25806            .insert_record(ManagedSessionRecord {
25807                record_id: pending_id.clone(),
25808                display_name: "pending".to_owned(),
25809                provider: agent("claude"),
25810                mode: SessionMode::Pty,
25811                state: ManagedSessionState::IdentityPending,
25812                workspace_id: workspace.workspace_id.clone(),
25813                canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
25814                provider_session: None,
25815                active_session: Some(address.clone()),
25816                environment_profile: None,
25817                bundle: None,
25818                context_id: None,
25819                context: None,
25820                exported_context: None,
25821                task_binding: None,
25822                created_at_unix_ms: 2,
25823                updated_at_unix_ms: 2,
25824                last_error: None,
25825            })
25826            .unwrap();
25827        shared.bind_managed_session(
25828            &address,
25829            pending_id.clone(),
25830            ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
25831            None,
25832        );
25833        let observed_identity = gate4agent_types::ProviderSessionIdentity {
25834            transcript_path: Some(r"C:\private\provider-transcript.jsonl".to_owned()),
25835            ..persisted_identity
25836        };
25837        let event = ControlEvent {
25838            sequence: 1,
25839            command_id: None,
25840            instance_id: address.session.instance_id,
25841            generation: address.session.generation,
25842            event: ControlEventKind::ProviderEvent {
25843                sequence: 1,
25844                source: gate4agent_types::ProviderSource {
25845                    family: gate4agent_types::AdapterFamily::PtySemantic,
25846                    binding: gate4agent_types::AdapterBinding::new(
25847                        gate4agent_types::AdapterId::new("claude").unwrap(),
25848                        "fixture/v1",
25849                        gate4agent_types::AdapterVerification::SyntheticFixture,
25850                    )
25851                    .unwrap(),
25852                },
25853                source_sequence: 1,
25854                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
25855                    identity: observed_identity.clone(),
25856                },
25857            },
25858        };
25859        shared.reconcile_managed_record(&pending_id, &address, &event, true, false);
25860
25861        let snapshot = shared.snapshot();
25862        assert_eq!(snapshot.session_records.len(), 1);
25863        let live = &snapshot.session_records[0];
25864        assert_eq!(live.record_id, durable_id);
25865        assert_eq!(live.state, ManagedSessionState::Live);
25866        assert_eq!(live.active_session.as_ref(), Some(&address));
25867        assert_eq!(live.provider_session.as_ref(), Some(&observed_identity));
25868        let binding = shared
25869            .session_bindings
25870            .lock()
25871            .unwrap_or_else(|poisoned| poisoned.into_inner())
25872            .get(&address.session.instance_id)
25873            .cloned()
25874            .unwrap();
25875        assert_eq!(binding.record_id.as_ref(), Some(&durable_id));
25876
25877        let mut duplicate = live.clone();
25878        duplicate.record_id = SessionRecordId::new("sr-duplicate").unwrap();
25879        duplicate.display_name = "duplicate".to_owned();
25880        duplicate.state = ManagedSessionState::Dormant;
25881        duplicate.active_session = None;
25882        duplicate.provider_session.as_mut().unwrap().transcript_path = None;
25883        let duplicate_error = shared.insert_record(duplicate).unwrap_err();
25884        assert_eq!(duplicate_error.code, NodeFailureCode::SessionRecordConflict);
25885        assert_eq!(shared.snapshot().session_records.len(), 1);
25886
25887        let terminal_size = gate4agent_types::TerminalSize {
25888            rows: 24,
25889            columns: 80,
25890        };
25891        let (first_busy, second_busy) = tokio::join!(
25892            shared.resume_session_record(&durable_id, terminal_size, None),
25893            shared.resume_session_record(&durable_id, terminal_size, None),
25894        );
25895        for busy in [first_busy.unwrap_err(), second_busy.unwrap_err()] {
25896            assert_eq!(busy.code, NodeFailureCode::SessionRecordBusy);
25897            assert_eq!(busy.message, "session-record-busy");
25898        }
25899
25900        let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
25901        assert_eq!(loaded.records.len(), 1);
25902        assert_eq!(loaded.records[0].record_id, durable_id);
25903        assert_eq!(loaded.records[0].state, ManagedSessionState::Dormant);
25904        assert_eq!(
25905            loaded.records[0]
25906                .provider_session
25907                .as_ref()
25908                .unwrap()
25909                .transcript_path,
25910            None,
25911        );
25912        let catalog = active_registry().unwrap();
25913        let (reloaded_handle, _reloaded_runtime) =
25914            NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25915        let reloaded = NodeShared::new_with_incarnation(
25916            reloaded_handle,
25917            "fixture-token".to_owned(),
25918            node_id,
25919            NodeIncarnationId::from_bytes([1; crate::protocol::NODE_INCARNATION_ID_BYTES]),
25920            vec![workspace],
25921            vec![agent("claude")],
25922            ProviderRuntimeStatuses::default(),
25923            None,
25924            Vec::new(),
25925            Vec::new(),
25926            SpawnProfileRegistry::default(),
25927            None,
25928            None,
25929            None,
25930            None,
25931            loaded.records,
25932            loaded.managed_worktrees,
25933            loaded.managed_worktree_tombstones,
25934            loaded.materializations,
25935            loaded.warning,
25936            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
25937        );
25938        assert_eq!(reloaded.snapshot().session_records.len(), 1);
25939        assert_eq!(reloaded.snapshot().session_records[0].record_id, durable_id);
25940        std::fs::remove_dir_all(root).unwrap();
25941    }
25942
25943    #[tokio::test]
25944    async fn workspace_reregistration_requires_the_recorded_root_and_restores_records() {
25945        let secondary_root = temporary_workspace_root("workspace-restore");
25946        let other_root = temporary_workspace_root("workspace-conflict");
25947        std::fs::create_dir_all(&secondary_root).unwrap();
25948        std::fs::create_dir_all(&other_root).unwrap();
25949        let catalog = active_registry().unwrap();
25950        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25951        let primary = WorkspaceConfig::new(
25952            WorkspaceId::new("primary").unwrap(),
25953            std::env::current_dir().unwrap(),
25954        )
25955        .unwrap();
25956        let secondary = WorkspaceConfig::new(
25957            WorkspaceId::new("secondary").unwrap(),
25958            &secondary_root,
25959        )
25960        .unwrap();
25961        let shared = NodeShared::new(
25962            handle,
25963            "fixture-token".to_owned(),
25964            NodeId::new("node-1").unwrap(),
25965            vec![primary, secondary.clone()],
25966            vec![agent("claude")],
25967        );
25968        let record_id = SessionRecordId::new("sr-workspace").unwrap();
25969        shared
25970            .insert_record(ManagedSessionRecord {
25971                record_id: record_id.clone(),
25972                display_name: "workspace record".to_owned(),
25973                provider: agent("claude"),
25974                mode: SessionMode::Pty,
25975                state: ManagedSessionState::Dormant,
25976                workspace_id: secondary.workspace_id.clone(),
25977                canonical_root: opaque_windows_path(secondary.canonical_root.clone()),
25978                provider_session: Some(gate4agent_types::ProviderSessionIdentity {
25979                    key: gate4agent_types::ProviderSessionKey::SessionId,
25980                    id: "provider-session".to_owned(),
25981                    transcript_path: None,
25982                }),
25983                active_session: None,
25984                environment_profile: None,
25985                bundle: None,
25986                context_id: None,
25987                context: None,
25988                exported_context: None,
25989                task_binding: None,
25990                created_at_unix_ms: 1,
25991                updated_at_unix_ms: 1,
25992                last_error: None,
25993            })
25994            .unwrap();
25995        shared.unregister_workspace(&secondary.workspace_id).unwrap();
25996        let unavailable = shared.record(&record_id).unwrap();
25997        assert_eq!(unavailable.state, ManagedSessionState::Unavailable);
25998        assert_eq!(windows_path_text(&unavailable.canonical_root), secondary.canonical_root);
25999
26000        let conflict = shared
26001            .register_workspace(
26002                secondary.workspace_id.clone(),
26003                other_root.to_string_lossy().into_owned(),
26004            )
26005            .await
26006            .unwrap_err();
26007        assert_eq!(conflict.code, NodeFailureCode::SessionRecordConflict);
26008        shared
26009            .register_workspace(
26010                secondary.workspace_id,
26011                secondary_root.to_string_lossy().into_owned(),
26012            )
26013            .await
26014            .unwrap();
26015        let restored = shared.record(&record_id).unwrap();
26016        assert_eq!(restored.state, ManagedSessionState::Dormant);
26017        assert!(restored.last_error.is_none());
26018        std::fs::remove_dir_all(secondary_root).unwrap();
26019        std::fs::remove_dir_all(other_root).unwrap();
26020    }
26021
26022    #[test]
26023    fn dynamic_workspace_root_is_rejected_before_filesystem_canonicalization() {
26024        let error = validate_workspace_request_root(
26025            &"x".repeat(MAX_WORKSPACE_ROOT_BYTES + 1),
26026        )
26027        .unwrap_err();
26028        assert_eq!(error.code, NodeFailureCode::InvalidWorkspaceRoot);
26029        let control = validate_workspace_request_root("C:\\repo\nchild").unwrap_err();
26030        assert_eq!(control.code, NodeFailureCode::InvalidWorkspaceRoot);
26031    }
26032
26033    #[test]
26034    fn concurrent_publish_and_resync_never_acknowledge_an_unappended_event() {
26035        let catalog = active_registry().unwrap();
26036        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26037        let workspace = WorkspaceConfig::new(
26038            WorkspaceId::new("primary").unwrap(),
26039            std::env::current_dir().unwrap(),
26040        )
26041        .unwrap();
26042        let shared = NodeShared::new(
26043            handle,
26044            "fixture-token".to_owned(),
26045            NodeId::new("node-1").unwrap(),
26046            vec![workspace],
26047            vec![agent("claude")],
26048        );
26049        let publishers = 4_u64;
26050        let events_per_publisher = 256_u64;
26051        let start = std::sync::Barrier::new(publishers as usize + 1);
26052        let finished = AtomicU64::new(0);
26053
26054        std::thread::scope(|scope| {
26055            for _ in 0..publishers {
26056                scope.spawn(|| {
26057                    start.wait();
26058                    for _ in 0..events_per_publisher {
26059                        shared.publish(NodeEvent::ControllerChanged { controller: None });
26060                    }
26061                    finished.fetch_add(1, Ordering::Release);
26062                });
26063            }
26064            start.wait();
26065            while finished.load(Ordering::Acquire) != publishers {
26066                assert_resync_history_is_linearized(shared.resync(0));
26067                std::thread::yield_now();
26068            }
26069        });
26070        let response = shared.resync(0);
26071        assert_resync_history_is_linearized(response.clone());
26072        let NodeResponse::Resync { event_sequence, events, .. } = response else {
26073            unreachable!("resync helper returned another response");
26074        };
26075        assert_eq!(event_sequence, publishers * events_per_publisher);
26076        assert_eq!(events.len() as u64, event_sequence);
26077    }
26078
26079    fn assert_resync_history_is_linearized(response: NodeResponse) {
26080        let NodeResponse::Resync { event_sequence, events, .. } = response else {
26081            unreachable!("resync helper returned another response");
26082        };
26083        assert!(events.windows(2).all(|pair| {
26084            pair[0].sequence.checked_add(1) == Some(pair[1].sequence)
26085        }));
26086        assert_eq!(events.last().map_or(0, |event| event.sequence), event_sequence);
26087    }
26088
26089    #[test]
26090    fn canonical_workspace_roots_are_safe_for_cmd_exe() {
26091        assert_eq!(
26092            platform::normalize_canonical_root(r"\\?\C:\repo\worktree".to_owned()),
26093            r"C:\repo\worktree",
26094        );
26095        assert_eq!(
26096            platform::normalize_canonical_root(r"\\?\UNC\server\share\repo".to_owned()),
26097            r"\\server\share\repo",
26098        );
26099        assert!(validate_workspace_root(
26100            &WorkspaceId::new("network").unwrap(),
26101            r"\\server\share\repo",
26102        )
26103        .is_err());
26104        let workspace = WorkspaceConfig::new(
26105            WorkspaceId::new("primary").unwrap(),
26106            std::env::current_dir().unwrap(),
26107        )
26108        .unwrap();
26109        assert!(!workspace.canonical_root.starts_with(r"\\?\"));
26110    }
26111
26112    #[test]
26113    fn resume_rebinds_only_on_the_expected_authorized_generation() {
26114        let catalog = active_registry().unwrap();
26115        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26116        let workspace_id = WorkspaceId::new("primary").unwrap();
26117        let workspace = WorkspaceConfig::new(
26118            workspace_id.clone(),
26119            std::env::current_dir().unwrap(),
26120        )
26121        .unwrap();
26122        let shared = NodeShared::new(
26123            handle,
26124            "fixture-token".to_owned(),
26125            NodeId::new("node-1").unwrap(),
26126            vec![workspace],
26127            vec![agent("claude")],
26128        );
26129        let instance_id = AgentInstanceId(17);
26130        let original = SessionAddress {
26131            workspace_id,
26132            session: SessionKey {
26133                instance_id,
26134                generation: SessionGeneration(3),
26135            },
26136        };
26137        shared.bind_session(
26138            instance_id,
26139            original.workspace_id.clone(),
26140            original.session.generation,
26141        );
26142        shared
26143            .arm_resume(
26144                &original,
26145                CommandId(41),
26146                ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26147            )
26148            .unwrap();
26149
26150        shared.publish_control(ControlEvent {
26151            sequence: 1,
26152            command_id: None,
26153            instance_id,
26154            generation: SessionGeneration(4),
26155            event: ControlEventKind::Running { process_id: None },
26156        });
26157        assert_eq!(
26158            shared.address_for(instance_id, SessionGeneration(3)),
26159            Some(original.clone()),
26160        );
26161        assert!(shared.address_for(instance_id, SessionGeneration(4)).is_none());
26162
26163        shared.publish_control(ControlEvent {
26164            sequence: 2,
26165            command_id: Some(CommandId(42)),
26166            instance_id,
26167            generation: SessionGeneration(3),
26168            event: ControlEventKind::CommandRejected {
26169                message: "unrelated command".to_owned(),
26170            },
26171        });
26172
26173        shared.publish_control(ControlEvent {
26174            sequence: 3,
26175            command_id: None,
26176            instance_id,
26177            generation: SessionGeneration(4),
26178            event: ControlEventKind::ResumeAuthorized {
26179                session: gate4agent_types::ResumeSessionSummary {
26180                    key: gate4agent_types::ProviderSessionKey::SessionId,
26181                    id: "fixture-session".to_owned(),
26182                },
26183            },
26184        });
26185        assert!(shared.address_for(instance_id, SessionGeneration(3)).is_none());
26186        let rebound = shared.address_for(instance_id, SessionGeneration(4)).unwrap();
26187        assert_eq!(rebound.workspace_id, original.workspace_id);
26188        let history = shared
26189            .history
26190            .lock()
26191            .unwrap_or_else(|poisoned| poisoned.into_inner());
26192        assert!(history.events.iter().any(|envelope| matches!(
26193            &envelope.event,
26194            NodeEvent::Control { address, event }
26195                if address == &rebound
26196                    && matches!(&event.event, ControlEventKind::ResumeAuthorized { .. })
26197        )));
26198    }
26199
26200    #[test]
26201    fn all_node_pty_prompts_use_bracketed_paste_framing() {
26202        for agent in ["claude", "codex", "kimi"] {
26203            assert_eq!(
26204                prompt_framing(&AgentId::new(agent).unwrap()),
26205                PromptFraming::BracketedPaste
26206            );
26207        }
26208    }
26209
26210    fn generous_walk_deadline() -> Instant {
26211        Instant::now() + Duration::from_secs(30)
26212    }
26213
26214    #[test]
26215    fn workspace_tree_is_relative_and_skips_heavy_directories() {
26216        let root = temporary_workspace_root("tree");
26217        std::fs::create_dir_all(root.join("src")).unwrap();
26218        std::fs::create_dir_all(root.join(".git/objects")).unwrap();
26219        std::fs::create_dir_all(root.join("target/debug")).unwrap();
26220        std::fs::create_dir_all(root.join("node_modules/package")).unwrap();
26221        std::fs::write(root.join("src/lib.rs"), b"pub fn fixture() {}\n").unwrap();
26222        std::fs::write(root.join("README.md"), b"fixture\n").unwrap();
26223        // A renamed/agent-scoped build directory (`target-a2`-style, per the
26224        // real repository's own build layout) that carries the Cache
26225        // Directory Tagging Specification marker cargo writes into every
26226        // `--target-dir` it creates: must be skipped even though its name
26227        // does not exactly match `target`.
26228        std::fs::create_dir_all(root.join("target-a2/debug")).unwrap();
26229        std::fs::write(root.join("target-a2/CACHEDIR.TAG"), b"Signature: 8a477f597d28d172789f06886806bc55\n").unwrap();
26230        // A directory that merely *starts with* `target` but carries no
26231        // CACHEDIR.TAG: the new prefix-adjacent check must not false-match
26232        // on the name alone, so this stays in the response.
26233        std::fs::create_dir_all(root.join("target-notes")).unwrap();
26234        std::fs::write(root.join("target-notes/plan.md"), b"not a build directory\n").unwrap();
26235
26236        let (entries, truncated, _budget) =
26237            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
26238        assert!(!truncated);
26239        assert!(entries.iter().any(|entry| {
26240            entry.relative_path.as_utf8() == Some("src")
26241                && entry.kind == WorkspaceEntryKind::Directory
26242        }));
26243        assert!(entries.iter().any(|entry| {
26244            entry.relative_path.as_utf8() == Some("src/lib.rs")
26245                && entry.kind == WorkspaceEntryKind::File
26246        }));
26247        assert!(entries.iter().any(|entry| {
26248            entry.relative_path.as_utf8() == Some("target-notes")
26249                && entry.kind == WorkspaceEntryKind::Directory
26250        }));
26251        assert!(entries.iter().any(|entry| {
26252            entry.relative_path.as_utf8() == Some("target-notes/plan.md")
26253                && entry.kind == WorkspaceEntryKind::File
26254        }));
26255        assert!(entries.iter().all(|entry| {
26256            let path = entry.relative_path.as_utf8().unwrap();
26257            !path.starts_with(".git")
26258                && !(path == "target" || path.starts_with("target/"))
26259                && !(path == "target-a2" || path.starts_with("target-a2/"))
26260                && !path.starts_with("node_modules")
26261        }));
26262
26263        std::fs::remove_dir_all(&root).unwrap();
26264    }
26265
26266    #[test]
26267    fn workspace_walk_stops_at_its_entry_cap_and_marks_a_partial_result() {
26268        let root = temporary_workspace_root("tree-entry-cap");
26269        std::fs::create_dir_all(&root).unwrap();
26270        for index in 0..20 {
26271            std::fs::write(root.join(format!("file-{index:02}.txt")), b"fixture\n").unwrap();
26272        }
26273
26274        let (entries, truncated, budget) =
26275            collect_workspace_entries(&root, generous_walk_deadline(), 5);
26276        assert!(truncated);
26277        assert!(budget.entry_cap_exceeded);
26278        assert!(!budget.time_budget_exceeded);
26279        assert!(entries.len() <= 5);
26280
26281        let inspection_truncation = workspace_inspection_truncation(&budget, false, Duration::from_millis(1))
26282            .expect("an entry-cap-truncated walk must report a truncation marker");
26283        assert!(inspection_truncation.walk_entry_cap_exceeded);
26284        assert!(!inspection_truncation.walk_time_budget_exceeded);
26285        assert!(!inspection_truncation.git_time_budget_exceeded);
26286        assert!(inspection_truncation.entries_visited >= 5);
26287
26288        std::fs::remove_dir_all(&root).unwrap();
26289    }
26290
26291    #[test]
26292    fn workspace_walk_within_budget_reports_no_truncation() {
26293        let root = temporary_workspace_root("tree-no-truncation");
26294        std::fs::create_dir_all(&root).unwrap();
26295        std::fs::write(root.join("only-file.txt"), b"fixture\n").unwrap();
26296
26297        let (_entries, truncated, budget) =
26298            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
26299        assert!(!truncated);
26300        assert!(workspace_inspection_truncation(&budget, false, Duration::from_millis(1)).is_none());
26301
26302        std::fs::remove_dir_all(&root).unwrap();
26303    }
26304
26305    #[test]
26306    fn workspace_walk_emits_strict_global_byte_order_for_prefix_named_siblings() {
26307        let root = temporary_workspace_root("tree-prefix-siblings");
26308        std::fs::create_dir_all(root.join("gate4agent-c2/src")).unwrap();
26309        std::fs::write(root.join("gate4agent-c2/src/lib.rs"), b"fixture\n").unwrap();
26310        std::fs::create_dir_all(root.join("gate4agent-c2-client")).unwrap();
26311        std::fs::write(root.join("gate4agent-c2-client/lib.rs"), b"fixture\n").unwrap();
26312
26313        let (entries, truncated, _budget) =
26314            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
26315        assert!(!truncated);
26316        let paths = entries
26317            .iter()
26318            .map(|entry| entry.relative_path.as_utf8().unwrap().to_owned())
26319            .collect::<Vec<_>>();
26320        assert!(paths.windows(2).all(|pair| pair[0] < pair[1]));
26321        assert_eq!(
26322            paths,
26323            vec![
26324                "gate4agent-c2".to_owned(),
26325                "gate4agent-c2-client".to_owned(),
26326                "gate4agent-c2-client/lib.rs".to_owned(),
26327                "gate4agent-c2/src".to_owned(),
26328                "gate4agent-c2/src/lib.rs".to_owned(),
26329            ],
26330        );
26331
26332        std::fs::remove_dir_all(&root).unwrap();
26333    }
26334
26335    #[test]
26336    fn git_parsers_cap_status_and_commit_results() {
26337        let mut status_output = Vec::new();
26338        for index in 0..=GIT_STATUS_MAX_ENTRIES {
26339            status_output.extend_from_slice(format!(" M src/file-{index}.rs\0").as_bytes());
26340        }
26341        let mut snapshot = git_snapshot_for_parser();
26342        parse_git_status(&status_output, &mut snapshot);
26343        assert_eq!(snapshot.status.len(), GIT_STATUS_MAX_ENTRIES);
26344        assert!(snapshot.truncated);
26345        assert_eq!(snapshot.status[0].index_status, " ");
26346        assert_eq!(snapshot.status[0].worktree_status, "M");
26347
26348        snapshot.truncated = false;
26349        let commit_output = (0..=GIT_COMMIT_MAX_ENTRIES)
26350            .map(|index| format!("abc{index}\u{1f}commit {index}\n"))
26351            .collect::<String>();
26352        parse_git_commits(commit_output.as_bytes(), &mut snapshot);
26353        assert_eq!(snapshot.recent_commits.len(), GIT_COMMIT_MAX_ENTRIES);
26354        assert!(snapshot.truncated);
26355    }
26356
26357    #[test]
26358    fn porcelain_v1_z_parser_preserves_current_then_previous_rename_and_copy_order() {
26359        let mut snapshot = git_snapshot_for_parser();
26360        parse_git_status(
26361            b"R  src/current name.rs\0src/previous name.rs\0 C src/copy.rs\0src/original.rs\0?? src/line\nname.rs\0",
26362            &mut snapshot,
26363        );
26364
26365        assert!(!snapshot.truncated);
26366        assert_eq!(snapshot.status.len(), 3);
26367        assert_eq!(snapshot.status[0].index_status, "R");
26368        assert_eq!(snapshot.status[0].worktree_status, " ");
26369        assert_eq!(snapshot.status[0].path.as_utf8(), Some("src/current name.rs"));
26370        assert_eq!(
26371            snapshot.status[0]
26372                .previous_path
26373                .as_ref()
26374                .and_then(RepositoryPath::as_utf8),
26375            Some("src/previous name.rs"),
26376        );
26377        assert_eq!(snapshot.status[1].index_status, " ");
26378        assert_eq!(snapshot.status[1].worktree_status, "C");
26379        assert_eq!(snapshot.status[1].path.as_utf8(), Some("src/copy.rs"));
26380        assert_eq!(
26381            snapshot.status[1]
26382                .previous_path
26383                .as_ref()
26384                .and_then(RepositoryPath::as_utf8),
26385            Some("src/original.rs"),
26386        );
26387        assert_eq!(snapshot.status[2].path.as_utf8(), Some("src/line\nname.rs"));
26388        assert!(snapshot.status[2].previous_path.is_none());
26389    }
26390
26391    #[test]
26392    fn porcelain_v1_z_parser_never_invents_invalid_or_incomplete_path_identity() {
26393        let mut snapshot = git_snapshot_for_parser();
26394        parse_git_status(
26395            b"?? valid.rs\0?? invalid-\xff.rs\0?? invalid\\windows.rs\0R  current.rs\0",
26396            &mut snapshot,
26397        );
26398
26399        assert!(snapshot.truncated);
26400        assert_eq!(snapshot.status.len(), 1);
26401        assert_eq!(snapshot.status[0].path.as_utf8(), Some("valid.rs"));
26402        assert!(snapshot.status[0].previous_path.is_none());
26403    }
26404
26405    #[tokio::test]
26406    async fn observer_can_browse_host_directories_without_controller() {
26407        let root = temporary_workspace_root("host-directory-observer");
26408        std::fs::create_dir_all(root.join("directory-b")).unwrap();
26409        std::fs::create_dir_all(root.join("directory-a")).unwrap();
26410        std::fs::write(root.join("ordinary-file"), b"not a directory").unwrap();
26411        let workspace_id = WorkspaceId::new("primary").unwrap();
26412        let workspace = WorkspaceConfig::new(workspace_id, &root).unwrap();
26413        let catalog = active_registry().unwrap();
26414        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26415        let shared = NodeShared::new(
26416            handle,
26417            "fixture-token".to_owned(),
26418            NodeId::new("node-1").unwrap(),
26419            vec![workspace],
26420            vec![agent("claude")],
26421        );
26422        let request = NodeRequest::BrowseHostDirectories {
26423            directory: Some(OpaqueHostPath::utf8(root.to_string_lossy().into_owned()).unwrap()),
26424            after: None,
26425        };
26426        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
26427            capability.as_str() == CAPABILITY_HOST_DIRECTORY_BROWSE_V1
26428        }));
26429        assert!(!request_uses_unnegotiated_capability(
26430            &request,
26431            &baseline_capabilities().unwrap(),
26432        ));
26433        assert!(request_uses_unnegotiated_capability(&request, &[]));
26434
26435        let response = process_request_inner(&shared, 77, ClientRole::Observer, request)
26436            .await
26437            .unwrap();
26438        let NodeResponse::HostDirectoriesBrowsed { listing } = response else {
26439            panic!("host directory browse returned another response");
26440        };
26441        assert_eq!(
26442            listing
26443                .entries
26444                .iter()
26445                .map(|entry| entry.display_name.as_str())
26446                .collect::<Vec<_>>(),
26447            vec!["directory-a", "directory-b"],
26448        );
26449        assert!(listing.entries.iter().all(|entry| !entry.is_link));
26450        assert!(!listing.incomplete);
26451        std::fs::remove_dir_all(root).unwrap();
26452    }
26453
26454    #[tokio::test]
26455    async fn observer_can_inspect_only_a_registered_workspace() {
26456        let root = temporary_workspace_root("observer");
26457        std::fs::create_dir_all(root.join("src")).unwrap();
26458        std::fs::write(root.join("src/main.rs"), b"fn main() {}\n").unwrap();
26459        let workspace_id = WorkspaceId::new("primary").unwrap();
26460        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
26461        let catalog = active_registry().unwrap();
26462        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26463        let shared = NodeShared::new(
26464            handle,
26465            "fixture-token".to_owned(),
26466            NodeId::new("node-1").unwrap(),
26467            vec![workspace],
26468            vec![agent("claude")],
26469        );
26470
26471        let response = process_request_inner(
26472            &shared,
26473            77,
26474            ClientRole::Observer,
26475            NodeRequest::InspectWorkspace {
26476                workspace_id: workspace_id.clone(),
26477            },
26478        )
26479        .await
26480        .unwrap();
26481        let NodeResponse::WorkspaceInspected { inspection } = response else {
26482            panic!("workspace inspection returned another response");
26483        };
26484        assert_eq!(inspection.workspace_id, workspace_id);
26485        assert!(inspection.entries.iter().any(|entry| {
26486            entry.relative_path.as_utf8() == Some("src/main.rs")
26487        }));
26488
26489        let unknown = process_request_inner(
26490            &shared,
26491            77,
26492            ClientRole::Observer,
26493            NodeRequest::InspectWorkspace {
26494                workspace_id: WorkspaceId::new("unknown").unwrap(),
26495            },
26496        )
26497        .await
26498        .unwrap_err();
26499        assert_eq!(unknown.code, NodeFailureCode::UnknownWorkspace);
26500        std::fs::remove_dir_all(&root).unwrap();
26501    }
26502
26503    #[test]
26504    fn context_pack_source_accepts_only_running_or_successful_exit() {
26505        assert!(context_pack_source_status_is_usable(&SessionStatus::Running));
26506        assert!(context_pack_source_status_is_usable(&SessionStatus::Exited {
26507            exit_code: Some(0),
26508        }));
26509        assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
26510            exit_code: Some(1),
26511        }));
26512        assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
26513            exit_code: None,
26514        }));
26515        assert!(!context_pack_source_status_is_usable(&SessionStatus::Failed {
26516            message: "provider failed".to_owned(),
26517        }));
26518        assert!(!context_pack_source_status_is_usable(&SessionStatus::Registered));
26519        assert!(!context_pack_source_status_is_usable(&SessionStatus::Starting));
26520        assert!(!context_pack_source_status_is_usable(&SessionStatus::Stopping));
26521    }
26522
26523    #[test]
26524    fn context_pack_repository_rejects_changed_head_with_same_git_snapshot() {
26525        let git = git_snapshot_for_parser();
26526        let first = ContextPackRepositoryObservation {
26527            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
26528            git: git.clone(),
26529            files: Vec::new(),
26530        };
26531        let second = ContextPackRepositoryObservation {
26532            head: ContextPackRepositoryHead::Commit("b".repeat(40)),
26533            git,
26534            files: Vec::new(),
26535        };
26536
26537        let error = stable_context_pack_repository(first, second).unwrap_err();
26538
26539        assert_eq!(
26540            error.code,
26541            NodeFailureCode::ContextPackMaterializationFailed,
26542        );
26543    }
26544
26545    #[test]
26546    fn context_pack_repository_rejects_changed_file_with_same_git_snapshot() {
26547        let git = git_snapshot_for_parser();
26548        let first = ContextPackRepositoryObservation {
26549            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
26550            git: git.clone(),
26551            files: vec![ContextPackRepositoryFileSource::utf8(
26552                "README.md",
26553                "first\n".to_owned(),
26554                6,
26555            )],
26556        };
26557        let second = ContextPackRepositoryObservation {
26558            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
26559            git,
26560            files: vec![ContextPackRepositoryFileSource::utf8(
26561                "README.md",
26562                "other\n".to_owned(),
26563                6,
26564            )],
26565        };
26566
26567        let error = stable_context_pack_repository(first, second).unwrap_err();
26568
26569        assert_eq!(
26570            error.code,
26571            NodeFailureCode::ContextPackMaterializationFailed,
26572        );
26573    }
26574
26575    #[tokio::test]
26576    async fn native_session_catalog_zero_active_session_fixture() {
26577        let workspace_root = temporary_workspace_root("native-catalog-workspace");
26578        let history_root = temporary_workspace_root("native-catalog-history");
26579        let outside_root = temporary_workspace_root("native-catalog-outside");
26580        std::fs::create_dir_all(&workspace_root).unwrap();
26581        std::fs::create_dir_all(&history_root).unwrap();
26582        std::fs::create_dir_all(&outside_root).unwrap();
26583        let transcript = |session_id: &str, cwd: &Path, title: &str| {
26584            [
26585                serde_json::json!({
26586                    "type": "user",
26587                    "sessionId": session_id,
26588                    "cwd": cwd.to_str().unwrap(),
26589                    "message": { "content": "raw user text must not cross the catalog wire" }
26590                }),
26591                serde_json::json!({
26592                    "type": "assistant",
26593                    "sessionId": session_id,
26594                    "cwd": cwd.to_str().unwrap(),
26595                    "message": { "model": "claude-sonnet", "content": "raw answer" }
26596                }),
26597                serde_json::json!({
26598                    "type": "ai-title",
26599                    "sessionId": session_id,
26600                    "aiTitle": title
26601                }),
26602            ]
26603            .into_iter()
26604            .map(|record| record.to_string())
26605            .collect::<Vec<_>>()
26606            .join("\n")
26607        };
26608        std::fs::write(
26609            history_root.join("stable-session.jsonl"),
26610            transcript("stable-session", &workspace_root, "Workspace review"),
26611        )
26612        .unwrap();
26613        std::fs::write(
26614            history_root.join("outside-session.jsonl"),
26615            transcript("outside-session", &outside_root, "Outside review"),
26616        )
26617        .unwrap();
26618        let history = NativeHistoryConfig::new(vec![
26619            NativeHistoryRoot::new(
26620                gate4agent_types::AdapterId::new("claude-code").unwrap(),
26621                HistorySourceLayout::SingleNdjson,
26622                history_root.clone(),
26623            )
26624            .unwrap(),
26625        ])
26626        .unwrap();
26627        let catalog = active_registry().unwrap();
26628        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26629        assert!(handle.snapshot().sessions.is_empty());
26630        let workspace_id = WorkspaceId::new("primary").unwrap();
26631        let workspace = WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap();
26632        let mut shared = NodeShared::new(
26633            handle,
26634            "fixture-token".to_owned(),
26635            NodeId::new("node-native-catalog").unwrap(),
26636            vec![workspace],
26637            vec![agent("claude")],
26638        );
26639        shared.native_session_catalog = Some(Arc::new(Mutex::new(
26640            NativeSessionCatalogAuthority::new(history),
26641        )));
26642        let route = NativeSessionCatalogRoute::workspace(
26643            workspace_id.clone(),
26644            agent("claude"),
26645        );
26646        let request = NodeRequest::CatalogNativeSessions {
26647            route: route.clone(),
26648            limit: 2,
26649        };
26650        let observer = process_request_inner(&shared, 7, ClientRole::Observer, request.clone())
26651            .await
26652            .unwrap_err();
26653        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26654        let response = process_request_inner(&shared, 7, ClientRole::Operator, request)
26655            .await
26656            .unwrap();
26657        let NodeResponse::NativeSessionsCataloged { entries, summary, .. } = response else {
26658            panic!("native catalog returned a different response");
26659        };
26660        let summary = summary.expect("node must emit native session catalog summary metadata");
26661        assert_eq!(entries.len(), 1);
26662        assert!(gate4agent_types::validate_candidate_id(&entries[0].selection_id).is_ok());
26663        assert_eq!(entries[0].external_group, None);
26664        assert_eq!(entries[0].record_id, None);
26665        assert_eq!(entries[0].title, None);
26666        assert_eq!(entries[0].model, None);
26667        assert_eq!(entries[0].message_count, 0);
26668        let encoded_entries = serde_json::to_string(&entries).unwrap();
26669        assert!(!encoded_entries.contains("stable-session"));
26670        assert!(!encoded_entries.contains(history_root.to_string_lossy().as_ref()));
26671        let page_request = NodeRequest::PageNativeSessions {
26672            route: route.clone(),
26673            window: crate::protocol::NativeSessionCatalogWindow::Recent,
26674            catalog_revision: summary.catalog_revision,
26675            recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
26676            after_selection_id: None,
26677            limit: 2,
26678        };
26679        let observer = process_request_inner(
26680            &shared,
26681            7,
26682            ClientRole::Observer,
26683            page_request.clone(),
26684        )
26685        .await
26686        .unwrap_err();
26687        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26688        let page_response = process_request_inner(
26689            &shared,
26690            7,
26691            ClientRole::Operator,
26692            page_request.clone(),
26693        )
26694        .await
26695        .unwrap();
26696        assert!(matches!(
26697            page_response,
26698            NodeResponse::NativeSessionsPaged { page, .. }
26699                if page.entries.len() == 1 && !page.has_more && page.remaining_count == 0
26700        ));
26701        let stale = process_request_inner(
26702            &shared,
26703            7,
26704            ClientRole::Operator,
26705            NodeRequest::PageNativeSessions {
26706                route: route.clone(),
26707                window: crate::protocol::NativeSessionCatalogWindow::Recent,
26708                catalog_revision: summary.catalog_revision.wrapping_add(1),
26709                recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
26710                after_selection_id: None,
26711                limit: 2,
26712            },
26713        )
26714        .await
26715        .unwrap_err();
26716        assert_eq!(stale.code, NodeFailureCode::StaleNativeSessionCatalog);
26717        let selection = NativeSessionSelection {
26718            route: route.clone(),
26719            catalog_revision: summary.catalog_revision,
26720            recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
26721            selection_id: entries[0].selection_id.clone(),
26722        };
26723        let preview_response = process_request_inner(
26724            &shared,
26725            7,
26726            ClientRole::Operator,
26727            NodeRequest::PreviewNativeSession {
26728                selection: selection.clone(),
26729                message_limit: 1,
26730            },
26731        )
26732        .await
26733        .unwrap();
26734        let NodeResponse::NativeSessionPreviewed { preview, .. } = preview_response else {
26735            panic!("native preview returned a different response");
26736        };
26737        assert_eq!(preview.messages.len(), 1);
26738        assert!(preview.message_count_exact);
26739        assert_eq!(preview.messages[0].text, "raw answer");
26740        assert!(preview.truncated);
26741
26742        let index_request = NodeRequest::IndexNativeSession {
26743            selection: selection.clone(),
26744            display_name: "Saved Claude".to_owned(),
26745        };
26746        let observer = process_request_inner(
26747            &shared,
26748            7,
26749            ClientRole::Observer,
26750            index_request.clone(),
26751        )
26752        .await
26753        .unwrap_err();
26754        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26755        let without_controller = process_request_inner(
26756            &shared,
26757            7,
26758            ClientRole::Operator,
26759            index_request.clone(),
26760        )
26761        .await
26762        .unwrap_err();
26763        assert_eq!(without_controller.code, NodeFailureCode::ControllerRequired);
26764        let acquired = process_request_inner(
26765            &shared,
26766            7,
26767            ClientRole::Operator,
26768            NodeRequest::AcquireController { lease_ms: 5_000 },
26769        )
26770        .await
26771        .unwrap();
26772        assert!(matches!(acquired, NodeResponse::Controller { controller: Some(_) }));
26773        let indexed = process_request_inner(
26774            &shared,
26775            7,
26776            ClientRole::Operator,
26777            index_request,
26778        )
26779        .await
26780        .unwrap();
26781        assert!(indexed.native_session_index_contract_is_valid());
26782        let NodeResponse::NativeSessionIndexed {
26783            selection: echoed_selection,
26784            record,
26785        } = indexed else {
26786            panic!("native session index returned a different response");
26787        };
26788        assert_eq!(echoed_selection, selection);
26789        let recataloged = process_request_inner(
26790            &shared,
26791            7,
26792            ClientRole::Operator,
26793            NodeRequest::CatalogNativeSessions {
26794                route,
26795                limit: 2,
26796            },
26797        )
26798        .await
26799        .unwrap();
26800        let NodeResponse::NativeSessionsCataloged { entries, .. } = recataloged else {
26801            panic!("native catalog returned a different response after indexing");
26802        };
26803        assert_eq!(entries[0].record_id.as_ref(), Some(&record.record_id));
26804
26805        let unregistered_route = NativeSessionCatalogRoute::unregistered(agent("claude"));
26806        let external_catalog = process_request_inner(
26807            &shared,
26808            7,
26809            ClientRole::Operator,
26810            NodeRequest::CatalogNativeSessions {
26811                route: unregistered_route.clone(),
26812                limit: 2,
26813            },
26814        )
26815        .await
26816        .unwrap();
26817        let external_json = serde_json::to_string(&external_catalog).unwrap();
26818        assert!(!external_json.contains("outside-session"));
26819        assert!(!external_json.contains(outside_root.to_string_lossy().as_ref()));
26820        assert!(!external_json.contains(history_root.to_string_lossy().as_ref()));
26821        assert!(!external_json.contains("\"session_id\""));
26822        assert!(!external_json.contains("\"cwd\""));
26823        assert!(!external_json.contains("\"path\""));
26824        let NodeResponse::NativeSessionsCataloged {
26825            route: echoed_route,
26826            entries: external_entries,
26827            summary: Some(external_summary),
26828        } = external_catalog
26829        else {
26830            panic!("unregistered native catalog returned a different response");
26831        };
26832        assert_eq!(echoed_route, unregistered_route);
26833        assert_eq!(external_entries.len(), 1);
26834        assert_eq!(external_entries[0].record_id, None);
26835        let external_group = external_entries[0]
26836            .external_group
26837            .as_ref()
26838            .expect("unregistered session must have an external group");
26839        assert_eq!(
26840            external_group.kind,
26841            gate4agent_types::NativeSessionExternalGroupKind::Project,
26842        );
26843        assert!(external_group.group_id.starts_with("external-"));
26844        assert_eq!(
26845            external_group.display_name,
26846            outside_root.file_name().unwrap().to_string_lossy(),
26847        );
26848        let external_selection = NativeSessionSelection {
26849            route: unregistered_route,
26850            catalog_revision: external_summary.catalog_revision,
26851            recent_cutoff_unix_ms: external_summary.recent_cutoff_unix_ms,
26852            selection_id: external_entries[0].selection_id.clone(),
26853        };
26854        let external_preview = process_request_inner(
26855            &shared,
26856            7,
26857            ClientRole::Operator,
26858            NodeRequest::PreviewNativeSession {
26859                selection: external_selection.clone(),
26860                message_limit: 1,
26861            },
26862        )
26863        .await
26864        .unwrap();
26865        let external_preview_json = serde_json::to_string(&external_preview).unwrap();
26866        assert!(!external_preview_json.contains("outside-session"));
26867        assert!(!external_preview_json.contains(outside_root.to_string_lossy().as_ref()));
26868        assert!(!external_preview_json.contains("\"session_id\""));
26869        let NodeResponse::NativeSessionPreviewed { preview, .. } = external_preview else {
26870            panic!("unregistered native preview returned a different response");
26871        };
26872        assert_eq!(preview.messages.len(), 1);
26873        let external_index = process_request_inner(
26874            &shared,
26875            7,
26876            ClientRole::Operator,
26877            NodeRequest::IndexNativeSession {
26878                selection: external_selection,
26879                display_name: "External Claude".to_owned(),
26880            },
26881        )
26882        .await
26883        .unwrap_err();
26884        assert_eq!(
26885            external_index.code,
26886            NodeFailureCode::WorkspaceRegistrationRequired,
26887        );
26888        let record_response = process_request_inner(
26889            &shared,
26890            7,
26891            ClientRole::Operator,
26892            NodeRequest::PreviewSessionRecord {
26893                record_id: record.record_id.clone(),
26894                message_limit: 2,
26895            },
26896        )
26897        .await
26898        .unwrap();
26899        let NodeResponse::SessionRecordPreviewed { record_id, preview } = record_response else {
26900            panic!("record preview returned a different response");
26901        };
26902        assert_eq!(record_id, record.record_id);
26903        assert_eq!(preview.messages.len(), 2);
26904        let encoded = serde_json::to_string(&preview).unwrap();
26905        assert!(!encoded.contains("stable-session"));
26906        assert!(!encoded.contains(history_root.to_string_lossy().as_ref()));
26907        let NodeResponse::Resync { events, .. } = shared.resync(0) else {
26908            panic!("record preview history summary returned a different response");
26909        };
26910        let summaries = events
26911            .iter()
26912            .filter_map(|envelope| match &envelope.event {
26913                NodeEvent::SessionRecordHistorySummarized { record_id, summary }
26914                    if record_id == &record.record_id =>
26915                {
26916                    Some(*summary)
26917                }
26918                _ => None,
26919            })
26920            .collect::<Vec<_>>();
26921        assert_eq!(summaries, vec![SessionHistorySummaryV1::from(&preview)]);
26922        let summary_json = serde_json::to_string(&summaries).unwrap();
26923        for private in [
26924            "stable-session",
26925            "raw user text must not cross the catalog wire",
26926            "raw answer",
26927            "Workspace review",
26928            "claude-sonnet",
26929        ] {
26930            assert!(!summary_json.contains(private), "{private}");
26931        }
26932        let identity = record.provider_session.clone().unwrap();
26933        assert_eq!(
26934            shared.revalidate_session_record_preview(&record, &identity),
26935            Ok(())
26936        );
26937        shared.remove_record_memory(&record.record_id);
26938        assert_eq!(
26939            shared
26940                .revalidate_session_record_preview(&record, &identity)
26941                .unwrap_err()
26942                .code,
26943            NodeFailureCode::UnknownSessionRecord
26944        );
26945        let released = process_request_inner(
26946            &shared,
26947            7,
26948            ClientRole::Operator,
26949            NodeRequest::ReleaseController,
26950        )
26951        .await
26952        .unwrap();
26953        assert!(matches!(released, NodeResponse::Controller { controller: None }));
26954        assert!(shared.controller_state().is_none());
26955        std::fs::remove_dir_all(workspace_root).unwrap();
26956        std::fs::remove_dir_all(history_root).unwrap();
26957        std::fs::remove_dir_all(outside_root).unwrap();
26958    }
26959
26960    #[tokio::test]
26961    async fn native_session_catalog_concurrent_operation_fails_fast_without_waiting_on_mutex() {
26962        let history_root = temporary_workspace_root("native-catalog-concurrent-operation");
26963        std::fs::create_dir_all(&history_root).unwrap();
26964        let history = NativeHistoryConfig::new(vec![
26965            NativeHistoryRoot::new(
26966                gate4agent_types::AdapterId::new("claude-code").unwrap(),
26967                HistorySourceLayout::SingleNdjson,
26968                history_root.clone(),
26969            )
26970            .unwrap(),
26971        ])
26972        .unwrap();
26973        let catalog = Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)));
26974        let held = catalog.lock().unwrap();
26975        let result = timeout(
26976            Duration::from_secs(1),
26977            run_native_session_catalog_operation(
26978                Arc::clone(&catalog),
26979                "native session catalog",
26980                |_| Ok::<(), std::convert::Infallible>(()),
26981            ),
26982        )
26983        .await
26984        .expect("concurrent native catalog request must not wait for the operation deadline")
26985        .unwrap_err();
26986        assert_eq!(result.code, NodeFailureCode::BackendBusy);
26987        drop(held);
26988        std::fs::remove_dir_all(history_root).unwrap();
26989    }
26990
26991    fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
26992        let unique = std::time::SystemTime::now()
26993            .duration_since(std::time::UNIX_EPOCH)
26994            .unwrap()
26995            .as_nanos();
26996        std::env::temp_dir().join(format!(
26997            "gate4agent-node-{label}-{}-{unique}",
26998            std::process::id(),
26999        ))
27000    }
27001
27002    fn git_snapshot_for_parser() -> GitSnapshot {
27003        GitSnapshot {
27004            is_repository: true,
27005            branch: Some("main".to_owned()),
27006            status: Vec::new(),
27007            recent_commits: Vec::new(),
27008            worktrees: Vec::new(),
27009            managed_worktree: None,
27010            truncated: false,
27011            diagnostic: None,
27012        }
27013    }
27014
27015    #[tokio::test]
27016    async fn workspace_inspection_scopes_only_exact_active_managed_branch() {
27017        let root = temporary_workspace_root("managed-git-scope");
27018        std::fs::create_dir_all(&root).unwrap();
27019        run_test_git(&root, &["init"]);
27020        run_test_git(&root, &["config", "user.email", "gate4agent@example.invalid"]);
27021        run_test_git(&root, &["config", "user.name", "Gate4Agent Test"]);
27022        std::fs::write(root.join("seed.txt"), "seed\n").unwrap();
27023        run_test_git(&root, &["add", "seed.txt"]);
27024        run_test_git(&root, &["commit", "-m", "seed"]);
27025        run_test_git(&root, &["checkout", "-b", "gate4agent/a"]);
27026
27027        let workspace_id = WorkspaceId::new("managed-a").unwrap();
27028        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27029        let catalog = active_registry().unwrap();
27030        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27031        let shared = NodeShared::new(
27032            handle,
27033            "fixture-token".to_owned(),
27034            NodeId::new("node-1").unwrap(),
27035            vec![workspace],
27036            vec![agent("claude")],
27037        );
27038        let mut lease = ManagedWorktreeLeaseRecord {
27039            lease_id: ManagedWorktreeLeaseId::new("mw-a").unwrap(),
27040            source_workspace_id: WorkspaceId::new("primary").unwrap(),
27041            workspace_id: workspace_id.clone(),
27042            profile_id: WorktreeProfileId::new("default").unwrap(),
27043            profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
27044            target_root: root.to_string_lossy().into_owned(),
27045            branch: "gate4agent/a".to_owned(),
27046            base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
27047            expected_head: None,
27048            retention: ManagedWorktreeRetention::Retain,
27049            state: ManagedWorktreeLeaseState::InUse,
27050            session_holders: vec![ManagedWorktreeSessionHolder {
27051                incarnation_id: shared.incarnation_id,
27052                instance_id: AgentInstanceId(7),
27053                generation: SessionGeneration(1),
27054            }],
27055            record_holders: Vec::new(),
27056            cleanup_failure: None,
27057            created_at_unix_ms: 1,
27058            updated_at_unix_ms: 1,
27059        };
27060        *shared
27061            .managed_worktrees
27062            .lock()
27063            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
27064            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
27065
27066        let inspection = shared.inspect_workspace(workspace_id.clone()).await.unwrap();
27067        let scope = inspection.git.managed_worktree.unwrap();
27068        assert_eq!(scope.lease_id, lease.lease_id);
27069        assert_eq!(scope.branch, "gate4agent/a");
27070        assert_eq!(scope.active_session_count, 1);
27071
27072        lease.branch = "gate4agent/b".to_owned();
27073        *shared
27074            .managed_worktrees
27075            .lock()
27076            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
27077            ManagedWorktreeRegistry::from_records(vec![lease], Vec::new()).unwrap();
27078        let mismatch = shared.inspect_workspace(workspace_id).await.unwrap();
27079        assert!(mismatch.git.managed_worktree.is_none());
27080        std::fs::remove_dir_all(root).unwrap();
27081    }
27082
27083    #[tokio::test]
27084    async fn workspace_file_write_requires_controller_and_rejects_stale_revision() {
27085        let root = temporary_workspace_root("workspace-file-write-controller");
27086        std::fs::create_dir_all(root.join("src")).unwrap();
27087        std::fs::write(root.join("src/lib.rs"), b"old\n").unwrap();
27088        let workspace_id = WorkspaceId::new("primary").unwrap();
27089        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27090        let catalog = active_registry().unwrap();
27091        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27092        let shared = NodeShared::new(
27093            handle,
27094            "fixture-token".to_owned(),
27095            NodeId::new("node-1").unwrap(),
27096            vec![workspace],
27097            vec![agent("claude")],
27098        );
27099        let path = RepositoryPath::utf8("src/lib.rs".to_owned()).unwrap();
27100        let expected_revision = workspace_file_revision(b"old\n");
27101        let request = NodeRequest::WriteWorkspaceFile {
27102            workspace_id: workspace_id.clone(),
27103            path: path.clone(),
27104            expected_revision: expected_revision.clone(),
27105            text: "new\n".to_owned(),
27106        };
27107        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
27108            .await
27109            .unwrap_err();
27110        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27111        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27112            .await
27113            .unwrap_err();
27114        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
27115        shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
27116        let response = process_request_inner(&shared, 77, ClientRole::Operator, request).await.unwrap();
27117        let NodeResponse::WorkspaceFileWritten { file } = response else {
27118            panic!("workspace file write returned another response");
27119        };
27120        assert_eq!(file.revision, Some(workspace_file_revision(b"new\n")));
27121        assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
27122        let stale = process_request_inner(
27123            &shared,
27124            77,
27125            ClientRole::Operator,
27126            NodeRequest::WriteWorkspaceFile {
27127                workspace_id,
27128                path,
27129                expected_revision,
27130                text: "lost update\n".to_owned(),
27131            },
27132        )
27133        .await
27134        .unwrap_err();
27135        assert_eq!(stale.code, NodeFailureCode::RepositoryFileRevisionConflict);
27136        assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
27137        std::fs::remove_dir_all(root).unwrap();
27138    }
27139
27140    #[tokio::test]
27141    async fn workspace_entry_create_requires_controller_and_never_overwrites() {
27142        let root = temporary_workspace_root("workspace-entry-create-controller");
27143        std::fs::create_dir_all(root.join("src")).unwrap();
27144        let workspace_id = WorkspaceId::new("primary").unwrap();
27145        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27146        let catalog = active_registry().unwrap();
27147        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27148        let shared = NodeShared::new(
27149            handle,
27150            "fixture-token".to_owned(),
27151            NodeId::new("node-1").unwrap(),
27152            vec![workspace],
27153            vec![agent("claude")],
27154        );
27155        let path = RepositoryPath::utf8("src/new.rs".to_owned()).unwrap();
27156        let request = NodeRequest::CreateWorkspaceFile {
27157            workspace_id: workspace_id.clone(),
27158            path: path.clone(),
27159        };
27160        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
27161            capability.as_str() == NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY
27162        }));
27163        assert!(!request_uses_unnegotiated_capability(
27164            &request,
27165            &baseline_capabilities().unwrap(),
27166        ));
27167        assert!(request_uses_unnegotiated_capability(&request, &[]));
27168        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
27169            .await
27170            .unwrap_err();
27171        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27172        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27173            .await
27174            .unwrap_err();
27175        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
27176        shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
27177
27178        let response = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27179            .await
27180            .unwrap();
27181        let NodeResponse::WorkspaceFileCreated { file } = response else {
27182            panic!("workspace file create returned another response");
27183        };
27184        assert_eq!(file.workspace_id, workspace_id);
27185        assert_eq!(file.path, path);
27186        assert_eq!(file.revision, Some(workspace_file_revision(&[])));
27187        assert_eq!(std::fs::read(root.join("src/new.rs")).unwrap(), Vec::<u8>::new());
27188
27189        let duplicate = process_request_inner(&shared, 77, ClientRole::Operator, request)
27190            .await
27191            .unwrap_err();
27192        assert_eq!(duplicate.code, NodeFailureCode::RepositoryEntryAlreadyExists);
27193
27194        let directory_path = RepositoryPath::utf8("src/nested".to_owned()).unwrap();
27195        let response = process_request_inner(
27196            &shared,
27197            77,
27198            ClientRole::Operator,
27199            NodeRequest::CreateWorkspaceDirectory {
27200                workspace_id: workspace_id.clone(),
27201                path: directory_path.clone(),
27202            },
27203        )
27204        .await
27205        .unwrap();
27206        assert_eq!(
27207            response,
27208            NodeResponse::WorkspaceDirectoryCreated {
27209                workspace_id: workspace_id.clone(),
27210                entry: WorkspaceEntry {
27211                    relative_path: directory_path,
27212                    kind: WorkspaceEntryKind::Directory,
27213                },
27214            },
27215        );
27216        assert!(root.join("src/nested").is_dir());
27217
27218        let missing_parent = process_request_inner(
27219            &shared,
27220            77,
27221            ClientRole::Operator,
27222            NodeRequest::CreateWorkspaceFile {
27223                workspace_id: workspace_id.clone(),
27224                path: RepositoryPath::utf8("missing/new.rs".to_owned()).unwrap(),
27225            },
27226        )
27227        .await
27228        .unwrap_err();
27229        assert_eq!(missing_parent.code, NodeFailureCode::RepositoryParentNotFound);
27230        let non_directory_parent = process_request_inner(
27231            &shared,
27232            77,
27233            ClientRole::Operator,
27234            NodeRequest::CreateWorkspaceFile {
27235                workspace_id,
27236                path: RepositoryPath::utf8("src/new.rs/child".to_owned()).unwrap(),
27237            },
27238        )
27239        .await
27240        .unwrap_err();
27241        assert_eq!(
27242            non_directory_parent.code,
27243            NodeFailureCode::RepositoryParentNotDirectory,
27244        );
27245        std::fs::remove_dir_all(root).unwrap();
27246    }
27247
27248    #[tokio::test]
27249    async fn workspace_entry_create_timeout_cancels_before_commit_without_late_mutation() {
27250        let root = temporary_workspace_root("workspace-entry-create-timeout-cancel");
27251        std::fs::create_dir_all(root.join("src")).unwrap();
27252        #[cfg(windows)]
27253        let root = std::fs::canonicalize(root).unwrap();
27254        let path = RepositoryPath::utf8("src/late.rs".to_owned()).unwrap();
27255        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
27256        let barrier = Arc::new(std::sync::Barrier::new(2));
27257        let done = Arc::new(AtomicBool::new(false));
27258        let task_commit_state = Arc::clone(&commit_state);
27259        let task_barrier = Arc::clone(&barrier);
27260        let task_done = Arc::clone(&done);
27261        let task_root = root.clone();
27262        let task = tokio::task::spawn_blocking(move || {
27263            task_barrier.wait();
27264            let result = create_workspace_file_on_disk(
27265                &task_root,
27266                &path,
27267                &task_commit_state,
27268            );
27269            task_done.store(true, Ordering::Release);
27270            result
27271        });
27272
27273        let error = settle_workspace_entry_create(
27274            task,
27275            Arc::clone(&commit_state),
27276            Duration::from_millis(10),
27277            "fixture deadline",
27278            "fixture task failed",
27279        )
27280        .await
27281        .unwrap_err();
27282        assert_eq!(error.code, NodeFailureCode::RepositoryEntryCreateTimedOut);
27283        assert_eq!(
27284            commit_state.load(Ordering::Acquire),
27285            WORKSPACE_ENTRY_CREATE_CANCELED,
27286        );
27287
27288        barrier.wait();
27289        timeout(Duration::from_secs(1), async {
27290            while !done.load(Ordering::Acquire) {
27291                tokio::task::yield_now().await;
27292            }
27293        })
27294        .await
27295        .expect("canceled blocking create did not settle");
27296        assert!(!root.join("src/late.rs").exists());
27297        std::fs::remove_dir_all(root).unwrap();
27298    }
27299
27300}