Skip to main content

gate4agent_node/
server.rs

1use crate::git_worktree::{
2    create_worktree as create_git_worktree,
3    create_worktree_with_timeout as create_git_worktree_with_timeout,
4    list_worktrees as list_git_worktrees,
5    list_worktrees_with_deadline as list_git_worktrees_with_deadline,
6    paths_equal as worktree_paths_equal, remove_worktree as remove_git_worktree,
7    removal_lookup_path as normalize_worktree_removal_target,
8    resolve_base_commit_with_timeout,
9    run_git_read_bounded, GitCommandOutput, GitWorktreeError, GitWorktreeErrorKind,
10    NativeGitWorktreeSnapshot,
11};
12use crate::host_directory_browser::{
13    browse_host_directories, HostDirectoryBrowseError, HostDirectoryBrowseErrorKind,
14};
15use crate::environment_profiles::{
16    EnvironmentProfileBinding, NodeEnvironmentProfile, MAX_NODE_ENVIRONMENT_PROFILES,
17};
18use crate::bundle_catalog::{BundleCatalog, NodeBundle};
19use self::bundle_delivery::{DeliveryStore, DeliveryStoreError};
20use self::context_pack_store::ContextPackStore;
21use crate::bundle_provider::{
22    bundle_launch_arguments, validate_bundle_binding, BundleProviderLayout,
23};
24use crate::context_pack::{
25    ContextPackCatalog, ContextPackRepository, ContextPackRepositoryFileSource,
26    ContextPackSelectedFileSkipReason, NodeContextPack, CONTEXT_PACK_SELECTED_FILES,
27};
28use crate::harness_mcp_proxy::{
29    HarnessMcpProxyError, HarnessMcpProxyRegistry, PreparedHarnessMcpSpawn,
30    ReviewedHarnessMcpProgram,
31};
32use crate::session_environment::{
33    MaterializationId, MaterializationOwner, MaterializationOwnershipRecord,
34    MaterializationState, NodeSecretResolver, NodeSessionMaterializationProfile,
35    SessionEnvironmentMaterializeError, SessionEnvironmentMaterializer,
36};
37use crate::worktree_service::{
38    exact_created_worktree, exact_owned_worktree, ManagedWorktreeLeaseRecord,
39    ManagedWorktreeProfile, ManagedWorktreeRegistry, ManagedWorktreeSessionHolder,
40};
41use crate::session_registry::{
42    self, validate_display_name, LoadedNodeState, ManagedWorktreeSpawnReplayRecordV10,
43    ManagedWorktreeSpawnReplayStateV10, MAX_MANAGED_SESSION_RECORDS,
44    MAX_MANAGED_WORKTREE_SPAWN_REPLAYS,
45};
46#[cfg(unix)]
47use crate::workspace_file_unix::{
48    create_workspace_directory as create_workspace_directory_on_disk,
49    create_workspace_file as create_workspace_file_on_disk,
50    read_workspace_file as read_workspace_file_from_disk,
51    write_workspace_file as write_workspace_file_to_disk, WorkspaceFileBytes,
52    WorkspaceFileReadError, WorkspaceFileReadErrorKind,
53    WORKSPACE_ENTRY_CREATE_CANCELED, WORKSPACE_ENTRY_CREATE_COMMITTING,
54    WORKSPACE_ENTRY_CREATE_PENDING,
55};
56#[cfg(windows)]
57use crate::workspace_file_windows::{
58    create_workspace_directory as create_workspace_directory_on_disk,
59    create_workspace_file as create_workspace_file_on_disk,
60    read_workspace_file as read_workspace_file_from_disk,
61    write_workspace_file as write_workspace_file_to_disk, WorkspaceFileBytes,
62    WorkspaceFileReadError, WorkspaceFileReadErrorKind,
63    WORKSPACE_ENTRY_CREATE_CANCELED, WORKSPACE_ENTRY_CREATE_COMMITTING,
64    WORKSPACE_ENTRY_CREATE_PENDING,
65};
66use crate::platform;
67use crate::provider_runtime::{
68    require_policy, ProviderRuntimeAdmissionError, ProviderRuntimeMonitor,
69    ProviderRuntimeRequirement,
70};
71use crate::spawn_spec::SpawnProfileRegistry;
72use crate::standalone_workspace::{
73    prepare_standalone_workspace, StandaloneWorkspaceError, StandaloneWorkspaceErrorKind,
74};
75use crate::protocol::{
76    read_json_frame_limited_body_timeout, write_json_frame, write_json_frame_limited,
77    validate_node_negotiated_handshake_capacity, validate_provider_contract_manifest,
78    provider_id_is_legacy, AdapterContractRevision, AgentStreamChunkKindV1, AgentStreamChunkV1,
79    AgentStreamInteractionOptionV1, AgentStreamNamedIdV1,
80    CapabilityId, ClientFrame, ClientRole,
81    ControllerState, DeliveryBlobChunkHexV1, DeliveryBlobDigestV1,
82    DeliveryBundleManifestV2, DeliveryCommitReceiptV1, DeliveryStageId,
83    AgentProgressAttentionKindV1, AgentProgressAttentionV1, AgentProgressCurrentV1,
84    AgentProgressEventKindV1, AgentProgressUsageV1, AgentProgressV1, FrameError,
85    GitCommitDetails, GitDiff, GitDiffMode, GitDiffRequest, GitHistoryPage,
86    GitObjectId, GitCommitSummary, GitSignatureStatus, GitSnapshot, GitStatusEntry,
87    GitWorktreeSnapshot, HarnessMcpActivationDigest, HarnessMcpReservationId,
88    HostDirectoryListing,
89    NodeCompatibilitySupport, NodeEvent,
90    NodeEventEnvelope, NodeFailure, NodeFailureCode, NodeHello, NodeId, NodeIncarnationId,
91    NegotiatedNodeCompatibility, NodeRequest, NodeResponse, NodeSnapshot, OpaqueHostPath,
92    ProtocolRange,
93    ProviderAdapterContractSupport,
94    ProviderContractRevision, ProviderContractSupport, RepositoryPath, RequestEnvelope,
95    ProviderRuntimeStatuses, ResolvedBundleReceipt, ResolvedEnvironmentProfileReceipt,
96    ContextPackLineageReceipt, ContextPackBytesRead, ResolvedContextPackReceipt,
97    ResolvedHarnessMcpProxyReceiptV1, ResolvedSpawnReceipt,
98    ResolvedSpawnSpec, ResponseEnvelope,
99    ServerChallenge, ServerFrame, SessionAddress, SessionAgentProgress, SessionKey, SessionMode,
100    ManagedSessionRecord,
101    ManagedSessionState, ManagedWorktreeCleanupFailure, ManagedWorktreeLeaseId,
102    SessionRecordRetentionConfig,
103    ManagedWorktreeLeaseSnapshot, ManagedWorktreeLeaseState, ManagedWorktreeRetention,
104    ManagedWorktreeSpawnReceipt, ManagedWorktreeSpawnRequest,
105    ManagedWorktreeSpawnRequestV2, SessionRecordId,
106    NativeSessionCatalogRoute, NativeSessionSelection, SessionTaskBindingV1,
107    SessionTaskTargetV1, TaskId,
108    BlockAuthorityV1, HarnessMcpLaunchV1, SessionHistorySummaryV1, MAX_ACP_BLOCKED_HELP_BYTES, MAX_ACP_BLOCKED_REASON_BYTES,
109    StateSchemaSupport, WorkspaceEntry, WorkspaceEntryKind, WorktreeProfileId,
110    SpawnContextId, SpawnEnvironmentProfileId, SpawnIdempotencyKey,
111    SpawnProfileDefaults, SpawnRequiredCapabilities, SpawnSpec, SpawnSpecResolveError,
112    WorkspaceFileContent, WorkspaceFileRevision,
113    WorkspaceFileRead, WorkspaceId, WorkspaceInspection, WorkspaceInspectionTruncationV1,
114    WorkspaceSnapshot,
115    WorktreeServiceMode,
116    LaunchInventory, ManagedWorktreeProfileSummary, SpawnProfileSummary,
117    WorktreeProfileInventory, MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
118    DEFAULT_CONTROLLER_LEASE_MS,
119    MAX_CONTROLLER_LEASE_MS, MIN_CONTROLLER_LEASE_MS, NODE_COMPATIBILITY_METADATA_CAPABILITY,
120    CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
121    NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY, NODE_REPOSITORY_PATH_CAPABILITY,
122    NODE_PROVIDER_ID_OPEN_CAPABILITY,
123    NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
124    NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY, NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
125    NODE_HISTORY_CONTEXT_PACK_CAPABILITY, NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
126    NODE_NATIVE_SESSION_CATALOG_CAPABILITY, NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
127    NODE_NATIVE_SESSION_INDEX_CAPABILITY, NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
128    NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
129    NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY,
130    NODE_HARNESS_MCP_READ_PROXY_CAPABILITY,
131    NODE_SESSION_TASK_CORRELATION_CAPABILITY,
132    NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY,
133    NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
134    NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
135    NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
136    NODE_SPAWN_PROFILE_REVISION_CAPABILITY, NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
137    NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
138    NODE_AGENT_STREAM_EVENTS_CAPABILITY, NODE_ACP_CONTROL_CAPABILITY,
139    NODE_GIT_READ_CAPABILITY, NODE_WORKSPACE_FILE_READ_CAPABILITY,
140    NODE_WORKSPACE_FILE_WRITE_CAPABILITY, NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
141    NODE_WORKTREE_SELECTION_CAPABILITY,
142    BUILD_STAMP, MAX_NODE_CLIENT_FRAME_BYTES, MAX_NODE_HELLO_FRAME_BYTES,
143    MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS, MAX_AGENT_PROGRESS_ENTRY_BYTES,
144    MAX_NODE_TERMINAL_BYTES, MAX_NODE_TEXT_BYTES,
145    MAX_REPOSITORY_PATH_BYTES,
146    MAX_GIT_DIFF_BYTES, MAX_GIT_HISTORY_COMMITS, MAX_WORKSPACE_FILE_BYTES,
147    MAX_HARNESS_MCP_PENDING_CALLS_PER_NODE,
148    MAX_WORKSPACE_ROOT_BYTES, NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10,
149    SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY, SPAWN_RUNTIME_RAW_PTY_LIFECYCLE,
150    SPAWN_RUNTIME_SEMANTIC_READINESS, SPAWN_RUNTIME_SEMANTIC_RESUME,
151    SPAWN_RUNTIME_STRUCTURED_PROMPT,
152};
153#[cfg(test)]
154use crate::protocol::WorktreeProfileRevision;
155use ring::digest::{digest, SHA256};
156use crate::protocol::correlation::{
157    interaction_correlation, sanitize_progress_tool_label, tool_class_label, tool_correlation,
158    truncate_text,
159};
160use gate4agent_catalog::{builtin_registry, AgentRegistry};
161use gate4agent_handle::{EventSubscription, Gate4AgentHandle, PortDispatchError};
162use gate4agent_runtime_native::{
163    NativeMcpServerLaunchOverlay, NativeInstanceLaunchOverlay,
164    NativeLaunchEnvironmentOverlay,
165    NativeHistoryConfig, NativeLaunchProfileControl, NativeRuntime, NativeRuntimeConfig,
166    NativeSessionCatalogAuthority, NativeSessionCatalogError, NativeSessionPreviewError,
167    ScopedNativeSessionCatalogEntry,
168};
169use gate4agent_runtime_native::shell_efficiency::ShellEfficiencyProfile;
170use std::sync::OnceLock;
171use gate4agent_runtime_native::tick_profile::TickProfileSnapshot;
172use drive_loop_profile::DriveLoopProfiler;
173#[cfg(test)]
174use gate4agent_runtime_native::{HistorySourceLayout, NativeHistoryRoot};
175use gate4agent_node_wire::{
176    auth_proof, negotiated_auth_proof, proofs_match, random_incarnation_id, random_nonce,
177    AuthDirection, OwnerOnlyLocalListener,
178};
179use gate4agent_types::{
180    AdapterBinding, AdapterFamily, AgentId, AgentInstanceId, AgentSpec, ApprovalLevel,
181    CommandEnvelope, CommandId,
182    validate_candidate_id, ControlCommand, ControlEvent, ControlEventKind,
183    HistoryCandidateSummary, HistoryOperation, HistoryQuery, HistorySessionRecord,
184    HostDecisionAuthority as ProviderHostDecisionAuthority,
185    HostRequestDecision as ProviderHostRequestDecision,
186    InputAction,
187    PromptFraming, PromptPayload, ResumeLaunchRequest,
188    ProviderActivity, ProviderEvent, ProviderInteractionKind, ProviderInteractionStatus,
189    ProviderRuntimeCapability, ProviderRuntimePolicy, ProviderSessionIdentity, ProviderSessionKey,
190    ProviderSnapshot, ProviderStopReason,
191    ResumeTarget,
192    SessionGeneration, StartRequest,
193    TerminalControl, TerminalText,
194    SessionStatus, TerminalFrame, TransportKind, CONTROL_SESSIONS_MAX,
195    HISTORY_DISCOVERY_LIMIT_MAX, WORKING_DIRECTORY_MAX_BYTES,
196};
197use std::collections::{BTreeMap, VecDeque};
198use std::ffi::OsString;
199use std::io;
200use std::path::{Path, PathBuf};
201use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, AtomicUsize, Ordering};
202use std::sync::{Arc, Mutex, RwLock};
203use std::time::{Duration, Instant};
204use thiserror::Error;
205use tokio::sync::{broadcast, mpsc, Mutex as AsyncMutex, Notify, OwnedSemaphorePermit, Semaphore};
206use tokio::task::{AbortHandle, JoinSet};
207use tokio::time::{sleep, timeout};
208
209mod http_api;
210
211mod browser_bridge;
212
213mod drive_loop_profile;
214
215#[path = "bundle_delivery.rs"]
216mod bundle_delivery;
217
218#[path = "context_pack_store.rs"]
219mod context_pack_store;
220
221#[cfg(windows)]
222pub use crate::platform::DEFAULT_NODE_ENDPOINT;
223
224const NODE_EVENT_HISTORY_MAX: usize = 4_096;
225const NODE_BROADCAST_CAPACITY: usize = 1_024;
226const NODE_TERMINAL_BROADCAST_CAPACITY: usize = 1;
227/// Unlike `NODE_TERMINAL_BROADCAST_CAPACITY`'s single replaceable slot (a
228/// terminal frame is a full-screen snapshot; only the latest ever matters),
229/// an `AgentStreamChunkV1` is one delta in an ordered sequence -- collapsing
230/// two pending sends into one would silently drop content a subscriber can
231/// never recover. This needs headroom for a burst instead.
232const NODE_AGENT_STREAM_BROADCAST_CAPACITY: usize = 256;
233/// The node never has more than `MAX_HARNESS_MCP_PENDING_CALLS_PER_NODE`
234/// `NodeEvent::HarnessMcpReadCall`s in flight at once (`begin_call`'s own
235/// admission check), and `publish_transient` sends each exactly once as a
236/// single-envelope batch -- so this many slots covers the entire pending
237/// set without ever lagging the one subscriber that matters, the harness's
238/// own connection, the way sharing the durable `event_tx`'s bus could.
239const NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY: usize = MAX_HARNESS_MCP_PENDING_CALLS_PER_NODE;
240const NODE_CONNECTION_EVENT_BURST_MAX: usize = 16;
241const CONTROL_EVENT_SUBSCRIPTION_CAPACITY: usize = 1_024;
242const MAX_PREAUTH_CONNECTIONS: usize = 32;
243const MAX_AUTHENTICATED_CONNECTIONS: usize = 16;
244const AUTH_FRAME_TIMEOUT_MS: u64 = 5_000;
245const FRAME_BODY_TIMEOUT_MS: u64 = 5_000;
246const CONNECTION_SHUTDOWN_GRACE_MS: u64 = 250;
247const SPAWN_DISPATCH_TIMEOUT_MS: u64 = 2_000;
248/// How often `drive_runtime_until_shutdown`'s loop re-evaluates the session
249/// record retention policy (`NodeServerConfig::session_record_retention`).
250/// A sweep cadence, not a policy value -- the policy itself lives entirely
251/// in the operator-configured age/keep-N flags; this just bounds how stale
252/// the in-memory view of dead `Unavailable` records can get between sweeps
253/// on an otherwise-idle node, mirroring the once-per-startup sweep that
254/// also runs alongside `reconcile_managed_worktrees`.
255const SESSION_RECORD_RETENTION_SWEEP_PERIOD: Duration = Duration::from_secs(5 * 60);
256/// TTL for `NodeShared::rejected_command_reasons` entries -- see that
257/// field's own doc for why a spawn waiter needs this at all. Must outlive
258/// the longest a waiter can still be polling for this exact command's
259/// outcome: `gate4agent_node_protocol::MAX_SPAWN_DEADLINE_MS` (120s) is the
260/// largest `deadline_ms` any caller may request, and the margin on top
261/// absorbs the waiter's own poll cadence plus clock skew between the drive
262/// loop (writer) and the waiter (reader).
263const REJECTED_COMMAND_REASON_TTL_MS: u64 = 150_000;
264/// Bounds `NodeShared::rejected_command_reasons` against an unbounded
265/// backlog of asynchronously-rejected commands nobody ever collects (a
266/// waiter that stops polling, or a burst of always-rejected commands from a
267/// misbehaving caller): once at capacity, the oldest entry is evicted to
268/// make room for the newest, mirroring the eviction rule already used by
269/// `spawn_idempotency`.
270const REJECTED_COMMAND_REASON_MAX_ENTRIES: usize = 256;
271/// How long `wait_until_removed` waits between two `Remove` re-dispatches
272/// for the same session. The presence poll runs far faster (2ms); this
273/// only paces the sending, so an accepted `Remove` still in flight is not
274/// piled on by a dozen more that land on an already-dropped instance.
275const REMOVE_REDISPATCH_INTERVAL_MS: u64 = 100;
276const SPAWN_IDEMPOTENCY_MAX_ENTRIES: usize = 256;
277const SPAWN_IDEMPOTENCY_TTL_MS: u64 = 15 * 60 * 1_000;
278const PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS: u64 = 2_500;
279const MUTATION_SETTLE_TIMEOUT_MS: u64 = 5_000;
280const READINESS_SETTLE_HEADROOM_MS: u64 = 2_000;
281const MANAGED_RESUME_SETTLE_TIMEOUT_MS: u64 = 30_000;
282const WORKSPACE_INSPECTION_MAX_CONCURRENCY: usize = 4;
283const WORKSPACE_FILE_READ_TIMEOUT_MS: u64 = 2_000;
284const HOST_DIRECTORY_BROWSE_TIMEOUT_MS: u64 = 2_000;
285const NATIVE_SESSION_CATALOG_TIMEOUT_MS: u64 = 30_000;
286const WORKSPACE_TREE_MAX_DEPTH: usize = 6;
287const WORKSPACE_TREE_MAX_ENTRIES: usize = 512;
288/// Default total wall-clock budget for `inspect_workspace`'s walk+git
289/// chain, overridable via `GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS`.
290const WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT: u64 = 8_000;
291/// The harness relay enforces its own flat response deadline
292/// (`HOST_RUN_READ_RESPONSE_DEADLINE`, `hatchery-harness-service::runtime`,
293/// 12s) above this node-side budget. The two constants live in different
294/// crates with no shared definition, so clamp any override here comfortably
295/// below that external ceiling — a misconfigured value above it would
296/// reopen the exact deadline race this budget exists to close.
297const WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX: u64 = 11_000;
298/// Default cap on directory entries visited (not just returned) by one
299/// walk, overridable via `GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP`.
300const WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT: usize = 50_000;
301const WORKSPACE_INSPECTION_ENTRY_CAP_MAX: usize = 1_000_000;
302const GIT_STATUS_MAX_ENTRIES: usize = 128;
303const GIT_COMMIT_MAX_ENTRIES: usize = 12;
304const GIT_OUTPUT_MAX_BYTES: usize = 64 * 1_024;
305const GIT_DIAGNOSTIC_MAX_BYTES: usize = 1_024;
306const WORKSPACE_FILE_WRITE_TIMEOUT_MS: u64 = 5_000;
307const WORKSPACE_ENTRY_CREATE_TIMEOUT_MS: u64 = 5_000;
308const GIT_COMMAND_TIMEOUT_MS: u64 = 1_500;
309const WORKSPACE_UNAVAILABLE_ERROR: &str = "workspace-unavailable";
310const PROVIDER_SESSION_SCOPE_CONFLICT_ERROR: &str = "provider-session-scope-conflict";
311const PROVIDER_SESSION_LIVE_CONFLICT_ERROR: &str = "provider-session-live-conflict";
312const MANAGED_SESSION_CAPACITY_ERROR: &str = "managed-session-capacity";
313const PROVIDER_IDENTITY_ALLOCATION_ERROR: &str =
314    "provider-session-identity-allocation-failed";
315const PROVIDER_RESUME_REJECTED_ERROR: &str = "provider-resume-rejected";
316const DURABLE_STATE_COMMIT_FAILED_ERROR: &str = "durable-state-commit-failed";
317const DURABLE_STATE_LOCK_FAILED_ERROR: &str = "durable-state-lock-failed";
318const DURABLE_STATE_LOAD_FAILED_ERROR: &str = "durable-state-load-failed";
319const DURABLE_STATE_CONFLICT_ERROR: &str = "durable-state-conflict";
320const DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR: &str = "durable-state-schema-unsupported";
321const DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR: &str =
322    "durable-state-path-semantics-unsupported";
323
324fn opaque_windows_path(value: String) -> OpaqueHostPath {
325    OpaqueHostPath::utf8(value).expect("validated Windows node path must remain wire-safe")
326}
327
328fn agent_progress_from_provider_snapshot(
329    address: SessionAddress,
330    provider: &ProviderSnapshot,
331) -> Option<SessionAgentProgress> {
332    let mut truncated = false;
333    let active_tool_count = bounded_progress_count(provider.active_tools.len(), &mut truncated);
334    let mut active_tool_labels = Vec::with_capacity(
335        provider
336            .active_tools
337            .len()
338            .min(MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS),
339    );
340    for tool in &provider.active_tools {
341        if active_tool_labels.len() == MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS {
342            truncated = true;
343            break;
344        }
345        if let Some(label) = sanitize_progress_tool_label(&tool.name, &mut truncated) {
346            active_tool_labels.push(label);
347        }
348    }
349    let attention = provider
350        .interactions
351        .iter()
352        .find(|interaction| {
353            matches!(
354                interaction.status,
355                ProviderInteractionStatus::Pending | ProviderInteractionStatus::Resolving { .. }
356            )
357        })
358        .map(|interaction| AgentProgressAttentionV1 {
359            kind: match interaction.interaction_kind {
360                ProviderInteractionKind::Approval => AgentProgressAttentionKindV1::Approval,
361                ProviderInteractionKind::Question => AgentProgressAttentionKindV1::Question,
362            },
363            tool_label: sanitize_progress_tool_label(&interaction.tool_name, &mut truncated),
364        });
365    let subagent_count = bounded_progress_count(provider.subagents.len(), &mut truncated);
366    let usage = (provider.completed_turns > 0
367        || provider.usage.input_tokens > 0
368        || provider.usage.output_tokens > 0
369        || provider.usage.cache_read_tokens > 0
370        || provider.usage.cache_write_tokens > 0
371        || provider.usage.reasoning_tokens > 0)
372        .then_some(AgentProgressUsageV1 {
373            input_tokens: provider.usage.input_tokens,
374            output_tokens: provider.usage.output_tokens,
375            cache_read_tokens: provider.usage.cache_read_tokens,
376            cache_write_tokens: provider.usage.cache_write_tokens,
377            reasoning_tokens: provider.usage.reasoning_tokens,
378        });
379    let progress = AgentProgressV1 {
380        provider_sequence: provider.sequence,
381        activity: provider.activity,
382        completed_turns: provider.completed_turns,
383        usage,
384        current: AgentProgressCurrentV1::from(provider.activity),
385        active_tool_labels,
386        active_tool_count,
387        attention,
388        subagent_count,
389        last_event_kind: provider.last_event.as_ref().and_then(agent_progress_event_kind),
390        gap_count: provider.gap_count,
391        stale: provider.stale,
392        truncated,
393    };
394    let entry = SessionAgentProgress { address, progress };
395    serde_json::to_vec(&entry)
396        .ok()
397        .filter(|encoded| encoded.len() <= MAX_AGENT_PROGRESS_ENTRY_BYTES)
398        .map(|_| entry)
399}
400
401fn bounded_progress_count(count: usize, truncated: &mut bool) -> u32 {
402    match u32::try_from(count) {
403        Ok(count) => count,
404        Err(_) => {
405            *truncated = true;
406            u32::MAX
407        }
408    }
409}
410
411fn agent_progress_event_kind(event: &ProviderEvent) -> Option<AgentProgressEventKindV1> {
412    if matches!(event, ProviderEvent::ContextWindowUsage { .. }) {
413        return None;
414    }
415    Some(match event {
416        ProviderEvent::SessionStarted { .. } => AgentProgressEventKindV1::SessionStarted,
417        ProviderEvent::SessionIdentityObserved { .. } => {
418            AgentProgressEventKindV1::SessionIdentityObserved
419        }
420        ProviderEvent::TurnStarted { .. } => AgentProgressEventKindV1::TurnStarted,
421        ProviderEvent::WorkingObserved => AgentProgressEventKindV1::WorkingObserved,
422        ProviderEvent::Text { .. } => AgentProgressEventKindV1::Text,
423        ProviderEvent::Thinking { .. } => AgentProgressEventKindV1::Thinking,
424        ProviderEvent::ToolStarted { .. } => AgentProgressEventKindV1::ToolStarted,
425        ProviderEvent::ToolCompleted { .. } => AgentProgressEventKindV1::ToolCompleted,
426        ProviderEvent::TurnCompleted { .. } => AgentProgressEventKindV1::TurnCompleted,
427        ProviderEvent::TurnInterrupted => AgentProgressEventKindV1::TurnInterrupted,
428        ProviderEvent::SessionEnded { .. } => AgentProgressEventKindV1::SessionEnded,
429        ProviderEvent::Error { .. } => AgentProgressEventKindV1::Error,
430        ProviderEvent::Ready => AgentProgressEventKindV1::Ready,
431        ProviderEvent::InteractionRequested { .. } => {
432            AgentProgressEventKindV1::InteractionRequested
433        }
434        ProviderEvent::InteractionResolved { .. } => AgentProgressEventKindV1::InteractionResolved,
435        ProviderEvent::SubagentStarted { .. } => AgentProgressEventKindV1::SubagentStarted,
436        ProviderEvent::SubagentStopped { .. } => AgentProgressEventKindV1::SubagentStopped,
437        ProviderEvent::RateLimited { .. } => AgentProgressEventKindV1::RateLimited,
438        ProviderEvent::HostRequestObserved { .. } => AgentProgressEventKindV1::HostRequestObserved,
439        ProviderEvent::UnrecognizedNotification { .. } => {
440            AgentProgressEventKindV1::UnrecognizedNotification
441        }
442        ProviderEvent::ContextWindowUsage { .. } => unreachable!("handled above"),
443        // ACP session/update coverage beyond text/tool/turn streaming
444        // (`plan`, `available_commands_update`, `current_mode_update`,
445        // `session_info_update`, `usage_update`, `config_option_update`,
446        // `user_message_chunk`). `AgentProgressEventKindV1` is a versioned
447        // wire enum without a `#[serde(other)]` fallback; minting new
448        // variants for it is a deliberate wire-contract decision for
449        // whoever owns this protocol, not a side effect of parsing more of
450        // ACP's own wire -- until that decision is made, these fold into
451        // the same "we saw something, no dedicated kind for it yet" bucket
452        // as `UnrecognizedNotification`.
453        ProviderEvent::UserMessage { .. }
454        | ProviderEvent::Plan { .. }
455        | ProviderEvent::AvailableCommandsUpdated { .. }
456        | ProviderEvent::ModeChanged { .. }
457        | ProviderEvent::SessionInfoUpdated { .. }
458        | ProviderEvent::UsageUpdated { .. }
459        | ProviderEvent::ConfigOptionsUpdated { .. } => {
460            AgentProgressEventKindV1::UnrecognizedNotification
461        }
462    })
463}
464
465/// Maps this harness's own `HostDecisionAuthority` onto the wider
466/// Maps a host request decider onto the `BlockAuthorityV1` a `Blocked` stream
467/// chunk carries -- the four authorities a `Denied` decision is ever made
468/// under, one-for-one. The remaining `BlockAuthorityV1` variants
469/// (`ProviderClassifier`, `ProviderPermissionRule`, `ProviderSandbox`,
470/// `ProviderRefusal`, `UserRejected`, `ProviderQuota`, `Unknown`) describe a
471/// block the PROVIDER side of the wire reported -- this function never
472/// produces one, because `ProviderHostDecisionAuthority` has no vocabulary for
473/// them; the provider-side blocks below mint those directly from typed fields.
474/// Whether a provider source is a live session transport -- the only kind
475/// whose events reach the agent stream. History, resume, session-option and
476/// capability-probe sources are not sessions and stream nothing.
477fn provider_family_is_live(family: AdapterFamily) -> bool {
478    match family {
479        AdapterFamily::PtySemantic
480        | AdapterFamily::Hook
481        | AdapterFamily::ManagedHook
482        | AdapterFamily::Pipe
483        | AdapterFamily::OneShot
484        | AdapterFamily::Acp => true,
485        AdapterFamily::History
486        | AdapterFamily::Resume
487        | AdapterFamily::SessionOptions
488        | AdapterFamily::CapabilityProbe => false,
489    }
490}
491
492fn block_authority(by: ProviderHostDecisionAuthority) -> BlockAuthorityV1 {
493    match by {
494        ProviderHostDecisionAuthority::Gate => BlockAuthorityV1::HostGate,
495        ProviderHostDecisionAuthority::Policy => BlockAuthorityV1::HostPolicy,
496        ProviderHostDecisionAuthority::Operator => BlockAuthorityV1::Operator,
497        ProviderHostDecisionAuthority::DeadlinePolicy => BlockAuthorityV1::HostDeadline,
498    }
499}
500
501/// The `Blocked::reason` for a `Denied` decision this build's own
502/// `AgentEvent::RpcIncomingRequest::reason` did not attach text to --
503/// reachable today for `Operator`/`HostDeadline`: a deferred `session/
504/// request_permission` request resolved by `AcpSession::
505/// resolve_pending_request`/`expire_deadlines` (`src/acp/session.rs`),
506/// neither of which computes free text (an operator answers approve/reject,
507/// never a comment; an unattended deadline asks nobody anything). `reason`
508/// is a REQUIRED field on the wire (see `AgentStreamChunkKindV1::Blocked`)
509/// -- a categorical fallback naming the authority is the
510/// honest minimum, never a placeholder standing in for detail nobody
511/// reported.
512fn action_blocked_fallback_reason(authority: BlockAuthorityV1) -> &'static str {
513    match authority {
514        BlockAuthorityV1::HostGate => "denied by the dangerous-command gate",
515        BlockAuthorityV1::HostPolicy => "denied by host policy",
516        BlockAuthorityV1::HostDeadline => {
517            "denied by host policy after the operator deadline expired"
518        }
519        BlockAuthorityV1::Operator => "declined by the operator",
520        BlockAuthorityV1::ProviderClassifier => "denied by the provider's classifier",
521        BlockAuthorityV1::ProviderPermissionRule => "denied by a provider permission rule",
522        BlockAuthorityV1::ProviderSandbox => "denied by the provider's sandbox",
523        BlockAuthorityV1::ProviderRefusal => "refused by the provider",
524        BlockAuthorityV1::UserRejected => "rejected by the user",
525        BlockAuthorityV1::ProviderQuota => "the provider's quota or rate limit was exhausted",
526        BlockAuthorityV1::Unknown => "blocked; no reason was reported",
527    }
528}
529
530/// Computes `AgentStreamChunkKindV1::Blocked`'s `tool_class`/`authority`/`reason`
531/// for a `HostRequestObserved` `Denied` decision.
532fn host_request_denied_block(
533    method: &str,
534    by: ProviderHostDecisionAuthority,
535    reason: Option<&str>,
536) -> (String, BlockAuthorityV1, String) {
537    let authority = block_authority(by);
538    let reason_source = reason.unwrap_or_else(|| action_blocked_fallback_reason(authority));
539    // `AgentStreamChunkKindV1::validate` REJECTS an over-long reason outright
540    // rather than truncating -- this cut happens here, at the producer, before
541    // the validator ever sees the text.
542    let (reason_text, _) =
543        truncate_text(reason_source, MAX_ACP_BLOCKED_REASON_BYTES);
544    (tool_class_label(method), authority, reason_text)
545}
546
547// ---------------------------------------------------------------------------
548// K2 -- provider-side blocks read off ACP `tool_call_update._meta.
549// nonExecutionKind` (`ProviderEvent::ToolCompleted::non_execution_kind`) and
550// `SessionEnded::stop_reason` (`stopReason: "refusal"`, or a `session/
551// prompt` RPC error whose vendor code names a quota/limit/rate
552// exhaustion). See
553// `docs/gate4agent/plans/gate4agent-blocked-action-event-2026-09-02.md`
554// §3 slice K2.
555// ---------------------------------------------------------------------------
556
557/// Classifies ACP's `nonExecutionKind` into the typed authority a block
558/// this build mints under, or `None` when the tool truly did not run for a
559/// reason that is NOT a block -- `"interrupted"`/`"cancelled"` mean the
560/// TURN stopped (session closed, the operator cancelled), not that a
561/// policy/classifier/rule refused the call, so those (and any unrecognized
562/// future value) stay a plain `ToolCompleted { success: false }` only, per
563/// this plan's own rule that a tool failure is never inflated into a block
564/// this build cannot actually attribute.
565fn provider_tool_block_authority(kind: Option<&str>) -> Option<(BlockAuthorityV1, &'static str)> {
566    match kind? {
567        "user-rejected" => Some((BlockAuthorityV1::UserRejected, "user-rejected")),
568        "permission-rule" => Some((BlockAuthorityV1::ProviderPermissionRule, "permission-rule")),
569        _ => None,
570    }
571}
572
573/// Splits a provider's own denial sentence into `reason`/`help`, when it
574/// matches the fixed shape Claude Code's classifier/permission-rule denial
575/// uses -- verified live, the owner's sample fixture
576/// (`docs/gate4agent/research/gate4agent-blocked-action-signals-2026-09-02.md`
577/// appendix): "...denied by <authority>. Reason: <reason>. <help...>".
578/// `authority` is NEVER derived from this text (see this module's own rule
579/// that authority comes only from a typed field) -- only `reason`/`help`
580/// are. Falls back to the full text as `reason` with no `help` when the
581/// shape does not match (a provider whose text does not carry this exact
582/// wording) -- the honest minimum: the tool truly did not run, so SOME
583/// reason is always reported, never fabricated detail beyond what arrived.
584fn split_provider_denial_text(text: &str) -> (String, Option<String>) {
585    let trimmed = text.trim();
586    let Some((_, after_reason)) = trimmed.split_once("Reason:") else {
587        return (trimmed.to_owned(), None);
588    };
589    let tail = after_reason.trim_start();
590    let Some((reason, help)) = tail.split_once(". ") else {
591        // "Reason:" present but no ". " to split help off -- the whole
592        // tail is the reason, e.g. "Reason: Blocked by classifier." with
593        // no help text following on some providers/versions.
594        let reason = tail.strip_suffix('.').unwrap_or(tail).trim();
595        return (reason.to_owned(), None);
596    };
597    let help = help.trim();
598    if help.is_empty() {
599        (reason.trim().to_owned(), None)
600    } else {
601        (reason.trim().to_owned(), Some(help.to_owned()))
602    }
603}
604
605/// `tool_class` for a provider-side `ActionBlocked` this build has no typed
606/// tool name for -- `ProviderEvent::ToolCompleted` carries only `id`, never
607/// `name` (see `ProviderEvent::ToolStarted`, which does, but a tool's own
608/// name is never threaded through to its completion). Reads the class off
609/// the guidance tail's own "add a <Class> permission rule" sentence when
610/// present (the shape the owner's sample fixture uses) -- text matching
611/// fills `tool_class` here, same as `reason`/`help`, NEVER `authority` (see
612/// this module's own rule). Falls back to the same generic `"Tool"` class
613/// `ToolCompleted`'s own observation already uses when nothing names one.
614fn tool_class_from_denial_help(help: Option<&str>) -> String {
615    help.and_then(|text| text.split("add a ").nth(1))
616        .and_then(|tail| tail.split(' ').next())
617        .filter(|word| !word.is_empty())
618        .map(str::to_owned)
619        .unwrap_or_else(|| "Tool".to_owned())
620}
621
622/// Whether a `session/prompt` RPC error's vendor code (or, absent one, its
623/// message) names a quota/rate-limit exhaustion -- Codex's
624/// `data.codexErrorInfo: "usageLimitExceeded"` (measured live 2026-09-05)
625/// contains "limit", the word this build actually keys on; "quota"/"rate"
626/// are kept for whichever OTHER vendor's own code names one of those
627/// instead, never confirmed live, but the honest generalization of the
628/// one confirmed sample.
629fn provider_error_is_quota(vendor_code: Option<&str>, message: &str) -> bool {
630    fn names_quota(s: &str) -> bool {
631        let lower = s.to_ascii_lowercase();
632        lower.contains("limit") || lower.contains("quota") || lower.contains("rate")
633    }
634    vendor_code.is_some_and(names_quota) || names_quota(message)
635}
636
637/// The `Blocked` chunk this build mints when a turn stopped via a
638/// typed provider fact rather than a tool call -- `stopReason: "refusal"`
639/// (`authority: ProviderRefusal`) or a `session/prompt` RPC error whose
640/// vendor code (or message) names a quota/limit/rate exhaustion
641/// (`authority: ProviderQuota`, `reason` = the error message verbatim,
642/// `reason_kind` = the vendor code). `None` for every other stop --
643/// `end_turn`, `max_tokens`, `max_turn_requests`, `cancelled`, an untagged
644/// `Other`, or an RPC error whose vendor code/message names none of the
645/// three words: `SessionEnded` stays a plain `Exited`/turn-end fact for
646/// those, never inflated into a block this build cannot actually
647/// attribute. `ProviderRefusal`'s `reason` has no source text to read on
648/// `SessionEnded` itself (ACP's `refusal` stopReason carries none) -- the
649/// honest minimum names the fact that happened, never invented detail; a
650/// later slice that threads the turn's last agent text chunk through can
651/// replace it without changing this function's contract.
652fn provider_session_end_block(
653    stop_reason: &ProviderStopReason,
654) -> Option<(BlockAuthorityV1, String, String)> {
655    match stop_reason {
656        ProviderStopReason::Refusal => Some((
657            BlockAuthorityV1::ProviderRefusal,
658            "refusal".to_owned(),
659            "the provider ended the turn with stopReason: refusal".to_owned(),
660        )),
661        ProviderStopReason::ProviderError { message, vendor_code, .. }
662            if provider_error_is_quota(vendor_code.as_deref(), message) =>
663        {
664            let reason_kind = vendor_code.clone().unwrap_or_else(|| "provider-error".to_owned());
665            Some((BlockAuthorityV1::ProviderQuota, reason_kind, message.clone()))
666        }
667        _ => None,
668    }
669}
670
671/// Builds the `AgentStreamChunkV1` `event`'s `ProviderEvent` carries onto the
672/// `agent-stream-events-v1` channel -- see
673/// `docs/gate4agent/plans/gate4agent-acp-control-plane-on-the-wire-2026-09-02.md`
674/// §5. Two invariants keep the chunk correlatable with the `NodeEvent::Control`
675/// event it derives from, so a client joining the two sees one id and one
676/// ordering:
677///
678/// - `source_sequence` on the chunk is the `source_sequence` the provider
679///   event itself carries.
680/// - `InteractionPrompt::correlation_id` is minted by
681///   `correlation::interaction_correlation` with the
682///   `(instance_id, generation, provider_sequence)` triple -- never the
683///   provider's own `request_id`, whose opacity on the wire is pinned by a
684///   dedicated test.
685///
686/// Returns `None` for every `ControlEventKind` other than `ProviderEvent`
687/// (nothing else carries stream content), for a source that is not a live
688/// session transport, and for the `ProviderEvent` variants below with no
689/// declared chunk kind to carry them, or whose declared chunk kind this build
690/// has no source for yet:
691///
692/// - `ModelCatalog { current, available }` has no `ProviderEvent` source at
693///   all -- grok's vendor model switch is not bridged into `ProviderEvent`
694///   yet, the same gap the plan names explicitly.
695/// - `UserMessage { text, is_delta }` is an echo of the OPERATOR's own
696///   prompt, replayed on session resume -- `Text`/`Thinking` are documented
697///   as "what the agent is saying", so mapping the operator's words onto
698///   `Text` would misrepresent whose content it is. No dedicated
699///   user-message kind is declared, so this stays unproduced rather than
700///   mislabeled.
701///
702/// `HostRequestObserved { .. }` is its own arm below: a `Granted`/`Deferred`
703/// decision returns `None` (nothing for the operator to react to), a `Denied`
704/// decision returns `Some(Blocked { .. })`.
705///
706/// `ToolCompleted { .. }`/`SessionEnded { .. }` are likewise their own arms
707/// below (K2): each returns `None` unless
708/// `provider_tool_block_authority`/`provider_session_end_block` classifies the
709/// event as an actual block (ACP's `_meta.nonExecutionKind`, or `stopReason`/an
710/// RPC error naming a quota).
711fn agent_stream_chunk(event: &ControlEvent) -> Option<AgentStreamChunkV1> {
712    let ControlEventKind::ProviderEvent {
713        sequence: provider_sequence,
714        source,
715        source_sequence,
716        event: provider_event,
717    } = &event.event
718    else {
719        return None;
720    };
721    if !provider_family_is_live(source.family) {
722        return None;
723    }
724    let kind = match provider_event {
725        ProviderEvent::Text { text, is_delta } => AgentStreamChunkKindV1::Text {
726            text: text.clone(),
727            is_delta: *is_delta,
728        },
729        ProviderEvent::Thinking { text } => {
730            AgentStreamChunkKindV1::Thinking { text: text.clone() }
731        }
732        ProviderEvent::InteractionRequested {
733            interaction_kind,
734            tool_name,
735            title,
736            prompt,
737            options,
738            ..
739        } => AgentStreamChunkKindV1::InteractionPrompt {
740            correlation_id: interaction_correlation(
741                event.instance_id,
742                event.generation,
743                *provider_sequence,
744            ),
745            interaction_kind: *interaction_kind,
746            tool_name: tool_name.clone(),
747            // `title`/`options` are carried straight through from
748            // `ProviderEvent::InteractionRequested`, as the agent itself
749            // gave them on `session/request_permission`'s `PermissionTool
750            // Call.title`/`options` (see `gate4agent-shell-native`'s
751            // `provider_event`, the only ACP-transport producer). A PTY- or
752            // hook-sourced interaction carries `title: None` and
753            // `options: []` from its own producer because that source has
754            // none to offer, never because this mapping dropped one.
755            title: title.clone(),
756            prompt: prompt.clone(),
757            options: options
758                .iter()
759                .map(|option| AgentStreamInteractionOptionV1 {
760                    option_id: option.option_id.clone(),
761                    name: option.name.clone(),
762                    kind: option.kind.clone(),
763                })
764                .collect(),
765        },
766        ProviderEvent::ConfigOptionsUpdated { options } => {
767            AgentStreamChunkKindV1::ConfigOptions { options: options.clone() }
768        }
769        // `available` is the mode catalogue `AcpSession::available_modes()`
770        // read back at `session/new` and threaded through by
771        // `gate4agent-shell-native`'s `provider_event` (see that crate's
772        // `ProviderEvent::ModeChanged` arm) -- an empty list here is the
773        // agent's own honest "no modes announced", never "not read": ACP
774        // orders `session/new` before any `session/update`, and the
775        // catalogue is fixed at handshake, so a `ModeChanged` event cannot
776        // exist before the same session object's catalogue is already
777        // populated (see `gate4agent_types::ProviderEvent::ModeChanged`'s
778        // own doc comment for the ordering argument in full).
779        ProviderEvent::ModeChanged { mode_id, available } => AgentStreamChunkKindV1::ModeCatalog {
780            current: Some(mode_id.clone()),
781            available: available
782                .iter()
783                .map(|mode| AgentStreamNamedIdV1 {
784                    id: mode.id.clone(),
785                    name: mode.name.clone(),
786                    description: mode.description.clone(),
787                })
788                .collect(),
789        },
790        // A denied host request is a block -- see this function's own doc
791        // comment for why `HostRequestObserved` is not in the exclusion list
792        // below.
793        ProviderEvent::HostRequestObserved { method, decision, reason, .. } => {
794            let ProviderHostRequestDecision::Denied { by } = decision else {
795                return None;
796            };
797            let (tool_class, authority, reason_text) =
798                host_request_denied_block(method, *by, reason.as_deref());
799            AgentStreamChunkKindV1::Blocked {
800                correlation_id: None,
801                tool_class,
802                authority,
803                reason_kind: None,
804                reason: reason_text,
805                help: None,
806            }
807        }
808        // Same relationship as `HostRequestObserved` above, driven by ACP's
809        // own `_meta.nonExecutionKind` instead of the host's
810        // `HostRequestDecision`.
811        ProviderEvent::ToolCompleted { id, output, non_execution_kind, .. } => {
812            let (authority, reason_kind) =
813                provider_tool_block_authority(non_execution_kind.as_deref())?;
814            let (reason, help) = split_provider_denial_text(output);
815            let (reason, _) =
816                truncate_text(&reason, MAX_ACP_BLOCKED_REASON_BYTES);
817            let help = help.map(|help| {
818                truncate_text(&help, MAX_ACP_BLOCKED_HELP_BYTES).0
819            });
820            AgentStreamChunkKindV1::Blocked {
821                correlation_id: Some(tool_correlation(event, source, id)),
822                tool_class: tool_class_from_denial_help(help.as_deref()),
823                authority,
824                reason_kind: Some(reason_kind.to_owned()),
825                reason,
826                help,
827            }
828        }
829        // Same relationship, driven by `stopReason: "refusal"` or a
830        // `session/prompt` RPC error naming a quota/limit/rate exhaustion
831        // instead of a tool-level fact.
832        ProviderEvent::SessionEnded { stop_reason, .. } => {
833            let (authority, reason_kind, reason) =
834                stop_reason.as_ref().and_then(provider_session_end_block)?;
835            let (reason, _) =
836                truncate_text(&reason, MAX_ACP_BLOCKED_REASON_BYTES);
837            AgentStreamChunkKindV1::Blocked {
838                correlation_id: None,
839                tool_class: "Session".to_owned(),
840                authority,
841                reason_kind: Some(reason_kind),
842                reason,
843                help: None,
844            }
845        }
846        ProviderEvent::SessionStarted { .. }
847        | ProviderEvent::SessionIdentityObserved { .. }
848        | ProviderEvent::TurnStarted { .. }
849        | ProviderEvent::WorkingObserved
850        | ProviderEvent::ToolStarted { .. }
851        | ProviderEvent::TurnCompleted { .. }
852        | ProviderEvent::ContextWindowUsage { .. }
853        | ProviderEvent::TurnInterrupted
854        | ProviderEvent::Error { .. }
855        | ProviderEvent::Ready
856        | ProviderEvent::InteractionResolved { .. }
857        | ProviderEvent::SubagentStarted { .. }
858        | ProviderEvent::SubagentStopped { .. }
859        | ProviderEvent::RateLimited { .. }
860        | ProviderEvent::UnrecognizedNotification { .. }
861        | ProviderEvent::UserMessage { .. }
862        | ProviderEvent::Plan { .. }
863        | ProviderEvent::AvailableCommandsUpdated { .. }
864        | ProviderEvent::SessionInfoUpdated { .. }
865        | ProviderEvent::UsageUpdated { .. } => return None,
866    };
867    Some(AgentStreamChunkV1 { source_sequence: *source_sequence, kind })
868}
869
870async fn run_native_session_catalog_operation<T, E, F>(
871    catalog: Arc<Mutex<NativeSessionCatalogAuthority>>,
872    operation: &'static str,
873    execute: F,
874) -> Result<T, NodeFailure>
875where
876    T: Send + 'static,
877    E: std::fmt::Display + Send + 'static,
878    F: FnOnce(&mut NativeSessionCatalogAuthority) -> Result<T, E> + Send + 'static,
879{
880    let task = tokio::task::spawn_blocking(move || {
881        let mut catalog = match catalog.try_lock() {
882            Ok(catalog) => catalog,
883            Err(std::sync::TryLockError::Poisoned(poisoned)) => poisoned.into_inner(),
884            Err(std::sync::TryLockError::WouldBlock) => {
885                return Err(failure(
886                    NodeFailureCode::BackendBusy,
887                    "native session catalog operation is already running",
888                ));
889            }
890        };
891        execute(&mut catalog).map_err(|error| {
892            failure(
893                NodeFailureCode::BackendOperationFailed,
894                &format!("{operation} failed: {error}"),
895            )
896        })
897    });
898    timeout(Duration::from_millis(NATIVE_SESSION_CATALOG_TIMEOUT_MS), task)
899        .await
900        .map_err(|_| {
901            failure(
902                NodeFailureCode::BackendBusy,
903                &format!("{operation} exceeded its bounded deadline"),
904            )
905        })?
906        .map_err(|_| {
907            failure(
908                NodeFailureCode::BackendOperationFailed,
909                &format!("{operation} task failed"),
910            )
911        })?
912}
913
914fn windows_path_text(path: &OpaqueHostPath) -> &str {
915    path.as_utf8()
916        .expect("Windows node only creates UTF-8 opaque host paths")
917}
918
919fn require_windows_path(path: OpaqueHostPath) -> Result<String, NodeFailure> {
920    path.as_utf8()
921        .map(str::to_owned)
922        .ok_or_else(|| failure(
923            NodeFailureCode::InvalidRequest,
924            "this node requires UTF-8 host paths",
925        ))
926}
927
928fn protocol_worktree(mut worktree: NativeGitWorktreeSnapshot) -> GitWorktreeSnapshot {
929    GitWorktreeSnapshot {
930        path: opaque_windows_path(worktree.path),
931        head: worktree.head,
932        branch: worktree.branch,
933        is_bare: worktree.is_bare,
934        is_main: worktree.is_main,
935        locked: worktree.locked,
936        lock_reason: worktree.lock_reason,
937        prunable: worktree.prunable,
938        prunable_reason: worktree.prunable_reason,
939        workspace_id: worktree.workspace_id.take(),
940    }
941}
942
943#[derive(Clone, Debug, Eq, PartialEq)]
944pub struct WorkspaceConfig {
945    workspace_id: WorkspaceId,
946    canonical_root: String,
947    worktree_service_mode: WorktreeServiceMode,
948    managed_worktree_profiles: BTreeMap<WorktreeProfileId, ManagedWorktreeProfile>,
949}
950
951impl WorkspaceConfig {
952    pub fn new(
953        workspace_id: WorkspaceId,
954        root: impl AsRef<Path>,
955    ) -> Result<Self, NodeServerError> {
956        let root = root.as_ref();
957        if !root.is_absolute() {
958            return Err(NodeServerError::InvalidWorkspaceRoot {
959                workspace_id,
960                path: root.to_string_lossy().into_owned(),
961                message: "path is not absolute".to_owned(),
962            });
963        }
964        let canonical = std::fs::canonicalize(root).map_err(|error| {
965            NodeServerError::InvalidWorkspaceRoot {
966                workspace_id: workspace_id.clone(),
967                path: root.to_string_lossy().into_owned(),
968                message: error.to_string(),
969            }
970        })?;
971        if !canonical.is_dir() {
972            return Err(NodeServerError::InvalidWorkspaceRoot {
973                workspace_id,
974                path: root.to_string_lossy().into_owned(),
975                message: "path is not a directory".to_owned(),
976            });
977        }
978        let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
979            NodeServerError::InvalidWorkspaceRoot {
980                workspace_id: workspace_id.clone(),
981                path: path.to_string_lossy().into_owned(),
982                message: "canonical path is not valid Unicode".to_owned(),
983            }
984        })?;
985        let canonical_root = platform::normalize_canonical_root(canonical_root);
986        validate_workspace_root(&workspace_id, &canonical_root)?;
987        Ok(Self {
988            workspace_id,
989            canonical_root,
990            worktree_service_mode: WorktreeServiceMode::Manual,
991            managed_worktree_profiles: BTreeMap::new(),
992        })
993    }
994
995    pub fn workspace_id(&self) -> &WorkspaceId {
996        &self.workspace_id
997    }
998
999    pub fn canonical_root(&self) -> &str {
1000        &self.canonical_root
1001    }
1002
1003    pub fn with_worktree_service_mode(mut self, mode: WorktreeServiceMode) -> Self {
1004        self.worktree_service_mode = mode;
1005        self
1006    }
1007
1008    pub fn with_managed_worktree_profile(
1009        mut self,
1010        profile: ManagedWorktreeProfile,
1011    ) -> Result<Self, NodeServerError> {
1012        profile.validate_for_workspace(&self.canonical_root).map_err(|message| {
1013            NodeServerError::InvalidManagedWorktreeProfile {
1014                workspace_id: self.workspace_id.clone(),
1015                profile_id: profile.profile_id().clone(),
1016                message,
1017            }
1018        })?;
1019        if self.managed_worktree_profiles.contains_key(profile.profile_id()) {
1020            return Err(NodeServerError::DuplicateManagedWorktreeProfile {
1021                workspace_id: self.workspace_id,
1022                profile_id: profile.profile_id().clone(),
1023            });
1024        }
1025        if self.managed_worktree_profiles.len()
1026            == MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE
1027        {
1028            return Err(NodeServerError::ManagedWorktreeProfileCapacity {
1029                workspace_id: self.workspace_id,
1030                max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
1031            });
1032        }
1033        self.managed_worktree_profiles
1034            .insert(profile.profile_id().clone(), profile);
1035        Ok(self)
1036    }
1037}
1038
1039#[derive(Clone)]
1040pub struct NodeServerConfig {
1041    pub endpoint: String,
1042    api_listen: Option<std::net::SocketAddr>,
1043    /// Opt-in node-envelope HTTP+WS browser bridge (separate from `--api-listen`
1044    /// ops HTTP and from framed C2). Loopback only; default `None` = off.
1045    bridge_listen: Option<std::net::SocketAddr>,
1046    /// Optional bridge shared secret (`GATE4AGENT_BRIDGE_TOKEN`). Distinct from
1047    /// NODE_TOKEN; never logged. `None` = loopback bind is the only gate.
1048    bridge_token: Option<String>,
1049    /// 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        if !relay.ip().is_loopback() || relay.port() == 0 {
1251            return Err(NodeServerError::InvalidCallHome(relay));
1252        }
1253        self.call_home = Some(relay);
1254        Ok(self)
1255    }
1256
1257    pub fn with_state_path(
1258        mut self,
1259        state_path: impl Into<PathBuf>,
1260    ) -> Result<Self, NodeServerError> {
1261        let state_path = state_path.into();
1262        if !state_path.is_absolute() || state_path.file_name().is_none() {
1263            return Err(NodeServerError::InvalidStatePath(
1264                state_path.to_string_lossy().into_owned(),
1265            ));
1266        }
1267        self.state_path = Some(state_path);
1268        Ok(self)
1269    }
1270
1271    pub fn with_spawn_profiles(mut self, spawn_profiles: SpawnProfileRegistry) -> Self {
1272        self.spawn_profiles = spawn_profiles;
1273        self
1274    }
1275
1276    /// Sets the age/keep-N policy the drive loop's retention sweep uses to
1277    /// retire dead `ManagedSessionState::Unavailable` records. Both fields
1278    /// default to `0` (disabled) via `SessionRecordRetentionConfig::default`
1279    /// when this is never called -- a node never deletes a durable record
1280    /// until an operator opts in with a real value.
1281    pub fn with_session_record_retention(
1282        mut self,
1283        retention: SessionRecordRetentionConfig,
1284    ) -> Self {
1285        self.session_record_retention = retention;
1286        self
1287    }
1288
1289    pub fn with_session_environment_materialization(
1290        mut self,
1291        root: impl Into<PathBuf>,
1292        resolver: Arc<dyn NodeSecretResolver>,
1293    ) -> Result<Self, NodeServerError> {
1294        let root = root.into();
1295        if !root.is_absolute() {
1296            return Err(NodeServerError::InvalidSessionEnvironmentRoot);
1297        }
1298        self.session_environment = Some(NodeSessionEnvironmentConfig { root, resolver });
1299        Ok(self)
1300    }
1301
1302    /// Enables bounded provider-history discovery and loading from explicit
1303    /// host-owned roots. Ambient vendor homes are never inferred here.
1304    pub fn with_history(mut self, history: NativeHistoryConfig) -> Self {
1305        self.history = Some(history);
1306        self
1307    }
1308
1309    /// Enables the H3B read proxy with one exact operator-reviewed helper file.
1310    pub fn with_harness_mcp_helper(
1311        mut self,
1312        helper_program: impl Into<PathBuf>,
1313    ) -> Result<Self, NodeServerError> {
1314        #[cfg(not(windows))]
1315        {
1316            let _ = helper_program;
1317            return Err(NodeServerError::InvalidHarnessMcpHelper);
1318        }
1319        #[cfg(windows)]
1320        {
1321        self.harness_mcp_helper = Some(
1322            ReviewedHarnessMcpProgram::review(helper_program.into())
1323                .map_err(|_| NodeServerError::InvalidHarnessMcpHelper)?,
1324        );
1325        Ok(self)
1326        }
1327    }
1328
1329    /// Install an in-memory station network allowlist catalog (opaque ids +
1330    /// optional node-local permits / provider-native mapping). Empty remains
1331    /// the default — unknown spawn ids refuse at resolve. Never cookies /
1332    /// OAuth / proxy credentials. C2 / inventory still list ids only.
1333    pub fn with_network_allowlist_catalog(
1334        mut self,
1335        catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog,
1336    ) -> Result<Self, NodeServerError> {
1337        if catalog.len() > crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES {
1338            return Err(NodeServerError::NetworkAllowlistCatalogCapacity {
1339                max: crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES,
1340            });
1341        }
1342        self.network_allowlist_catalog = catalog;
1343        Ok(self)
1344    }
1345
1346    /// Load station network allowlist catalog from an absolute regular file
1347    /// (v1 id-list or v2 JSON schema). Unset path is not used here — callers
1348    /// that want env fallback use `resolve_network_allowlist_catalog`.
1349    pub fn with_network_allowlist_catalog_file(
1350        self,
1351        path: impl AsRef<std::path::Path>,
1352    ) -> Result<Self, NodeServerError> {
1353        let catalog = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(path)
1354            .map_err(NodeServerError::from)?;
1355        self.with_network_allowlist_catalog(catalog)
1356    }
1357}
1358
1359pub fn default_state_path(node_id: &NodeId) -> Result<PathBuf, NodeServerError> {
1360    platform::default_state_path(node_id.as_str())
1361        .ok_or(NodeServerError::LocalStateDirectoryUnavailable)
1362}
1363
1364pub fn default_node_endpoint() -> Result<PathBuf, NodeServerError> {
1365    platform::default_node_endpoint().ok_or(NodeServerError::LocalRuntimeDirectoryUnavailable)
1366}
1367
1368fn delivery_store_root_for_state_path(state_path: &Path) -> Option<PathBuf> {
1369    let parent = state_path.parent()?;
1370    let mut name = state_path.file_name()?.to_os_string();
1371    name.push(".delivery-store");
1372    Some(parent.join(name))
1373}
1374
1375fn context_pack_store_root_for_state_path(state_path: &Path) -> Option<PathBuf> {
1376    let parent = state_path.parent()?;
1377    let mut name = state_path.file_name()?.to_os_string();
1378    name.push(".context-pack-store");
1379    Some(parent.join(name))
1380}
1381
1382pub struct NodeServer {
1383    config: NodeServerConfig,
1384    runtime: NativeRuntime,
1385    shared: Arc<NodeShared>,
1386    events: EventSubscription,
1387    state_path_lock: Option<session_registry::StatePathLock>,
1388}
1389
1390#[cfg(feature = "fixture")]
1391#[derive(Clone, Default)]
1392pub struct SpawnManagedWorktreeV2FailureProbe {
1393    latest_code: Arc<Mutex<Option<NodeFailureCode>>>,
1394}
1395
1396#[cfg(feature = "fixture")]
1397impl SpawnManagedWorktreeV2FailureProbe {
1398    pub fn latest_code(&self) -> Option<NodeFailureCode> {
1399        *self
1400            .latest_code
1401            .lock()
1402            .unwrap_or_else(|poisoned| poisoned.into_inner())
1403    }
1404
1405    fn record(&self, code: Option<NodeFailureCode>) {
1406        *self
1407            .latest_code
1408            .lock()
1409            .unwrap_or_else(|poisoned| poisoned.into_inner()) = code;
1410    }
1411}
1412
1413#[cfg(all(feature = "fixture", windows))]
1414struct IsolatedCodexHomeFixtureEnvironment;
1415
1416#[cfg(all(feature = "fixture", windows))]
1417impl gate4agent_runtime_native::NativeChildEnvironmentResolver
1418    for IsolatedCodexHomeFixtureEnvironment
1419{
1420    fn resolve_child_environment(
1421        &self,
1422    ) -> Result<
1423        Vec<gate4agent_catalog::EnvMutation>,
1424        gate4agent_runtime_native::NativeChildEnvironmentResolveError,
1425    > {
1426        Ok(vec![gate4agent_catalog::EnvMutation {
1427            key: OsString::from("USERPROFILE"),
1428            value: None,
1429        }])
1430    }
1431}
1432
1433impl NodeServer {
1434    pub fn new(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1435        let catalog = active_registry()?;
1436        Self::new_with_registry(config, catalog)
1437    }
1438
1439    #[cfg(feature = "fixture")]
1440    pub fn new_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1441        let mut spec = gate4agent_testkit::interactive_agent_spec();
1442        spec.id = AgentId::new("claude").map_err(|error| NodeServerError::Registry(error.to_string()))?;
1443        spec.display_name = "Controlled Claude PTY fixture".to_owned();
1444        Self::new_fixture_with_spec(config, spec)
1445    }
1446
1447    #[cfg(feature = "fixture")]
1448    pub fn new_clean_exit_fixture(
1449        config: NodeServerConfig,
1450        fixture_root: PathBuf,
1451        started_marker: PathBuf,
1452        release_signal: PathBuf,
1453    ) -> Result<Self, NodeServerError> {
1454        let mut spec = gate4agent_testkit::controlled_clean_exit_agent_spec(
1455            &fixture_root,
1456            &started_marker,
1457            &release_signal,
1458        )
1459        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1460        spec.id = AgentId::new("claude")
1461            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1462        spec.display_name = "Controlled Claude clean-exit fixture".to_owned();
1463        Self::new_fixture_with_spec(config, spec)
1464    }
1465
1466    /// A `claude` source whose session establishes a verified provider
1467    /// identity via an early `SessionStart` Hook post (so it is eligible for
1468    /// auto-export-at-exit and its native history is independently
1469    /// loadable), waits for `release_signal`, then exits cleanly — paired
1470    /// on the same Node with a plain `codex` target that never validates
1471    /// anything about what it receives. Built for A2 durable ContextPack
1472    /// E2E coverage: the test itself inspects materialized files and
1473    /// managed-session-record fields directly, rather than having the
1474    /// target script self-validate.
1475    #[cfg(all(feature = "fixture", windows))]
1476    pub fn new_durable_context_pack_clean_exit_fixture(
1477        config: NodeServerConfig,
1478        fixture_root: PathBuf,
1479        started_marker: PathBuf,
1480        release_signal: PathBuf,
1481        session_id: &str,
1482    ) -> Result<Self, NodeServerError> {
1483        let mut source_spec = gate4agent_testkit::identified_clean_exit_agent_spec(
1484            &fixture_root,
1485            &started_marker,
1486            &release_signal,
1487            session_id,
1488        )
1489        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1490        let source_id = AgentId::new("claude")
1491            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1492        let source_provider = builtin_registry().get(&source_id).ok_or_else(|| {
1493            NodeServerError::Registry(
1494                "durable context pack clean-exit fixture source adapter is unavailable"
1495                    .to_owned(),
1496            )
1497        })?;
1498        source_spec.capabilities.adapters.history =
1499            source_provider.capabilities.adapters.history.clone();
1500        source_spec.id = source_id;
1501        source_spec.display_name =
1502            "Controlled Claude durable context-pack clean-exit fixture".to_owned();
1503        // Both specs launch via `powershell.exe`; the registry's ambiguity
1504        // check keys on `detection.command` (+ aliases), not `launch.program`
1505        // -- give each a distinct detection identity, same precedent as
1506        // `context_pack_fixture_catalog`'s per-provider
1507        // `gate4agent-{provider}-context-fixture.cmd` commands.
1508        source_spec.detection.command =
1509            "gate4agent-claude-durable-context-pack-fixture.cmd".to_owned();
1510        source_spec.detection.aliases.clear();
1511
1512        let mut target_spec = gate4agent_testkit::interactive_agent_spec();
1513        target_spec.id = AgentId::new("codex")
1514            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1515        target_spec.display_name =
1516            "Controlled Codex durable context-pack target fixture".to_owned();
1517        target_spec.detection.command =
1518            "gate4agent-codex-durable-context-pack-fixture.cmd".to_owned();
1519        target_spec.detection.aliases.clear();
1520
1521        let catalog = AgentRegistry::new([source_spec, target_spec])
1522            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1523        let mut server = Self::new_with_registry(config, catalog)?;
1524        Arc::get_mut(&mut server.shared)
1525            .expect("fixture Node shared state must remain uniquely owned before run")
1526            .fixture_semantic_hook_policy = true;
1527        Ok(server)
1528    }
1529
1530    #[cfg(feature = "fixture")]
1531    pub fn new_monitoring_hook_fixture(
1532        config: NodeServerConfig,
1533    ) -> Result<Self, NodeServerError> {
1534        let mut spec = gate4agent_testkit::monitoring_hook_agent_spec();
1535        spec.id = AgentId::new("claude")
1536            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1537        let mut server = Self::new_fixture_with_spec(config, spec)?;
1538        Arc::get_mut(&mut server.shared)
1539            .expect("fixture Node shared state must remain uniquely owned before run")
1540            .fixture_semantic_hook_policy = true;
1541        Ok(server)
1542    }
1543
1544    #[cfg(all(feature = "fixture", windows))]
1545    pub fn new_provider_bundle_argv_fixture(
1546        config: NodeServerConfig,
1547        agent_id: AgentId,
1548        proof_path: PathBuf,
1549    ) -> Result<Self, NodeServerError> {
1550        let spec = Self::provider_bundle_argv_fixture_spec(agent_id, proof_path, None)?;
1551        Self::new_fixture_with_spec(config, spec)
1552    }
1553
1554    #[cfg(all(feature = "fixture", windows))]
1555    pub fn new_provider_bundle_argv_hold_fixture(
1556        config: NodeServerConfig,
1557        agent_id: AgentId,
1558        proof_path: PathBuf,
1559        release_signal: PathBuf,
1560    ) -> Result<Self, NodeServerError> {
1561        let spec = Self::provider_bundle_argv_fixture_spec(
1562            agent_id,
1563            proof_path,
1564            Some(release_signal),
1565        )?;
1566        Self::new_fixture_with_spec(config, spec)
1567    }
1568
1569    #[cfg(all(feature = "fixture", windows))]
1570    fn provider_bundle_argv_fixture_spec(
1571        agent_id: AgentId,
1572        proof_path: PathBuf,
1573        release_signal: Option<PathBuf>,
1574    ) -> Result<gate4agent_types::AgentSpec, NodeServerError> {
1575        if !matches!(agent_id.as_str(), "claude" | "codex" | "kimi") {
1576            return Err(NodeServerError::Registry(
1577                "provider bundle argv fixture requires Claude, Codex, or Kimi".to_owned(),
1578            ));
1579        }
1580        let mut spec = gate4agent_testkit::interactive_agent_spec();
1581        spec.id = agent_id.clone();
1582        spec.display_name = format!("Controlled {agent_id} bundle argv fixture");
1583        let provider = builtin_registry()
1584            .get(&agent_id)
1585            .ok_or_else(|| {
1586                NodeServerError::Registry(
1587                    "provider bundle argv fixture adapter is unavailable".to_owned(),
1588                )
1589            })?;
1590        spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1591        if !proof_path.is_absolute()
1592            || !proof_path
1593                .parent()
1594                .is_some_and(|parent| parent.is_dir())
1595        {
1596            return Err(NodeServerError::Registry(
1597                "provider bundle argv proof path requires an existing absolute parent"
1598                    .to_owned(),
1599            ));
1600        }
1601        if let Some(release_signal) = release_signal.as_ref() {
1602            if !release_signal.is_absolute()
1603                || !release_signal
1604                    .parent()
1605                    .is_some_and(|parent| parent.is_dir())
1606            {
1607                return Err(NodeServerError::Registry(
1608                    "provider bundle argv release path requires an existing absolute parent"
1609                        .to_owned(),
1610                ));
1611            }
1612            if release_signal.exists() {
1613                return Err(NodeServerError::Registry(
1614                    "provider bundle argv release path must not exist".to_owned(),
1615                ));
1616            }
1617            let proof_parent = std::fs::canonicalize(
1618                proof_path
1619                    .parent()
1620                    .expect("validated provider bundle proof parent"),
1621            )
1622            .map_err(|_| {
1623                NodeServerError::Registry(
1624                    "provider bundle argv proof parent is unavailable".to_owned(),
1625                )
1626            })?;
1627            let release_parent = std::fs::canonicalize(
1628                release_signal
1629                    .parent()
1630                    .expect("validated provider bundle release parent"),
1631            )
1632            .map_err(|_| {
1633                NodeServerError::Registry(
1634                    "provider bundle argv release parent is unavailable".to_owned(),
1635                )
1636            })?;
1637            if proof_parent != release_parent {
1638                return Err(NodeServerError::Registry(
1639                    "provider bundle argv release path must share the proof parent".to_owned(),
1640                ));
1641            }
1642        }
1643        let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1644            NodeServerError::Registry(
1645                "provider bundle argv proof path is not valid Unicode".to_owned(),
1646            )
1647        })?;
1648        let release_signal = release_signal.map(|release_signal| {
1649            release_signal.into_os_string().into_string().map_err(|_| {
1650                NodeServerError::Registry(
1651                    "provider bundle argv release path is not valid Unicode".to_owned(),
1652                )
1653            })
1654        }).transpose()?;
1655        let script = spec
1656            .launch
1657            .fixed_args
1658            .pop()
1659            .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
1660        let bundle_validation = if agent_id.as_str() == "codex" {
1661            "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') }"
1662        } else if agent_id.as_str() == "claude" {
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 '--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 }"
1664        } else {
1665            "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 }"
1666        };
1667        if let Some(release_signal) = release_signal {
1668            spec.launch.fixed_args.push(format!(
1669                "& {{ 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} }}",
1670            ));
1671            spec.launch.fixed_args.push(proof_path);
1672            spec.launch.fixed_args.push(release_signal);
1673        } else {
1674            spec.launch.fixed_args.push(format!(
1675                "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {bundle_validation}; {script} }}",
1676            ));
1677            spec.launch.fixed_args.push(proof_path);
1678        }
1679        Ok(spec)
1680    }
1681
1682    #[cfg(all(feature = "fixture", windows))]
1683    pub fn new_context_pack_fixture(
1684        config: NodeServerConfig,
1685        proof_path: PathBuf,
1686    ) -> Result<Self, NodeServerError> {
1687        let catalog = Self::context_pack_fixture_catalog(proof_path, None, false)?;
1688        Self::new_with_registry(config, catalog)
1689    }
1690
1691    #[cfg(all(feature = "fixture", windows))]
1692    pub fn new_monitoring_context_pack_fixture(
1693        config: NodeServerConfig,
1694        proof_path: PathBuf,
1695    ) -> Result<Self, NodeServerError> {
1696        let catalog = Self::context_pack_fixture_catalog(proof_path, None, true)?;
1697        let mut server = Self::new_with_registry(config, catalog)?;
1698        Arc::get_mut(&mut server.shared)
1699            .expect("fixture Node shared state must remain uniquely owned before run")
1700            .fixture_semantic_hook_policy = true;
1701        Ok(server)
1702    }
1703
1704    #[cfg(all(feature = "fixture", windows))]
1705    pub fn install_isolated_codex_home_environment_fixture(
1706        &mut self,
1707        profile_id: SpawnEnvironmentProfileId,
1708        profile_revision: crate::protocol::SpawnEnvironmentProfileRevision,
1709    ) -> Result<(), NodeServerError> {
1710        let provider = AgentId::new("codex")
1711            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1712        let native_profile = gate4agent_runtime_native::NativeLaunchProfile::new(
1713            gate4agent_runtime_native::NativeLaunchProfileId::new(format!(
1714                "{}-pty",
1715                profile_id.as_str(),
1716            ))
1717            .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1718            provider.clone(),
1719            TransportKind::Pty,
1720            vec![OsString::from("USERPROFILE")],
1721            Arc::new(IsolatedCodexHomeFixtureEnvironment),
1722        )
1723        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1724        let materialization = NodeSessionMaterializationProfile::new(
1725            Vec::new(),
1726            vec![crate::session_environment::NodeSessionPathBinding::new(
1727                "CODEX_HOME",
1728                crate::session_environment::NodeSessionPathClass::ProviderHome,
1729            )
1730            .map_err(|error| NodeServerError::Registry(error.to_string()))?],
1731            Vec::new(),
1732        )
1733        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1734        self.install_environment_profile(
1735            NodeEnvironmentProfile::new_with_materialization(
1736                profile_id,
1737                profile_revision,
1738                provider,
1739                [native_profile],
1740                Some(materialization),
1741            )
1742            .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1743        )
1744    }
1745
1746    #[cfg(all(feature = "fixture", windows))]
1747    pub fn new_context_only_proof_fixture(
1748        config: NodeServerConfig,
1749        target_provider: AgentId,
1750        proof_path: PathBuf,
1751    ) -> Result<Self, NodeServerError> {
1752        if target_provider.as_str() != "kimi" {
1753            return Err(NodeServerError::Registry(
1754                "context-only proof fixture requires Kimi".to_owned(),
1755            ));
1756        }
1757        let catalog = Self::context_pack_fixture_catalog(
1758            proof_path,
1759            Some(&target_provider),
1760            false,
1761        )?;
1762        Self::new_with_registry(config, catalog)
1763    }
1764
1765    #[cfg(all(feature = "fixture", windows))]
1766    fn context_pack_fixture_catalog(
1767        proof_path: PathBuf,
1768        context_only_target: Option<&AgentId>,
1769        monitoring_claude_source: bool,
1770    ) -> Result<AgentRegistry, NodeServerError> {
1771        if !proof_path.is_absolute()
1772            || !proof_path
1773                .parent()
1774                .is_some_and(|parent| parent.is_dir())
1775        {
1776            return Err(NodeServerError::Registry(
1777                "context pack proof path requires an existing absolute parent".to_owned(),
1778            ));
1779        }
1780        let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1781            NodeServerError::Registry(
1782                "context pack proof path is not valid Unicode".to_owned(),
1783            )
1784        })?;
1785        let providers = ["claude", "codex", "grok", "kimi"];
1786        let builtins = builtin_registry();
1787        let mut specs = Vec::with_capacity(providers.len());
1788        for provider_id in providers {
1789            let agent_id = AgentId::new(provider_id)
1790                .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1791            let provider = builtins.get(&agent_id).ok_or_else(|| {
1792                NodeServerError::Registry(
1793                    "context pack fixture provider adapter is unavailable".to_owned(),
1794                )
1795            })?;
1796            let monitored_provider =
1797                monitoring_claude_source && matches!(provider_id, "claude" | "codex");
1798            let mut spec = if monitored_provider {
1799                gate4agent_testkit::monitoring_hook_agent_spec()
1800            } else {
1801                gate4agent_testkit::interactive_agent_spec()
1802            };
1803            spec.id = agent_id.clone();
1804            spec.display_name = format!("Controlled {agent_id} context pack fixture");
1805            spec.detection.command = format!("gate4agent-{provider_id}-context-fixture.cmd");
1806            spec.detection.aliases.clear();
1807            spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1808            if provider_id == "codex" {
1809                let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1810                    NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1811                })?;
1812                let validation = r#"
1813$bundleArgs = @($bundleArgs)
1814if ($bundleArgs.Count -ne 0) { exit 91 }
1815$codexHome = [Environment]::GetEnvironmentVariable('CODEX_HOME', 'Process')
1816$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1817$cwd = (Get-Location).ProviderPath
1818if ([string]::IsNullOrEmpty($codexHome) -or -not [IO.Path]::IsPathRooted($codexHome) -or -not (Test-Path -LiteralPath $codexHome -PathType Container)) { exit 92 }
1819$skillPath = Join-Path $codexHome 'skills/review-code/SKILL.md'
1820if (-not (Test-Path -LiteralPath $skillPath -PathType Leaf)) { exit 93 }
1821$homeFiles = @(Get-ChildItem -LiteralPath $codexHome -Recurse -Force -File)
1822if ($homeFiles.Count -ne 1 -or [IO.Path]::GetFullPath($homeFiles[0].FullName) -ine [IO.Path]::GetFullPath($skillPath)) { exit 94 }
1823$sha = [Security.Cryptography.SHA256]::Create()
1824try { $skillHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($skillPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1825if ($skillHash -cne 'd78fe03be106b673fcf5415c8359e99f0298b5071cd11822c58ebcb27e52a68d') { exit 95 }
1826if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 96 }
1827$contextPath = Join-Path $contextRoot 'context-pack.json'
1828if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 97 }
1829$contextFiles = @(Get-ChildItem -LiteralPath $contextRoot -Recurse -Force -File)
1830if ($contextFiles.Count -ne 1 -or [IO.Path]::GetFullPath($contextFiles[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 98 }
1831$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1832$codexFull = [IO.Path]::GetFullPath($codexHome).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1833$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1834$separator = [IO.Path]::DirectorySeparatorChar
1835if ($contextFull.Equals($codexFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($codexFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 99 }
1836if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 100 }
1837$rawContext = [IO.File]::ReadAllText($contextPath)
1838if ($rawContext.Contains('g4a-private-provider-session-canary') -or $rawContext.Contains('private-provider-login-identity')) { exit 101 }
1839try { $document = $rawContext | ConvertFrom-Json -ErrorAction Stop } catch { exit 102 }
1840$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')
1841$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')
1842foreach ($property in @($document.PSObject.Properties.Name)) {
1843    if ($allowedTopLevel -cnotcontains [string]$property) { exit 103 }
1844}
1845foreach ($property in $requiredTopLevel) {
1846    if ($null -eq $document.PSObject.Properties[$property]) { exit 104 }
1847}
1848if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 105 }
1849if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 106 }
1850$messages = @($document.retained_messages)
1851if ($messages.Count -eq 0) { exit 107 }
1852foreach ($message in $messages) {
1853    $messageProperties = @($message.PSObject.Properties.Name)
1854    foreach ($property in $messageProperties) {
1855        if (@('role', 'text') -cnotcontains [string]$property) { exit 108 }
1856    }
1857    if ($messageProperties.Count -ne 2 -or @('user', 'assistant') -cnotcontains [string]$message.role -or [string]::IsNullOrEmpty([string]$message.text)) { exit 109 }
1858}
1859$sha = [Security.Cryptography.SHA256]::Create()
1860try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1861[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))
1862$rawProof = [IO.File]::ReadAllText($proofPath)
1863if ($rawProof.Contains('g4a-private-provider-session-canary') -or $rawProof.Contains('private-provider-login-identity')) { exit 110 }
1864[Console]::Out.WriteLine('F7_CODEX_BUNDLE_CONTEXT_VALIDATED')
1865"#;
1866                spec.launch.fixed_args.push(format!(
1867                    "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1868                ));
1869                spec.launch.fixed_args.push(proof_path.clone());
1870            } else if context_only_target.map(AgentId::as_str) == Some(provider_id) {
1871                let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1872                    NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1873                })?;
1874                let validation = r#"
1875$bundleArgs = @($bundleArgs)
1876if ($bundleArgs.Count -ne 0) { exit 91 }
1877$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1878$cwd = (Get-Location).ProviderPath
1879if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 92 }
1880$contextPath = Join-Path $contextRoot 'context-pack.json'
1881if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 93 }
1882$entries = @(Get-ChildItem -LiteralPath $contextRoot -Force)
1883if ($entries.Count -ne 1 -or $entries[0] -isnot [IO.FileInfo] -or [IO.Path]::GetFullPath($entries[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 94 }
1884$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1885$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1886$separator = [IO.Path]::DirectorySeparatorChar
1887if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase) -or $cwdFull.StartsWith($contextFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 95 }
1888try { $document = [IO.File]::ReadAllText($contextPath) | ConvertFrom-Json -ErrorAction Stop } catch { exit 96 }
1889if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 97 }
1890if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 98 }
1891$messages = @($document.retained_messages)
1892if ($messages.Count -eq 0) { exit 99 }
1893$hasUser = $false
1894$hasAssistant = $false
1895foreach ($message in $messages) {
1896    if ([string]::IsNullOrWhiteSpace([string]$message.text)) { exit 100 }
1897    if ([string]$message.role -ceq 'user') { $hasUser = $true }
1898    elseif ([string]$message.role -ceq 'assistant') { $hasAssistant = $true }
1899    else { exit 101 }
1900}
1901if (-not $hasUser -or -not $hasAssistant) { exit 102 }
1902$sha = [Security.Cryptography.SHA256]::Create()
1903try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1904[IO.File]::WriteAllLines($proofPath, [string[]]@('context-only', $contextRoot, $cwd, $contextHash, [string]$document.schema, [string]$document.source_provider, [string]$messages.Count))
1905[Console]::Out.WriteLine('F7_KIMI_CONTEXT_ONLY_VALIDATED')
1906"#;
1907                spec.launch.fixed_args.push(format!(
1908                    "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1909                ));
1910                spec.launch.fixed_args.push(proof_path.clone());
1911            }
1912            specs.push(spec);
1913        }
1914        let catalog = AgentRegistry::new(specs)
1915            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1916        Ok(catalog)
1917    }
1918
1919    #[cfg(feature = "fixture")]
1920    pub fn new_exact_launcher_fixture(
1921        config: NodeServerConfig,
1922        agent_id: AgentId,
1923        launcher: String,
1924    ) -> Result<Self, NodeServerError> {
1925        if !matches!(agent_id.as_str(), "claude" | "codex" | "grok" | "kimi") {
1926            return Err(NodeServerError::Registry(
1927                "exact launcher fixture requires a supported provider".to_owned(),
1928            ));
1929        }
1930        if launcher.contains('\0') {
1931            return Err(NodeServerError::Registry(
1932                "exact launcher fixture path contains NUL".to_owned(),
1933            ));
1934        }
1935        let launcher_path = Path::new(&launcher);
1936        if !launcher_path.is_absolute() {
1937            return Err(NodeServerError::Registry(
1938                "exact launcher fixture path must be absolute".to_owned(),
1939            ));
1940        }
1941        let metadata = std::fs::metadata(launcher_path).map_err(|_| {
1942            NodeServerError::Registry("exact launcher fixture path is unavailable".to_owned())
1943        })?;
1944        if !metadata.is_file() {
1945            return Err(NodeServerError::Registry(
1946                "exact launcher fixture path must be a regular file".to_owned(),
1947            ));
1948        }
1949        let mut spec = builtin_registry()
1950            .get(&agent_id)
1951            .ok_or_else(|| {
1952                NodeServerError::Registry(
1953                    "exact launcher fixture provider is unavailable".to_owned(),
1954                )
1955            })?
1956            .clone();
1957        spec.launch.program = launcher;
1958        Self::new_fixture_with_spec(config, spec)
1959    }
1960
1961    /// Shared H3B validation and spec construction for
1962    /// `new_harness_mcp_proxy_fixture` and
1963    /// `new_harness_mcp_acp_launcher_fixture`: an absolute, exact-regular-
1964    /// file provider launcher plus at most 32 Unicode, NUL-free fixed
1965    /// arguments (65,536 bytes total), replacing `spec.launch` on a clone of
1966    /// the named builtin catalog entry -- detection command and expected
1967    /// process matcher rebased on the launcher's own basename, exactly the
1968    /// same shape `new_harness_mcp_proxy_fixture` always built.
1969    #[cfg(feature = "fixture")]
1970    fn h3b_provider_spec_with_program_and_args(
1971        agent_id: AgentId,
1972        provider_program: PathBuf,
1973        fixed_args: Vec<OsString>,
1974    ) -> Result<AgentSpec, NodeServerError> {
1975        if !provider_program.is_absolute() || fixed_args.len() > 32 {
1976            return Err(NodeServerError::Registry(
1977                "H3B fixture requires an absolute provider and at most 32 arguments".to_owned(),
1978            ));
1979        }
1980        let metadata = std::fs::symlink_metadata(&provider_program).map_err(|_| {
1981            NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
1982        })?;
1983        if !metadata.file_type().is_file() || metadata.file_type().is_symlink() {
1984            return Err(NodeServerError::Registry(
1985                "H3B fixture provider must be an exact regular file".to_owned(),
1986            ));
1987        }
1988        let program = provider_program.into_os_string().into_string().map_err(|_| {
1989            NodeServerError::Registry("H3B fixture provider path is not Unicode".to_owned())
1990        })?;
1991        let fixed_args = fixed_args.into_iter().map(|argument| {
1992            argument.into_string().map_err(|_| NodeServerError::Registry(
1993                "H3B fixture argument is not Unicode".to_owned(),
1994            ))
1995        }).collect::<Result<Vec<_>, _>>()?;
1996        let argument_bytes = fixed_args.iter()
1997            .try_fold(0usize, |total, argument| total.checked_add(argument.len()));
1998        if program.contains('\0')
1999            || fixed_args.iter().any(|argument| argument.contains('\0'))
2000            || !matches!(argument_bytes, Some(total) if total <= 65_536)
2001        {
2002            return Err(NodeServerError::Registry(
2003                "H3B fixture provider or arguments are invalid".to_owned(),
2004            ));
2005        }
2006        let mut spec = builtin_registry().get(&agent_id).ok_or_else(|| {
2007            NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
2008        })?.clone();
2009        let process_name = Path::new(&program).file_name().and_then(|name| name.to_str())
2010            .ok_or_else(|| NodeServerError::Registry(
2011                "H3B fixture provider basename is unavailable".to_owned(),
2012            ))?.to_owned();
2013        spec.detection.command = process_name.clone();
2014        spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
2015            name: process_name,
2016        }];
2017        spec.launch.program = program;
2018        spec.launch.fixed_args = fixed_args;
2019        Ok(spec)
2020    }
2021
2022    #[cfg(feature = "fixture")]
2023    pub fn new_harness_mcp_proxy_fixture(
2024        config: NodeServerConfig,
2025        provider_program: PathBuf,
2026        fixed_args: Vec<OsString>,
2027        harness_mcp_program: PathBuf,
2028    ) -> Result<Self, NodeServerError> {
2029        let mut config = config.with_harness_mcp_helper(harness_mcp_program)?;
2030        let agent_id = AgentId::new("codex")
2031            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2032        let mut spec =
2033            Self::h3b_provider_spec_with_program_and_args(agent_id, provider_program, fixed_args)?;
2034        spec.display_name = "Controlled H3B MCP proxy fixture".to_owned();
2035        config.fixture_raw_pty_runtime = true;
2036        Self::new_fixture_with_spec(config, spec)
2037    }
2038
2039    /// The ACP-mode sibling of `new_harness_mcp_proxy_fixture`: the same
2040    /// `--harness-mcp-helper` wiring and the same absolute-program-plus-
2041    /// fixed-args launcher replacement as `new_exact_launcher_fixture`, but
2042    /// admitting `ProviderRuntimeRequirement::Acp` the honest way instead of
2043    /// hardcoding it. `new_harness_mcp_proxy_fixture` forces `config.
2044    /// fixture_raw_pty_runtime = true`, which tears out the live
2045    /// `ProviderRuntimeMonitor` entirely (`NodeServer::new_with_registry`)
2046    /// and makes `NodeShared::admit_provider_runtime`'s no-monitor branch
2047    /// hardcode `acp_transport = false` for every provider, refusing
2048    /// `ProviderRuntimeRequirement::Acp` unconditionally regardless of what
2049    /// the catalog declares. This constructor leaves `fixture_raw_pty_
2050    /// runtime` at its default `false`, so a real `ProviderRuntimeMonitor`
2051    /// is built from the (cloned, launcher-replaced) catalog spec, and
2052    /// `ProviderRuntimeMonitor::supports_acp_transport` reads the honest,
2053    /// unmodified catalog fact: every builtin provider this accepts
2054    /// (`claude`, `codex`, `grok`, `kimi`) already declares `capabilities.
2055    /// transports.acp` in `gate4agent-catalog::builtin`, so no capability is
2056    /// invented here -- this constructor only removes the fixture-only
2057    /// bypass standing in its way. `provider_runtime::policy_for_transport`'s
2058    /// `TransportKind::Acp` arm (applied unconditionally at spawn-prep time
2059    /// for every ACP session, fixture or not) is what actually grants the
2060    /// ACP-shaped `ProviderRuntimePolicy` downstream of that admission --
2061    /// this constructor never calls it directly, and does not need to.
2062    #[cfg(feature = "fixture")]
2063    pub fn new_harness_mcp_acp_launcher_fixture(
2064        config: NodeServerConfig,
2065        helper_program: PathBuf,
2066        provider: AgentId,
2067        program: PathBuf,
2068        args: Vec<OsString>,
2069    ) -> Result<Self, NodeServerError> {
2070        if !matches!(provider.as_str(), "claude" | "codex" | "grok" | "kimi") {
2071            return Err(NodeServerError::Registry(
2072                "ACP harness-MCP launcher fixture requires a supported provider".to_owned(),
2073            ));
2074        }
2075        let config = config.with_harness_mcp_helper(helper_program)?;
2076        let mut spec = Self::h3b_provider_spec_with_program_and_args(provider, program, args)?;
2077        let launch = spec.launch.clone();
2078        let acp = spec.capabilities.transports.acp.as_mut().ok_or_else(|| {
2079            NodeServerError::Registry(
2080                "ACP harness-MCP launcher fixture provider does not declare an ACP transport"
2081                    .to_owned(),
2082            )
2083        })?;
2084        // Routes the ACP spawn itself through the exact replaced launcher
2085        // (`AcpSession::spawn_with_launch` -> a bare `Command::new(&launch.
2086        // program)`, `gate4agent`'s `src/acp/spawn.rs`) instead of the
2087        // catalog's own vendor-npm resolution (`acp_command`, same file) --
2088        // without this the ACP transport ignores `spec.launch` entirely and
2089        // tries to spawn the real vendor CLI (e.g. `npx -y @agentclientprotocol/
2090        // codex-acp@1.10.0`), which this fixture must never reach.
2091        acp.launch_override = Some(launch);
2092        spec.display_name = "Controlled ACP harness-MCP launcher fixture".to_owned();
2093        Self::new_fixture_with_spec(config, spec)
2094    }
2095
2096    #[cfg(feature = "fixture")]
2097    pub fn new_resume_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
2098        let mut spec = gate4agent_testkit::interactive_agent_spec();
2099        let claude_id = AgentId::new("claude")
2100            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2101        let claude = builtin_registry()
2102            .get(&claude_id)
2103            .ok_or_else(|| NodeServerError::Registry("Claude fixture adapter is unavailable".to_owned()))?;
2104        spec.id = claude_id;
2105        spec.display_name = "Resumable Claude PTY fixture".to_owned();
2106        spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
2107            name: "claude".to_owned(),
2108        }];
2109        spec.capabilities.transports.pty_adapter = claude
2110            .capabilities
2111            .transports
2112            .pty_adapter
2113            .clone();
2114        spec.capabilities.adapters.resume = claude
2115            .capabilities
2116            .adapters
2117            .resume
2118            .clone();
2119        let script = spec
2120            .launch
2121            .fixed_args
2122            .pop()
2123            .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
2124        spec.launch.fixed_args.push(format!(
2125            "& {{ param([Parameter(ValueFromRemainingArguments=$true)][string[]]$ignored) {script} }}",
2126        ));
2127        Self::new_fixture_with_spec(config, spec)
2128    }
2129
2130    #[cfg(feature = "fixture")]
2131    pub fn new_cmd_cwd_fixture(
2132        config: NodeServerConfig,
2133        command: String,
2134    ) -> Result<Self, NodeServerError> {
2135        let mut spec = gate4agent_testkit::interactive_agent_spec();
2136        spec.id = AgentId::new("claude")
2137            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2138        spec.display_name = "Controlled cmd cwd fixture".to_owned();
2139        spec.detection.command = "gate4agent-cwd-fixture.cmd".to_owned();
2140        spec.launch.program = command;
2141        spec.launch.fixed_args.clear();
2142        Self::new_fixture_with_spec(config, spec)
2143    }
2144
2145    #[cfg(feature = "fixture")]
2146    fn new_fixture_with_spec(
2147        config: NodeServerConfig,
2148        spec: gate4agent_types::AgentSpec,
2149    ) -> Result<Self, NodeServerError> {
2150        let catalog = AgentRegistry::new([spec])
2151            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2152        Self::new_with_registry(config, catalog)
2153    }
2154
2155    fn new_with_registry(
2156        config: NodeServerConfig,
2157        catalog: AgentRegistry,
2158    ) -> Result<Self, NodeServerError> {
2159        for profile in config.spawn_profiles.iter() {
2160            if catalog.get(&profile.provider).is_none() {
2161                return Err(NodeServerError::Registry(
2162                    "spawn profile references an unavailable provider".to_owned(),
2163                ));
2164            }
2165        }
2166        let input_settle_timeout_ms = catalog
2167            .iter()
2168            .map(|spec| spec.readiness.timeout_ms)
2169            .max()
2170            .unwrap_or(MUTATION_SETTLE_TIMEOUT_MS)
2171            .saturating_add(READINESS_SETTLE_HEADROOM_MS)
2172            .max(MUTATION_SETTLE_TIMEOUT_MS);
2173        let (provider_contracts, provider_adapter_contracts) =
2174            provider_contract_manifest(&catalog)?;
2175        let compatibility_support = node_compatibility_support_for_manifest(
2176            &provider_contracts,
2177            &provider_adapter_contracts,
2178        )?;
2179        validate_node_negotiated_handshake_capacity(&compatibility_support)
2180            .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2181        let enabled_providers = provider_contracts
2182            .iter()
2183            .map(|contract| contract.provider.clone())
2184            .collect();
2185        #[cfg(feature = "fixture")]
2186        let fixture_raw_pty_runtime = config.fixture_raw_pty_runtime;
2187        #[cfg(not(feature = "fixture"))]
2188        let fixture_raw_pty_runtime = false;
2189        let (provider_runtime_monitor, provider_runtime_statuses) = if fixture_raw_pty_runtime {
2190            let statuses = ProviderRuntimeStatuses::new(catalog.iter().map(|spec| {
2191                crate::protocol::ProviderRuntimeStatus::raw_passthrough(spec.id.clone(), None)
2192            })).expect("fixture provider catalog remains bounded");
2193            (None, statuses)
2194        } else {
2195            let monitor = Arc::new(ProviderRuntimeMonitor::new(&catalog));
2196            let statuses = monitor.collect();
2197            (Some(monitor), statuses)
2198        };
2199        let state_path_lock =
2200            session_registry::StatePathLock::acquire(config.state_path.as_deref())
2201                .map_err(|error| {
2202                    durable_state_server_error(error, DURABLE_STATE_LOCK_FAILED_ERROR)
2203                })?;
2204        let (delivery_store, delivered_bundles) = match config.state_path.as_ref() {
2205            Some(state_path) => {
2206                let delivery_root = delivery_store_root_for_state_path(state_path)
2207                    .ok_or_else(|| {
2208                        NodeServerError::DeliveryStore(
2209                            "delivery store root could not be derived from state path".to_owned(),
2210                        )
2211                    })?;
2212                let (store, bundles) = DeliveryStore::open(delivery_root).map_err(|error| {
2213                    let detail = match &error {
2214                        DeliveryStoreError::Storage(io_error) => {
2215                            format!("{error}: {io_error}")
2216                        }
2217                        _ => error.to_string(),
2218                    };
2219                    NodeServerError::DeliveryStore(detail)
2220                })?;
2221                (Some(store), bundles)
2222            }
2223            None => (None, Vec::new()),
2224        };
2225        let delivered_catalog = BundleCatalog::new(delivered_bundles).map_err(|error| {
2226            NodeServerError::DeliveryStore(format!("bundle catalog: {error}"))
2227        })?;
2228        let (context_pack_store, durable_context_packs) = match config.state_path.as_ref() {
2229            Some(state_path) => {
2230                let context_pack_root = context_pack_store_root_for_state_path(state_path)
2231                    .ok_or(NodeServerError::ContextPackStore)?;
2232                let (store, packs) = ContextPackStore::open(context_pack_root)
2233                    .map_err(|_| NodeServerError::ContextPackStore)?;
2234                (Some(store), packs)
2235            }
2236            None => (None, Vec::new()),
2237        };
2238        let mut durable_context_catalog = ContextPackCatalog::default();
2239        for pack in durable_context_packs {
2240            durable_context_catalog
2241                .insert(pack)
2242                .map_err(|_| NodeServerError::ContextPackStore)?;
2243        }
2244        let session_environment_materializer = config
2245            .session_environment
2246            .as_ref()
2247            .map(|environment| {
2248                SessionEnvironmentMaterializer::new(
2249                    environment.root.clone(),
2250                    Arc::clone(&environment.resolver),
2251                )
2252                .map_err(|_| NodeServerError::SessionEnvironmentMaterializer)
2253            })
2254            .transpose()?;
2255        let native_session_catalog = config.history.clone().map(|history| {
2256            Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)))
2257        });
2258        let (handle, runtime) = match config.history.clone() {
2259            Some(history) => NativeRuntime::new_with_history(catalog, config.runtime, history),
2260            None => NativeRuntime::new(catalog, config.runtime),
2261        };
2262        let native_launch_profile_control = runtime.native_launch_profile_control();
2263        let harness_mcp_registry = config
2264            .harness_mcp_helper
2265            .clone()
2266            .map(HarnessMcpProxyRegistry::new);
2267        let events = handle.subscribe(CONTROL_EVENT_SUBSCRIPTION_CAPACITY);
2268        let incarnation_id = random_incarnation_id()
2269            .map_err(NodeServerError::IncarnationIdentity)?;
2270        let mut loaded = session_registry::load(config.state_path.as_deref(), &config.node_id)
2271            .map_err(durable_state_load_error)?;
2272        let managed_worktrees = std::mem::take(&mut loaded.managed_worktrees);
2273        let managed_worktree_tombstones =
2274            std::mem::take(&mut loaded.managed_worktree_tombstones);
2275        let materializations = std::mem::take(&mut loaded.materializations);
2276        let managed_spawn_replays = std::mem::take(&mut loaded.managed_spawn_replays);
2277        let (workspaces, records, persistence_warning) =
2278            merge_durable_state(&config.workspaces, loaded)?;
2279        let mut shared = NodeShared::new_with_incarnation(
2280            handle,
2281            config.access_token.clone(),
2282            config.node_id.clone(),
2283            incarnation_id,
2284            workspaces,
2285            enabled_providers,
2286            provider_runtime_statuses,
2287            provider_runtime_monitor,
2288            provider_contracts,
2289            provider_adapter_contracts,
2290            config.spawn_profiles.clone(),
2291            Some(native_launch_profile_control),
2292            native_session_catalog,
2293            session_environment_materializer,
2294            config.state_path.clone(),
2295            records,
2296            managed_worktrees,
2297            managed_worktree_tombstones,
2298            materializations,
2299            persistence_warning,
2300            input_settle_timeout_ms,
2301        );
2302        shared.harness_mcp_registry = harness_mcp_registry.clone();
2303        shared.session_record_retention = config.session_record_retention;
2304        shared.network_allowlist_catalog =
2305            RwLock::new(config.network_allowlist_catalog.clone());
2306        shared.bundle_catalog = RwLock::new(delivered_catalog);
2307        shared.delivery_store = Mutex::new(delivery_store);
2308        shared.context_catalog = RwLock::new(durable_context_catalog);
2309        shared.context_pack_store = Mutex::new(context_pack_store);
2310        shared.managed_spawn_replays = Mutex::new(
2311            managed_spawn_replays
2312                .into_iter()
2313                .map(|record| (record.idempotency_key.clone(), record))
2314                .collect(),
2315        );
2316        let shared = Arc::new(shared);
2317        if let Some(registry) = harness_mcp_registry {
2318            let weak = Arc::downgrade(&shared);
2319            registry.set_event_sink(Arc::new(move |event| match weak.upgrade() {
2320                Some(shared) => shared.publish_transient(event),
2321                None => false,
2322            }));
2323        }
2324        if shared.persistence_error().is_none() {
2325            shared.persist_state().map_err(|error| {
2326                durable_state_server_error(error, DURABLE_STATE_COMMIT_FAILED_ERROR)
2327            })?;
2328        }
2329        Ok(Self {
2330            config,
2331            runtime,
2332            shared,
2333            events,
2334            state_path_lock,
2335        })
2336    }
2337
2338    pub fn shutdown_handle(&self) -> NodeShutdownHandle {
2339        NodeShutdownHandle {
2340            shared: Arc::clone(&self.shared),
2341        }
2342    }
2343
2344    #[cfg(feature = "fixture")]
2345    pub fn spawn_managed_worktree_v2_failure_probe_fixture(
2346        &self,
2347    ) -> SpawnManagedWorktreeV2FailureProbe {
2348        self.shared
2349            .fixture_spawn_managed_worktree_v2_failure_probe
2350            .clone()
2351    }
2352
2353    /// Installs one immutable host-local environment profile before the server runs.
2354    ///
2355    /// Native resolvers remain inside the runtime; the shared node registry keeps
2356    /// only public IDs, revisions, provider bindings, and opaque native profile IDs.
2357    pub fn install_environment_profile(
2358        &mut self,
2359        profile: NodeEnvironmentProfile,
2360    ) -> Result<(), NodeServerError> {
2361        if !self.shared.enabled_providers.contains(profile.provider()) {
2362            return Err(NodeServerError::EnvironmentProfileProviderUnavailable(
2363                profile.provider().clone(),
2364            ));
2365        }
2366        let (binding, native_profiles, materialization) = profile.into_parts();
2367        if materialization.is_some() && self.shared.session_environment_materializer.is_none() {
2368            return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2369        }
2370        let mut profiles = self
2371            .shared
2372            .environment_profiles
2373            .write()
2374            .unwrap_or_else(|poisoned| poisoned.into_inner());
2375        if profiles.contains_key(&binding.id) {
2376            return Err(NodeServerError::DuplicateEnvironmentProfile(
2377                binding.id.clone(),
2378            ));
2379        }
2380        if profiles.len() >= MAX_NODE_ENVIRONMENT_PROFILES {
2381            return Err(NodeServerError::EnvironmentProfileCapacity {
2382                max: MAX_NODE_ENVIRONMENT_PROFILES,
2383            });
2384        }
2385        for native_id in binding.native_profile_ids() {
2386            if profiles.values().any(|current| {
2387                current.native_profile_ids().any(|current_id| current_id == native_id)
2388            }) {
2389                return Err(NodeServerError::DuplicateNativeEnvironmentProfile(
2390                    native_id.to_string(),
2391                ));
2392            }
2393        }
2394
2395        let mut installed = Vec::with_capacity(native_profiles.len());
2396        for native_profile in native_profiles {
2397            let native_id = native_profile.id().clone();
2398            if let Err(error) = self.runtime.upsert_native_launch_profile(native_profile) {
2399                for installed_id in installed.into_iter().rev() {
2400                    let _ = self.runtime.remove_native_launch_profile(&installed_id);
2401                }
2402                return Err(NodeServerError::NativeEnvironmentProfile(error.to_string()));
2403            }
2404            installed.push(native_id);
2405        }
2406        if let Some(materialization) = materialization {
2407            self.shared
2408                .environment_materialization_profiles
2409                .write()
2410                .unwrap_or_else(|poisoned| poisoned.into_inner())
2411                .insert(binding.id.clone(), materialization);
2412        }
2413        profiles.insert(binding.id.clone(), binding);
2414        Ok(())
2415    }
2416
2417    /// Installs one immutable host-local bundle before the server runs.
2418    pub fn install_bundle(&mut self, bundle: NodeBundle) -> Result<(), NodeServerError> {
2419        if self.shared.session_environment_materializer.is_none() {
2420            return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2421        }
2422        let mut catalog = self
2423            .shared
2424            .bundle_catalog
2425            .write()
2426            .unwrap_or_else(|poisoned| poisoned.into_inner());
2427        catalog
2428            .insert_idempotent(bundle)
2429            .map_err(|error| NodeServerError::BundleCatalog(error.to_string()))?;
2430        Ok(())
2431    }
2432
2433    pub async fn run_until_ctrl_signal(self) -> Result<(), NodeServerError> {
2434        let shutdown = self.shutdown_handle();
2435        let run = self.run();
2436        tokio::pin!(run);
2437        tokio::select! {
2438            result = &mut run => result,
2439            signal = wait_for_ctrl_signal() => {
2440                signal?;
2441                let shutdown_result = shutdown.request_shutdown().await;
2442                let run_result = run.await;
2443                shutdown_result.and(run_result)
2444            }
2445        }
2446    }
2447
2448    pub async fn run(self) -> Result<(), NodeServerError> {
2449        let NodeServer {
2450            config,
2451            mut runtime,
2452            shared,
2453            events,
2454            state_path_lock: _state_path_lock,
2455        } = self;
2456        shared.reconcile_materializations();
2457        shared.reconcile_managed_worktrees().await;
2458        shared.retire_unavailable_session_records().await;
2459        shared.reconcile_context_pack_exports().await;
2460        // Lifecycle hooks are retired (owner ruling 2026-09-25): the node
2461        // observes sessions through ACP where a provider has it; there is no
2462        // hook ingress.
2463        let endpoint = config.endpoint.clone();
2464        let call_home = config.call_home;
2465        let api_listen = config.api_listen;
2466        let bridge_listen = config.bridge_listen;
2467        let bridge_underlay = config.bridge_underlay;
2468        let bridge_auth = browser_bridge::BridgeAuth::from_optional(config.bridge_token)
2469            .map_err(|_| NodeServerError::InvalidBridgeToken)?;
2470        let accept_shared = Arc::clone(&shared);
2471        let api_shared = Arc::clone(&shared);
2472        let bridge_shared = Arc::clone(&shared);
2473        let shutdown_shared = Arc::clone(&shared);
2474        let shutdown_timeout = Duration::from_millis(
2475            config.runtime.provider_shutdown_timeout_ms.max(1),
2476        );
2477        let result = {
2478            let runtime_loop = drive_runtime_until_shutdown(
2479                &mut runtime,
2480                events,
2481                Arc::clone(&shared),
2482                shutdown_timeout,
2483            );
2484            let accept_loop = accept_connections(&endpoint, call_home, accept_shared);
2485            let api_loop = http_api::run(api_listen, api_shared);
2486            let bridge_loop = browser_bridge::run(bridge_listen, bridge_auth, bridge_shared, bridge_underlay);
2487            tokio::pin!(runtime_loop);
2488            tokio::pin!(accept_loop);
2489            tokio::pin!(api_loop);
2490            tokio::pin!(bridge_loop);
2491            tokio::select! {
2492                runtime_result = &mut runtime_loop => {
2493                    let shutdown_result = shutdown_shared
2494                        .begin_shutdown()
2495                        .await
2496                        .map_err(NodeServerError::ShutdownDispatch);
2497                    let (accept_result, api_result, bridge_result) =
2498                        tokio::join!(&mut accept_loop, &mut api_loop, &mut bridge_loop);
2499                    match runtime_result {
2500                        Err(error) => Err(error),
2501                        Ok(()) => shutdown_result
2502                            .and(accept_result)
2503                            .and(api_result.map_err(NodeServerError::HttpApi))
2504                            .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2505                    }
2506                }
2507                accept_result = &mut accept_loop => {
2508                    let shutdown_result = shutdown_shared
2509                        .begin_shutdown()
2510                        .await
2511                        .map_err(NodeServerError::ShutdownDispatch);
2512                    let (runtime_result, api_result, bridge_result) =
2513                        tokio::join!(&mut runtime_loop, &mut api_loop, &mut bridge_loop);
2514                    match accept_result {
2515                        Err(error) => Err(error),
2516                        Ok(()) => shutdown_result
2517                            .and(runtime_result)
2518                            .and(api_result.map_err(NodeServerError::HttpApi))
2519                            .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2520                    }
2521                }
2522                api_result = &mut api_loop => {
2523                    let shutdown_result = shutdown_shared
2524                        .begin_shutdown()
2525                        .await
2526                        .map_err(NodeServerError::ShutdownDispatch);
2527                    let (runtime_result, accept_result, bridge_result) =
2528                        tokio::join!(&mut runtime_loop, &mut accept_loop, &mut bridge_loop);
2529                    match api_result {
2530                        Err(error) => Err(NodeServerError::HttpApi(error)),
2531                        Ok(()) => shutdown_result
2532                            .and(runtime_result)
2533                            .and(accept_result)
2534                            .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2535                    }
2536                }
2537                bridge_result = &mut bridge_loop => {
2538                    let shutdown_result = shutdown_shared
2539                        .begin_shutdown()
2540                        .await
2541                        .map_err(NodeServerError::ShutdownDispatch);
2542                    let (runtime_result, accept_result, api_result) =
2543                        tokio::join!(&mut runtime_loop, &mut accept_loop, &mut api_loop);
2544                    match bridge_result {
2545                        Err(error) => Err(NodeServerError::BridgeApi(error)),
2546                        Ok(()) => shutdown_result
2547                            .and(runtime_result)
2548                            .and(accept_result)
2549                            .and(api_result.map_err(NodeServerError::HttpApi)),
2550                    }
2551                }
2552            }
2553        };
2554        // Persistence gap fix: durable DeliveryStore / ContextPackStore hold
2555        // exclusive flock (Unix) / share-mode locks (Windows) inside NodeShared.
2556        // NodeShutdownHandle also clones that Arc, so without an explicit release
2557        // here a supervisor that keeps the shutdown handle cannot reopen the same
2558        // state path after run() returns (EAGAIN / Resource temporarily unavailable).
2559        // StatePathLock already drops with this stack frame (`_state_path_lock`).
2560        if let Ok(mut guard) = shared.delivery_store.lock() {
2561            *guard = None;
2562        }
2563        if let Ok(mut guard) = shared.context_pack_store.lock() {
2564            *guard = None;
2565        }
2566        result
2567    }
2568}
2569
2570async fn drive_runtime_until_shutdown(
2571    runtime: &mut NativeRuntime,
2572    events: EventSubscription,
2573    shared: Arc<NodeShared>,
2574    shutdown_timeout: Duration,
2575) -> Result<(), NodeServerError> {
2576    // Published once, never per iteration. The worker loops inside
2577    // `NativeRuntime` fold into this profile continuously; turning it into
2578    // percentiles is what costs, and that is `GET /metrics`'s bill to pay.
2579    let _ = shared
2580        .shell_efficiency_profile
2581        .set(runtime.shell_efficiency_profile());
2582    let mut shutdown_started = None;
2583    let mut last_retention_sweep = Instant::now();
2584    loop {
2585        // Every phase below is timed unconditionally -- see
2586        // `drive_loop_profile`'s own doc comment for why an always-on,
2587        // allocation-free ring is cheap enough to never need a feature
2588        // flag. This is the only place these numbers can be taken: the
2589        // phases are calls made directly from this loop, not something a
2590        // callee could time on our behalf.
2591        let runtime_tick_start = Instant::now();
2592        let tick_result = runtime.tick().await;
2593        let runtime_tick_elapsed = runtime_tick_start.elapsed();
2594
2595        let event_drain_start = Instant::now();
2596        // Every command the kernel refused, named. `NativeRuntimeTick`
2597        // has carried these outcomes all along and nothing read them, so a
2598        // rejected command -- a hook event denied by runtime policy, a
2599        // stale generation, an unsupported capability -- vanished at the
2600        // last step of the loop that produced it. A refusal the operator
2601        // cannot see is indistinguishable from an event that never arrived,
2602        // and telling those two apart is most of debugging this stack.
2603        for outcome in &tick_result.command_outcomes {
2604            if let Err(error) = &outcome.result {
2605                shared.rejected_commands_total.fetch_add(1, Ordering::Relaxed);
2606                tracing::warn!(
2607                    command_id = outcome.command_id.0,
2608                    cause = %error,
2609                    "control command rejected"
2610                );
2611                shared.record_rejected_command(
2612                    outcome.command_id,
2613                    error.to_string(),
2614                    error.is_unsupported_transport(),
2615                );
2616            }
2617        }
2618        let mut events_drained: u64 = 0;
2619        while let Ok(event) = events.try_recv() {
2620            events_drained += 1;
2621            let clean_exit = matches!(
2622                event.event,
2623                ControlEventKind::Exited { exit_code: Some(0), forced: false },
2624            );
2625            let instance_id = event.instance_id;
2626            let generation = event.generation;
2627            // Resolve and spawn before publish_control: publish_control's own
2628            // managed-record bookkeeping (reconcile_managed_record) downgrades
2629            // this exact record from Live to Dormant and clears
2630            // active_session for every Exited event, inside the same
2631            // synchronous call. Capturing (record_id, session) here — before
2632            // that runs — is the only way to observe them still Live and
2633            // still populated; the export itself must still run on its own
2634            // detached task (never awaited inline on this loop) because it
2635            // needs NativeRuntime::tick() to keep running to ever settle, and
2636            // tick() is only ever driven by this same loop. See
2637            // reconcile_context_pack_export_for_record's doc comment.
2638            if clean_exit {
2639                if let Some((record_id, session)) =
2640                    shared.managed_record_export_target_for_instance(instance_id, generation)
2641                {
2642                    let export_shared = Arc::clone(&shared);
2643                    tokio::spawn(async move {
2644                        export_shared
2645                            .reconcile_context_pack_export_for_record(&record_id, session)
2646                            .await;
2647                    });
2648                }
2649            }
2650            shared.publish_control(event);
2651        }
2652        let event_drain_elapsed = event_drain_start.elapsed();
2653
2654        let publish_terminal_frames_start = Instant::now();
2655        shared.publish_terminal_frames();
2656        let publish_terminal_frames_elapsed = publish_terminal_frames_start.elapsed();
2657
2658        // Physical PTY count and the runtime's own per-phase tick timings
2659        // both live inside `NativeRuntime`, which `GET /metrics` (reading
2660        // only `NodeShared`) cannot reach directly -- this loop is the one
2661        // place holding `&mut NativeRuntime`, so it is the only place that
2662        // can copy either out.
2663        shared
2664            .native_session_gauge
2665            .store(runtime.active_native_sessions(), Ordering::Relaxed);
2666        *shared
2667            .runtime_tick_profile
2668            .lock()
2669            .unwrap_or_else(|poisoned| poisoned.into_inner()) = runtime.tick_profile_snapshot();
2670
2671        let remainder_start = Instant::now();
2672        if shared.shutdown.load(Ordering::Acquire) {
2673            let started = *shutdown_started.get_or_insert_with(Instant::now);
2674            let snapshot = shared.handle.snapshot();
2675            let sessions_settled = snapshot.sessions.iter().all(|session| {
2676                !matches!(
2677                    session.status,
2678                    gate4agent_types::SessionStatus::Starting
2679                        | gate4agent_types::SessionStatus::Running
2680                        | gate4agent_types::SessionStatus::Stopping
2681                )
2682            });
2683            if runtime.active_native_sessions() == 0 && sessions_settled {
2684                return Ok(());
2685            }
2686            if started.elapsed() >= shutdown_timeout {
2687                return Err(NodeServerError::ShutdownTimedOut {
2688                    active_native_sessions: runtime.active_native_sessions(),
2689                });
2690            }
2691        }
2692        let remainder_elapsed = remainder_start.elapsed();
2693
2694        {
2695            let mut profile = shared
2696                .drive_loop_profile
2697                .lock()
2698                .unwrap_or_else(|poisoned| poisoned.into_inner());
2699            profile.record_runtime_tick(runtime_tick_elapsed);
2700            profile.record_event_drain(event_drain_elapsed);
2701            profile.record_publish_terminal_frames(publish_terminal_frames_elapsed);
2702            profile.record_remainder(remainder_elapsed);
2703            profile.note_iteration(Instant::now());
2704        }
2705
2706        // "Produced nothing" means this iteration's `NativeRuntime::tick`
2707        // applied zero observations (`tick_result.observations_applied`,
2708        // which already folds in any terminal frames collected -- see
2709        // `NativeEffectDispatcher::drain_observations`) AND the
2710        // control-event subscription drained above forwarded zero events.
2711        // Command ingress and dispatched effects are deliberately NOT part
2712        // of this definition: an idle node still runs `step_control_plane`
2713        // and every provider supervisor's `tick` regardless, every 10ms --
2714        // so "nothing" here means nothing OBSERVABLE left this iteration,
2715        // matching what a reader would assume from `drive_loop_iterations`'s
2716        // own field name, not that the tick itself was free of work.
2717        shared.drive_loop_iterations_total.fetch_add(1, Ordering::Relaxed);
2718        if tick_result.observations_applied == 0 && events_drained == 0 {
2719            shared.drive_loop_iterations_idle.fetch_add(1, Ordering::Relaxed);
2720        }
2721
2722        // A sweep cadence, not a policy value -- see
2723        // `SESSION_RECORD_RETENTION_SWEEP_PERIOD`'s own doc comment. The
2724        // policy itself (whether this does anything at all) lives entirely
2725        // in `NodeShared::session_record_retention`.
2726        if last_retention_sweep.elapsed() >= SESSION_RECORD_RETENTION_SWEEP_PERIOD {
2727            shared.retire_unavailable_session_records().await;
2728            last_retention_sweep = Instant::now();
2729        }
2730
2731        sleep(Duration::from_millis(10)).await;
2732    }
2733}
2734
2735#[derive(Clone)]
2736pub struct NodeShutdownHandle {
2737    shared: Arc<NodeShared>,
2738}
2739
2740impl NodeShutdownHandle {
2741    pub async fn request_shutdown(&self) -> Result<(), NodeServerError> {
2742        self.shared
2743            .begin_shutdown()
2744            .await
2745            .map_err(NodeServerError::ShutdownDispatch)
2746    }
2747}
2748
2749async fn wait_for_ctrl_signal() -> Result<(), NodeServerError> {
2750    #[cfg(windows)]
2751    {
2752    let mut ctrl_c = tokio::signal::windows::ctrl_c()?;
2753    let mut ctrl_break = tokio::signal::windows::ctrl_break()?;
2754    return tokio::select! {
2755        signal = ctrl_c.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2756        signal = ctrl_break.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2757    };
2758    }
2759    #[cfg(unix)]
2760    {
2761        let mut interrupt = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())?;
2762        let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
2763        tokio::select! {
2764            signal = interrupt.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2765            signal = terminate.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2766        }
2767    }
2768}
2769
2770/// The providers this node actually runs.
2771///
2772/// `builtin_registry()` is a reference catalog of everything the project has
2773/// ever described; the fleet is this list, and it is deliberately short. A
2774/// provider only belongs here if it can be exercised for real -- there is a
2775/// subscription behind it and a failure in it can be reproduced and
2776/// debugged. Carrying one that cannot is carrying code nobody can verify,
2777/// which is worse than not supporting it: it looks supported.
2778///
2779/// A provider dropped from the fleet by this rule is removed outright, not
2780/// carried dark: no catalog entry, no adapter, no dead code claiming support
2781/// for something nobody can verify runs.
2782fn active_registry() -> Result<AgentRegistry, NodeServerError> {
2783    let specs = builtin_registry()
2784        .iter()
2785        .filter(|spec| matches!(spec.id.as_str(), "claude" | "codex" | "grok" | "kimi"))
2786        .cloned()
2787        .collect::<Vec<_>>();
2788    AgentRegistry::new(specs).map_err(|error| NodeServerError::Registry(error.to_string()))
2789}
2790
2791fn provider_contract_manifest(
2792    catalog: &AgentRegistry,
2793) -> Result<
2794    (
2795        Vec<ProviderContractSupport>,
2796        Vec<ProviderAdapterContractSupport>,
2797    ),
2798    NodeServerError,
2799> {
2800    let mut provider_contracts = Vec::new();
2801    let mut provider_adapter_contracts = Vec::new();
2802    for spec in catalog.iter() {
2803        let provider = spec.id.clone();
2804        let revision = ProviderContractRevision::new(spec.revision.clone())
2805            .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2806        provider_contracts.push(ProviderContractSupport {
2807            provider: provider.clone(),
2808            revision,
2809        });
2810        for (family, binding) in declared_adapter_bindings(spec)? {
2811            let revision = AdapterContractRevision::new(binding.revision.clone())
2812                .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2813            provider_adapter_contracts.push(ProviderAdapterContractSupport {
2814                provider: provider.clone(),
2815                family,
2816                adapter_id: binding.id.clone(),
2817                revision,
2818            });
2819        }
2820    }
2821    validate_provider_contract_manifest(&provider_contracts, &provider_adapter_contracts)
2822        .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2823    Ok((provider_contracts, provider_adapter_contracts))
2824}
2825
2826fn declared_adapter_bindings(
2827    spec: &AgentSpec,
2828) -> Result<Vec<(AdapterFamily, &AdapterBinding)>, NodeServerError> {
2829    let mut bindings = Vec::new();
2830    push_declared_binding(
2831        &mut bindings,
2832        AdapterFamily::PtySemantic,
2833        spec.capabilities.transports.pty_adapter.as_ref(),
2834    )?;
2835    push_declared_binding(
2836        &mut bindings,
2837        AdapterFamily::Pipe,
2838        spec.capabilities.adapters.pty_sidecar.as_ref(),
2839    )?;
2840    let pipe_transport = spec.capabilities.transports.pipe.as_ref();
2841    let pipe_family = pipe_transport.map(|transport| match transport.protocol {
2842        gate4agent_types::PipeProtocol::SemanticNdjson
2843        | gate4agent_types::PipeProtocol::StructuredJsonl => AdapterFamily::Pipe,
2844        gate4agent_types::PipeProtocol::OneShotText => AdapterFamily::OneShot,
2845    });
2846    push_declared_binding(
2847        &mut bindings,
2848        AdapterFamily::Pipe,
2849        pipe_transport
2850            .filter(|_| pipe_family == Some(AdapterFamily::Pipe))
2851            .map(|transport| &transport.adapter),
2852    )?;
2853    push_declared_binding(
2854        &mut bindings,
2855        AdapterFamily::Acp,
2856        spec.capabilities
2857            .transports
2858            .acp
2859            .as_ref()
2860            .map(|transport| &transport.adapter),
2861    )?;
2862    push_declared_binding(
2863        &mut bindings,
2864        AdapterFamily::Hook,
2865        spec.capabilities.adapters.hook.as_ref(),
2866    )?;
2867    push_declared_binding(
2868        &mut bindings,
2869        AdapterFamily::ManagedHook,
2870        spec.capabilities.adapters.managed_hook.as_ref(),
2871    )?;
2872    push_declared_binding(
2873        &mut bindings,
2874        AdapterFamily::OneShot,
2875        pipe_transport
2876            .filter(|_| pipe_family == Some(AdapterFamily::OneShot))
2877            .map(|transport| &transport.adapter),
2878    )?;
2879    push_declared_binding(
2880        &mut bindings,
2881        AdapterFamily::OneShot,
2882        spec.capabilities.adapters.one_shot.as_ref(),
2883    )?;
2884    push_declared_binding(
2885        &mut bindings,
2886        AdapterFamily::History,
2887        spec.capabilities.adapters.history.as_ref(),
2888    )?;
2889    push_declared_binding(
2890        &mut bindings,
2891        AdapterFamily::Resume,
2892        spec.capabilities.adapters.resume.as_ref(),
2893    )?;
2894    push_declared_binding(
2895        &mut bindings,
2896        AdapterFamily::SessionOptions,
2897        spec.capabilities.adapters.session_options.as_ref(),
2898    )?;
2899    push_declared_binding(
2900        &mut bindings,
2901        AdapterFamily::CapabilityProbe,
2902        spec.capabilities.adapters.capability_probe.as_ref(),
2903    )?;
2904    Ok(bindings)
2905}
2906
2907fn push_declared_binding<'a>(
2908    bindings: &mut Vec<(AdapterFamily, &'a AdapterBinding)>,
2909    family: AdapterFamily,
2910    binding: Option<&'a AdapterBinding>,
2911) -> Result<(), NodeServerError> {
2912    let Some(binding) = binding else {
2913        return Ok(());
2914    };
2915    if let Some((_, existing)) = bindings
2916        .iter()
2917        .find(|(existing_family, _)| *existing_family == family)
2918    {
2919        if *existing == binding {
2920            return Ok(());
2921        }
2922        return Err(NodeServerError::ProviderContractManifest(format!(
2923            "provider declares conflicting {family:?} adapter bindings"
2924        )));
2925    }
2926    bindings.push((family, binding));
2927    Ok(())
2928}
2929
2930fn merge_durable_state(
2931    configured: &[WorkspaceConfig],
2932    mut loaded: LoadedNodeState,
2933) -> Result<(Vec<WorkspaceConfig>, Vec<ManagedSessionRecord>, Option<String>), NodeServerError> {
2934    let mut workspaces = configured
2935        .iter()
2936        .cloned()
2937        .map(|workspace| (workspace.workspace_id.clone(), workspace))
2938        .collect::<BTreeMap<_, _>>();
2939    let mut roots = workspaces
2940        .values()
2941        .map(|workspace| {
2942            (
2943                platform::root_identity(&workspace.canonical_root),
2944                workspace.workspace_id.clone(),
2945            )
2946        })
2947        .collect::<BTreeMap<_, _>>();
2948    let mut warning = loaded
2949        .warning
2950        .take()
2951        .map(|message| session_registry::sanitized_persistence_summary(&message));
2952
2953    for (workspace_id, canonical_root) in loaded.workspaces {
2954        if let Some(existing) = workspaces.get(&workspace_id) {
2955            if !platform::roots_equal(&existing.canonical_root, &canonical_root) {
2956                return Err(NodeServerError::DurableState(io::Error::new(
2957                    io::ErrorKind::InvalidData,
2958                    DURABLE_STATE_CONFLICT_ERROR,
2959                )));
2960            }
2961            continue;
2962        }
2963        if roots.get(&platform::root_identity(&canonical_root)).is_some() {
2964            return Err(NodeServerError::DurableState(io::Error::new(
2965                io::ErrorKind::InvalidData,
2966                DURABLE_STATE_CONFLICT_ERROR,
2967            )));
2968        }
2969        if !Path::new(&canonical_root).is_dir() {
2970            warning.get_or_insert_with(|| {
2971                session_registry::DURABLE_STATE_WORKSPACE_WARNING.to_owned()
2972            });
2973            continue;
2974        }
2975        let workspace = WorkspaceConfig::new(workspace_id.clone(), &canonical_root).map_err(|_| {
2976            NodeServerError::DurableState(io::Error::new(
2977                io::ErrorKind::InvalidData,
2978                DURABLE_STATE_CONFLICT_ERROR,
2979            ))
2980        })?;
2981        roots.insert(
2982            platform::root_identity(&workspace.canonical_root),
2983            workspace.workspace_id.clone(),
2984        );
2985        workspaces.insert(workspace_id, workspace);
2986    }
2987
2988    for record in &mut loaded.records {
2989        match workspaces.get(&record.workspace_id) {
2990            Some(workspace)
2991                if platform::roots_equal(
2992                    &workspace.canonical_root,
2993                    windows_path_text(&record.canonical_root),
2994                ) => {}
2995            Some(_) => {
2996                return Err(NodeServerError::DurableState(io::Error::new(
2997                    io::ErrorKind::InvalidData,
2998                    DURABLE_STATE_CONFLICT_ERROR,
2999                )));
3000            }
3001            None => {
3002                record.state = ManagedSessionState::Unavailable;
3003                record.active_session = None;
3004                record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
3005            }
3006        }
3007    }
3008    Ok((workspaces.into_values().collect(), loaded.records, warning))
3009}
3010
3011/// One entry of `NodeShared::rejected_command_reasons` -- see that field's
3012/// own doc for the mechanism this exists to close.
3013struct RejectedCommandReason {
3014    message: String,
3015    /// True when the kernel refused this command specifically because the
3016    /// agent does not declare the requested transport (`KernelCommandError::
3017    /// UnsupportedTransport`) -- lets the reader choose the named
3018    /// `NodeFailureCode::UnsupportedTransport` over the generic
3019    /// `BackendOperationFailed` bucket, so this one well-known rejection
3020    /// keeps its identity all the way to the operator wire.
3021    unsupported_transport: bool,
3022    expires_at: Instant,
3023}
3024
3025struct NodeShared {
3026    handle: Gate4AgentHandle,
3027    access_token: String,
3028    node_id: NodeId,
3029    incarnation_id: NodeIncarnationId,
3030    started_at_unix_ms: u64,
3031    workspaces: RwLock<BTreeMap<WorkspaceId, String>>,
3032    worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode>,
3033    managed_worktree_profiles:
3034        BTreeMap<WorkspaceId, BTreeMap<WorktreeProfileId, ManagedWorktreeProfile>>,
3035    managed_worktrees: Mutex<ManagedWorktreeRegistry>,
3036    enabled_providers: Vec<AgentId>,
3037    provider_runtime_statuses: ProviderRuntimeStatuses,
3038    provider_runtime_status_updates:
3039        RwLock<BTreeMap<AgentId, crate::protocol::ProviderRuntimeStatus>>,
3040    provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
3041    provider_contracts: Vec<ProviderContractSupport>,
3042    provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
3043    spawn_profiles: SpawnProfileRegistry,
3044    environment_profiles:
3045        RwLock<BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>>,
3046    environment_materialization_profiles:
3047        RwLock<BTreeMap<SpawnEnvironmentProfileId, NodeSessionMaterializationProfile>>,
3048    /// Station **network** allowlist catalog (opaque policy ids only).
3049    /// Empty by default — any `network_allowlist` on spawn must be registered
3050    /// here or resolve refuses with `UnknownNetworkAllowlist`. Optional
3051    /// startup load via `NodeServerConfig::with_network_allowlist_catalog(_file)`
3052    /// / env `GATE4AGENT_NODE_NETWORK_ALLOWLIST_CATALOG`. Plan
3053    /// `station-network-and-browser-profile-knobs-2026-10-02.md` §2.1 / §4.
3054    /// Never holds cookies / OAuth / proxy credentials.
3055    network_allowlist_catalog: RwLock<crate::network_allowlist_catalog::NetworkAllowlistCatalog>,
3056    /// Node-local exclusive dig2browser station profile leases (feature
3057    /// `dig2-station-probe`). Opaque `browser_profile_id` ↔ session holder;
3058    /// cleared on session end. Never ImportSession / cookies on C2.
3059    #[cfg(feature = "dig2-station-probe")]
3060    browser_station_leases: crate::dig2_station_lease::BrowserStationLeaseTable,
3061    bundle_catalog: RwLock<BundleCatalog>,
3062    delivery_store: Mutex<Option<DeliveryStore>>,
3063    context_pack_store: Mutex<Option<ContextPackStore>>,
3064    context_catalog: RwLock<ContextPackCatalog>,
3065    native_launch_profile_control: Option<NativeLaunchProfileControl>,
3066    harness_mcp_registry: Option<HarnessMcpProxyRegistry>,
3067    native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
3068    session_environment_materializer: Option<SessionEnvironmentMaterializer>,
3069    materializations: Mutex<BTreeMap<MaterializationId, MaterializationOwnershipRecord>>,
3070    spawn_idempotency: Mutex<SpawnIdempotencyCache>,
3071    managed_spawn_replays:
3072        Mutex<BTreeMap<SpawnIdempotencyKey, ManagedWorktreeSpawnReplayRecordV10>>,
3073    controller: Mutex<Option<ControllerLease>>,
3074    history: Mutex<NodeEventHistory>,
3075    event_tx: broadcast::Sender<NodeEventEnvelope>,
3076    terminal_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3077    /// The outbound `NodeEvent::AgentStream` push channel -- its own
3078    /// dedicated, capability-gated broadcast, mirroring `terminal_event_tx`
3079    /// exactly rather than riding the durable `event_tx` history (see
3080    /// `publish`'s own assertion): a subscriber who never negotiated
3081    /// `NODE_AGENT_STREAM_EVENTS_CAPABILITY` must never receive one of
3082    /// these, the same guarantee `include_terminal_frame_events` already
3083    /// gives terminal frames.
3084    agent_stream_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3085    /// The outbound `NodeEvent::HarnessMcpReadCall` push channel -- mirrors
3086    /// `agent_stream_event_tx` exactly, its own dedicated, capability-gated
3087    /// broadcast rather than a ride on the durable `event_tx` history. A
3088    /// `HarnessMcpReadCall` is a request/response call a live proxy call is
3089    /// waiting on (`harness_mcp_proxy.rs`'s `begin_call`); the shared bus's
3090    /// finite capacity and its willingness to silently evict a lagging
3091    /// subscriber's backlog is exactly how the call vanished with neither
3092    /// side ever logging it (see `publish_transient`'s own doc).
3093    harness_mcp_proxy_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3094    terminal_frame_watermarks:
3095        Mutex<BTreeMap<AgentInstanceId, (SessionAddress, u64)>>,
3096    next_connection_id: AtomicU64,
3097    next_instance_id: AtomicU64,
3098    next_command_id: AtomicU64,
3099    shutdown: AtomicBool,
3100    shutdown_notify: Notify,
3101    preauth_slots: Arc<Semaphore>,
3102    authenticated_slots: Arc<Semaphore>,
3103    inspection_slots: Arc<Semaphore>,
3104    mutation_gate: AsyncMutex<()>,
3105    session_bindings: Mutex<BTreeMap<AgentInstanceId, SessionBinding>>,
3106    session_records: Mutex<SessionRecords>,
3107    state_path: Option<PathBuf>,
3108    persistence_error: RwLock<Option<String>>,
3109    state_transaction: Mutex<()>,
3110    input_settle_timeout_ms: u64,
3111    /// Drive-loop phase/rate timings -- see `drive_loop_profile`'s own doc
3112    /// comment. Written once per iteration by
3113    /// `drive_runtime_until_shutdown`, read by `GET /metrics`.
3114    drive_loop_profile: Mutex<DriveLoopProfiler>,
3115    /// Latest copy of `NativeRuntime::tick_profile_snapshot()` -- `Copy`,
3116    /// so an overwrite each iteration is cheap. The runtime itself is not
3117    /// reachable from here (it is owned locally by
3118    /// `drive_runtime_until_shutdown`), so this is how its own per-phase
3119    /// tick timings reach `GET /metrics`.
3120    runtime_tick_profile: Mutex<TickProfileSnapshot>,
3121    /// The live shell-efficiency profile, shared with the per-instance
3122    /// worker loops that fold into it.
3123    ///
3124    /// Deliberately NOT the per-iteration snapshot copy `runtime_tick_
3125    /// profile` above keeps: producing a snapshot sorts every ring behind
3126    /// it, and that sort belongs on the read. A `OnceLock` rather than a
3127    /// field set at construction because `NodeShared` is built before the
3128    /// `NativeRuntime` that owns the profile exists -- the drive loop
3129    /// publishes it once, before its first iteration. Unset means the
3130    /// runtime has not started driving yet, which `GET /metrics` reports as
3131    /// empty series rather than as zeroes it never measured.
3132    shell_efficiency_profile: OnceLock<Arc<Mutex<ShellEfficiencyProfile>>>,
3133    /// Physical PTY sessions the native runtime currently owns
3134    /// (`NativeRuntime::active_native_sessions`), refreshed once per drive
3135    /// loop iteration -- deliberately a separate count from
3136    /// `handle.snapshot().sessions.len()` (the control-plane kernel's own
3137    /// session bookkeeping), since the two can disagree and the gap is
3138    /// itself a diagnostic signal `GET /metrics` exposes.
3139    native_session_gauge: AtomicUsize,
3140    /// Lifetime totals for the per-connection serve loop, summed across
3141    /// every live connection.
3142    ///
3143    /// The drive loop's own phase timings account for about ten
3144    /// microseconds an iteration on an idle node, yet the process burns a
3145    /// quarter of a core — and burns it ONLY while a c2 is attached (0.5%
3146    /// with none, 23% with two, measured). That gap is somewhere in
3147    /// serving a connection, and there was no way to see it: iterations
3148    /// says whether the loop is spinning rather than sleeping, and events
3149    /// says whether it is spinning over real work or over nothing.
3150    connection_loop_iterations: AtomicU64,
3151    connection_events_sent: AtomicU64,
3152    /// Lifetime totals for `drive_runtime_until_shutdown`'s own iterations
3153    /// -- `drive_loop_iterations_idle` counts iterations that applied zero
3154    /// `NativeRuntime::tick` observations AND forwarded zero control
3155    /// events; see the exact "produced nothing" definition at the site in
3156    /// `drive_runtime_until_shutdown` that increments these. Read twice
3157    /// and divide by the interval, same rule as `connections` above --
3158    /// `idle / total` is backlog item 5's "wake on work" number.
3159    /// Lifetime count of control commands the kernel refused.
3160    rejected_commands_total: AtomicU64,
3161    /// The named reason behind a recent kernel rejection, by the
3162    /// `CommandId` `dispatch_bounded` handed back when it enqueued that
3163    /// command -- populated by `drive_runtime_until_shutdown` for every
3164    /// `Err` in a tick's `command_outcomes`, and drained by whichever
3165    /// spawn-dispatch waiter recognizes its own `Register`/`Start`
3166    /// `CommandId` among them.
3167    ///
3168    /// A `ControlCommand` only ever fails synchronously (a `NodeFailure`
3169    /// out of `dispatch`/`dispatch_bounded` itself) for a port-level ingress
3170    /// problem -- full or disconnected -- never for the kernel refusing the
3171    /// command's content (an unsupported transport, an unknown agent, ...);
3172    /// that verdict exists only once `NativeRuntime::tick` has processed the
3173    /// command, one drive-loop iteration later, in a different task than
3174    /// whichever caller is awaiting the spawn. Without this map, a spawn
3175    /// wait loop that polls `self.handle.snapshot()` for a session that
3176    /// will now never appear has no way to learn why -- it can only spin
3177    /// until its own commit deadline and report a generic "did not commit"
3178    /// timeout, discarding a reason the kernel already named precisely (see
3179    /// `KernelCommandError`'s variants, e.g. `UnsupportedTransport`).
3180    rejected_command_reasons: Mutex<BTreeMap<CommandId, RejectedCommandReason>>,
3181    drive_loop_iterations_total: AtomicU64,
3182    drive_loop_iterations_idle: AtomicU64,
3183    /// The operator-chosen age/keep-N policy the retention sweep applies to
3184    /// `ManagedSessionState::Unavailable` records. Both `0` by default --
3185    /// see `SessionRecordRetentionConfig`'s own doc comment.
3186    session_record_retention: SessionRecordRetentionConfig,
3187    /// Lifetime count of managed session records the retention sweep has
3188    /// retired. Same tier as `rejected_commands_total` above: a live,
3189    /// process-lifetime instrument, never persisted and never reloaded from
3190    /// `state-v1.json` -- a restart legitimately resets it, matching every
3191    /// other `*_total` counter here.
3192    retired_records_total: AtomicU64,
3193    #[cfg(feature = "fixture")]
3194    fixture_semantic_hook_policy: bool,
3195    #[cfg(feature = "fixture")]
3196    fixture_spawn_managed_worktree_v2_failure_probe: SpawnManagedWorktreeV2FailureProbe,
3197}
3198
3199impl Drop for NodeShared {
3200    fn drop(&mut self) {
3201        if let Some(registry) = self.harness_mcp_registry.as_ref() {
3202            let instances = registry.shutdown();
3203            if let Some(control) = self.native_launch_profile_control.as_ref() {
3204                for instance_id in instances {
3205                    control.clear_native_mcp_server_launch_overlay(instance_id);
3206                }
3207            }
3208        }
3209    }
3210}
3211
3212#[derive(Clone, Debug, Eq, PartialEq)]
3213struct SessionBinding {
3214    workspace_id: WorkspaceId,
3215    generation: SessionGeneration,
3216    runtime_policy: ProviderRuntimePolicy,
3217    pending_resume: Option<(SessionGeneration, CommandId, ProviderRuntimePolicy)>,
3218    record_id: Option<SessionRecordId>,
3219    managed_worktree_lease_id: Option<ManagedWorktreeLeaseId>,
3220    environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
3221    bundle: Option<ResolvedBundleReceipt>,
3222    context: Option<ResolvedContextPackReceipt>,
3223    materialization_id: Option<MaterializationId>,
3224}
3225
3226#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3227enum SpawnRecordPolicy {
3228    ProviderIdentityOnly,
3229    Always,
3230}
3231
3232struct NativeEnvironmentSelectionGuard {
3233    control: Option<NativeLaunchProfileControl>,
3234    instance_id: AgentInstanceId,
3235}
3236
3237struct NativeInstanceOverlayGuard {
3238    control: Option<NativeLaunchProfileControl>,
3239    instance_id: AgentInstanceId,
3240}
3241
3242struct NativeHarnessMcpOverlayGuard {
3243    control: NativeLaunchProfileControl,
3244    instance_id: AgentInstanceId,
3245}
3246
3247impl NativeHarnessMcpOverlayGuard {
3248    fn retain(self) { std::mem::forget(self); }
3249}
3250
3251impl Drop for NativeHarnessMcpOverlayGuard {
3252    fn drop(&mut self) {
3253        self.control
3254            .clear_native_mcp_server_launch_overlay(self.instance_id);
3255    }
3256}
3257
3258impl NativeInstanceOverlayGuard {
3259    fn retain(mut self) {
3260        self.control = None;
3261    }
3262}
3263
3264impl Drop for NativeInstanceOverlayGuard {
3265    fn drop(&mut self) {
3266        if let Some(control) = self.control.as_ref() {
3267            control.clear_native_instance_launch_overlay(self.instance_id);
3268        }
3269    }
3270}
3271
3272enum PreparedNativeLaunchOverlay {
3273    Environment(NativeLaunchEnvironmentOverlay),
3274    Instance(NativeInstanceLaunchOverlay),
3275}
3276
3277impl NativeEnvironmentSelectionGuard {
3278    fn retain(mut self) {
3279        self.control = None;
3280    }
3281}
3282
3283impl Drop for NativeEnvironmentSelectionGuard {
3284    fn drop(&mut self) {
3285        if let Some(control) = self.control.as_ref() {
3286            control.clear_native_launch_profile_selection(self.instance_id);
3287        }
3288    }
3289}
3290
3291struct SessionMaterializationGuard<'a> {
3292    shared: &'a NodeShared,
3293    id: Option<MaterializationId>,
3294    /// Absolute ProviderHome (`home/`) under the session materialization root.
3295    /// Used for Claude station network `--settings` sidecar writes.
3296    provider_home: Option<PathBuf>,
3297}
3298
3299impl SessionMaterializationGuard<'_> {
3300    fn id(&self) -> Option<&MaterializationId> {
3301        self.id.as_ref()
3302    }
3303
3304    fn provider_home(&self) -> Option<&Path> {
3305        self.provider_home.as_deref()
3306    }
3307
3308    fn retain(mut self) {
3309        self.id = None;
3310    }
3311}
3312
3313impl Drop for SessionMaterializationGuard<'_> {
3314    fn drop(&mut self) {
3315        if let Some(id) = self.id.take() {
3316            let _ = self.shared.cleanup_materialization(&id);
3317        }
3318    }
3319}
3320
3321struct SessionRecords {
3322    records: BTreeMap<SessionRecordId, ManagedSessionRecord>,
3323}
3324
3325struct SpawnIdempotencyCache {
3326    entries: BTreeMap<SpawnIdempotencyKey, SpawnIdempotencyEntry>,
3327}
3328
3329struct SpawnIdempotencyEntry {
3330    value: SpawnIdempotencyValue,
3331    expires_at: Instant,
3332}
3333
3334enum SpawnIdempotencyValue {
3335    Standard {
3336        spec: SpawnSpec,
3337        result: Result<ResolvedSpawnReceipt, NodeFailure>,
3338    },
3339    Managed {
3340        request: ManagedWorktreeSpawnRequest,
3341        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
3342    },
3343    HarnessMcp,
3344}
3345
3346enum DurableManagedSpawnReplayDecision {
3347    Reserved,
3348    PendingLinked,
3349    Committed(ManagedWorktreeSpawnReceipt),
3350}
3351
3352impl SpawnIdempotencyValue {
3353    fn references_context(&self, context_id: &SpawnContextId) -> bool {
3354        match self {
3355            Self::Standard { result, .. } => result
3356                .as_ref()
3357                .ok()
3358                .and_then(|receipt| receipt.context.as_ref()),
3359            Self::Managed { result, .. } => result
3360                .as_ref()
3361                .ok()
3362                .and_then(|receipt| receipt.spawn.context.as_ref()),
3363            Self::HarnessMcp => None,
3364        }
3365        .is_some_and(|receipt| &receipt.id == context_id)
3366    }
3367}
3368
3369#[derive(Clone, Copy)]
3370struct ControllerLease {
3371    connection_id: u64,
3372    expires_at: Instant,
3373}
3374
3375struct NodeEventHistory {
3376    last_sequence: u64,
3377    replay_floor_sequence: u64,
3378    events: VecDeque<NodeEventEnvelope>,
3379    record_providers: BTreeMap<SessionRecordId, AgentId>,
3380    removed_record_providers: BTreeMap<u64, AgentId>,
3381}
3382
3383impl NodeEventHistory {
3384    fn new(record_providers: BTreeMap<SessionRecordId, AgentId>) -> Self {
3385        Self {
3386            last_sequence: 0,
3387            replay_floor_sequence: 1,
3388            events: VecDeque::with_capacity(NODE_EVENT_HISTORY_MAX),
3389            record_providers,
3390            removed_record_providers: BTreeMap::new(),
3391        }
3392    }
3393}
3394
3395impl NodeShared {
3396    fn new_with_incarnation(
3397        handle: Gate4AgentHandle,
3398        access_token: String,
3399        node_id: NodeId,
3400        incarnation_id: NodeIncarnationId,
3401        workspaces: Vec<WorkspaceConfig>,
3402        enabled_providers: Vec<AgentId>,
3403        provider_runtime_statuses: ProviderRuntimeStatuses,
3404        provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
3405        provider_contracts: Vec<ProviderContractSupport>,
3406        provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
3407        spawn_profiles: SpawnProfileRegistry,
3408        native_launch_profile_control: Option<NativeLaunchProfileControl>,
3409        native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
3410        session_environment_materializer: Option<SessionEnvironmentMaterializer>,
3411        state_path: Option<PathBuf>,
3412        records: Vec<ManagedSessionRecord>,
3413        managed_worktrees: Vec<ManagedWorktreeLeaseRecord>,
3414        managed_worktree_tombstones: Vec<ManagedWorktreeLeaseRecord>,
3415        materializations: Vec<MaterializationOwnershipRecord>,
3416        persistence_warning: Option<String>,
3417        input_settle_timeout_ms: u64,
3418    ) -> Self {
3419        let (event_tx, _) = broadcast::channel(NODE_BROADCAST_CAPACITY);
3420        let (terminal_event_tx, _) = broadcast::channel(NODE_TERMINAL_BROADCAST_CAPACITY);
3421        let (agent_stream_event_tx, _) = broadcast::channel(NODE_AGENT_STREAM_BROADCAST_CAPACITY);
3422        let (harness_mcp_proxy_event_tx, _) =
3423            broadcast::channel(NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY);
3424        let record_providers = records
3425            .iter()
3426            .map(|record| (record.record_id.clone(), record.provider.clone()))
3427            .collect();
3428        let mut worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode> = workspaces
3429            .iter()
3430            .map(|workspace| {
3431                (workspace.workspace_id.clone(), workspace.worktree_service_mode)
3432            })
3433            .collect();
3434        for lease in &managed_worktrees {
3435            worktree_service_modes.insert(lease.workspace_id.clone(), WorktreeServiceMode::Off);
3436        }
3437        let managed_worktree_profiles = workspaces.iter()
3438            .map(|workspace| (
3439                workspace.workspace_id.clone(),
3440                workspace.managed_worktree_profiles.clone(),
3441            ))
3442            .collect();
3443        let mut managed_worktrees = ManagedWorktreeRegistry::from_records(
3444            managed_worktrees,
3445            managed_worktree_tombstones,
3446        ).expect("durable managed worktree registry was validated while loading");
3447        managed_worktrees.clear_stale_session_holders(incarnation_id, unix_time_ms());
3448        managed_worktrees.reattach_record_holders(
3449            &managed_worktree_record_holders(&records),
3450            unix_time_ms(),
3451        );
3452        Self {
3453            handle,
3454            access_token,
3455            node_id,
3456            incarnation_id,
3457            started_at_unix_ms: std::time::SystemTime::now()
3458                .duration_since(std::time::UNIX_EPOCH)
3459                .unwrap_or_default()
3460                .as_millis()
3461                .min(u64::MAX as u128) as u64,
3462            workspaces: RwLock::new(
3463                workspaces
3464                    .into_iter()
3465                    .map(|workspace| (workspace.workspace_id, workspace.canonical_root))
3466                    .collect(),
3467            ),
3468            worktree_service_modes,
3469            managed_worktree_profiles,
3470            managed_worktrees: Mutex::new(managed_worktrees),
3471            enabled_providers,
3472            provider_runtime_statuses,
3473            provider_runtime_status_updates: RwLock::new(BTreeMap::new()),
3474            provider_runtime_monitor,
3475            provider_contracts,
3476            provider_adapter_contracts,
3477            spawn_profiles,
3478            environment_profiles: RwLock::new(BTreeMap::new()),
3479            environment_materialization_profiles: RwLock::new(BTreeMap::new()),
3480            network_allowlist_catalog: RwLock::new(crate::network_allowlist_catalog::NetworkAllowlistCatalog::new()),
3481            #[cfg(feature = "dig2-station-probe")]
3482            browser_station_leases: crate::dig2_station_lease::new_lease_table(),
3483            bundle_catalog: RwLock::new(BundleCatalog::default()),
3484            delivery_store: Mutex::new(None),
3485            context_pack_store: Mutex::new(None),
3486            context_catalog: RwLock::new(ContextPackCatalog::default()),
3487            native_launch_profile_control,
3488            harness_mcp_registry: None,
3489            native_session_catalog,
3490            session_environment_materializer,
3491            materializations: Mutex::new(
3492                materializations
3493                    .into_iter()
3494                    .map(|record| (record.id().clone(), record))
3495                    .collect(),
3496            ),
3497            spawn_idempotency: Mutex::new(SpawnIdempotencyCache {
3498                entries: BTreeMap::new(),
3499            }),
3500            managed_spawn_replays: Mutex::new(BTreeMap::new()),
3501            controller: Mutex::new(None),
3502            history: Mutex::new(NodeEventHistory::new(record_providers)),
3503            event_tx,
3504            terminal_event_tx,
3505            agent_stream_event_tx,
3506            harness_mcp_proxy_event_tx,
3507            terminal_frame_watermarks: Mutex::new(BTreeMap::new()),
3508            next_connection_id: AtomicU64::new(1),
3509            next_instance_id: AtomicU64::new(1),
3510            next_command_id: AtomicU64::new(1),
3511            shutdown: AtomicBool::new(false),
3512            shutdown_notify: Notify::new(),
3513            preauth_slots: Arc::new(Semaphore::new(MAX_PREAUTH_CONNECTIONS)),
3514            authenticated_slots: Arc::new(Semaphore::new(MAX_AUTHENTICATED_CONNECTIONS)),
3515            inspection_slots: Arc::new(Semaphore::new(WORKSPACE_INSPECTION_MAX_CONCURRENCY)),
3516            mutation_gate: AsyncMutex::new(()),
3517            session_bindings: Mutex::new(BTreeMap::new()),
3518            session_records: Mutex::new(SessionRecords {
3519                records: records
3520                    .into_iter()
3521                    .map(|record| (record.record_id.clone(), record))
3522                    .collect(),
3523            }),
3524            state_path,
3525            persistence_error: RwLock::new(
3526                persistence_warning
3527                    .map(|message| session_registry::sanitized_persistence_summary(&message)),
3528            ),
3529            state_transaction: Mutex::new(()),
3530            input_settle_timeout_ms,
3531            drive_loop_profile: Mutex::new(DriveLoopProfiler::default()),
3532            runtime_tick_profile: Mutex::new(TickProfileSnapshot::default()),
3533            shell_efficiency_profile: OnceLock::new(),
3534            native_session_gauge: AtomicUsize::new(0),
3535            connection_loop_iterations: AtomicU64::new(0),
3536            connection_events_sent: AtomicU64::new(0),
3537            rejected_commands_total: AtomicU64::new(0),
3538            rejected_command_reasons: Mutex::new(BTreeMap::new()),
3539            drive_loop_iterations_total: AtomicU64::new(0),
3540            drive_loop_iterations_idle: AtomicU64::new(0),
3541            session_record_retention: SessionRecordRetentionConfig::default(),
3542            retired_records_total: AtomicU64::new(0),
3543            #[cfg(feature = "fixture")]
3544            fixture_semantic_hook_policy: false,
3545            #[cfg(feature = "fixture")]
3546            fixture_spawn_managed_worktree_v2_failure_probe:
3547                SpawnManagedWorktreeV2FailureProbe::default(),
3548        }
3549    }
3550
3551    #[cfg(test)]
3552    fn new(
3553        handle: Gate4AgentHandle,
3554        access_token: String,
3555        node_id: NodeId,
3556        workspaces: Vec<WorkspaceConfig>,
3557        enabled_providers: Vec<AgentId>,
3558    ) -> Self {
3559        Self::new_with_incarnation(
3560            handle,
3561            access_token,
3562            node_id,
3563            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
3564            workspaces,
3565            enabled_providers,
3566            ProviderRuntimeStatuses::default(),
3567            None,
3568            Vec::new(),
3569            Vec::new(),
3570            SpawnProfileRegistry::default(),
3571            None,
3572            None,
3573            None,
3574            None,
3575            Vec::new(),
3576            Vec::new(),
3577            Vec::new(),
3578            Vec::new(),
3579            None,
3580            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
3581        )
3582    }
3583
3584    fn persistence_error(&self) -> Option<String> {
3585        self.persistence_error
3586            .read()
3587            .unwrap_or_else(|poisoned| poisoned.into_inner())
3588            .clone()
3589    }
3590
3591    async fn admit_provider_runtime(
3592        &self,
3593        provider: &AgentId,
3594        requirement: ProviderRuntimeRequirement,
3595    ) -> Result<ProviderRuntimePolicy, NodeFailure> {
3596        let admission = if let Some(monitor) = self.provider_runtime_monitor.clone() {
3597            let pipe_transport = monitor.supports_pipe_transport(provider);
3598            let acp_transport = monitor.supports_acp_transport(provider);
3599            let provider = provider.clone();
3600            let refresh = tokio::task::spawn_blocking(move || monitor.evaluate(&provider));
3601            let (status, admission) = timeout(
3602                Duration::from_millis(PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS),
3603                refresh,
3604            )
3605            .await
3606            .map_err(|_| failure(
3607                NodeFailureCode::BackendBusy,
3608                "provider runtime probe exceeded its bounded deadline",
3609            ))?
3610            .map_err(|_| failure(
3611                NodeFailureCode::BackendOperationFailed,
3612                "provider runtime probe task failed",
3613            ))?;
3614            if let Some(status) = status {
3615                self
3616                    .provider_runtime_status_updates
3617                    .write()
3618                    .unwrap_or_else(|poisoned| poisoned.into_inner())
3619                    .insert(status.provider().clone(), status);
3620            }
3621            admission.and_then(|policy| {
3622                require_policy(policy, pipe_transport, acp_transport, requirement).map(|()| policy)
3623            })
3624        } else {
3625            // No live monitor means no catalog to consult -- there is
3626            // nothing here to confirm this provider declares a Pipe or ACP
3627            // transport, so Inline/Acp are rejected the same conservative
3628            // way an undeclared provider always was, rather than admitted
3629            // on trust.
3630            crate::provider_runtime::admit_status(
3631                &self.provider_runtime_statuses,
3632                provider,
3633                false,
3634                false,
3635                requirement,
3636            )
3637        };
3638        let admission = admission.map(|policy| self.admit_declared_hook_semantics(provider, policy));
3639        admission.map_err(|error| match error {
3640            ProviderRuntimeAdmissionError::LauncherUnavailable => failure(
3641                NodeFailureCode::BackendOperationFailed,
3642                "provider launcher is unavailable",
3643            ),
3644            ProviderRuntimeAdmissionError::SemanticCapabilityUnverified => failure(
3645                NodeFailureCode::UnsupportedCapability,
3646                "provider semantic capability is not verified",
3647            ),
3648            ProviderRuntimeAdmissionError::ProbeBusy => failure(
3649                NodeFailureCode::BackendBusy,
3650                "provider runtime probe is already in progress",
3651            ),
3652        })
3653    }
3654
3655    /// Whether the catalog declares a Pipe transport for `provider` --
3656    /// the fact `require_policy` needs to admit or reject
3657    /// `ProviderRuntimeRequirement::Inline`. With no live monitor there is no
3658    /// catalog to consult, so this stays conservative and rejects, matching
3659    /// `admit_provider_runtime`'s own no-monitor branch.
3660    fn provider_supports_pipe_transport(&self, provider: &AgentId) -> bool {
3661        match self.provider_runtime_monitor.as_deref() {
3662            Some(monitor) => monitor.supports_pipe_transport(provider),
3663            None => false,
3664        }
3665    }
3666
3667    /// Whether the catalog declares an ACP transport for `provider` -- the
3668    /// fact `require_policy` needs to admit or reject
3669    /// `ProviderRuntimeRequirement::Acp`. Mirrors
3670    /// `provider_supports_pipe_transport` exactly, including the
3671    /// conservative no-monitor default.
3672    fn provider_supports_acp_transport(&self, provider: &AgentId) -> bool {
3673        match self.provider_runtime_monitor.as_deref() {
3674            Some(monitor) => monitor.supports_acp_transport(provider),
3675            None => false,
3676        }
3677    }
3678
3679    /// Grants hook ingestion to a provider whose catalog entry declares a
3680    /// Hook adapter, whatever runtime mode its launcher probe landed in.
3681    ///
3682    /// `admit_status`'s `RawPassthrough` arm hands back
3683    /// `ProviderRuntimePolicy::raw_pty()`, which grants nothing semantic --
3684    /// correct for everything it was written to answer, and wrong for hooks
3685    /// specifically. A hook event is not inferred from terminal behaviour,
3686    /// so it has nothing to do with whether a launcher probe verified this
3687    /// CLI version's PTY contract; the authority is the catalog declaring
3688    /// the adapter plus the ingress authenticating the route. Without this
3689    /// the node installs a provider's hooks, the CLI faithfully calls them,
3690    /// and every event is refused -- which is exactly the state grok was in.
3691    ///
3692    /// Applied to both admission paths so the monitor's own derivation and
3693    /// this coarse fallback cannot disagree about the same provider.
3694    fn admit_declared_hook_semantics(
3695        &self,
3696        provider: &AgentId,
3697        policy: ProviderRuntimePolicy,
3698    ) -> ProviderRuntimePolicy {
3699        if policy.hook_semantics || !policy.raw_pty_lifecycle {
3700            return policy;
3701        }
3702        let declares_hook = self.provider_adapter_contracts.iter().any(|contract| {
3703            contract.provider == *provider && contract.family == AdapterFamily::Hook
3704        });
3705        if !declares_hook {
3706            return policy;
3707        }
3708        ProviderRuntimePolicy::new(
3709            policy.raw_pty_lifecycle,
3710            policy.semantic_readiness,
3711            policy.structured_prompt,
3712            policy.provider_session_identity,
3713            policy.semantic_resume,
3714            true,
3715        )
3716        .expect("granting hook semantics over a raw-PTY policy is internally valid")
3717    }
3718
3719    /// Station network allowlist → provider launch overlay argv.
3720    ///
3721    /// - **Codex**: first-slice `-c sandbox_workspace_write.network_access` from
3722    ///   `provider_native` under Moderate (PTY or ACP).
3723    /// - **Claude**: catalog `permits` → ProviderHome sidecar settings JSON +
3724    ///   documented `--settings <path>` on **PTY** only (Linux/macOS/WSL2).
3725    ///   ACP refuses (no measured ACP settings-path surface). Native Windows
3726    ///   refuses (no vendor Bash sandbox). Membership-only (empty permits) =
3727    ///   no overlay.
3728    /// - **Kimi / Grok**: no first-party settings/argv network surface here —
3729    ///   empty overlay when no `provider_native`; invented native keys already
3730    ///   refuse at load / resolve.
3731    ///
3732    /// Empty when no allowlist / nothing to map. Never cookies / secrets.
3733    fn resolve_network_allowlist_overlay_args(
3734        &self,
3735        provider: &AgentId,
3736        mode: SessionMode,
3737        approval_level: ApprovalLevel,
3738        network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
3739        provider_home: Option<&Path>,
3740    ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
3741        let Some(allowlist_id) = network_allowlist else {
3742            return Ok(Vec::new());
3743        };
3744        let catalog = self
3745            .network_allowlist_catalog
3746            .read()
3747            .unwrap_or_else(|poisoned| poisoned.into_inner());
3748        let Some(entry) = catalog.get(allowlist_id) else {
3749            return Err(failure(
3750                NodeFailureCode::UnknownNetworkAllowlist,
3751                "spawn network allowlist is unavailable on this node",
3752            ));
3753        };
3754
3755        // Claude: settings-shaped overlay from permits (not provider_native).
3756        if provider.as_str() == "claude" && !entry.permits.is_empty() {
3757            crate::network_allowlist_catalog::claude_bash_sandbox_network_os_supported().map_err(
3758                |message| {
3759                    failure(
3760                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3761                        &message,
3762                    )
3763                },
3764            )?;
3765            if !matches!(mode, SessionMode::Pty) {
3766                return Err(failure(
3767                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3768                    "Claude sandbox.network --settings overlay requires PTY transport; ACP has no measured settings-path surface (refuse rather than invent RPC)",
3769                ));
3770            }
3771            let Some(home) = provider_home else {
3772                return Err(failure(
3773                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3774                    "Claude sandbox.network --settings overlay requires ProviderHome materialization (CLAUDE_CONFIG_DIR)",
3775                ));
3776            };
3777            let domains =
3778                crate::network_allowlist_catalog::claude_allowed_domains_from_permits(&entry.permits);
3779            if domains.is_empty() {
3780                return Err(failure(
3781                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3782                    "Claude sandbox.network overlay could not map catalog permits to allowedDomains",
3783                ));
3784            }
3785            let settings_path = home.join(
3786                crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
3787            );
3788            let json =
3789                crate::network_allowlist_catalog::claude_station_network_settings_json(&domains);
3790            crate::session_environment::secure_create_file(&settings_path, json.as_bytes()).map_err(
3791                |_| {
3792                    failure(
3793                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3794                        "Claude station network settings sidecar could not be written under ProviderHome",
3795                    )
3796                },
3797            )?;
3798            let overlay =
3799                crate::network_allowlist_catalog::claude_settings_network_overlay_args(&settings_path);
3800            return Ok(overlay.into_iter().map(std::ffi::OsString::from).collect());
3801        }
3802
3803        let Some(native) = entry.provider_native.as_ref() else {
3804            return Ok(Vec::new());
3805        };
3806        let overlay = crate::network_allowlist_catalog::resolve_provider_native_launch_overlay(
3807            provider.as_str(),
3808            approval_level,
3809            native,
3810        )
3811        .map_err(|message| {
3812            failure(
3813                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3814                &message,
3815            )
3816        })?;
3817        if overlay.is_empty() {
3818            return Ok(Vec::new());
3819        }
3820        if !matches!(mode, SessionMode::Pty | SessionMode::Acp) {
3821            return Err(failure(
3822                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3823                "Codex network_access -c overlay requires PTY or ACP transport",
3824            ));
3825        }
3826        Ok(overlay.into_iter().map(std::ffi::OsString::from).collect())
3827    }
3828
3829    /// Backward-compatible alias used by Codex-focused unit tests.
3830    fn resolve_codex_network_access_overlay_args(
3831        &self,
3832        provider: &AgentId,
3833        mode: SessionMode,
3834        approval_level: ApprovalLevel,
3835        network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
3836    ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
3837        self.resolve_network_allowlist_overlay_args(
3838            provider,
3839            mode,
3840            approval_level,
3841            network_allowlist,
3842            None,
3843        )
3844    }
3845
3846    fn resolve_environment_profile(
3847        &self,
3848        resolved: &ResolvedSpawnSpec,
3849    ) -> Result<Option<ResolvedEnvironmentProfileReceipt>, NodeFailure> {
3850        // Station network allowlist catalog enforce (plan
3851        // station-network-and-browser-profile-knobs §2.1 / §4): empty-default
3852        // catalog — unknown ids refuse rather than silent ambient. Runs even
3853        // when no environment_profile_id is present so a bare allowlist still
3854        // cannot bypass the catalog. Never cookies / OAuth / proxy credentials.
3855        if let Some(allowlist_id) = resolved.network_allowlist.as_ref() {
3856            let catalog = self
3857                .network_allowlist_catalog
3858                .read()
3859                .unwrap_or_else(|poisoned| poisoned.into_inner());
3860            let Some(entry) = catalog.get(allowlist_id) else {
3861                return Err(failure(
3862                    NodeFailureCode::UnknownNetworkAllowlist,
3863                    "spawn network allowlist is unavailable on this node",
3864                ));
3865            };
3866            // Track B: refuse provider-native mappings this provider cannot
3867            // honor (e.g. codex_network_access on Claude). Never silent ambient.
3868            if let Some(native) = entry.provider_native.as_ref() {
3869                if let Err(message) = crate::network_allowlist_catalog::provider_native_mapping_supported(
3870                    resolved.provider.as_str(),
3871                    resolved.approval_level,
3872                    native,
3873                ) {
3874                    return Err(failure(
3875                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3876                        &message,
3877                    ));
3878                }
3879            }
3880        }
3881        // Station knobs Track A (dig2browser-station-probe-and-network-permit-set):
3882        // when feature `dig2-station-probe` is on and browser_profile_id is set,
3883        // cheap named-pipe path reachability must succeed (Windows
3884        // `\\.\pipe\{suffix}`); platform-unsupported or unreachable → clear
3885        // NodeFailureCode. Runs even without environment_profile_id (same
3886        // placement spirit as allowlist catalog). Feature off keeps stub echo.
3887        // Never cookies / OAuth / proxy credentials. Exclusive lease is
3888        // acquired later in spawn_session_with_deadline (after instance id).
3889        #[cfg(feature = "dig2-station-probe")]
3890        if resolved.browser_profile_id.is_some() {
3891            use crate::dig2_station_probe::{probe_station_reachable, StationProbeError};
3892            match probe_station_reachable() {
3893                Ok(()) => {}
3894                Err(StationProbeError::UnsupportedPlatform)
3895                | Err(StationProbeError::InvalidPipeSuffix) => {
3896                    return Err(failure(
3897                        NodeFailureCode::BrowserStationProbeUnavailable,
3898                        "dig2browser station probe unavailable on this platform",
3899                    ));
3900                }
3901                Err(StationProbeError::Unreachable) => {
3902                    return Err(failure(
3903                        NodeFailureCode::BrowserStationUnreachable,
3904                        "dig2browser station named-pipe is unreachable",
3905                    ));
3906                }
3907            }
3908        }
3909        let Some(profile_id) = resolved.environment_profile_id.as_ref() else {
3910            return Ok(None);
3911        };
3912        let profiles = self
3913            .environment_profiles
3914            .read()
3915            .unwrap_or_else(|poisoned| poisoned.into_inner());
3916        let binding = profiles.get(profile_id).ok_or_else(|| {
3917            failure(
3918                NodeFailureCode::UnknownEnvironmentProfile,
3919                "spawn environment profile is unavailable on this node",
3920            )
3921        })?;
3922        if binding.provider != resolved.provider
3923            || binding.native_profile_id(resolved.mode).is_none()
3924        {
3925            return Err(failure(
3926                NodeFailureCode::EnvironmentProfileBindingMismatch,
3927                "spawn environment profile does not match the provider and mode",
3928            ));
3929        }
3930        // Echo opaque network_allowlist / browser_profile_id onto the receipt
3931        // after catalog + optional dig2 probe above. Empty/whitespace ids
3932        // already refuse at Spawn*Id construction.
3933        Ok(Some(ResolvedEnvironmentProfileReceipt {
3934            profile_id: binding.id.clone(),
3935            profile_revision: binding.revision.clone(),
3936            network_allowlist: resolved.network_allowlist.clone(),
3937            browser_profile_id: resolved.browser_profile_id.clone(),
3938        }))
3939    }
3940
3941    fn resolve_bundle(
3942        &self,
3943        resolved: &ResolvedSpawnSpec,
3944        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3945    ) -> Result<Option<ResolvedBundleReceipt>, NodeFailure> {
3946        let Some(bundle_id) = resolved.bundle_id.as_ref() else {
3947            return Ok(None);
3948        };
3949        let catalog = self.bundle_catalog
3950            .read()
3951            .unwrap_or_else(|poisoned| poisoned.into_inner());
3952        let bundle = catalog.get(bundle_id).ok_or_else(|| {
3953                failure(
3954                    NodeFailureCode::UnknownBundle,
3955                    "spawn bundle is unavailable on this node",
3956                )
3957            })?;
3958        self.bundle_layout(
3959            &resolved.provider,
3960            resolved.mode,
3961            environment_profile,
3962            bundle,
3963        )?;
3964        Ok(Some(bundle.receipt()))
3965    }
3966
3967    fn begin_delivery_stage(
3968        &self,
3969        manifest: DeliveryBundleManifestV2,
3970    ) -> Result<NodeResponse, NodeFailure> {
3971        let mut store = self
3972            .delivery_store
3973            .lock()
3974            .unwrap_or_else(|poisoned| poisoned.into_inner());
3975        let store = store.as_mut().ok_or_else(|| {
3976            failure(
3977                NodeFailureCode::DeliveryStageStorageFailed,
3978                "node delivery store is unavailable",
3979            )
3980        })?;
3981        let begun = store.begin(manifest).map_err(delivery_failure)?;
3982        Ok(NodeResponse::DeliveryStageBegun {
3983            stage_id: begun.stage_id,
3984            manifest_digest: begun.manifest_digest,
3985            missing_blobs: begun.missing_blobs,
3986        })
3987    }
3988
3989    fn put_delivery_blob_chunk(
3990        &self,
3991        stage_id: DeliveryStageId,
3992        blob_digest: DeliveryBlobDigestV1,
3993        offset: u64,
3994        chunk_hex: DeliveryBlobChunkHexV1,
3995    ) -> Result<NodeResponse, NodeFailure> {
3996        let mut store = self
3997            .delivery_store
3998            .lock()
3999            .unwrap_or_else(|poisoned| poisoned.into_inner());
4000        let store = store.as_mut().ok_or_else(|| {
4001            failure(
4002                NodeFailureCode::DeliveryStageStorageFailed,
4003                "node delivery store is unavailable",
4004            )
4005        })?;
4006        let accepted = store
4007            .put_chunk(&stage_id, &blob_digest, offset, &chunk_hex.decode())
4008            .map_err(delivery_failure)?;
4009        Ok(NodeResponse::DeliveryBlobChunkAccepted {
4010            stage_id: accepted.stage_id,
4011            blob_digest: accepted.blob_digest,
4012            next_offset: accepted.next_offset,
4013        })
4014    }
4015
4016    fn commit_delivery_stage(
4017        &self,
4018        stage_id: DeliveryStageId,
4019    ) -> Result<DeliveryCommitReceiptV1, NodeFailure> {
4020        let mut store = self
4021            .delivery_store
4022            .lock()
4023            .unwrap_or_else(|poisoned| poisoned.into_inner());
4024        let store = store.as_mut().ok_or_else(|| {
4025            failure(
4026                NodeFailureCode::DeliveryStageStorageFailed,
4027                "node delivery store is unavailable",
4028            )
4029        })?;
4030        let prepared = store.prepare_commit(&stage_id).map_err(delivery_failure)?;
4031        let mut catalog = self
4032            .bundle_catalog
4033            .write()
4034            .unwrap_or_else(|poisoned| poisoned.into_inner());
4035        let mut next_catalog = catalog.clone();
4036        next_catalog
4037            .insert_idempotent(prepared.bundle().clone())
4038            .map_err(|_| {
4039                failure(
4040                    NodeFailureCode::DeliveryStageConflict,
4041                    "delivery bundle identity conflicts with installed content",
4042                )
4043            })?;
4044        let receipt = store.publish_commit(prepared).map_err(delivery_failure)?;
4045        *catalog = next_catalog;
4046        Ok(receipt)
4047    }
4048
4049    fn abort_delivery_stage(
4050        &self,
4051        stage_id: &DeliveryStageId,
4052    ) -> Result<(), NodeFailure> {
4053        let mut store = self
4054            .delivery_store
4055            .lock()
4056            .unwrap_or_else(|poisoned| poisoned.into_inner());
4057        let store = store.as_mut().ok_or_else(|| {
4058            failure(
4059                NodeFailureCode::DeliveryStageStorageFailed,
4060                "node delivery store is unavailable",
4061            )
4062        })?;
4063        store.abort(stage_id).map_err(delivery_failure)
4064    }
4065
4066    fn resolve_context(
4067        &self,
4068        resolved: &ResolvedSpawnSpec,
4069    ) -> Result<Option<ResolvedContextPackReceipt>, NodeFailure> {
4070        let Some(context_id) = resolved.context_id.as_ref() else {
4071            return Ok(None);
4072        };
4073        self.context_catalog
4074            .read()
4075            .unwrap_or_else(|poisoned| poisoned.into_inner())
4076            .get(context_id)
4077            .map(|context| context.receipt().clone())
4078            .ok_or_else(|| {
4079                failure(
4080                    NodeFailureCode::UnknownContextPack,
4081                    "spawn context pack is unavailable on this node",
4082                )
4083            })
4084            .map(Some)
4085    }
4086
4087    fn bundle_layout(
4088        &self,
4089        provider: &AgentId,
4090        mode: SessionMode,
4091        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4092        bundle: &NodeBundle,
4093    ) -> Result<BundleProviderLayout, NodeFailure> {
4094        let layout = validate_bundle_binding(provider, mode, bundle).map_err(|_| {
4095            failure(
4096                NodeFailureCode::BundleBindingMismatch,
4097                "spawn bundle does not match the provider and mode",
4098            )
4099        })?;
4100        if layout != BundleProviderLayout::Codex {
4101            return Ok(layout);
4102        }
4103        let profile = environment_profile.and_then(|receipt| {
4104            let profiles = self
4105                .environment_profiles
4106                .read()
4107                .unwrap_or_else(|poisoned| poisoned.into_inner());
4108            let binding = profiles.get(&receipt.profile_id)?;
4109            if binding.revision != receipt.profile_revision
4110                || &binding.provider != provider
4111                || binding.native_profile_id(mode).is_none()
4112            {
4113                return None;
4114            }
4115            self.environment_materialization_profiles
4116                .read()
4117                .unwrap_or_else(|poisoned| poisoned.into_inner())
4118                .get(&receipt.profile_id)
4119                .cloned()
4120        });
4121        if !profile.is_some_and(|profile| profile.supports_bundle_layout(layout)) {
4122            return Err(failure(
4123                NodeFailureCode::BundleBindingMismatch,
4124                "Codex bundle requires an exact isolated CODEX_HOME profile",
4125            ));
4126        }
4127        Ok(layout)
4128    }
4129
4130    fn select_environment_profile(
4131        &self,
4132        instance_id: AgentInstanceId,
4133        provider: &AgentId,
4134        mode: SessionMode,
4135        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4136    ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
4137        let Some(environment_profile) = environment_profile else {
4138            return Ok(None);
4139        };
4140        let native_profile_id = {
4141            let profiles = self
4142                .environment_profiles
4143                .read()
4144                .unwrap_or_else(|poisoned| poisoned.into_inner());
4145            let binding = profiles
4146                .get(&environment_profile.profile_id)
4147                .ok_or_else(|| {
4148                    failure(
4149                        NodeFailureCode::UnknownEnvironmentProfile,
4150                        "session environment profile is unavailable on this node",
4151                    )
4152                })?;
4153            if &binding.revision != &environment_profile.profile_revision
4154                || &binding.provider != provider
4155            {
4156                return Err(failure(
4157                    NodeFailureCode::EnvironmentProfileBindingMismatch,
4158                    "session environment profile revision or provider changed",
4159                ));
4160            }
4161            binding.native_profile_id(mode).cloned().ok_or_else(|| {
4162                failure(
4163                    NodeFailureCode::EnvironmentProfileBindingMismatch,
4164                    "session environment profile does not support this mode",
4165                )
4166            })?
4167        };
4168        let control = self.native_launch_profile_control.clone().ok_or_else(|| {
4169            failure(
4170                NodeFailureCode::BackendOperationFailed,
4171                "native environment profile controller is unavailable",
4172            )
4173        })?;
4174        control
4175            .select_native_launch_profile(instance_id, native_profile_id)
4176            .map_err(|_| {
4177                failure(
4178                    NodeFailureCode::BackendOperationFailed,
4179                    "native environment profile selection failed",
4180                )
4181            })?;
4182        Ok(Some(NativeEnvironmentSelectionGuard {
4183            control: Some(control),
4184            instance_id,
4185        }))
4186    }
4187
4188    fn install_prepared_launch_overlay(
4189        &self,
4190        instance_id: AgentInstanceId,
4191        overlay: PreparedNativeLaunchOverlay,
4192    ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
4193        let control = self.native_launch_profile_control.clone().ok_or_else(|| {
4194            failure(
4195                NodeFailureCode::BackendOperationFailed,
4196                "native launch profile controller is unavailable",
4197            )
4198        })?;
4199        match overlay {
4200            PreparedNativeLaunchOverlay::Environment(overlay) => {
4201                control
4202                    .install_native_launch_environment_overlay(instance_id, overlay)
4203                    .map_err(|_| failure(
4204                        NodeFailureCode::BackendOperationFailed,
4205                        "session environment overlay installation failed",
4206                    ))?;
4207                Ok(None)
4208            }
4209            PreparedNativeLaunchOverlay::Instance(overlay) => {
4210                control
4211                    .install_native_instance_launch_overlay(instance_id, overlay)
4212                    .map_err(|_| failure(
4213                        NodeFailureCode::BundleBindingMismatch,
4214                        "session bundle launch overlay installation failed",
4215                    ))?;
4216                Ok(Some(NativeInstanceOverlayGuard {
4217                    control: Some(control),
4218                    instance_id,
4219                }))
4220            }
4221        }
4222    }
4223
4224    fn environment_materialization_profile(
4225        &self,
4226        environment_profile: &ResolvedEnvironmentProfileReceipt,
4227    ) -> Option<NodeSessionMaterializationProfile> {
4228        self.environment_materialization_profiles
4229            .read()
4230            .unwrap_or_else(|poisoned| poisoned.into_inner())
4231            .get(&environment_profile.profile_id)
4232            .cloned()
4233    }
4234
4235    fn materialization_profile(
4236        &self,
4237        provider: &AgentId,
4238        mode: SessionMode,
4239        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4240        bundle: Option<&ResolvedBundleReceipt>,
4241        context: Option<&ResolvedContextPackReceipt>,
4242    ) -> Result<Option<(
4243        NodeSessionMaterializationProfile,
4244        bool,
4245        Option<BundleProviderLayout>,
4246    )>, NodeFailure> {
4247        let environment = environment_profile
4248            .and_then(|receipt| self.environment_materialization_profile(receipt));
4249        let (bundle, bundle_layout) = match bundle {
4250            Some(receipt) => {
4251                let catalog = self.bundle_catalog.read()
4252                    .unwrap_or_else(|poisoned| poisoned.into_inner());
4253                let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
4254                    NodeFailureCode::UnknownBundle,
4255                    "session bundle is unavailable on this node",
4256                ))?;
4257                if installed.receipt() != *receipt {
4258                    return Err(failure(
4259                        NodeFailureCode::BundleBindingMismatch,
4260                        "session bundle revision or digest changed",
4261                    ));
4262                }
4263                let layout = self.bundle_layout(
4264                    provider,
4265                    mode,
4266                    environment_profile,
4267                    installed,
4268                )?;
4269                (Some(installed.clone()), Some(layout))
4270            }
4271            None => (None, None),
4272        };
4273        let has_environment_overlay = environment.is_some() || context.is_some();
4274        let profile = match (environment, bundle.as_ref(), bundle_layout) {
4275            (None, None, None) => None,
4276            (Some(profile), None, None) => Some(Ok(profile)),
4277            (None, Some(bundle), Some(layout)) => {
4278                Some(NodeSessionMaterializationProfile::from_bundle(bundle, layout))
4279            }
4280            (Some(profile), Some(bundle), Some(layout)) => {
4281                Some(profile.with_bundle(bundle, layout))
4282            }
4283            _ => {
4284                return Err(failure(
4285                    NodeFailureCode::BundleBindingMismatch,
4286                    "session bundle provider layout is unavailable",
4287                ));
4288            }
4289        };
4290        let profile = profile
4291            .transpose()
4292            .map_err(|_| failure(
4293                NodeFailureCode::BundleMaterializationFailed,
4294                "session bundle materialization profile is invalid",
4295            ))?;
4296        let profile = match (profile, context) {
4297            (Some(profile), Some(receipt)) => {
4298                let catalog = self.context_catalog
4299                    .read()
4300                    .unwrap_or_else(|poisoned| poisoned.into_inner());
4301                let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
4302                    NodeFailureCode::UnknownContextPack,
4303                    "session context pack is unavailable on this node",
4304                ))?;
4305                if pack.receipt() != receipt {
4306                    return Err(failure(
4307                        NodeFailureCode::ContextPackMaterializationFailed,
4308                        "session context pack identity changed",
4309                    ));
4310                }
4311                profile.with_context(pack)
4312            }
4313            (None, Some(receipt)) => {
4314                let catalog = self.context_catalog
4315                    .read()
4316                    .unwrap_or_else(|poisoned| poisoned.into_inner());
4317                let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
4318                    NodeFailureCode::UnknownContextPack,
4319                    "session context pack is unavailable on this node",
4320                ))?;
4321                if pack.receipt() != receipt {
4322                    return Err(failure(
4323                        NodeFailureCode::ContextPackMaterializationFailed,
4324                        "session context pack identity changed",
4325                    ));
4326                }
4327                NodeSessionMaterializationProfile::from_context(pack)
4328            }
4329            (Some(profile), None) => Ok(profile),
4330            (None, None) => return Ok(None),
4331        }
4332        .map_err(|_| failure(
4333            NodeFailureCode::ContextPackMaterializationFailed,
4334            "session context pack materialization profile is invalid",
4335        ))?;
4336        Ok(Some((profile, has_environment_overlay, bundle_layout)))
4337    }
4338
4339    fn prepare_session_materialization<'a>(
4340        &'a self,
4341        address: &SessionAddress,
4342        provider: &AgentId,
4343        mode: SessionMode,
4344        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4345        bundle: Option<&ResolvedBundleReceipt>,
4346        context: Option<&ResolvedContextPackReceipt>,
4347        managed_lease_id: Option<ManagedWorktreeLeaseId>,
4348    ) -> Result<Option<(Option<PreparedNativeLaunchOverlay>, SessionMaterializationGuard<'a>)>, NodeFailure> {
4349        let Some((profile, has_environment_overlay, bundle_layout)) =
4350            self.materialization_profile(provider, mode, environment_profile, bundle, context)?
4351        else {
4352            return Ok(None);
4353        };
4354        let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
4355            failure(
4356                NodeFailureCode::BackendOperationFailed,
4357                "session environment materializer is unavailable",
4358            )
4359        })?;
4360        let materialization_failure_code = if context.is_some() {
4361            NodeFailureCode::ContextPackMaterializationFailed
4362        } else if bundle.is_some() {
4363            NodeFailureCode::BundleMaterializationFailed
4364        } else {
4365            NodeFailureCode::BackendOperationFailed
4366        };
4367        let id = MaterializationId::new(format!(
4368            "mat-{}-{}-{}",
4369            self.incarnation_id,
4370            address.session.instance_id.0,
4371            address.session.generation.0,
4372        ))
4373        .map_err(|_| {
4374            failure(
4375                NodeFailureCode::BackendOperationFailed,
4376                "session environment materialization identity failed",
4377            )
4378        })?;
4379        let owner = MaterializationOwner::Session {
4380            incarnation_id: self.incarnation_id,
4381            instance_id: address.session.instance_id,
4382            generation: address.session.generation,
4383        };
4384        let mut ownership = materializer
4385            .begin(
4386                id.clone(),
4387                environment_profile.cloned(),
4388                bundle.cloned(),
4389                context.cloned(),
4390                owner,
4391                managed_lease_id,
4392                &profile,
4393                unix_time_ms(),
4394            )
4395            .map_err(|_| {
4396                failure(
4397                    materialization_failure_code,
4398                    "session materialization preparation failed",
4399                )
4400            })?;
4401        {
4402            let _transaction = self
4403                .state_transaction
4404                .lock()
4405                .unwrap_or_else(|poisoned| poisoned.into_inner());
4406            let duplicate = self
4407                .materializations
4408                .lock()
4409                .unwrap_or_else(|poisoned| poisoned.into_inner())
4410                .insert(id.clone(), ownership.clone());
4411            if duplicate.is_some() {
4412                return Err(failure(
4413                    NodeFailureCode::BackendOperationFailed,
4414                    "session environment materialization identity conflict",
4415                ));
4416            }
4417            if let Err(error) = self.persist_state_locked() {
4418                self.materializations
4419                    .lock()
4420                    .unwrap_or_else(|poisoned| poisoned.into_inner())
4421                    .remove(&id);
4422                return Err(persistence_failure(error));
4423            }
4424        }
4425        let environment = match materializer.materialize(&mut ownership, &profile, unix_time_ms()) {
4426            Ok(environment) => environment,
4427            Err(_) => {
4428                let transaction = self
4429                    .state_transaction
4430                    .lock()
4431                    .unwrap_or_else(|poisoned| poisoned.into_inner());
4432                self.materializations
4433                    .lock()
4434                    .unwrap_or_else(|poisoned| poisoned.into_inner())
4435                    .insert(id.clone(), ownership.clone());
4436                if self.persist_state_locked().is_err() {
4437                    self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
4438                }
4439                drop(transaction);
4440                if ownership.state() == MaterializationState::CleanupRequired {
4441                    let _ = self.cleanup_materialization(&id);
4442                }
4443                return Err(failure(
4444                    materialization_failure_code,
4445                    "session materialization failed",
4446                ));
4447            }
4448        };
4449        {
4450            let _transaction = self
4451                .state_transaction
4452                .lock()
4453                .unwrap_or_else(|poisoned| poisoned.into_inner());
4454            self.materializations
4455                .lock()
4456                .unwrap_or_else(|poisoned| poisoned.into_inner())
4457                .insert(id.clone(), ownership.clone());
4458            if let Err(error) = self.persist_state_locked() {
4459                drop(_transaction);
4460                let _ = self.cleanup_materialization(&id);
4461                return Err(persistence_failure(error));
4462            }
4463        }
4464        let guard = SessionMaterializationGuard {
4465            shared: self,
4466            id: Some(id),
4467            provider_home: Some(ownership.provider_home().to_path_buf()),
4468        };
4469        let bundle_arguments = match bundle {
4470            Some(receipt) => {
4471                let catalog = self.bundle_catalog.read()
4472                    .unwrap_or_else(|poisoned| poisoned.into_inner());
4473                let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
4474                    NodeFailureCode::UnknownBundle,
4475                    "session bundle is unavailable on this node",
4476                ))?;
4477                if installed.receipt() != *receipt {
4478                    return Err(failure(
4479                        NodeFailureCode::BundleBindingMismatch,
4480                        "session bundle revision or digest changed",
4481                    ));
4482                }
4483                let layout = bundle_layout.ok_or_else(|| failure(
4484                    NodeFailureCode::BundleBindingMismatch,
4485                    "materialized bundle provider layout is unavailable",
4486                ))?;
4487                materializer.revalidate_bundle(&ownership, installed, layout).map_err(|_| {
4488                    failure(
4489                        NodeFailureCode::BundleMaterializationFailed,
4490                        "session bundle byte revalidation failed",
4491                    )
4492                })?;
4493                Some(bundle_launch_arguments(
4494                    layout,
4495                    ownership.bundle_root(),
4496                    ownership.provider_home(),
4497                ).map_err(|_| failure(
4498                    NodeFailureCode::BundleBindingMismatch,
4499                    "session bundle launch binding is invalid",
4500                ))?)
4501            }
4502            None => None,
4503        };
4504        if let Some(receipt) = context {
4505            materializer
4506                .revalidate_context(&ownership, receipt)
4507                .map_err(|_| failure(
4508                    NodeFailureCode::ContextPackMaterializationFailed,
4509                    "session context pack byte revalidation failed",
4510                ))?;
4511        }
4512        let transport = match mode {
4513            SessionMode::Pty => TransportKind::Pty,
4514            SessionMode::Inline => TransportKind::Pipe,
4515            SessionMode::Acp => TransportKind::Acp,
4516        };
4517        let overlay = if let Some(extra_args) = bundle_arguments {
4518            Some(PreparedNativeLaunchOverlay::Instance(
4519                NativeInstanceLaunchOverlay::new(
4520                    provider.clone(),
4521                    transport,
4522                    if has_environment_overlay {
4523                        environment
4524                    } else {
4525                        Vec::new()
4526                    },
4527                    extra_args,
4528                )
4529                .map_err(|_| {
4530                    failure(
4531                        NodeFailureCode::BundleBindingMismatch,
4532                        "session bundle launch overlay is invalid",
4533                    )
4534                })?,
4535            ))
4536        } else if has_environment_overlay {
4537            let context_only = environment_profile.is_none()
4538                && bundle.is_none()
4539                && context.is_some();
4540            let overlay = if context_only {
4541                NativeLaunchEnvironmentOverlay::new_context_root(
4542                    provider.clone(),
4543                    transport,
4544                    environment,
4545                )
4546                .map_err(|_| {
4547                    failure(
4548                        NodeFailureCode::ContextPackMaterializationFailed,
4549                        "context-only launch overlay is invalid",
4550                    )
4551                })?
4552            } else {
4553                NativeLaunchEnvironmentOverlay::new(
4554                    provider.clone(),
4555                    transport,
4556                    environment,
4557                )
4558                .map_err(|_| {
4559                    failure(
4560                        NodeFailureCode::BackendOperationFailed,
4561                        "session environment overlay is invalid",
4562                    )
4563                })?
4564            };
4565            Some(PreparedNativeLaunchOverlay::Environment(overlay))
4566        } else {
4567            None
4568        };
4569        Ok(Some((overlay, guard)))
4570    }
4571
4572    fn cleanup_materialization(&self, id: &MaterializationId) -> Result<(), NodeFailure> {
4573        let Some(mut ownership) = self
4574            .materializations
4575            .lock()
4576            .unwrap_or_else(|poisoned| poisoned.into_inner())
4577            .get(id)
4578            .cloned()
4579        else {
4580            return Ok(());
4581        };
4582        if ownership.state() != MaterializationState::CleanupRequired {
4583            ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
4584                failure(
4585                    NodeFailureCode::BackendOperationFailed,
4586                    "session environment cleanup state is invalid",
4587                )
4588            })?;
4589        }
4590        {
4591            let _transaction = self
4592                .state_transaction
4593                .lock()
4594                .unwrap_or_else(|poisoned| poisoned.into_inner());
4595            let previous = self
4596                .materializations
4597                .lock()
4598                .unwrap_or_else(|poisoned| poisoned.into_inner())
4599                .insert(id.clone(), ownership.clone());
4600            if let Err(error) = self.persist_state_locked() {
4601                let mut materializations = self
4602                    .materializations
4603                    .lock()
4604                    .unwrap_or_else(|poisoned| poisoned.into_inner());
4605                if let Some(previous) = previous {
4606                    materializations.insert(id.clone(), previous);
4607                }
4608                return Err(persistence_failure(error));
4609            }
4610        }
4611        let cleanup = self
4612            .session_environment_materializer
4613            .as_ref()
4614            .ok_or(())
4615            .and_then(|materializer| materializer.cleanup(&ownership).map_err(|_| ()));
4616        if cleanup.is_err() {
4617            let _ = ownership.mark_recovery_required(unix_time_ms());
4618            self.materializations
4619                .lock()
4620                .unwrap_or_else(|poisoned| poisoned.into_inner())
4621                .insert(id.clone(), ownership);
4622            if self.persist_state().is_err() {
4623                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
4624            }
4625            return Err(failure(
4626                NodeFailureCode::BackendOperationFailed,
4627                "session environment cleanup requires recovery",
4628            ));
4629        }
4630        let _transaction = self
4631            .state_transaction
4632            .lock()
4633            .unwrap_or_else(|poisoned| poisoned.into_inner());
4634        self.materializations
4635            .lock()
4636            .unwrap_or_else(|poisoned| poisoned.into_inner())
4637            .remove(id);
4638        if let Err(error) = self.persist_state_locked() {
4639            self.materializations
4640                .lock()
4641                .unwrap_or_else(|poisoned| poisoned.into_inner())
4642                .insert(id.clone(), ownership);
4643            return Err(persistence_failure(error));
4644        }
4645        Ok(())
4646    }
4647
4648    fn resolve_record_materialization(
4649        &self,
4650        record_id: &SessionRecordId,
4651        provider: &AgentId,
4652        mode: SessionMode,
4653        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4654        bundle: Option<&ResolvedBundleReceipt>,
4655        context: Option<&ResolvedContextPackReceipt>,
4656    ) -> Result<Option<(MaterializationId, Option<PreparedNativeLaunchOverlay>)>, NodeFailure> {
4657        let ownership = {
4658            let materializations = self
4659                .materializations
4660                .lock()
4661                .unwrap_or_else(|poisoned| poisoned.into_inner());
4662            let mut matches = materializations.values().filter(|record| {
4663                record.owner()
4664                    == &MaterializationOwner::Record {
4665                        record_id: record_id.clone(),
4666                    }
4667            });
4668            let ownership = matches.next().cloned();
4669            if matches.next().is_some() {
4670                return Err(failure(
4671                    NodeFailureCode::BackendOperationFailed,
4672                    "managed session has conflicting environment ownership",
4673                ));
4674            }
4675            ownership
4676        };
4677        if let (Some(ownership), Some(receipt)) = (ownership.as_ref(), context) {
4678            let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
4679                failure(
4680                    NodeFailureCode::ContextPackMaterializationFailed,
4681                    "session context materializer is unavailable",
4682                )
4683            })?;
4684            let pack = materializer
4685                .revalidate_context(ownership, receipt)
4686                .map_err(|_| {
4687                    self.mark_materialization_recovery_required(ownership.id());
4688                    failure(
4689                        NodeFailureCode::ContextPackMaterializationFailed,
4690                        "managed session context pack revalidation failed",
4691                    )
4692                })?;
4693            self.context_catalog
4694                .write()
4695                .unwrap_or_else(|poisoned| poisoned.into_inner())
4696                .insert(pack)
4697                .map_err(|_| failure(
4698                    NodeFailureCode::ContextPackMaterializationFailed,
4699                    "managed session context pack catalog restore failed",
4700                ))?;
4701        }
4702        let materialization = self.materialization_profile(
4703            provider,
4704            mode,
4705            environment_profile,
4706            bundle,
4707            context,
4708        )?;
4709        match (ownership, materialization) {
4710            (None, None) => Ok(None),
4711            (None, Some(_)) | (Some(_), None) => Err(failure(
4712                NodeFailureCode::BackendOperationFailed,
4713                "managed session materialization is unavailable",
4714            )),
4715            (Some(ownership), Some((profile, has_environment_materialization, bundle_layout))) => {
4716                if ownership.environment_profile() != environment_profile
4717                    || ownership.bundle() != bundle
4718                    || ownership.context() != context
4719                    || ownership.state() != MaterializationState::Ready
4720                {
4721                    self.mark_materialization_recovery_required(ownership.id());
4722                    return Err(failure(
4723                        NodeFailureCode::BackendOperationFailed,
4724                        "managed session environment ownership requires recovery",
4725                    ));
4726                }
4727                let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
4728                    failure(
4729                        NodeFailureCode::BackendOperationFailed,
4730                        "session environment materializer is unavailable",
4731                    )
4732                })?;
4733                if materializer.revalidate(&ownership).is_err() {
4734                    self.mark_materialization_recovery_required(ownership.id());
4735                    return Err(failure(
4736                        NodeFailureCode::BackendOperationFailed,
4737                        "managed session environment revalidation failed",
4738                    ));
4739                }
4740                let bundle_arguments = if let Some(bundle_receipt) = bundle {
4741                    let catalog = self.bundle_catalog.read()
4742                        .unwrap_or_else(|poisoned| poisoned.into_inner());
4743                    let Some(installed) = catalog.get(&bundle_receipt.id) else {
4744                        return Err(failure(
4745                            NodeFailureCode::UnknownBundle,
4746                            "managed session bundle is unavailable",
4747                        ));
4748                    };
4749                    let layout = bundle_layout.ok_or_else(|| failure(
4750                        NodeFailureCode::BundleBindingMismatch,
4751                        "record bundle provider layout is unavailable",
4752                    ))?;
4753                    if materializer.revalidate_bundle(&ownership, installed, layout).is_err() {
4754                        drop(catalog);
4755                        self.mark_materialization_recovery_required(ownership.id());
4756                        return Err(failure(
4757                            NodeFailureCode::BundleMaterializationFailed,
4758                            "managed session bundle revalidation failed",
4759                        ));
4760                    }
4761                    Some(bundle_launch_arguments(
4762                        layout,
4763                        ownership.bundle_root(),
4764                        ownership.provider_home(),
4765                    ).map_err(|_| failure(
4766                        NodeFailureCode::BundleBindingMismatch,
4767                        "managed session bundle launch binding is invalid",
4768                    ))?)
4769                } else {
4770                    None
4771                };
4772                if !has_environment_materialization {
4773                    let overlay = bundle_arguments.map(|extra_args| {
4774                        NativeInstanceLaunchOverlay::new(
4775                            provider.clone(),
4776                            TransportKind::Pty,
4777                            Vec::new(),
4778                            extra_args,
4779                        )
4780                        .map(PreparedNativeLaunchOverlay::Instance)
4781                        .map_err(|_| failure(
4782                            NodeFailureCode::BundleBindingMismatch,
4783                            "managed session bundle launch overlay is invalid",
4784                        ))
4785                    }).transpose()?;
4786                    return Ok(Some((ownership.id().clone(), overlay)));
4787                }
4788                let environment = match materializer.resolve_environment(&ownership, &profile) {
4789                    Ok(environment) => environment,
4790                    Err(
4791                        SessionEnvironmentMaterializeError::SecretUnavailable
4792                        | SessionEnvironmentMaterializeError::SecretDenied
4793                        | SessionEnvironmentMaterializeError::InvalidSecretValue,
4794                    ) => {
4795                        return Err(failure(
4796                            NodeFailureCode::BackendOperationFailed,
4797                            "managed session environment resolution failed",
4798                        ));
4799                    }
4800                    Err(
4801                        SessionEnvironmentMaterializeError::InvalidRoot
4802                        | SessionEnvironmentMaterializeError::OwnershipMismatch
4803                        | SessionEnvironmentMaterializeError::Filesystem(_)
4804                        | SessionEnvironmentMaterializeError::Record(_),
4805                    ) => {
4806                        self.mark_materialization_recovery_required(ownership.id());
4807                        return Err(failure(
4808                            NodeFailureCode::BackendOperationFailed,
4809                            "managed session environment resolution failed",
4810                        ));
4811                    }
4812                };
4813                let transport = match mode {
4814                    SessionMode::Pty => TransportKind::Pty,
4815                    SessionMode::Inline => TransportKind::Pipe,
4816                    SessionMode::Acp => TransportKind::Acp,
4817                };
4818                let overlay = match bundle_arguments {
4819                    Some(extra_args) => PreparedNativeLaunchOverlay::Instance(
4820                        NativeInstanceLaunchOverlay::new(
4821                            provider.clone(),
4822                            transport,
4823                            environment,
4824                            extra_args,
4825                        )
4826                        .map_err(|_| failure(
4827                            NodeFailureCode::BundleBindingMismatch,
4828                            "managed session bundle launch overlay is invalid",
4829                        ))?,
4830                    ),
4831                    None => PreparedNativeLaunchOverlay::Environment(
4832                        NativeLaunchEnvironmentOverlay::new(
4833                            provider.clone(),
4834                            transport,
4835                            environment,
4836                        )
4837                        .map_err(|_| {
4838                            failure(
4839                                NodeFailureCode::BackendOperationFailed,
4840                                "managed session environment overlay is invalid",
4841                            )
4842                        })?,
4843                    ),
4844                };
4845                Ok(Some((ownership.id().clone(), Some(overlay))))
4846            }
4847        }
4848    }
4849
4850    fn destroy_record_materialization(
4851        &self,
4852        record_id: &SessionRecordId,
4853    ) -> Result<bool, NodeFailure> {
4854        let ids = self
4855            .materializations
4856            .lock()
4857            .unwrap_or_else(|poisoned| poisoned.into_inner())
4858            .values()
4859            .filter_map(|record| {
4860                (record.owner()
4861                    == &MaterializationOwner::Record {
4862                        record_id: record_id.clone(),
4863                    })
4864                .then(|| record.id().clone())
4865            })
4866            .collect::<Vec<_>>();
4867        if ids.len() > 1 {
4868            return Err(failure(
4869                NodeFailureCode::BackendOperationFailed,
4870                "managed session has conflicting environment ownership",
4871            ));
4872        }
4873        let Some(id) = ids.first().cloned() else {
4874            return Ok(false);
4875        };
4876        let (terminal_record, mut cleanup_ownership) = {
4877            let _transaction = self
4878                .state_transaction
4879                .lock()
4880                .unwrap_or_else(|poisoned| poisoned.into_inner());
4881            let original_record = self.record(record_id)?;
4882            let mut terminal_record = original_record.clone();
4883            terminal_record.provider_session = None;
4884            terminal_record.active_session = None;
4885            terminal_record.state = ManagedSessionState::Unavailable;
4886            terminal_record.last_error = Some("environment-profile-unavailable".to_owned());
4887            terminal_record.updated_at_unix_ms = unix_time_ms();
4888            let original_ownership = self
4889                .materializations
4890                .lock()
4891                .unwrap_or_else(|poisoned| poisoned.into_inner())
4892                .get(&id)
4893                .cloned()
4894                .ok_or_else(|| {
4895                    failure(
4896                        NodeFailureCode::BackendOperationFailed,
4897                        "managed session environment ownership disappeared during cleanup",
4898                    )
4899                })?;
4900            let mut cleanup_ownership = original_ownership.clone();
4901            if cleanup_ownership.state() != MaterializationState::CleanupRequired {
4902                cleanup_ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
4903                    failure(
4904                        NodeFailureCode::BackendOperationFailed,
4905                        "managed session environment cleanup state is invalid",
4906                    )
4907                })?;
4908            }
4909            self.session_records
4910                .lock()
4911                .unwrap_or_else(|poisoned| poisoned.into_inner())
4912                .records
4913                .insert(record_id.clone(), terminal_record.clone());
4914            self.materializations
4915                .lock()
4916                .unwrap_or_else(|poisoned| poisoned.into_inner())
4917                .insert(id.clone(), cleanup_ownership.clone());
4918            if let Err(error) = self.persist_state_locked() {
4919                self.session_records
4920                    .lock()
4921                    .unwrap_or_else(|poisoned| poisoned.into_inner())
4922                    .records
4923                    .insert(record_id.clone(), original_record);
4924                self.materializations
4925                    .lock()
4926                    .unwrap_or_else(|poisoned| poisoned.into_inner())
4927                    .insert(id.clone(), original_ownership);
4928                return Err(persistence_failure(error));
4929            }
4930            (terminal_record, cleanup_ownership)
4931        };
4932        let cleanup = self
4933            .session_environment_materializer
4934            .as_ref()
4935            .ok_or(())
4936            .and_then(|materializer| materializer.cleanup(&cleanup_ownership).map_err(|_| ()));
4937        if cleanup.is_err() {
4938            let _ = cleanup_ownership.mark_recovery_required(unix_time_ms());
4939            self.materializations
4940                .lock()
4941                .unwrap_or_else(|poisoned| poisoned.into_inner())
4942                .insert(id.clone(), cleanup_ownership);
4943            if self.persist_state().is_err() {
4944                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
4945            }
4946            return Err(failure(
4947                NodeFailureCode::BackendOperationFailed,
4948                "managed session environment cleanup requires recovery",
4949            ));
4950        }
4951        let _transaction = self
4952            .state_transaction
4953            .lock()
4954            .unwrap_or_else(|poisoned| poisoned.into_inner());
4955        self.remove_record_memory(record_id);
4956        self.materializations
4957            .lock()
4958            .unwrap_or_else(|poisoned| poisoned.into_inner())
4959            .remove(&id);
4960        if let Err(error) = self.persist_state_locked() {
4961            self.session_records
4962                .lock()
4963                .unwrap_or_else(|poisoned| poisoned.into_inner())
4964                .records
4965                .insert(record_id.clone(), terminal_record);
4966            self.materializations
4967                .lock()
4968                .unwrap_or_else(|poisoned| poisoned.into_inner())
4969                .insert(id, cleanup_ownership);
4970            return Err(persistence_failure(error));
4971        }
4972        Ok(true)
4973    }
4974
4975    fn mark_materialization_recovery_required(&self, id: &MaterializationId) {
4976        let changed = {
4977            let mut materializations = self
4978                .materializations
4979                .lock()
4980                .unwrap_or_else(|poisoned| poisoned.into_inner());
4981            let Some(ownership) = materializations.get_mut(id) else {
4982                return;
4983            };
4984            ownership.mark_recovery_required(unix_time_ms()).is_ok()
4985        };
4986        if changed && self.persist_state().is_err() {
4987            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
4988        }
4989    }
4990
4991    fn reconcile_materializations(&self) {
4992        let records = self
4993            .materializations
4994            .lock()
4995            .unwrap_or_else(|poisoned| poisoned.into_inner())
4996            .values()
4997            .cloned()
4998            .collect::<Vec<_>>();
4999        for mut ownership in records {
5000            let result = match (ownership.state(), ownership.owner()) {
5001                (MaterializationState::Preparing, _)
5002                | (MaterializationState::CleanupRequired, _) => {
5003                    self.cleanup_materialization(ownership.id())
5004                }
5005                (MaterializationState::Ready, MaterializationOwner::Session { .. }) => {
5006                    if ownership.mark_recovery_required(unix_time_ms()).is_ok() {
5007                        self.materializations
5008                            .lock()
5009                            .unwrap_or_else(|poisoned| poisoned.into_inner())
5010                            .insert(ownership.id().clone(), ownership.clone());
5011                        self.persist_state().map_err(persistence_failure)
5012                    } else {
5013                        Err(failure(
5014                            NodeFailureCode::BackendOperationFailed,
5015                            "session environment reconciliation failed",
5016                        ))
5017                    }
5018                }
5019                (MaterializationState::Ready, MaterializationOwner::Record { record_id }) => self
5020                    .session_environment_materializer
5021                    .as_ref()
5022                    .ok_or_else(|| {
5023                        failure(
5024                            NodeFailureCode::BackendOperationFailed,
5025                            "session environment materializer is unavailable",
5026                        )
5027                    })
5028                    .and_then(|materializer| {
5029                        materializer.revalidate(&ownership).map_err(|_| failure(
5030                            NodeFailureCode::BackendOperationFailed,
5031                            "session materialization revalidation failed",
5032                        ))?;
5033                        let record = self.record(record_id)?;
5034                        if record.environment_profile.as_ref() != ownership.environment_profile()
5035                            || record.bundle.as_ref() != ownership.bundle()
5036                            || record.context.as_ref() != ownership.context()
5037                        {
5038                            return Err(failure(
5039                                NodeFailureCode::BackendOperationFailed,
5040                                "session materialization ownership changed after restart",
5041                            ));
5042                        }
5043                        if let Some(receipt) = ownership.bundle() {
5044                            let catalog = self.bundle_catalog.read()
5045                                .unwrap_or_else(|poisoned| poisoned.into_inner());
5046                            let bundle = catalog.get(&receipt.id).ok_or_else(|| failure(
5047                                NodeFailureCode::UnknownBundle,
5048                                "session bundle is unavailable after restart",
5049                            ))?;
5050                            if bundle.receipt() != *receipt {
5051                                return Err(failure(
5052                                    NodeFailureCode::BundleBindingMismatch,
5053                                    "session bundle changed after restart",
5054                                ));
5055                            }
5056                            let layout = self.bundle_layout(
5057                                &record.provider,
5058                                record.mode,
5059                                record.environment_profile.as_ref(),
5060                                bundle,
5061                            )?;
5062                            materializer
5063                                .revalidate_bundle(&ownership, bundle, layout)
5064                                .map_err(|_| failure(
5065                                    NodeFailureCode::BundleMaterializationFailed,
5066                                    "session bundle revalidation failed",
5067                                ))?;
5068                        }
5069                        if let Some(receipt) = ownership.context() {
5070                            let pack = materializer
5071                                .revalidate_context(&ownership, receipt)
5072                                .map_err(|_| {
5073                                    failure(
5074                                        NodeFailureCode::ContextPackMaterializationFailed,
5075                                        "session context revalidation failed",
5076                                    )
5077                                })?;
5078                            self.context_catalog
5079                                .write()
5080                                .unwrap_or_else(|poisoned| poisoned.into_inner())
5081                                .insert(pack)
5082                                .map_err(|_| {
5083                                    failure(
5084                                        NodeFailureCode::ContextPackMaterializationFailed,
5085                                        "session context catalog restore failed",
5086                                    )
5087                                })?;
5088                        }
5089                        Ok(())
5090                    }),
5091                (MaterializationState::RecoveryRequired, _) => Ok(()),
5092            };
5093            if result.is_err() && ownership.state() != MaterializationState::RecoveryRequired {
5094                let _ = ownership.mark_recovery_required(unix_time_ms());
5095                self.materializations
5096                    .lock()
5097                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5098                    .insert(ownership.id().clone(), ownership);
5099                if self.persist_state().is_err() {
5100                    self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5101                }
5102            }
5103        }
5104        let materialized_profiles = self
5105            .environment_materialization_profiles
5106            .read()
5107            .unwrap_or_else(|poisoned| poisoned.into_inner())
5108            .keys()
5109            .cloned()
5110            .collect::<Vec<_>>();
5111        let ownership = self
5112            .materializations
5113            .lock()
5114            .unwrap_or_else(|poisoned| poisoned.into_inner())
5115            .values()
5116            .cloned()
5117            .collect::<Vec<_>>();
5118        let changed = {
5119            let mut session_records = self
5120                .session_records
5121                .lock()
5122                .unwrap_or_else(|poisoned| poisoned.into_inner());
5123            let mut changed = false;
5124            for record in session_records.records.values_mut() {
5125                let environment_profile = record.environment_profile.as_ref();
5126                if environment_profile.is_none()
5127                    && record.bundle.is_none()
5128                    && record.context.is_none()
5129                {
5130                    continue;
5131                }
5132                if record.bundle.is_none()
5133                    && environment_profile.is_some_and(|profile| {
5134                        !materialized_profiles.contains(&profile.profile_id)
5135                    })
5136                {
5137                    continue;
5138                }
5139                let has_exact_ownership = ownership.iter().any(|candidate| {
5140                    candidate.state() == MaterializationState::Ready
5141                        && candidate.environment_profile() == environment_profile
5142                        && candidate.bundle() == record.bundle.as_ref()
5143                        && candidate.context() == record.context.as_ref()
5144                        && candidate.owner()
5145                            == &MaterializationOwner::Record {
5146                                record_id: record.record_id.clone(),
5147                            }
5148                });
5149                if !has_exact_ownership {
5150                    record.provider_session = None;
5151                    record.active_session = None;
5152                    record.state = ManagedSessionState::Unavailable;
5153                    record.last_error = Some(if record.bundle.is_some() {
5154                        "bundle-unavailable".to_owned()
5155                    } else if record.context.is_some() {
5156                        "context-unavailable".to_owned()
5157                    } else {
5158                        "environment-profile-unavailable".to_owned()
5159                    });
5160                    record.updated_at_unix_ms = unix_time_ms();
5161                    changed = true;
5162                }
5163            }
5164            changed
5165        };
5166        if changed && self.persist_state().is_err() {
5167            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5168        }
5169    }
5170
5171    fn replay_spawn_spec(
5172        &self,
5173        spec: &SpawnSpec,
5174        now: Instant,
5175    ) -> Result<Option<Result<ResolvedSpawnReceipt, NodeFailure>>, NodeFailure> {
5176        let mut cache = self
5177            .spawn_idempotency
5178            .lock()
5179            .unwrap_or_else(|poisoned| poisoned.into_inner());
5180        cache.entries.retain(|_, entry| entry.expires_at > now);
5181        if let Some(entry) = cache.entries.get(&spec.idempotency_key) {
5182            let SpawnIdempotencyValue::Standard { spec: existing, result } = &entry.value else {
5183                return Err(failure(
5184                    NodeFailureCode::SpawnIdempotencyConflict,
5185                    "spawn idempotency key was reused across spawn kinds",
5186                ));
5187            };
5188            if existing != spec {
5189                return Err(failure(
5190                    NodeFailureCode::SpawnIdempotencyConflict,
5191                    "spawn idempotency key was reused with a different specification",
5192                ));
5193            }
5194            return Ok(Some(result.clone()));
5195        }
5196        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5197            return Err(failure(
5198                NodeFailureCode::SpawnIdempotencyCapacity,
5199                "spawn idempotency cache reached its bounded capacity",
5200            ));
5201        }
5202        Ok(None)
5203    }
5204
5205    fn remember_spawn_spec(
5206        &self,
5207        spec: SpawnSpec,
5208        result: Result<ResolvedSpawnReceipt, NodeFailure>,
5209        accepted_at: Instant,
5210    ) {
5211        self
5212            .spawn_idempotency
5213            .lock()
5214            .unwrap_or_else(|poisoned| poisoned.into_inner())
5215            .entries
5216            .insert(
5217                spec.idempotency_key.clone(),
5218                SpawnIdempotencyEntry {
5219                    value: SpawnIdempotencyValue::Standard { spec, result },
5220                    expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5221                },
5222            );
5223    }
5224
5225    fn claim_harness_mcp_spawn(
5226        &self,
5227        _reservation_id: &HarnessMcpReservationId,
5228        _activation_digest: &HarnessMcpActivationDigest,
5229        spec: &SpawnSpec,
5230        now: Instant,
5231    ) -> Result<(), NodeFailure> {
5232        let mut cache = self.spawn_idempotency.lock()
5233            .unwrap_or_else(|poisoned| poisoned.into_inner());
5234        cache.entries.retain(|_, entry| entry.expires_at > now);
5235        if cache.entries.contains_key(&spec.idempotency_key) {
5236            return Err(failure(
5237                NodeFailureCode::SpawnIdempotencyConflict,
5238                "spawn idempotency key was reused across spawn kinds",
5239            ));
5240        }
5241        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5242            return Err(failure(
5243                NodeFailureCode::SpawnIdempotencyCapacity,
5244                "spawn idempotency cache reached its bounded capacity",
5245            ));
5246        }
5247        cache.entries.insert(spec.idempotency_key.clone(), SpawnIdempotencyEntry {
5248            value: SpawnIdempotencyValue::HarnessMcp,
5249            expires_at: now + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5250        });
5251        Ok(())
5252    }
5253
5254    fn replay_managed_spawn(
5255        &self,
5256        request: &ManagedWorktreeSpawnRequest,
5257        now: Instant,
5258    ) -> Result<Option<Result<ManagedWorktreeSpawnReceipt, NodeFailure>>, NodeFailure> {
5259        let mut cache = self.spawn_idempotency.lock()
5260            .unwrap_or_else(|poisoned| poisoned.into_inner());
5261        cache.entries.retain(|_, entry| entry.expires_at > now);
5262        if let Some(entry) = cache.entries.get(&request.spawn_spec.idempotency_key) {
5263            let SpawnIdempotencyValue::Managed { request: existing, result } = &entry.value else {
5264                return Err(failure(
5265                    NodeFailureCode::SpawnIdempotencyConflict,
5266                    "spawn idempotency key was reused across spawn kinds",
5267                ));
5268            };
5269            if existing != request {
5270                return Err(failure(
5271                    NodeFailureCode::SpawnIdempotencyConflict,
5272                    "spawn idempotency key was reused with a different managed specification",
5273                ));
5274            }
5275            return Ok(Some(result.clone()));
5276        }
5277        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5278            return Err(failure(
5279                NodeFailureCode::SpawnIdempotencyCapacity,
5280                "spawn idempotency cache reached its bounded capacity",
5281            ));
5282        }
5283        Ok(None)
5284    }
5285
5286    fn remember_managed_spawn(
5287        &self,
5288        request: ManagedWorktreeSpawnRequest,
5289        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
5290        accepted_at: Instant,
5291    ) {
5292        self.spawn_idempotency.lock()
5293            .unwrap_or_else(|poisoned| poisoned.into_inner())
5294            .entries
5295            .insert(
5296                request.spawn_spec.idempotency_key.clone(),
5297                SpawnIdempotencyEntry {
5298                    value: SpawnIdempotencyValue::Managed { request, result },
5299                    expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5300                },
5301            );
5302    }
5303
5304    fn remember_managed_spawn_attempt(
5305        &self,
5306        durable_v2_key: Option<&SpawnIdempotencyKey>,
5307        request: ManagedWorktreeSpawnRequest,
5308        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
5309        accepted_at: Instant,
5310    ) {
5311        if durable_v2_key.is_none() {
5312            self.remember_managed_spawn(request, result, accepted_at);
5313        }
5314    }
5315
5316    fn acquire_durable_managed_spawn_v2(
5317        &self,
5318        request: &ManagedWorktreeSpawnRequestV2,
5319        request_digest: String,
5320    ) -> Result<DurableManagedSpawnReplayDecision, NodeFailure> {
5321        if self.state_path.is_none() {
5322            return Err(failure(
5323                NodeFailureCode::BackendOperationFailed,
5324                "managed worktree V2 requires durable node state",
5325            ));
5326        }
5327        let _transaction = self.state_transaction
5328            .lock()
5329            .unwrap_or_else(|poisoned| poisoned.into_inner());
5330        let key = request.spawn_spec.idempotency_key.clone();
5331        let mut previous = None;
5332        {
5333            let mut replays = self.managed_spawn_replays
5334                .lock()
5335                .unwrap_or_else(|poisoned| poisoned.into_inner());
5336            if let Some(record) = replays.get_mut(&key) {
5337                if record.request_digest != request_digest {
5338                    return Err(failure(
5339                        NodeFailureCode::SpawnIdempotencyConflict,
5340                        "managed worktree V2 idempotency key was reused with a different request",
5341                    ));
5342                }
5343                match &record.state {
5344                    ManagedWorktreeSpawnReplayStateV10::Pending {
5345                        owner_incarnation_id,
5346                        lease_id: None,
5347                    } if owner_incarnation_id != &self.incarnation_id => {
5348                        previous = Some(record.clone());
5349                        record.state = ManagedWorktreeSpawnReplayStateV10::Pending {
5350                            owner_incarnation_id: self.incarnation_id,
5351                            lease_id: None,
5352                        };
5353                        record.updated_at_unix_ms = unix_time_ms();
5354                    }
5355                    ManagedWorktreeSpawnReplayStateV10::Pending {
5356                        lease_id: None,
5357                        ..
5358                    } => {
5359                        return Err(failure(
5360                            NodeFailureCode::BackendBusy,
5361                            "managed worktree V2 reservation is already active",
5362                        ));
5363                    }
5364                    ManagedWorktreeSpawnReplayStateV10::Pending {
5365                        lease_id: Some(_),
5366                        ..
5367                    } => {
5368                        return Ok(DurableManagedSpawnReplayDecision::PendingLinked);
5369                    }
5370                    ManagedWorktreeSpawnReplayStateV10::Committed { receipt } => {
5371                        return Ok(DurableManagedSpawnReplayDecision::Committed(
5372                            receipt.clone(),
5373                        ));
5374                    }
5375                }
5376            } else if replays.len() >= MAX_MANAGED_WORKTREE_SPAWN_REPLAYS {
5377                return Err(failure(
5378                    NodeFailureCode::SpawnIdempotencyCapacity,
5379                    "durable managed worktree replay ledger reached its bounded capacity",
5380                ));
5381            } else {
5382                let now = unix_time_ms();
5383                replays.insert(key.clone(), ManagedWorktreeSpawnReplayRecordV10 {
5384                    idempotency_key: key.clone(),
5385                    request_digest,
5386                    source_workspace_id: request.spawn_spec.target.workspace_id.clone(),
5387                    profile_id: request.worktree_profile_id.clone(),
5388                    expected_profile_revision: request.expected_profile_revision.clone(),
5389                    state: ManagedWorktreeSpawnReplayStateV10::Pending {
5390                        owner_incarnation_id: self.incarnation_id,
5391                        lease_id: None,
5392                    },
5393                    created_at_unix_ms: now,
5394                    updated_at_unix_ms: now,
5395                });
5396            }
5397        }
5398        if let Err(error) = self.persist_state_locked() {
5399            let mut replays = self.managed_spawn_replays
5400                .lock()
5401                .unwrap_or_else(|poisoned| poisoned.into_inner());
5402            match previous {
5403                Some(previous) => {
5404                    replays.insert(key, previous);
5405                }
5406                None => {
5407                    replays.remove(&key);
5408                }
5409            }
5410            return Err(persistence_failure(error));
5411        }
5412        Ok(DurableManagedSpawnReplayDecision::Reserved)
5413    }
5414
5415    fn link_durable_managed_spawn_v2_lease_locked(
5416        &self,
5417        key: &SpawnIdempotencyKey,
5418        lease_id: &ManagedWorktreeLeaseId,
5419    ) -> Result<ManagedWorktreeSpawnReplayRecordV10, NodeFailure> {
5420        let mut replays = self.managed_spawn_replays
5421            .lock()
5422            .unwrap_or_else(|poisoned| poisoned.into_inner());
5423        let record = replays.get_mut(key).ok_or_else(|| {
5424            failure(
5425                NodeFailureCode::ManagedWorktreeRecoveryRequired,
5426                "managed worktree V2 reservation disappeared before allocation",
5427            )
5428        })?;
5429        let previous = record.clone();
5430        match &mut record.state {
5431            ManagedWorktreeSpawnReplayStateV10::Pending {
5432                lease_id: current,
5433                ..
5434            }
5435                if current.is_none() =>
5436            {
5437                *current = Some(lease_id.clone());
5438                record.updated_at_unix_ms = unix_time_ms();
5439                Ok(previous)
5440            }
5441            _ => Err(failure(
5442                NodeFailureCode::ManagedWorktreeRecoveryRequired,
5443                "managed worktree V2 reservation is not allocation-ready",
5444            )),
5445        }
5446    }
5447
5448    fn restore_durable_managed_spawn_v2_locked(
5449        &self,
5450        key: &SpawnIdempotencyKey,
5451        previous: ManagedWorktreeSpawnReplayRecordV10,
5452    ) {
5453        self.managed_spawn_replays
5454            .lock()
5455            .unwrap_or_else(|poisoned| poisoned.into_inner())
5456            .insert(key.clone(), previous);
5457    }
5458
5459    fn commit_durable_managed_spawn_v2(
5460        &self,
5461        request: &ManagedWorktreeSpawnRequestV2,
5462        receipt: ManagedWorktreeSpawnReceipt,
5463    ) -> Result<(), NodeFailure> {
5464        let _transaction = self.state_transaction
5465            .lock()
5466            .unwrap_or_else(|poisoned| poisoned.into_inner());
5467        let key = &request.spawn_spec.idempotency_key;
5468        let previous = {
5469            let mut replays = self.managed_spawn_replays
5470                .lock()
5471                .unwrap_or_else(|poisoned| poisoned.into_inner());
5472            let record = replays.get_mut(key).ok_or_else(|| {
5473                failure(
5474                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
5475                    "managed worktree V2 reservation disappeared before commit",
5476                )
5477            })?;
5478            let previous = record.clone();
5479            match &record.state {
5480                ManagedWorktreeSpawnReplayStateV10::Pending {
5481                    lease_id: Some(lease_id),
5482                    ..
5483                } if lease_id == &receipt.lease.lease_id => {}
5484                _ => {
5485                    return Err(failure(
5486                        NodeFailureCode::ManagedWorktreeRecoveryRequired,
5487                        "managed worktree V2 reservation is not linked to the spawned lease",
5488                    ));
5489                }
5490            }
5491            record.state = ManagedWorktreeSpawnReplayStateV10::Committed { receipt };
5492            record.updated_at_unix_ms = unix_time_ms();
5493            previous
5494        };
5495        if let Err(error) = self.persist_state_locked() {
5496            self.restore_durable_managed_spawn_v2_locked(key, previous);
5497            return Err(persistence_failure(error));
5498        }
5499        Ok(())
5500    }
5501
5502    fn remove_unallocated_durable_managed_spawn_v2(
5503        &self,
5504        request: &ManagedWorktreeSpawnRequestV2,
5505    ) -> Result<(), NodeFailure> {
5506        let _transaction = self.state_transaction
5507            .lock()
5508            .unwrap_or_else(|poisoned| poisoned.into_inner());
5509        let key = &request.spawn_spec.idempotency_key;
5510        let removed = {
5511            let mut replays = self.managed_spawn_replays
5512                .lock()
5513                .unwrap_or_else(|poisoned| poisoned.into_inner());
5514            match replays.get(key).map(|record| &record.state) {
5515                Some(ManagedWorktreeSpawnReplayStateV10::Pending {
5516                    lease_id: None,
5517                    ..
5518                }) => {
5519                    replays.remove(key)
5520                }
5521                _ => None,
5522            }
5523        };
5524        let Some(removed) = removed else {
5525            return Ok(());
5526        };
5527        if let Err(error) = self.persist_state_locked() {
5528            self.managed_spawn_replays
5529                .lock()
5530                .unwrap_or_else(|poisoned| poisoned.into_inner())
5531                .insert(key.clone(), removed);
5532            return Err(persistence_failure(error));
5533        }
5534        Ok(())
5535    }
5536
5537    fn resolve_spawn_spec(&self, spec: &SpawnSpec) -> Result<ResolvedSpawnSpec, NodeFailure> {
5538        if spec.target.node_id != self.node_id {
5539            return Err(failure(
5540                NodeFailureCode::SpawnTargetMismatch,
5541                "spawn target does not match this node",
5542            ));
5543        }
5544        let defaults = self.spawn_profiles.get(&spec.profile_id).ok_or_else(|| {
5545            failure(
5546                NodeFailureCode::UnknownSpawnProfile,
5547                "spawn profile is unavailable on this node",
5548            )
5549        })?;
5550        let resolved = spec.resolve(defaults).map_err(|error| match error {
5551            SpawnSpecResolveError::ProfileRevisionMismatch { .. } => failure(
5552                NodeFailureCode::SpawnProfileRevisionMismatch,
5553                "spawn profile revision does not match the loaded profile",
5554            ),
5555            _ => failure(
5556                NodeFailureCode::InvalidRequest,
5557                "spawn specification could not be resolved",
5558            ),
5559        })?;
5560        if resolved.target.worktree_id.is_some() {
5561            self.require_worktree_service(&resolved.target.workspace_id)?;
5562        }
5563        let environment_profile = self.resolve_environment_profile(&resolved)?;
5564        self.resolve_bundle(&resolved, environment_profile.as_ref())?;
5565        self.resolve_context(&resolved)?;
5566        Ok(resolved)
5567    }
5568
5569    async fn resolve_spawn_workspace(
5570        &self,
5571        resolved: &ResolvedSpawnSpec,
5572        deadline: Instant,
5573    ) -> Result<WorkspaceId, NodeFailure> {
5574        let Some(worktree_id) = resolved.target.worktree_id.as_ref() else {
5575            return Ok(resolved.target.workspace_id.clone());
5576        };
5577        if self.managed_worktrees.lock()
5578            .unwrap_or_else(|poisoned| poisoned.into_inner())
5579            .lease_for_workspace(worktree_id)
5580            .is_some()
5581        {
5582            return Err(failure(
5583                NodeFailureCode::ManagedWorktreeOwnershipConflict,
5584                "managed worktree targets may only be spawned through their lease request",
5585            ));
5586        }
5587        self.require_worktree_service(&resolved.target.workspace_id)?;
5588        let source_root = self.workspace_root(&resolved.target.workspace_id)?;
5589        let selected_root = self.workspace_root(worktree_id)?;
5590        let remaining = spawn_deadline_remaining(deadline)?;
5591        let worktrees = timeout(remaining, list_git_worktrees(&source_root))
5592            .await
5593            .map_err(|_| {
5594                failure(
5595                    NodeFailureCode::SpawnDeadlineExceeded,
5596                    "Git worktree selection exceeded the spawn deadline",
5597                )
5598            })?
5599            .map_err(git_worktree_failure)?;
5600        validate_selected_worktree(&source_root, &selected_root, &worktrees)?;
5601        Ok(worktree_id.clone())
5602    }
5603
5604    async fn spawn_from_spec(
5605        &self,
5606        spec: SpawnSpec,
5607    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
5608        self.spawn_from_spec_with_proxy(spec, None).await
5609    }
5610
5611    async fn spawn_from_spec_with_proxy(
5612        &self,
5613        spec: SpawnSpec,
5614        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
5615    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
5616        let accepted_at = Instant::now();
5617        if harness_mcp.is_none() {
5618            if let Some(replayed) = self.replay_spawn_spec(&spec, accepted_at)? {
5619                return replayed;
5620            }
5621        }
5622        let resolved = self.resolve_spawn_spec(&spec)?;
5623        let environment_profile = self.resolve_environment_profile(&resolved)?;
5624        let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
5625        let context = self.resolve_context(&resolved)?;
5626        let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
5627        let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
5628        let spawn_workspace_id = self.resolve_spawn_workspace(&resolved, deadline).await;
5629        let spawn_workspace_id = match spawn_workspace_id {
5630            Ok(workspace_id) => workspace_id,
5631            Err(error) => {
5632                let result = Err(error);
5633                if harness_mcp.is_none() {
5634                    self.remember_spawn_spec(spec, result.clone(), accepted_at);
5635                }
5636                return result;
5637            }
5638        };
5639        if self.managed_worktrees.lock()
5640            .unwrap_or_else(|poisoned| poisoned.into_inner())
5641            .lease_for_workspace(&spawn_workspace_id)
5642            .is_some()
5643        {
5644            let result = Err(failure(
5645                NodeFailureCode::ManagedWorktreeOwnershipConflict,
5646                "managed worktree targets may only be spawned through their lease request",
5647            ));
5648            if harness_mcp.is_none() {
5649                self.remember_spawn_spec(spec, result.clone(), accepted_at);
5650            }
5651            return result;
5652        }
5653        let result = self
5654            .spawn_session_with_deadline(
5655                spawn_workspace_id,
5656                resolved.provider.clone(),
5657                resolved.mode,
5658                resolved.terminal_size,
5659                resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
5660                environment_profile.clone(),
5661                bundle.clone(),
5662                context.clone(),
5663                None,
5664                None,
5665                SpawnRecordPolicy::Always,
5666                Some(deadline),
5667                &required_capabilities,
5668                harness_mcp,
5669                resolved.approval_level,
5670                resolved.network_allowlist.clone(),
5671                resolved.browser_profile_id.clone(),
5672            )
5673            .await
5674            .map(|(session, _runtime_policy)| {
5675                resolved.receipt_with_materialization(
5676                    self.incarnation_id,
5677                    session,
5678                    environment_profile,
5679                    bundle,
5680                    context,
5681                )
5682            });
5683        if harness_mcp.is_none() {
5684            self.remember_spawn_spec(spec, result.clone(), accepted_at);
5685        }
5686        result
5687    }
5688
5689    async fn spawn_from_spec_with_harness_mcp(
5690        &self,
5691        reservation_id: HarnessMcpReservationId,
5692        activation_digest: HarnessMcpActivationDigest,
5693        spec: SpawnSpec,
5694        deadline_unix_ms: u64,
5695    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
5696        let registry = self.harness_mcp_registry.as_ref().ok_or_else(|| {
5697            failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
5698        })?;
5699        if let Some(receipt) = registry
5700            .replay_spawn(&reservation_id, &activation_digest, &spec)
5701            .map_err(harness_mcp_failure)?
5702        {
5703            return Ok(receipt);
5704        }
5705        let resolved = self.resolve_spawn_spec(&spec)?;
5706        let prepared = registry
5707            .prepare_spawn(
5708                &reservation_id,
5709                &activation_digest,
5710                &spec,
5711                deadline_unix_ms,
5712                resolved.provider.clone(),
5713            )
5714            .map_err(harness_mcp_failure)?;
5715        self.claim_harness_mcp_spawn(
5716            &reservation_id,
5717            &activation_digest,
5718            &spec,
5719            Instant::now(),
5720        )?;
5721        let spawn = self.spawn_from_spec_with_proxy(spec, Some(&prepared)).await;
5722        let mut receipt = match spawn {
5723            Ok(receipt) => receipt,
5724            Err(error) => {
5725                let _ = registry.abort(&reservation_id, &activation_digest);
5726                return Err(error);
5727            }
5728        };
5729        let record_id = self
5730            .session_bindings
5731            .lock()
5732            .unwrap_or_else(|poisoned| poisoned.into_inner())
5733            .get(&receipt.session.session.instance_id)
5734            .and_then(|binding| binding.record_id.clone());
5735        let record_id = match record_id {
5736            Some(record_id) => record_id,
5737            None => {
5738                let rollback = self.remove_session(&receipt.session).await;
5739                let _ = registry.abort(&reservation_id, &activation_digest);
5740                if let Err(rollback_error) = rollback {
5741                    return Err(rollback_error);
5742                }
5743                return Err(failure(
5744                    NodeFailureCode::BindingMismatch,
5745                    "harness MCP spawn did not retain its exact managed record",
5746                ));
5747            }
5748        };
5749        receipt.harness_mcp_proxy = Some(ResolvedHarnessMcpProxyReceiptV1 {
5750            reservation_id: reservation_id.clone(),
5751            activation_digest: activation_digest.clone(),
5752        });
5753        if let Err(error) = registry.mark_spawned(
5754            &prepared,
5755            receipt.session.clone(),
5756            record_id,
5757            receipt.clone(),
5758        ) {
5759            let rollback = self.remove_session(&receipt.session).await;
5760            let _ = registry.abort(&reservation_id, &activation_digest);
5761            if let Err(rollback_error) = rollback {
5762                return Err(rollback_error);
5763            }
5764            return Err(harness_mcp_failure(error));
5765        }
5766        Ok(receipt)
5767    }
5768
5769    async fn spawn_managed_worktree(
5770        &self,
5771        request: ManagedWorktreeSpawnRequest,
5772    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
5773        self.spawn_managed_worktree_inner(request, None).await
5774    }
5775
5776    async fn spawn_managed_worktree_inner(
5777        &self,
5778        request: ManagedWorktreeSpawnRequest,
5779        durable_v2_key: Option<&SpawnIdempotencyKey>,
5780    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
5781        let accepted_at = Instant::now();
5782        if durable_v2_key.is_none() {
5783            if let Some(replayed) = self.replay_managed_spawn(&request, accepted_at)? {
5784                return replayed;
5785            }
5786        }
5787        let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
5788        if request.spawn_spec.target.worktree_id.is_some() {
5789            let result = Err(failure(
5790                NodeFailureCode::InvalidRequest,
5791                "managed worktree spawn must not select a caller-provided worktree",
5792            ));
5793            self.remember_managed_spawn_attempt(
5794                durable_v2_key,
5795                request,
5796                result.clone(),
5797                accepted_at,
5798            );
5799            return result;
5800        }
5801        let environment_profile = self.resolve_environment_profile(&resolved)?;
5802        let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
5803        let context = self.resolve_context(&resolved)?;
5804        let source_workspace_id = resolved.target.workspace_id.clone();
5805        let profile = self.managed_profile(&source_workspace_id, &request.worktree_profile_id)?;
5806        let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
5807        let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
5808        let runtime_requirement = match (resolved.mode, resolved.prompt.is_some()) {
5809            (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
5810            (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
5811            (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
5812            (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
5813        };
5814        let runtime_policy = timeout(
5815            spawn_deadline_remaining(deadline)?,
5816            self.admit_provider_runtime(&resolved.provider, runtime_requirement),
5817        ).await.map_err(|_| failure(
5818            NodeFailureCode::SpawnDeadlineExceeded,
5819            "provider admission exceeded the managed spawn deadline",
5820        ))??;
5821        if required_capabilities.iter().any(|capability| !runtime_policy.admits(*capability)) {
5822            return Err(failure(
5823                NodeFailureCode::UnsupportedSpawnCapability,
5824                "provider runtime does not admit a required spawn capability",
5825            ));
5826        }
5827        self.ensure_binding_capacity()?;
5828        let source_root = self.workspace_root(&source_workspace_id)?;
5829        profile.validate_for_workspace(&source_root)
5830            .map_err(|message| failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message))?;
5831        let base_timeout_ms = u64::try_from(
5832            spawn_deadline_remaining(deadline)?.as_millis(),
5833        ).unwrap_or(u64::MAX).max(1);
5834        let base_commit = resolve_base_commit_with_timeout(
5835            &source_root,
5836            profile.base(),
5837            base_timeout_ms,
5838        ).await.map_err(|error| {
5839            if Instant::now() >= deadline || error.message.contains("timed out") {
5840                failure(
5841                    NodeFailureCode::SpawnDeadlineExceeded,
5842                    "managed worktree base resolution exceeded the spawn deadline",
5843                )
5844            } else {
5845                managed_git_worktree_failure(error)
5846            }
5847        })?;
5848
5849        let lease = {
5850            let _transaction = self.state_transaction.lock()
5851                .unwrap_or_else(|poisoned| poisoned.into_inner());
5852            let previous = self.managed_worktrees.lock()
5853                .unwrap_or_else(|poisoned| poisoned.into_inner()).clone();
5854            let lease = self.managed_worktrees.lock()
5855                .unwrap_or_else(|poisoned| poisoned.into_inner())
5856                .allocate(source_workspace_id.clone(), &profile, base_commit.clone(), unix_time_ms())
5857                .map_err(|message| failure(NodeFailureCode::BackendBusy, &message))?;
5858            let previous_replay = match durable_v2_key
5859                .map(|key| self.link_durable_managed_spawn_v2_lease_locked(key, &lease.lease_id))
5860                .transpose()
5861            {
5862                Ok(previous) => previous,
5863                Err(error) => {
5864                    *self.managed_worktrees.lock()
5865                        .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
5866                    return Err(error);
5867                }
5868            };
5869            if let Err(error) = self.persist_state_locked() {
5870                *self.managed_worktrees.lock()
5871                    .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
5872                if let (Some(key), Some(previous_replay)) =
5873                    (durable_v2_key, previous_replay)
5874                {
5875                    self.restore_durable_managed_spawn_v2_locked(key, previous_replay);
5876                }
5877                return Err(persistence_failure(error));
5878            }
5879            lease
5880        };
5881        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() });
5882        if let Err(message) = profile.validate_target_authority(&lease.lease_id, &lease.target_root) {
5883            self.managed_worktrees.lock()
5884                .unwrap_or_else(|poisoned| poisoned.into_inner())
5885                .remove_unmutated(&lease.lease_id);
5886            if self.persist_state().is_err() {
5887                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5888            }
5889            let result = Err(failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message));
5890            self.remember_managed_spawn_attempt(
5891                durable_v2_key,
5892                request,
5893                result.clone(),
5894                accepted_at,
5895            );
5896            return result;
5897        }
5898        let mutation_timeout_ms = match spawn_deadline_remaining(deadline) {
5899            Ok(remaining) => u64::try_from(remaining.as_millis()).unwrap_or(u64::MAX).max(1),
5900            Err(error) => {
5901                self.reconcile_failed_allocation(&lease, &source_root).await;
5902                let result = Err(error);
5903                self.remember_managed_spawn_attempt(
5904                    durable_v2_key,
5905                    request,
5906                    result.clone(),
5907                    accepted_at,
5908                );
5909                return result;
5910            }
5911        };
5912        let created = match create_git_worktree_with_timeout(
5913                &source_root,
5914                &lease.target_root,
5915                &lease.branch,
5916                Some(&base_commit),
5917                mutation_timeout_ms,
5918            ).await {
5919            Ok(created) => created,
5920            Err(error) => {
5921                self.reconcile_failed_allocation(&lease, &source_root).await;
5922                let result = Err(if Instant::now() >= deadline
5923                    || error.message.contains("timed out")
5924                    || error.message.contains("deadline")
5925                {
5926                    failure(
5927                        NodeFailureCode::SpawnDeadlineExceeded,
5928                        "managed worktree creation exceeded the spawn deadline",
5929                    )
5930                } else {
5931                    managed_git_worktree_failure(error)
5932                });
5933                self.remember_managed_spawn_attempt(
5934                    durable_v2_key,
5935                    request,
5936                    result.clone(),
5937                    accepted_at,
5938                );
5939                return result;
5940            }
5941        };
5942        if !exact_created_worktree(&lease, &created.path, created.branch.as_deref(), &created.head) {
5943            self.mark_managed_recovery_required(&lease.lease_id, ManagedWorktreeCleanupFailure::OwnershipConflict);
5944            let result = Err(failure(
5945                NodeFailureCode::ManagedWorktreeRecoveryRequired,
5946                "created worktree identity did not match its durable lease",
5947            ));
5948            self.remember_managed_spawn_attempt(
5949                durable_v2_key,
5950                request,
5951                result.clone(),
5952                accepted_at,
5953            );
5954            return result;
5955        }
5956        {
5957            let mut registry = self.managed_worktrees.lock()
5958                .unwrap_or_else(|poisoned| poisoned.into_inner());
5959            let current = registry.get_mut(&lease.lease_id)
5960                .expect("allocated managed worktree lease remains present");
5961            current.expected_head = Some(created.head.clone());
5962            current.state = ManagedWorktreeLeaseState::Ready;
5963            current.updated_at_unix_ms = unix_time_ms();
5964        }
5965        if let Err(error) = self.persist_state() {
5966            self.mark_managed_recovery_required(
5967                &lease.lease_id,
5968                ManagedWorktreeCleanupFailure::Backend,
5969            );
5970            let result = Err(persistence_failure(error));
5971            self.remember_managed_spawn_attempt(
5972                durable_v2_key,
5973                request,
5974                result.clone(),
5975                accepted_at,
5976            );
5977            return result;
5978        }
5979        let ready = self.managed_worktrees.lock()
5980            .unwrap_or_else(|poisoned| poisoned.into_inner())
5981            .get(&lease.lease_id)
5982            .expect("ready managed worktree lease remains present")
5983            .snapshot();
5984        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: ready });
5985        if let Err(error) = self.register_workspace(lease.workspace_id.clone(), created.path.clone()).await {
5986            let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
5987            let result = Err(error);
5988            self.remember_managed_spawn_attempt(
5989                durable_v2_key,
5990                request,
5991                result.clone(),
5992                accepted_at,
5993            );
5994            return result;
5995        }
5996        let spawn = self.spawn_session_with_deadline(
5997            lease.workspace_id.clone(),
5998            resolved.provider.clone(),
5999            resolved.mode,
6000            resolved.terminal_size,
6001            resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
6002            environment_profile.clone(),
6003            bundle.clone(),
6004            context.clone(),
6005            Some(lease.lease_id.clone()),
6006            Some(runtime_policy),
6007            SpawnRecordPolicy::Always,
6008            Some(deadline),
6009            &required_capabilities,
6010            None,
6011            resolved.approval_level,
6012            resolved.network_allowlist.clone(),
6013            resolved.browser_profile_id.clone(),
6014        ).await;
6015        let (session, effective_runtime_policy) = match spawn {
6016            Ok(spawned) => spawned,
6017            Err(error) => {
6018                if self
6019                    .cleanup_managed_worktree(&lease.lease_id, true)
6020                    .await
6021                    .is_err()
6022                {
6023                    self.mark_managed_recovery_required(
6024                        &lease.lease_id,
6025                        ManagedWorktreeCleanupFailure::Backend,
6026                    );
6027                }
6028                let result = Err(error);
6029                self.remember_managed_spawn_attempt(
6030                    durable_v2_key,
6031                    request,
6032                    result.clone(),
6033                    accepted_at,
6034                );
6035                return result;
6036            }
6037        };
6038        let (record_id, raw_inventory_record_id) = {
6039            let mut bindings = self.session_bindings.lock()
6040                .unwrap_or_else(|poisoned| poisoned.into_inner());
6041            let binding = bindings.get_mut(&session.session.instance_id)
6042                .expect("successful spawn retains its session binding");
6043            binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
6044            managed_spawn_record_ownership(binding, effective_runtime_policy)
6045        };
6046        let bound = self.managed_worktrees.lock()
6047            .unwrap_or_else(|poisoned| poisoned.into_inner())
6048            .bind_session(
6049                &lease.lease_id,
6050                ManagedWorktreeSessionHolder {
6051                    incarnation_id: self.incarnation_id,
6052                    instance_id: session.session.instance_id,
6053                    generation: session.session.generation,
6054                },
6055                record_id,
6056                unix_time_ms(),
6057            )
6058            .map_err(|message| failure(NodeFailureCode::ManagedWorktreeRecoveryRequired, &message));
6059        let snapshot = match bound {
6060            Ok(snapshot) => snapshot,
6061            Err(error) => {
6062                if raw_inventory_record_id
6063                    .as_ref()
6064                    .is_some_and(|record_id| self.discard_record(record_id).is_err())
6065                {
6066                    self.mark_managed_recovery_required(
6067                        &lease.lease_id,
6068                        ManagedWorktreeCleanupFailure::Backend,
6069                    );
6070                }
6071                let _ = self.remove_session(&session).await;
6072                self.mark_managed_recovery_required(
6073                    &lease.lease_id,
6074                    ManagedWorktreeCleanupFailure::Backend,
6075                );
6076                let result = Err(error);
6077                self.remember_managed_spawn_attempt(
6078                    durable_v2_key,
6079                    request,
6080                    result.clone(),
6081                    accepted_at,
6082                );
6083                return result;
6084            }
6085        };
6086        if let Err(error) = self.persist_state() {
6087            if raw_inventory_record_id
6088                .as_ref()
6089                .is_some_and(|record_id| self.discard_record(record_id).is_err())
6090            {
6091                self.mark_managed_recovery_required(
6092                    &lease.lease_id,
6093                    ManagedWorktreeCleanupFailure::Backend,
6094                );
6095            }
6096            let _ = self.remove_session(&session).await;
6097            if self
6098                .cleanup_managed_worktree(&lease.lease_id, true)
6099                .await
6100                .is_err()
6101            {
6102                self.mark_managed_recovery_required(
6103                    &lease.lease_id,
6104                    ManagedWorktreeCleanupFailure::Backend,
6105                );
6106            }
6107            let result = Err(persistence_failure(error));
6108            self.remember_managed_spawn_attempt(
6109                durable_v2_key,
6110                request,
6111                result.clone(),
6112                accepted_at,
6113            );
6114            return result;
6115        }
6116        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
6117        let receipt = managed_spawn_receipt(
6118            &resolved,
6119            self.incarnation_id,
6120            session,
6121            snapshot,
6122            environment_profile,
6123            bundle,
6124            context,
6125        );
6126        let result = Ok(receipt);
6127        self.remember_managed_spawn_attempt(
6128            durable_v2_key,
6129            request,
6130            result.clone(),
6131            accepted_at,
6132        );
6133        result
6134    }
6135
6136    async fn spawn_managed_worktree_v2(
6137        &self,
6138        request: ManagedWorktreeSpawnRequestV2,
6139    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6140        if request.spawn_spec.target.node_id != self.node_id {
6141            return Err(failure(
6142                NodeFailureCode::SpawnTargetMismatch,
6143                "spawn target does not match this node",
6144            ));
6145        }
6146        if request.spawn_spec.target.worktree_id.is_some() {
6147            return Err(failure(
6148                NodeFailureCode::InvalidRequest,
6149                "managed worktree spawn must not select a caller-provided worktree",
6150            ));
6151        }
6152        let request_digest = managed_spawn_request_digest_v2(&request)?;
6153        let replay = self.acquire_durable_managed_spawn_v2(&request, request_digest)?;
6154        match replay {
6155            DurableManagedSpawnReplayDecision::Committed(receipt) => return Ok(receipt),
6156            DurableManagedSpawnReplayDecision::PendingLinked => {
6157                return Err(failure(
6158                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
6159                    "managed worktree V2 reservation has an incomplete durable allocation",
6160                ));
6161            }
6162            DurableManagedSpawnReplayDecision::Reserved => {}
6163        }
6164        let validated = (|| {
6165            let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6166            let profile = self.managed_profile(
6167                &resolved.target.workspace_id,
6168                &request.worktree_profile_id,
6169            )?;
6170            if profile.revision() != &request.expected_profile_revision {
6171                return Err(failure(
6172                    NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
6173                    "managed worktree profile revision does not match the loaded profile",
6174                ));
6175            }
6176            Ok(())
6177        })();
6178        match validated {
6179            Ok(()) => {}
6180            Err(error) => {
6181                self.remove_unallocated_durable_managed_spawn_v2(&request)?;
6182                return Err(error);
6183            }
6184        }
6185        let legacy = ManagedWorktreeSpawnRequest {
6186            spawn_spec: request.spawn_spec.clone(),
6187            worktree_profile_id: request.worktree_profile_id.clone(),
6188        };
6189        match self
6190            .spawn_managed_worktree_inner(
6191                legacy,
6192                Some(&request.spawn_spec.idempotency_key),
6193            )
6194            .await
6195        {
6196            Ok(receipt) => {
6197                self.commit_durable_managed_spawn_v2(&request, receipt.clone())?;
6198                Ok(receipt)
6199            }
6200            Err(error) => {
6201                self.remove_unallocated_durable_managed_spawn_v2(&request)?;
6202                Err(error)
6203            }
6204        }
6205    }
6206
6207    fn managed_profile(
6208        &self,
6209        workspace_id: &WorkspaceId,
6210        profile_id: &WorktreeProfileId,
6211    ) -> Result<ManagedWorktreeProfile, NodeFailure> {
6212        if self.worktree_service_modes.get(workspace_id).copied()
6213            != Some(WorktreeServiceMode::Managed)
6214        {
6215            return Err(failure(
6216                NodeFailureCode::UnsupportedSpawnCapability,
6217                "workspace does not enable managed worktrees",
6218            ));
6219        }
6220        self.managed_worktree_profiles.get(workspace_id)
6221            .and_then(|profiles| profiles.get(profile_id))
6222            .cloned()
6223            .ok_or_else(|| failure(
6224                NodeFailureCode::InvalidRequest,
6225                "managed worktree profile is unavailable for this workspace",
6226            ))
6227    }
6228
6229    async fn reconcile_failed_allocation(
6230        &self,
6231        lease: &ManagedWorktreeLeaseRecord,
6232        source_root: &str,
6233    ) {
6234        match list_git_worktrees(source_root).await {
6235            Ok(worktrees) => {
6236                match worktrees.iter().find(|item| worktree_paths_equal(&item.path, &lease.target_root)) {
6237                    None => {
6238                        match std::fs::symlink_metadata(&lease.target_root) {
6239                            Err(error) if error.kind() == io::ErrorKind::NotFound => {
6240                                self.managed_worktrees.lock()
6241                                    .unwrap_or_else(|poisoned| poisoned.into_inner())
6242                                    .remove_unmutated(&lease.lease_id);
6243                                let _ = self.persist_state();
6244                            }
6245                            _ => self.mark_managed_recovery_required(
6246                                &lease.lease_id,
6247                                ManagedWorktreeCleanupFailure::OwnershipConflict,
6248                            ),
6249                        }
6250                    }
6251                    Some(item) if exact_owned_worktree(
6252                        lease,
6253                        &item.path,
6254                        item.branch.as_deref(),
6255                        &item.head,
6256                    ) => {
6257                        let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
6258                    }
6259                    Some(_) => self.mark_managed_recovery_required(
6260                        &lease.lease_id,
6261                        ManagedWorktreeCleanupFailure::OwnershipConflict,
6262                    ),
6263                }
6264            }
6265            Err(_) => self.mark_managed_recovery_required(
6266                &lease.lease_id,
6267                ManagedWorktreeCleanupFailure::Backend,
6268            ),
6269        }
6270    }
6271
6272    async fn reconcile_managed_worktrees(&self) {
6273        let leases = self.managed_worktrees.lock()
6274            .unwrap_or_else(|poisoned| poisoned.into_inner())
6275            .active_records()
6276            .cloned()
6277            .collect::<Vec<_>>();
6278        for lease in leases {
6279            let profile = match self.managed_profile(&lease.source_workspace_id, &lease.profile_id) {
6280                Ok(profile) if profile.revision() == &lease.profile_revision => profile,
6281                _ => {
6282                    self.mark_managed_recovery_required(
6283                        &lease.lease_id,
6284                        ManagedWorktreeCleanupFailure::OwnershipConflict,
6285                    );
6286                    continue;
6287                }
6288            };
6289            if profile.validate_target_authority(&lease.lease_id, &lease.target_root).is_err() {
6290                self.mark_managed_recovery_required(
6291                    &lease.lease_id,
6292                    ManagedWorktreeCleanupFailure::OwnershipConflict,
6293                );
6294                continue;
6295            }
6296            let source_root = match self.workspace_root(&lease.source_workspace_id) {
6297                Ok(root) => root,
6298                Err(_) => {
6299                    self.mark_managed_recovery_required(
6300                        &lease.lease_id,
6301                        ManagedWorktreeCleanupFailure::OwnershipConflict,
6302                    );
6303                    continue;
6304                }
6305            };
6306            let worktrees = match list_git_worktrees(&source_root).await {
6307                Ok(worktrees) => worktrees,
6308                Err(_) => {
6309                    self.mark_managed_recovery_required(
6310                        &lease.lease_id,
6311                        ManagedWorktreeCleanupFailure::Backend,
6312                    );
6313                    continue;
6314                }
6315            };
6316            let listed = worktrees.iter()
6317                .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
6318            let Some(listed) = listed else {
6319                match std::fs::symlink_metadata(&lease.target_root) {
6320                    Err(error) if error.kind() == io::ErrorKind::NotFound
6321                        && lease.state == ManagedWorktreeLeaseState::Allocating
6322                        && !lease.has_holders() => {
6323                            let removed = self.managed_worktrees.lock()
6324                                .unwrap_or_else(|poisoned| poisoned.into_inner())
6325                                .tombstone(&lease.lease_id, unix_time_ms());
6326                            if self.persist_state().is_ok() && removed.is_some() {
6327                                self.publish(NodeEvent::ManagedWorktreeRemoved {
6328                                    lease_id: lease.lease_id.clone(),
6329                                });
6330                            }
6331                        }
6332                    _ => self.mark_managed_recovery_required(
6333                        &lease.lease_id,
6334                        ManagedWorktreeCleanupFailure::OwnershipConflict,
6335                    ),
6336                }
6337                continue;
6338            };
6339            if listed.is_main || listed.is_bare || !exact_owned_worktree(
6340                &lease,
6341                &listed.path,
6342                listed.branch.as_deref(),
6343                &listed.head,
6344            ) {
6345                self.mark_managed_recovery_required(
6346                    &lease.lease_id,
6347                    ManagedWorktreeCleanupFailure::OwnershipConflict,
6348                );
6349                continue;
6350            }
6351            if !self.workspaces.read()
6352                .unwrap_or_else(|poisoned| poisoned.into_inner())
6353                .contains_key(&lease.workspace_id)
6354            {
6355                if self.register_workspace(lease.workspace_id.clone(), listed.path.clone()).await.is_err() {
6356                    self.mark_managed_recovery_required(
6357                        &lease.lease_id,
6358                        ManagedWorktreeCleanupFailure::Backend,
6359                    );
6360                    continue;
6361                }
6362            }
6363            let snapshot = {
6364                let mut registry = self.managed_worktrees.lock()
6365                    .unwrap_or_else(|poisoned| poisoned.into_inner());
6366                let current = registry.get_mut(&lease.lease_id)
6367                    .expect("reconciled managed lease remains present");
6368                current.expected_head = Some(listed.head.clone());
6369                current.cleanup_failure = None;
6370                current.state = if current.has_holders() {
6371                    ManagedWorktreeLeaseState::InUse
6372                } else if current.retention == ManagedWorktreeRetention::Retain {
6373                    ManagedWorktreeLeaseState::Retained
6374                } else {
6375                    ManagedWorktreeLeaseState::Ready
6376                };
6377                current.updated_at_unix_ms = unix_time_ms();
6378                current.snapshot()
6379            };
6380            if self.persist_state().is_err() {
6381                self.mark_managed_recovery_required(
6382                    &lease.lease_id,
6383                    ManagedWorktreeCleanupFailure::Backend,
6384                );
6385                continue;
6386            }
6387            self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
6388            if snapshot.state == ManagedWorktreeLeaseState::Ready {
6389                let _ = self.cleanup_managed_worktree(&lease.lease_id, false).await;
6390            }
6391        }
6392    }
6393
6394    /// Crash-recovery startup sweep: durably capture the pack for every
6395    /// `Live` managed record whose most recent session has not yet produced
6396    /// one (recovers a session whose `Exited{0}` event was observed but the
6397    /// Node crashed before the export finished). The reactive fast path for
6398    /// a single just-exited record does not go through this — see
6399    /// `drive_runtime_until_shutdown`, which resolves and spawns directly so
6400    /// it can capture the exact `SessionAddress` before `publish_control`
6401    /// clears it, rather than re-deriving it from (by then likely already
6402    /// `Dormant`) record state the way a rescan naturally would.
6403    ///
6404    /// Returns as soon as every eligible record's export has been *spawned*,
6405    /// not completed — each one runs on its own detached task (see
6406    /// `reconcile_context_pack_export_for_record`'s own doc comment for why:
6407    /// `NativeRuntime::tick()` has not even started its own loop yet at the
6408    /// point this is called from `NodeServer::run()`, so nothing could ever
6409    /// drain the history round trip if this awaited it inline here either).
6410    /// Best-effort throughout: never panics, no unwrap/expect; every failure
6411    /// is a silent skip for THIS record — an unusable source or a racing
6412    /// record is not surfaced to any caller.
6413    async fn reconcile_context_pack_exports(self: &Arc<Self>) {
6414        let candidates: Vec<(SessionRecordId, SessionAddress)> = self
6415            .session_records
6416            .lock()
6417            .unwrap_or_else(|poisoned| poisoned.into_inner())
6418            .records
6419            .values()
6420            .filter(|record| {
6421                record.state == ManagedSessionState::Live
6422                    && record.exported_context.is_none()
6423                    && record.provider_session.is_some()
6424            })
6425            .filter_map(|record| {
6426                record
6427                    .active_session
6428                    .clone()
6429                    .map(|session| (record.record_id.clone(), session))
6430            })
6431            .collect();
6432        for (record_id, session) in candidates {
6433            let shared = Arc::clone(self);
6434            tokio::spawn(async move {
6435                shared
6436                    .reconcile_context_pack_export_for_record(&record_id, session)
6437                    .await;
6438            });
6439        }
6440    }
6441
6442    /// Runs on its own detached task (`tokio::spawn`, see both call sites),
6443    /// never inline on `drive_runtime_until_shutdown`'s own task: the history
6444    /// round trip this performs (`discover_history`/`load_history`, via
6445    /// `export_context_pack_for_session_record`) can only ever settle once
6446    /// `NativeRuntime::tick()` runs again to drain the authority worker's
6447    /// completion — and `tick()` is exclusively owned by, and only ever
6448    /// called from, that same drive-loop task. Awaiting this inline there
6449    /// would starve `tick()` and this call could never observe its own
6450    /// command complete.
6451    ///
6452    /// Because this now genuinely races the drive loop's own
6453    /// `publish_control` (which downgrades this exact record from `Live` to
6454    /// `Dormant`, clearing `active_session`, for the very `Exited` event that
6455    /// triggered this reconcile), the record legitimately may already be
6456    /// `Dormant` by the time this reads it, or may transition mid-flight —
6457    /// `allow_clean_detachment: true` on the downstream export call is what
6458    /// tolerates exactly that transition (and only that transition; see
6459    /// `session_record_export_target_matches`).
6460    async fn reconcile_context_pack_export_for_record(
6461        &self,
6462        record_id: &SessionRecordId,
6463        session: SessionAddress,
6464    ) {
6465        let Ok(record) = self.record(record_id) else {
6466            return;
6467        };
6468        if !matches!(
6469            record.state,
6470            ManagedSessionState::Live | ManagedSessionState::Dormant
6471        ) || record.exported_context.is_some()
6472            || record.provider_session.is_none()
6473        {
6474            return;
6475        }
6476        // Pack bytes are already durably committed inside this call, via
6477        // commit_context_pack_for_session_record's own durable
6478        // write-through (context_pack_store) — no separate step here.
6479        // Unclean exits (non-zero/forced/no loadable history) are already
6480        // rejected by context_pack_source_status_is_usable inside
6481        // materialize_context_pack: this simply returns Err and is skipped.
6482        let Ok(receipt) = self
6483            .export_context_pack_for_session_record(&record.record_id, &session, true)
6484            .await
6485        else {
6486            return;
6487        };
6488        self.set_exported_context(&record.record_id, receipt);
6489    }
6490
6491    /// Records the record's most recent clean-exit pack. No-op (not an
6492    /// error) if the record disappeared, or if it already carries an
6493    /// `exported_context` — first-write-wins, matching the harness-side
6494    /// idempotent reconciliation that consumes this field.
6495    fn set_exported_context(&self, record_id: &SessionRecordId, receipt: ResolvedContextPackReceipt) {
6496        let transaction = self
6497            .state_transaction
6498            .lock()
6499            .unwrap_or_else(|poisoned| poisoned.into_inner());
6500        let (previous, updated) = {
6501            let mut records = self
6502                .session_records
6503                .lock()
6504                .unwrap_or_else(|poisoned| poisoned.into_inner());
6505            let Some(record) = records.records.get_mut(record_id) else {
6506                return;
6507            };
6508            if record.exported_context.is_some() {
6509                return;
6510            }
6511            let previous = record.clone();
6512            record.exported_context = Some(receipt);
6513            record.updated_at_unix_ms = unix_time_ms().max(record.updated_at_unix_ms);
6514            (previous, record.clone())
6515        };
6516        if self.persist_state_locked().is_err() {
6517            self.session_records
6518                .lock()
6519                .unwrap_or_else(|poisoned| poisoned.into_inner())
6520                .records
6521                .insert(record_id.clone(), previous);
6522            return;
6523        }
6524        drop(transaction);
6525        self.publish_record(updated);
6526    }
6527
6528    /// Resolves the managed record and its exact `SessionAddress` currently
6529    /// bound to `instance_id` at exactly `generation`, used by the reactive
6530    /// `Exited{0}` hook in `drive_runtime_until_shutdown` to target a single
6531    /// record without a full rescan. Mirrors the lookup already inlined in
6532    /// `publish_control`. Captured synchronously, before `publish_control`
6533    /// for the same event clears the record's own `active_session` — the
6534    /// caller passes the resolved address through to a detached
6535    /// reconciliation task rather than re-deriving it from the record later,
6536    /// after that race has necessarily already happened.
6537    fn managed_record_export_target_for_instance(
6538        &self,
6539        instance_id: AgentInstanceId,
6540        generation: SessionGeneration,
6541    ) -> Option<(SessionRecordId, SessionAddress)> {
6542        let bindings = self
6543            .session_bindings
6544            .lock()
6545            .unwrap_or_else(|poisoned| poisoned.into_inner());
6546        let binding = bindings.get(&instance_id).filter(|binding| binding.generation == generation)?;
6547        let record_id = binding.record_id.clone()?;
6548        Some((
6549            record_id,
6550            SessionAddress {
6551                workspace_id: binding.workspace_id.clone(),
6552                session: SessionKey { instance_id, generation },
6553            },
6554        ))
6555    }
6556
6557    fn mark_managed_recovery_required(
6558        &self,
6559        lease_id: &ManagedWorktreeLeaseId,
6560        failure_kind: ManagedWorktreeCleanupFailure,
6561    ) {
6562        let snapshot = {
6563            let mut registry = self.managed_worktrees.lock()
6564                .unwrap_or_else(|poisoned| poisoned.into_inner());
6565            let Some(lease) = registry.get_mut(lease_id) else { return };
6566            lease.state = ManagedWorktreeLeaseState::RecoveryRequired;
6567            lease.cleanup_failure = Some(failure_kind);
6568            lease.updated_at_unix_ms = unix_time_ms();
6569            lease.snapshot()
6570        };
6571        let _ = self.persist_state();
6572        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
6573    }
6574
6575    fn set_persistence_error(&self, error: Option<String>) {
6576        *self
6577            .persistence_error
6578            .write()
6579            .unwrap_or_else(|poisoned| poisoned.into_inner()) = error
6580            .map(|message| session_registry::sanitized_persistence_summary(&message));
6581    }
6582
6583    fn persist_state(&self) -> io::Result<()> {
6584        let _transaction = self
6585            .state_transaction
6586            .lock()
6587            .unwrap_or_else(|poisoned| poisoned.into_inner());
6588        self.persist_state_locked()
6589    }
6590
6591    fn persist_state_locked(&self) -> io::Result<()> {
6592        let workspaces = self
6593            .workspaces
6594            .read()
6595            .unwrap_or_else(|poisoned| poisoned.into_inner())
6596            .clone();
6597        let records = self
6598            .session_records
6599            .lock()
6600            .unwrap_or_else(|poisoned| poisoned.into_inner())
6601            .records
6602            .values()
6603            .cloned()
6604            .collect::<Vec<_>>();
6605        let mut managed = self.managed_worktrees
6606            .lock()
6607            .unwrap_or_else(|poisoned| poisoned.into_inner());
6608        managed.reattach_record_holders(
6609            &managed_worktree_record_holders(&records),
6610            unix_time_ms(),
6611        );
6612        let materializations = self
6613            .materializations
6614            .lock()
6615            .unwrap_or_else(|poisoned| poisoned.into_inner())
6616            .values()
6617            .cloned()
6618            .collect::<Vec<_>>();
6619        let managed_spawn_replays = self.managed_spawn_replays
6620            .lock()
6621            .unwrap_or_else(|poisoned| poisoned.into_inner())
6622            .values()
6623            .cloned()
6624            .collect::<Vec<_>>();
6625        let result = session_registry::save_v10(
6626            self.state_path.as_deref(),
6627            &self.node_id,
6628            &workspaces,
6629            &records,
6630            &managed.records(),
6631            &managed.tombstones(),
6632            &materializations,
6633            &managed_spawn_replays,
6634        );
6635        match &result {
6636            Ok(warning) => self.set_persistence_error(warning.clone()),
6637            Err(_) => self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned())),
6638        }
6639        result.map(|_| ())
6640    }
6641
6642    async fn begin_shutdown(&self) -> Result<(), NodeFailure> {
6643        let _mutation_guard = self.mutation_gate.lock().await;
6644        self.begin_shutdown_locked().await
6645    }
6646
6647    async fn begin_shutdown_locked(&self) -> Result<(), NodeFailure> {
6648        if self.shutdown.swap(true, Ordering::AcqRel) {
6649            return Ok(());
6650        }
6651        if let Some(registry) = self.harness_mcp_registry.as_ref() {
6652            let instances = registry.shutdown();
6653            if let Some(control) = self.native_launch_profile_control.as_ref() {
6654                for instance_id in instances {
6655                    control.clear_native_mcp_server_launch_overlay(instance_id);
6656                }
6657            }
6658        }
6659        let deadline = Instant::now() + Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
6660        let mut first_error = None;
6661        for session in &self.handle.snapshot().sessions {
6662            let remaining = deadline.saturating_duration_since(Instant::now());
6663            if remaining.is_zero() {
6664                first_error.get_or_insert_with(|| {
6665                    failure(NodeFailureCode::BackendBusy, "stop-all dispatch exceeded its bounded deadline")
6666                });
6667                break;
6668            }
6669            if let Err(error) = self
6670                .dispatch_bounded(
6671                    ControlCommand::Stop {
6672                        instance_id: session.instance_id,
6673                        force: true,
6674                    },
6675                    remaining,
6676                )
6677                .await
6678            {
6679                first_error.get_or_insert(error);
6680            }
6681        }
6682        self.shutdown_notify.notify_waiters();
6683        match first_error {
6684            Some(error) => Err(error),
6685            None => Ok(()),
6686        }
6687    }
6688
6689    fn ensure_binding_capacity(&self) -> Result<(), NodeFailure> {
6690        let bindings = self
6691            .session_bindings
6692            .lock()
6693            .unwrap_or_else(|poisoned| poisoned.into_inner());
6694        if bindings.len() >= CONTROL_SESSIONS_MAX {
6695            Err(failure(
6696                NodeFailureCode::BackendBusy,
6697                "node session-binding capacity is full; remove an existing session first",
6698            ))
6699        } else {
6700            Ok(())
6701        }
6702    }
6703
6704    /// One-owner workspace/env collision guard: a live session on
6705    /// `workspace_id` with a different `environment_profile` (option
6706    /// equality on the resolved receipt, including Some vs None) blocks
6707    /// another spawn on that same cwd. Same profile may still share the
6708    /// workspace; distinct profiles need distinct workspace ids (managed
6709    /// worktree).
6710    fn ensure_workspace_environment_profile_compatible(
6711        &self,
6712        workspace_id: &WorkspaceId,
6713        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
6714    ) -> Result<(), NodeFailure> {
6715        let bindings = self
6716            .session_bindings
6717            .lock()
6718            .unwrap_or_else(|poisoned| poisoned.into_inner());
6719        for binding in bindings.values() {
6720            if &binding.workspace_id != workspace_id {
6721                continue;
6722            }
6723            if binding.environment_profile.as_ref() != environment_profile {
6724                return Err(failure(
6725                    NodeFailureCode::WorkspaceBusy,
6726                    "workspace already has a live session with a different environment profile; use a managed worktree for concurrent distinct ProviderHome/env bindings on one repo",
6727                ));
6728            }
6729        }
6730        Ok(())
6731    }
6732
6733    #[cfg(test)]
6734    fn bind_session(
6735        &self,
6736        instance_id: AgentInstanceId,
6737        workspace_id: WorkspaceId,
6738        generation: SessionGeneration,
6739    ) {
6740        self.bind_session_with_policy(
6741            &SessionAddress {
6742                workspace_id,
6743                session: SessionKey { instance_id, generation },
6744            },
6745            ProviderRuntimePolicy::raw_pty(),
6746            None,
6747        );
6748    }
6749
6750    #[cfg(test)]
6751    fn bind_session_with_policy(
6752        &self,
6753        address: &SessionAddress,
6754        runtime_policy: ProviderRuntimePolicy,
6755        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
6756    ) {
6757        self.bind_session_with_materialization(
6758            address,
6759            runtime_policy,
6760            environment_profile,
6761            None,
6762            None,
6763            None,
6764        );
6765    }
6766
6767    fn bind_session_with_materialization(
6768        &self,
6769        address: &SessionAddress,
6770        runtime_policy: ProviderRuntimePolicy,
6771        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
6772        bundle: Option<ResolvedBundleReceipt>,
6773        context: Option<ResolvedContextPackReceipt>,
6774        materialization_id: Option<MaterializationId>,
6775    ) {
6776        let mut bindings = self
6777            .session_bindings
6778            .lock()
6779            .unwrap_or_else(|poisoned| poisoned.into_inner());
6780        debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
6781        debug_assert!(!bindings.contains_key(&address.session.instance_id));
6782        bindings.insert(address.session.instance_id, SessionBinding {
6783            workspace_id: address.workspace_id.clone(),
6784            generation: address.session.generation,
6785            runtime_policy,
6786            pending_resume: None,
6787            record_id: None,
6788            managed_worktree_lease_id: None,
6789            environment_profile,
6790            bundle,
6791            context,
6792            materialization_id,
6793        });
6794    }
6795
6796    #[cfg(test)]
6797    fn bind_managed_session(
6798        &self,
6799        address: &SessionAddress,
6800        record_id: SessionRecordId,
6801        runtime_policy: ProviderRuntimePolicy,
6802        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
6803    ) {
6804        self.bind_managed_session_with_materialization(
6805            address,
6806            record_id,
6807            runtime_policy,
6808            environment_profile,
6809            None,
6810            None,
6811            None,
6812        );
6813    }
6814
6815    fn bind_managed_session_with_materialization(
6816        &self,
6817        address: &SessionAddress,
6818        record_id: SessionRecordId,
6819        runtime_policy: ProviderRuntimePolicy,
6820        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
6821        bundle: Option<ResolvedBundleReceipt>,
6822        context: Option<ResolvedContextPackReceipt>,
6823        materialization_id: Option<MaterializationId>,
6824    ) {
6825        let mut bindings = self
6826            .session_bindings
6827            .lock()
6828            .unwrap_or_else(|poisoned| poisoned.into_inner());
6829        debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
6830        debug_assert!(!bindings.contains_key(&address.session.instance_id));
6831        bindings.insert(
6832            address.session.instance_id,
6833            SessionBinding {
6834                workspace_id: address.workspace_id.clone(),
6835                generation: address.session.generation,
6836                runtime_policy,
6837                pending_resume: None,
6838                record_id: Some(record_id),
6839                managed_worktree_lease_id: None,
6840                environment_profile,
6841                bundle,
6842                context,
6843                materialization_id,
6844            },
6845        );
6846    }
6847
6848    #[cfg(test)]
6849    fn bind_spawn_session(
6850        &self,
6851        address: &SessionAddress,
6852        provider: AgentId,
6853        mode: SessionMode,
6854        runtime_policy: ProviderRuntimePolicy,
6855        record_policy: SpawnRecordPolicy,
6856        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
6857    ) -> Result<Option<SessionRecordId>, NodeFailure> {
6858        self.bind_spawn_session_with_materialization(
6859            address,
6860            provider,
6861            mode,
6862            runtime_policy,
6863            record_policy,
6864            environment_profile,
6865            None,
6866            None,
6867            None,
6868        )
6869    }
6870
6871    fn bind_spawn_session_with_materialization(
6872        &self,
6873        address: &SessionAddress,
6874        provider: AgentId,
6875        mode: SessionMode,
6876        runtime_policy: ProviderRuntimePolicy,
6877        record_policy: SpawnRecordPolicy,
6878        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
6879        bundle: Option<ResolvedBundleReceipt>,
6880        context: Option<ResolvedContextPackReceipt>,
6881        materialization_id: Option<MaterializationId>,
6882    ) -> Result<Option<SessionRecordId>, NodeFailure> {
6883        if record_policy == SpawnRecordPolicy::ProviderIdentityOnly
6884            && !runtime_policy.provider_session_identity
6885        {
6886            self.bind_session_with_materialization(
6887                address,
6888                runtime_policy,
6889                environment_profile,
6890                bundle,
6891                context,
6892                materialization_id,
6893            );
6894            return Ok(None);
6895        }
6896        let record_owns_lifecycle = runtime_policy.provider_session_identity;
6897        let record = self.new_record(
6898            address,
6899            provider,
6900            mode,
6901            active_record_state(runtime_policy.provider_session_identity, false),
6902            None,
6903            record_owns_lifecycle.then(|| environment_profile.clone()).flatten(),
6904            record_owns_lifecycle.then(|| bundle.clone()).flatten(),
6905            record_owns_lifecycle.then(|| context.clone()).flatten(),
6906        )?;
6907        let record_id = record.record_id.clone();
6908        let transaction = self
6909            .state_transaction
6910            .lock()
6911            .unwrap_or_else(|poisoned| poisoned.into_inner());
6912        self.insert_record(record.clone())?;
6913        let previous_materialization = if record_owns_lifecycle {
6914            if let Some(materialization_id) = materialization_id.as_ref() {
6915                let mut materializations = self
6916                    .materializations
6917                    .lock()
6918                    .unwrap_or_else(|poisoned| poisoned.into_inner());
6919                let Some(ownership) = materializations.get_mut(materialization_id) else {
6920                    drop(materializations);
6921                    self.remove_record_memory(&record_id);
6922                    return Err(failure(
6923                        NodeFailureCode::BackendOperationFailed,
6924                        "session environment ownership disappeared before binding",
6925                    ));
6926                };
6927                let previous = ownership.clone();
6928                if ownership
6929                    .transfer_to_record(record_id.clone(), unix_time_ms())
6930                    .is_err()
6931                {
6932                    drop(materializations);
6933                    self.remove_record_memory(&record_id);
6934                    return Err(failure(
6935                        NodeFailureCode::BackendOperationFailed,
6936                        "session environment ownership transfer failed",
6937                    ));
6938                }
6939                Some(previous)
6940            } else {
6941                None
6942            }
6943        } else {
6944            None
6945        };
6946        self.bind_managed_session_with_materialization(
6947            address,
6948            record_id.clone(),
6949            runtime_policy,
6950            environment_profile,
6951            bundle,
6952            context,
6953            materialization_id.clone(),
6954        );
6955        if let Err(error) = self.persist_state_locked() {
6956            self.remove_binding(address);
6957            self.remove_record_memory(&record_id);
6958            if let (Some(materialization_id), Some(previous)) =
6959                (materialization_id.as_ref(), previous_materialization)
6960            {
6961                self.materializations
6962                    .lock()
6963                    .unwrap_or_else(|poisoned| poisoned.into_inner())
6964                    .insert(materialization_id.clone(), previous);
6965            }
6966            return Err(persistence_failure(error));
6967        }
6968        drop(transaction);
6969        self.publish_record(record);
6970        Ok(Some(record_id))
6971    }
6972
6973    fn allocate_record_id(&self) -> Result<SessionRecordId, NodeFailure> {
6974        for _ in 0..8 {
6975            let bytes = random_nonce().map_err(|error| {
6976                failure(
6977                    NodeFailureCode::BackendOperationFailed,
6978                    &format!("session record identity generation failed: {error}"),
6979                )
6980            })?;
6981            let mut value = String::with_capacity(27);
6982            value.push_str("sr-");
6983            for byte in &bytes[..12] {
6984                use std::fmt::Write as _;
6985                let _ = write!(&mut value, "{byte:02x}");
6986            }
6987            let record_id = SessionRecordId::new(value).map_err(|error| {
6988                failure(NodeFailureCode::BackendOperationFailed, &error.to_string())
6989            })?;
6990            let records = self
6991                .session_records
6992                .lock()
6993                .unwrap_or_else(|poisoned| poisoned.into_inner());
6994            if !records.records.contains_key(&record_id) {
6995                return Ok(record_id);
6996            }
6997        }
6998        Err(failure(
6999            NodeFailureCode::BackendBusy,
7000            "could not allocate a unique session record ID",
7001        ))
7002    }
7003
7004    fn allocate_task_id(&self) -> Result<TaskId, NodeFailure> {
7005        for _ in 0..8 {
7006            let bytes = random_nonce().map_err(|error| {
7007                failure(
7008                    NodeFailureCode::BackendOperationFailed,
7009                    &format!("task identity generation failed: {error}"),
7010                )
7011            })?;
7012            let mut nonce = [0_u8; 12];
7013            nonce.copy_from_slice(&bytes[..12]);
7014            let task_id = TaskId::from_nonce(nonce);
7015            let records = self
7016                .session_records
7017                .lock()
7018                .unwrap_or_else(|poisoned| poisoned.into_inner());
7019            if !records.records.values().any(|record| {
7020                record.task_binding.as_ref().and_then(|binding| binding.task_id.as_ref())
7021                    == Some(&task_id)
7022            }) {
7023                return Ok(task_id);
7024            }
7025        }
7026        Err(failure(
7027            NodeFailureCode::BackendBusy,
7028            "could not allocate a unique task ID",
7029        ))
7030    }
7031
7032    fn default_record_name(&self, provider: &AgentId) -> String {
7033        let records = self
7034            .session_records
7035            .lock()
7036            .unwrap_or_else(|poisoned| poisoned.into_inner());
7037        let ordinal = records
7038            .records
7039            .values()
7040            .filter(|record| &record.provider == provider)
7041            .count()
7042            .saturating_add(1);
7043        format!("{} #{ordinal}", provider.as_str())
7044    }
7045
7046    fn new_record(
7047        &self,
7048        address: &SessionAddress,
7049        provider: AgentId,
7050        mode: SessionMode,
7051        state: ManagedSessionState,
7052        provider_session: Option<gate4agent_types::ProviderSessionIdentity>,
7053        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7054        bundle: Option<ResolvedBundleReceipt>,
7055        context: Option<ResolvedContextPackReceipt>,
7056    ) -> Result<ManagedSessionRecord, NodeFailure> {
7057        let canonical_root = self.workspace_root(&address.workspace_id)?;
7058        let now = unix_time_ms();
7059        Ok(ManagedSessionRecord {
7060            record_id: self.allocate_record_id()?,
7061            display_name: self.default_record_name(&provider),
7062            provider,
7063            mode,
7064            state,
7065            workspace_id: address.workspace_id.clone(),
7066            canonical_root: opaque_windows_path(canonical_root),
7067            provider_session,
7068            active_session: Some(address.clone()),
7069            environment_profile,
7070            bundle,
7071            context_id: context.as_ref().map(|receipt| receipt.id.clone()),
7072            context,
7073            exported_context: None,
7074            task_binding: None,
7075            created_at_unix_ms: now,
7076            updated_at_unix_ms: now,
7077            last_error: None,
7078        })
7079    }
7080
7081    fn insert_record(&self, record: ManagedSessionRecord) -> Result<(), NodeFailure> {
7082        let mut records = self
7083            .session_records
7084            .lock()
7085            .unwrap_or_else(|poisoned| poisoned.into_inner());
7086        if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
7087            return Err(failure(
7088                NodeFailureCode::BackendBusy,
7089                "managed session record capacity is full; forget an inactive record first",
7090            ));
7091        }
7092        if let Some(identity) = record.provider_session.as_ref() {
7093            if records.records.values().any(|candidate| {
7094                candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
7095                    session_registry::same_provider_session(
7096                        &candidate.provider,
7097                        candidate_identity,
7098                        &record.provider,
7099                        identity,
7100                    )
7101                })
7102            }) {
7103                return Err(failure(
7104                    NodeFailureCode::SessionRecordConflict,
7105                    "provider session is already represented by another managed record",
7106                ));
7107            }
7108        }
7109        match records.records.entry(record.record_id.clone()) {
7110            std::collections::btree_map::Entry::Vacant(entry) => {
7111                entry.insert(record);
7112            }
7113            std::collections::btree_map::Entry::Occupied(_) => {
7114                return Err(failure(
7115                    NodeFailureCode::SessionRecordConflict,
7116                    "session record ID collision",
7117                ));
7118            }
7119        }
7120        Ok(())
7121    }
7122
7123    fn record(&self, record_id: &SessionRecordId) -> Result<ManagedSessionRecord, NodeFailure> {
7124        self.session_records
7125            .lock()
7126            .unwrap_or_else(|poisoned| poisoned.into_inner())
7127            .records
7128            .get(record_id)
7129            .cloned()
7130            .ok_or_else(|| {
7131                failure(
7132                    NodeFailureCode::UnknownSessionRecord,
7133                    "managed session record does not exist",
7134                )
7135            })
7136    }
7137
7138    fn revalidate_session_record_preview(
7139        &self,
7140        expected: &ManagedSessionRecord,
7141        identity: &ProviderSessionIdentity,
7142    ) -> Result<(), NodeFailure> {
7143        let current = self.record(&expected.record_id)?;
7144        let current_root = self.workspace_root(&current.workspace_id)?;
7145        if current.provider != expected.provider
7146            || current.workspace_id != expected.workspace_id
7147            || current.canonical_root != expected.canonical_root
7148            || current.provider_session.as_ref() != Some(identity)
7149            || !platform::roots_equal(
7150                &current_root,
7151                windows_path_text(&current.canonical_root),
7152            )
7153        {
7154            return Err(failure(
7155                NodeFailureCode::SessionRecordConflict,
7156                "managed session changed while its preview was loading",
7157            ));
7158        }
7159        Ok(())
7160    }
7161
7162    fn revalidate_session_record_context_export(
7163        &self,
7164        expected: &ManagedSessionRecord,
7165        identity: Option<&ProviderSessionIdentity>,
7166        session: &SessionAddress,
7167        allow_clean_detachment: bool,
7168    ) -> Result<(), NodeFailure> {
7169        let current = self.record(&expected.record_id)?;
7170        let current_root = self.workspace_root(&current.workspace_id)?;
7171        let snapshot = self.internal_session_snapshot(session)?;
7172        let binding = self
7173            .session_bindings
7174            .lock()
7175            .unwrap_or_else(|poisoned| poisoned.into_inner())
7176            .get(&session.session.instance_id)
7177            .cloned()
7178            .ok_or_else(|| {
7179                failure(
7180                    NodeFailureCode::SessionRecordConflict,
7181                    "managed session runtime binding disappeared during context export",
7182                )
7183            })?;
7184        if !session_record_context_export_binding_is_exact(
7185            expected,
7186            &current,
7187            identity,
7188            session,
7189            &binding,
7190            &snapshot.agent_id,
7191            &current_root,
7192            allow_clean_detachment,
7193        ) || !session_record_context_export_source_is_usable(
7194            &current,
7195            &snapshot.status,
7196            allow_clean_detachment,
7197        ) {
7198            return Err(failure(
7199                NodeFailureCode::SessionRecordConflict,
7200                "managed session binding changed during context export",
7201            ));
7202        }
7203        Ok(())
7204    }
7205
7206    fn revalidate_loaded_history(
7207        &self,
7208        session: &SessionAddress,
7209        candidate_id: &str,
7210        loaded: &HistorySessionRecord,
7211    ) -> Result<(), NodeFailure> {
7212        let snapshot = self.internal_session_snapshot(session)?;
7213        if snapshot.history.pending.is_some()
7214            || snapshot.history.loaded_candidate_id.as_deref() != Some(candidate_id)
7215            || snapshot.history.loaded.as_ref() != Some(loaded)
7216        {
7217            return Err(failure(
7218                NodeFailureCode::SessionRecordConflict,
7219                "managed session history changed during context export",
7220            ));
7221        }
7222        Ok(())
7223    }
7224
7225    fn remove_record_memory(&self, record_id: &SessionRecordId) -> Option<ManagedSessionRecord> {
7226        self.session_records
7227            .lock()
7228            .unwrap_or_else(|poisoned| poisoned.into_inner())
7229            .records
7230            .remove(record_id)
7231    }
7232
7233    fn discard_record(&self, record_id: &SessionRecordId) -> Result<(), NodeFailure> {
7234        if self.destroy_record_materialization(record_id)? {
7235            self.publish(NodeEvent::SessionRecordRemoved {
7236                record_id: record_id.clone(),
7237            });
7238            return Ok(());
7239        }
7240        let transaction = self
7241            .state_transaction
7242            .lock()
7243            .unwrap_or_else(|poisoned| poisoned.into_inner());
7244        let Some(removed) = self.remove_record_memory(record_id) else {
7245            return Ok(());
7246        };
7247        if let Err(error) = self.persist_state_locked() {
7248            let _ = self.insert_record(removed);
7249            return Err(persistence_failure(error));
7250        }
7251        drop(transaction);
7252        self.publish(NodeEvent::SessionRecordRemoved {
7253            record_id: record_id.clone(),
7254        });
7255        Ok(())
7256    }
7257
7258    fn publish_record(&self, record: ManagedSessionRecord) {
7259        self.publish(NodeEvent::SessionRecordUpserted { record });
7260    }
7261
7262    fn mark_record_error(
7263        &self,
7264        record_id: &SessionRecordId,
7265        message: &str,
7266    ) -> Result<(), NodeFailure> {
7267        let transaction = self
7268            .state_transaction
7269            .lock()
7270            .unwrap_or_else(|poisoned| poisoned.into_inner());
7271        let previous = {
7272            let mut records = self
7273                .session_records
7274                .lock()
7275                .unwrap_or_else(|poisoned| poisoned.into_inner());
7276            let Some(record) = records.records.get_mut(record_id) else {
7277                return Ok(());
7278            };
7279            let previous = record.clone();
7280            record.state = ManagedSessionState::Unavailable;
7281            record.last_error = Some(session_registry::sanitized_record_error_summary(message));
7282            record.updated_at_unix_ms = unix_time_ms();
7283            previous
7284        };
7285        if let Err(error) = self.persist_state_locked() {
7286            self.session_records
7287                .lock()
7288                .unwrap_or_else(|poisoned| poisoned.into_inner())
7289                .records
7290                .insert(record_id.clone(), previous);
7291            return Err(persistence_failure(error));
7292        }
7293        let record = self.record(record_id)?;
7294        drop(transaction);
7295        self.publish_record(record);
7296        Ok(())
7297    }
7298
7299    fn rename_session_record(
7300        &self,
7301        record_id: &SessionRecordId,
7302        display_name: String,
7303    ) -> Result<ManagedSessionRecord, NodeFailure> {
7304        validate_display_name(&display_name)
7305            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
7306        let _transaction = self
7307            .state_transaction
7308            .lock()
7309            .unwrap_or_else(|poisoned| poisoned.into_inner());
7310        let previous = {
7311            let mut records = self
7312                .session_records
7313                .lock()
7314                .unwrap_or_else(|poisoned| poisoned.into_inner());
7315            let record = records.records.get_mut(record_id).ok_or_else(|| {
7316                failure(
7317                    NodeFailureCode::UnknownSessionRecord,
7318                    "managed session record does not exist",
7319                )
7320            })?;
7321            let previous = record.clone();
7322            record.display_name = display_name;
7323            record.updated_at_unix_ms = unix_time_ms();
7324            previous
7325        };
7326        if let Err(error) = self.persist_state_locked() {
7327            self.session_records
7328                .lock()
7329                .unwrap_or_else(|poisoned| poisoned.into_inner())
7330                .records
7331                .insert(record_id.clone(), previous);
7332            return Err(persistence_failure(error));
7333        }
7334        let updated = self.record(record_id)?;
7335        self.publish_record(updated.clone());
7336        Ok(updated)
7337    }
7338
7339    fn set_session_task(
7340        &self,
7341        record_id: &SessionRecordId,
7342        expected_revision: u64,
7343        target: SessionTaskTargetV1,
7344    ) -> Result<ManagedSessionRecord, NodeFailure> {
7345        let transaction = self
7346            .state_transaction
7347            .lock()
7348            .unwrap_or_else(|poisoned| poisoned.into_inner());
7349        let current = self.record(record_id)?;
7350        let current_task_id = current
7351            .task_binding
7352            .as_ref()
7353            .and_then(|binding| binding.task_id.as_ref());
7354        let current_revision = current
7355            .task_binding
7356            .as_ref()
7357            .map_or(0, |binding| binding.revision);
7358        if expected_revision != current_revision {
7359            return Err(failure(
7360                NodeFailureCode::SessionRecordConflict,
7361                "managed session task binding revision changed",
7362            ));
7363        }
7364        let exact = match &target {
7365            SessionTaskTargetV1::Existing { task_id } => current_task_id == Some(task_id),
7366            SessionTaskTargetV1::Clear => current_task_id.is_none(),
7367            SessionTaskTargetV1::New => false,
7368        };
7369        if exact {
7370            return Ok(current);
7371        }
7372        let revision = current_revision.checked_add(1).ok_or_else(|| {
7373            failure(
7374                NodeFailureCode::SessionRecordConflict,
7375                "managed session task binding revision is exhausted",
7376            )
7377        })?;
7378        let task_id = match target {
7379            SessionTaskTargetV1::New => Some(self.allocate_task_id()?),
7380            SessionTaskTargetV1::Existing { task_id } => Some(task_id),
7381            SessionTaskTargetV1::Clear => None,
7382        };
7383        let changed_at_unix_ms = unix_time_ms()
7384            .max(current.updated_at_unix_ms.checked_add(1).ok_or_else(|| {
7385                failure(
7386                    NodeFailureCode::SessionRecordConflict,
7387                    "managed session record timestamp is exhausted",
7388                )
7389            })?);
7390        let updated = {
7391            let mut records = self
7392                .session_records
7393                .lock()
7394                .unwrap_or_else(|poisoned| poisoned.into_inner());
7395            let record = records.records.get_mut(record_id).ok_or_else(|| {
7396                failure(
7397                    NodeFailureCode::UnknownSessionRecord,
7398                    "managed session record does not exist",
7399                )
7400            })?;
7401            record.task_binding = Some(SessionTaskBindingV1 {
7402                revision,
7403                task_id,
7404                changed_at_unix_ms,
7405            });
7406            record.updated_at_unix_ms = changed_at_unix_ms;
7407            record.clone()
7408        };
7409        if let Err(error) = self.persist_state_locked() {
7410            self.session_records
7411                .lock()
7412                .unwrap_or_else(|poisoned| poisoned.into_inner())
7413                .records
7414                .insert(record_id.clone(), current);
7415            return Err(persistence_failure(error));
7416        }
7417        drop(transaction);
7418        self.publish_record(updated.clone());
7419        Ok(updated)
7420    }
7421
7422    fn index_provider_session(
7423        &self,
7424        workspace_id: WorkspaceId,
7425        provider: AgentId,
7426        identity: ProviderSessionIdentity,
7427        display_name: String,
7428    ) -> Result<ManagedSessionRecord, NodeFailure> {
7429        self.index_provider_session_with_policy(
7430            workspace_id,
7431            provider,
7432            identity,
7433            display_name,
7434            false,
7435        )
7436    }
7437
7438    fn index_provider_session_with_policy(
7439        &self,
7440        workspace_id: WorkspaceId,
7441        provider: AgentId,
7442        identity: ProviderSessionIdentity,
7443        display_name: String,
7444        allow_validated_transcript_path: bool,
7445    ) -> Result<ManagedSessionRecord, NodeFailure> {
7446        validate_display_name(&display_name)
7447            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
7448        identity
7449            .validate()
7450            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
7451        if identity.transcript_path.is_some() && !allow_validated_transcript_path {
7452            return Err(failure(
7453                NodeFailureCode::InvalidRequest,
7454                "provider session reference index v1 does not accept transcript paths",
7455            ));
7456        }
7457        let canonical_root = self.workspace_root(&workspace_id)?;
7458        if !self.enabled_providers.contains(&provider) {
7459            return Err(failure(
7460                NodeFailureCode::InvalidRequest,
7461                "provider is not enabled on this node",
7462            ));
7463        }
7464
7465        let transaction = self
7466            .state_transaction
7467            .lock()
7468            .unwrap_or_else(|poisoned| poisoned.into_inner());
7469        {
7470            let records = self
7471                .session_records
7472                .lock()
7473                .unwrap_or_else(|poisoned| poisoned.into_inner());
7474            if let Some(existing) = records.records.values().find(|candidate| {
7475                candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
7476                    candidate_identity.key == identity.key && candidate_identity.id == identity.id
7477                })
7478            }) {
7479                if existing.provider == provider && existing.workspace_id == workspace_id {
7480                    return Ok(existing.clone());
7481                }
7482                return Err(failure(
7483                    NodeFailureCode::SessionRecordConflict,
7484                    "provider session reference conflicts with another provider or workspace",
7485                ));
7486            }
7487            if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
7488                return Err(failure(
7489                    NodeFailureCode::BackendBusy,
7490                    "managed session record capacity is full; forget an inactive record first",
7491                ));
7492            }
7493        }
7494
7495        let now = unix_time_ms();
7496        let record = ManagedSessionRecord {
7497            record_id: self.allocate_record_id()?,
7498            display_name,
7499            provider,
7500            mode: SessionMode::Pty,
7501            state: ManagedSessionState::Dormant,
7502            workspace_id,
7503            canonical_root: opaque_windows_path(canonical_root),
7504            provider_session: Some(identity),
7505            active_session: None,
7506            environment_profile: None,
7507            bundle: None,
7508            context_id: None,
7509            context: None,
7510            exported_context: None,
7511            task_binding: None,
7512            created_at_unix_ms: now,
7513            updated_at_unix_ms: now,
7514            last_error: None,
7515        };
7516        self.insert_record(record.clone())?;
7517        if let Err(error) = self.persist_state_locked() {
7518            self.remove_record_memory(&record.record_id);
7519            return Err(persistence_failure(error));
7520        }
7521        drop(transaction);
7522        self.publish_record(record.clone());
7523        Ok(record)
7524    }
7525
7526    fn bound_address_for_record(&self, record_id: &SessionRecordId) -> Option<SessionAddress> {
7527        self.session_bindings
7528            .lock()
7529            .unwrap_or_else(|poisoned| poisoned.into_inner())
7530            .iter()
7531            .find_map(|(instance_id, binding)| {
7532                (binding.record_id.as_ref() == Some(record_id)).then(|| SessionAddress {
7533                    workspace_id: binding.workspace_id.clone(),
7534                    session: SessionKey {
7535                        instance_id: *instance_id,
7536                        generation: binding.generation,
7537                    },
7538                })
7539            })
7540    }
7541
7542    async fn forget_session_record(
7543        &self,
7544        record_id: &SessionRecordId,
7545    ) -> Result<(), NodeFailure> {
7546        let record = self.record(record_id)?;
7547        if record.active_session.is_some()
7548            || matches!(
7549                record.state,
7550                ManagedSessionState::Live | ManagedSessionState::IdentityPending
7551            )
7552        {
7553            return Err(failure(
7554                NodeFailureCode::SessionRecordBusy,
7555                "stop the managed session before forgetting it",
7556            ));
7557        }
7558        if let Some(address) = self.bound_address_for_record(record_id) {
7559            self.remove_session(&address).await?;
7560        }
7561        self.remove_managed_session_record(record_id, &record.workspace_id).await
7562    }
7563
7564    /// Removes one managed session record from durable state and notifies
7565    /// every listener -- the shared transaction body behind both
7566    /// `forget_session_record`'s operator-driven path (busy-checked above
7567    /// it, and clears any still-live session binding before calling in
7568    /// here) and the retention sweep (`retire_unavailable_session_records`,
7569    /// which only ever selects `ManagedSessionState::Unavailable` records
7570    /// that are already known never-busy and never bound, so it calls
7571    /// straight into this with neither check). Cleans a managed-worktree
7572    /// lease if the record held one, removes the record's environment
7573    /// materialization (or, absent one, removes it from memory directly),
7574    /// persists under `state_transaction` with rollback on persist failure,
7575    /// then publishes `NodeEvent::SessionRecordRemoved` so c2's own
7576    /// legacy-provider bookkeeping (`record_is_legacy`) stays in sync
7577    /// exactly as it does for an operator-driven forget.
7578    async fn remove_managed_session_record(
7579        &self,
7580        record_id: &SessionRecordId,
7581        workspace_id: &WorkspaceId,
7582    ) -> Result<(), NodeFailure> {
7583        let managed_lease_id = self.managed_worktrees.lock()
7584            .unwrap_or_else(|poisoned| poisoned.into_inner())
7585            .lease_for_workspace(workspace_id);
7586        if !self.destroy_record_materialization(record_id)? {
7587            let transaction = self
7588                .state_transaction
7589                .lock()
7590                .unwrap_or_else(|poisoned| poisoned.into_inner());
7591            let removed = self.remove_record_memory(record_id).ok_or_else(|| {
7592                failure(
7593                    NodeFailureCode::UnknownSessionRecord,
7594                    "managed session record does not exist",
7595                )
7596            })?;
7597            if let Err(error) = self.persist_state_locked() {
7598                let _ = self.insert_record(removed);
7599                return Err(persistence_failure(error));
7600            }
7601            drop(transaction);
7602        }
7603        self.publish(NodeEvent::SessionRecordRemoved {
7604            record_id: record_id.clone(),
7605        });
7606        if let Some(lease_id) = managed_lease_id {
7607            if let Some(snapshot) = self.managed_worktrees.lock()
7608                .unwrap_or_else(|poisoned| poisoned.into_inner())
7609                .get(&lease_id)
7610                .map(ManagedWorktreeLeaseRecord::snapshot)
7611            {
7612                self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
7613            }
7614            let _ = self.cleanup_managed_worktree(&lease_id, false).await;
7615        }
7616        Ok(())
7617    }
7618
7619    /// Retires `ManagedSessionState::Unavailable` records against the
7620    /// node's configured `session_record_retention` age/keep-N policy.
7621    /// Never touches `Live`, `IdentityPending`, or `Dormant` records --
7622    /// `Dormant` is resumable and folding it into this sweep would delete
7623    /// records a caller can still act on. Both policy fields default to
7624    /// `0` (disabled); when both are `0` this is a no-op. When both are
7625    /// set, a record is retired only if it fails BOTH tests (older than
7626    /// `age_ms` AND beyond the newest `keep_per_workspace` in its
7627    /// workspace); when only one is set, that one alone decides. Called
7628    /// once at startup (alongside `reconcile_managed_worktrees`) and then
7629    /// on a `SESSION_RECORD_RETENTION_SWEEP_PERIOD` cadence from the drive
7630    /// loop -- see both call sites.
7631    async fn retire_unavailable_session_records(&self) {
7632        let policy = self.session_record_retention;
7633        let age_enabled = policy.age_ms > 0;
7634        let keep_enabled = policy.keep_per_workspace > 0;
7635        if !age_enabled && !keep_enabled {
7636            return;
7637        }
7638        let now = unix_time_ms();
7639        let candidates: Vec<(SessionRecordId, WorkspaceId, AgentId, u64)> = {
7640            self.session_records
7641                .lock()
7642                .unwrap_or_else(|poisoned| poisoned.into_inner())
7643                .records
7644                .values()
7645                .filter(|record| record.state == ManagedSessionState::Unavailable)
7646                .map(|record| {
7647                    (
7648                        record.record_id.clone(),
7649                        record.workspace_id.clone(),
7650                        record.provider.clone(),
7651                        record.updated_at_unix_ms,
7652                    )
7653                })
7654                .collect()
7655        };
7656        if candidates.is_empty() {
7657            return;
7658        }
7659        let mut by_workspace: BTreeMap<WorkspaceId, Vec<(SessionRecordId, AgentId, u64)>> =
7660            BTreeMap::new();
7661        for (record_id, workspace_id, provider, updated_at_unix_ms) in candidates {
7662            by_workspace
7663                .entry(workspace_id)
7664                .or_default()
7665                .push((record_id, provider, updated_at_unix_ms));
7666        }
7667        let reason = if age_enabled && keep_enabled {
7668            "age+keep-n"
7669        } else if age_enabled {
7670            "age"
7671        } else {
7672            "keep-n"
7673        };
7674        let mut retired: u64 = 0;
7675        for (workspace_id, mut records) in by_workspace {
7676            // Newest `updated_at_unix_ms` first, so index `>=
7677            // keep_per_workspace` names exactly "beyond the newest N".
7678            records.sort_by(|left, right| right.2.cmp(&left.2));
7679            for (index, (record_id, provider, updated_at_unix_ms)) in
7680                records.into_iter().enumerate()
7681            {
7682                let record_age_ms = now.saturating_sub(updated_at_unix_ms);
7683                let age_hit = age_enabled && record_age_ms >= policy.age_ms;
7684                let keep_hit = keep_enabled && index >= policy.keep_per_workspace as usize;
7685                let should_retire = if age_enabled && keep_enabled {
7686                    age_hit && keep_hit
7687                } else {
7688                    age_hit || keep_hit
7689                };
7690                if !should_retire {
7691                    continue;
7692                }
7693                match self.remove_managed_session_record(&record_id, &workspace_id).await {
7694                    Ok(()) => {
7695                        self.retired_records_total.fetch_add(1, Ordering::Relaxed);
7696                        retired += 1;
7697                        tracing::info!(
7698                            node_id = %self.node_id,
7699                            record_id = %record_id,
7700                            workspace_id = %workspace_id,
7701                            provider = %provider,
7702                            age_ms = record_age_ms,
7703                            reason,
7704                            "managed session record retired"
7705                        );
7706                    }
7707                    Err(error) => tracing::warn!(
7708                        node_id = %self.node_id,
7709                        record_id = %record_id,
7710                        workspace_id = %workspace_id,
7711                        code = ?error.code,
7712                        cause = %error.message,
7713                        "session record retention sweep failed to retire a record"
7714                    ),
7715                }
7716            }
7717        }
7718        if retired > 0 {
7719            let remaining_unavailable = self
7720                .session_records
7721                .lock()
7722                .unwrap_or_else(|poisoned| poisoned.into_inner())
7723                .records
7724                .values()
7725                .filter(|record| record.state == ManagedSessionState::Unavailable)
7726                .count();
7727            tracing::info!(
7728                node_id = %self.node_id,
7729                retired,
7730                remaining_unavailable,
7731                "session record retention sweep completed"
7732            );
7733        }
7734    }
7735
7736    async fn resume_session_record(
7737        &self,
7738        record_id: &SessionRecordId,
7739        terminal_size: gate4agent_types::TerminalSize,
7740        initial_prompt: Option<String>,
7741    ) -> Result<(ManagedSessionRecord, SessionAddress), NodeFailure> {
7742        let record = self.record(record_id)?;
7743        if record.active_session.is_some()
7744            || matches!(
7745                record.state,
7746                ManagedSessionState::Live | ManagedSessionState::IdentityPending
7747            )
7748        {
7749            return Err(failure(
7750                NodeFailureCode::SessionRecordBusy,
7751                "managed session already has a live runtime binding",
7752            ));
7753        }
7754        let identity = record.provider_session.clone().ok_or_else(|| {
7755            failure(
7756                NodeFailureCode::SessionRecordNotResumable,
7757                "managed session has no verified provider session identity",
7758            )
7759        })?;
7760        let working_directory = self.workspace_root(&record.workspace_id)?;
7761        if !platform::roots_equal(
7762            &working_directory,
7763            windows_path_text(&record.canonical_root),
7764        ) {
7765            return Err(failure(
7766                NodeFailureCode::SessionRecordConflict,
7767                "managed session workspace root changed; refusing to resume in another directory",
7768            ));
7769        }
7770        {
7771            let records = self
7772                .session_records
7773                .lock()
7774                .unwrap_or_else(|poisoned| poisoned.into_inner());
7775            if records.records.values().any(|candidate| {
7776                candidate.record_id != *record_id
7777                    && candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
7778                        session_registry::same_provider_session(
7779                            &candidate.provider,
7780                            candidate_identity,
7781                            &record.provider,
7782                            &identity,
7783                        )
7784                    })
7785                    && (candidate.active_session.is_some()
7786                        || matches!(
7787                            candidate.state,
7788                            ManagedSessionState::Live | ManagedSessionState::IdentityPending
7789                        ))
7790            }) {
7791                return Err(failure(
7792                    NodeFailureCode::SessionRecordConflict,
7793                    "provider session is already active through another managed record",
7794                ));
7795            }
7796        }
7797        let runtime_requirement = if initial_prompt.is_some() {
7798            ProviderRuntimeRequirement::ResumeWithPrompt
7799        } else {
7800            ProviderRuntimeRequirement::Resume
7801        };
7802        let runtime_policy = self.admit_provider_runtime(
7803            &record.provider,
7804            runtime_requirement,
7805        )
7806        .await?;
7807        let request = ResumeLaunchRequest {
7808            working_directory,
7809            terminal_size,
7810            initial_prompt,
7811        };
7812        request
7813            .validate()
7814            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
7815        self.ensure_binding_capacity()?;
7816        let resolved_materialization = match self.resolve_record_materialization(
7817            record_id,
7818            &record.provider,
7819            record.mode,
7820            record.environment_profile.as_ref(),
7821            record.bundle.as_ref(),
7822            record.context.as_ref(),
7823        ) {
7824            Ok(materialization) => materialization,
7825            Err(error) => {
7826                let label = match error.code {
7827                    NodeFailureCode::UnknownBundle => "bundle-unavailable",
7828                    NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
7829                    NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
7830                    _ => "environment-profile-unavailable",
7831                };
7832                self.mark_record_error(record_id, label)?;
7833                return Err(error);
7834            }
7835        };
7836        let (materialization_id, environment_overlay) = match resolved_materialization {
7837            Some((id, overlay)) => (Some(id), overlay),
7838            None => (None, None),
7839        };
7840        let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
7841        let mut environment_selection = match self.select_environment_profile(
7842            instance_id,
7843            &record.provider,
7844            record.mode,
7845            record.environment_profile.as_ref(),
7846        ) {
7847            Ok(selection) => selection,
7848            Err(error) => {
7849                self.mark_record_error(record_id, "environment-profile-unavailable")?;
7850                return Err(error);
7851            }
7852        };
7853        let mut instance_overlay = environment_overlay
7854            .map(|overlay| self.install_prepared_launch_overlay(instance_id, overlay))
7855            .transpose()?
7856            .flatten();
7857        if let Some(stale_address) = self.bound_address_for_record(record_id) {
7858            self.remove_session(&stale_address).await?;
7859        }
7860        let address = SessionAddress {
7861            workspace_id: record.workspace_id.clone(),
7862            session: SessionKey {
7863                instance_id,
7864                generation: SessionGeneration(1),
7865            },
7866        };
7867        let transaction = self
7868            .state_transaction
7869            .lock()
7870            .unwrap_or_else(|poisoned| poisoned.into_inner());
7871        let current = self.record(record_id)?;
7872        if current.active_session.is_some()
7873            || matches!(
7874                current.state,
7875                ManagedSessionState::Live | ManagedSessionState::IdentityPending
7876            )
7877        {
7878            return Err(failure(
7879                NodeFailureCode::SessionRecordBusy,
7880                "managed session became active while resume was being prepared",
7881            ));
7882        }
7883        self.bind_managed_session_with_materialization(
7884            &address,
7885            record_id.clone(),
7886            runtime_policy,
7887            record.environment_profile.clone(),
7888            record.bundle.clone(),
7889            record.context.clone(),
7890            materialization_id,
7891        );
7892        if let Some(selection) = environment_selection.take() {
7893            selection.retain();
7894        }
7895        if let Some(overlay) = instance_overlay.take() {
7896            overlay.retain();
7897        }
7898        drop(transaction);
7899
7900        let transport = match record.mode {
7901            SessionMode::Pty => TransportKind::Pty,
7902            SessionMode::Inline => TransportKind::Pipe,
7903            SessionMode::Acp => TransportKind::Acp,
7904        };
7905        let agent_id = record.provider.clone();
7906        let dispatch_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
7907        if let Err(error) = self
7908            .dispatch_bounded(
7909                ControlCommand::Register {
7910                    instance_id,
7911                    agent_id,
7912                    transport,
7913                },
7914                dispatch_timeout,
7915            )
7916            .await
7917        {
7918            if let Some(binding) = self.remove_binding(&address) {
7919                self.cleanup_session_owned_materialization(&address, &binding)?;
7920            }
7921            return Err(error);
7922        }
7923        let command = self.prepare_command(ControlCommand::Resume {
7924            instance_id,
7925            target: ResumeTarget::ProviderSession { identity },
7926            runtime_policy,
7927            request,
7928        });
7929        let started_after = self.current_sequence();
7930        if let Err(error) = self.arm_resume(&address, command.id, runtime_policy) {
7931            self.rollback_spawn(&address, dispatch_timeout).await?;
7932            return Err(error);
7933        }
7934        if let Err(error) = self.dispatch_envelope(command) {
7935            self.clear_armed_resume(&address);
7936            self.rollback_spawn(&address, dispatch_timeout).await?;
7937            return Err(error);
7938        }
7939        match self
7940            .wait_for_record_resume(&address, started_after)
7941            .await
7942        {
7943            Ok(settled_address) => {
7944                let managed_lease_id = {
7945                    self.managed_worktrees.lock()
7946                        .unwrap_or_else(|poisoned| poisoned.into_inner())
7947                        .lease_for_workspace(&record.workspace_id)
7948                };
7949                if let Some(lease_id) = managed_lease_id {
7950                    {
7951                        let mut bindings = self.session_bindings.lock()
7952                            .unwrap_or_else(|poisoned| poisoned.into_inner());
7953                        let binding = bindings.get_mut(&settled_address.session.instance_id)
7954                            .ok_or_else(|| failure(
7955                                NodeFailureCode::ManagedWorktreeRecoveryRequired,
7956                                "resumed managed session lost its exact binding",
7957                            ))?;
7958                        if binding.generation != settled_address.session.generation {
7959                            return Err(failure(
7960                                NodeFailureCode::ManagedWorktreeRecoveryRequired,
7961                                "resumed managed session generation diverged",
7962                            ));
7963                        }
7964                        binding.managed_worktree_lease_id = Some(lease_id.clone());
7965                    }
7966                    let snapshot = self.managed_worktrees.lock()
7967                        .unwrap_or_else(|poisoned| poisoned.into_inner())
7968                        .bind_session(
7969                            &lease_id,
7970                            ManagedWorktreeSessionHolder {
7971                                incarnation_id: self.incarnation_id,
7972                                instance_id: settled_address.session.instance_id,
7973                                generation: settled_address.session.generation,
7974                            },
7975                            Some(record_id.clone()),
7976                            unix_time_ms(),
7977                        ).map_err(|message| failure(
7978                            NodeFailureCode::ManagedWorktreeRecoveryRequired,
7979                            &message,
7980                        ))?;
7981                    if let Err(error) = self.persist_state() {
7982                        let _ = self.remove_session(&settled_address).await;
7983                        return Err(persistence_failure(error));
7984                    }
7985                    self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
7986                }
7987                Ok((self.record(record_id)?, settled_address))
7988            }
7989            Err(error) => {
7990                if matches!(
7991                    error.code,
7992                    NodeFailureCode::SessionRecordNotResumable
7993                        | NodeFailureCode::BackendOperationFailed
7994                ) {
7995                    if let Some(bound) = self.bound_address_for_record(record_id) {
7996                        let _ = self.remove_session(&bound).await;
7997                    }
7998                }
7999                Err(error)
8000            }
8001        }
8002    }
8003
8004    async fn wait_for_record_resume(
8005        &self,
8006        initial_address: &SessionAddress,
8007        after_sequence: u64,
8008    ) -> Result<SessionAddress, NodeFailure> {
8009        let deadline = Instant::now()
8010            + Duration::from_millis(MANAGED_RESUME_SETTLE_TIMEOUT_MS);
8011        let mut scan_after = after_sequence;
8012        loop {
8013            let events = self
8014                .history
8015                .lock()
8016                .unwrap_or_else(|poisoned| poisoned.into_inner())
8017                .events
8018                .iter()
8019                .filter(|event| event.sequence > scan_after)
8020                .cloned()
8021                .collect::<Vec<_>>();
8022            for envelope in events {
8023                scan_after = scan_after.max(envelope.sequence);
8024                let NodeEvent::Control { address, event } = envelope.event else {
8025                    continue;
8026                };
8027                if address.session.instance_id != initial_address.session.instance_id {
8028                    continue;
8029                }
8030                match event.event {
8031                    ControlEventKind::Resumed { .. } => return Ok(address),
8032                    ControlEventKind::ResumeDenied { reason } => {
8033                        return Err(failure(
8034                            NodeFailureCode::SessionRecordNotResumable,
8035                            &reason,
8036                        ));
8037                    }
8038                    ControlEventKind::ResumeFailed { message }
8039                    | ControlEventKind::CommandRejected { message } => {
8040                        return Err(failure(
8041                            NodeFailureCode::BackendOperationFailed,
8042                            &message,
8043                        ));
8044                    }
8045                    _ => {}
8046                }
8047            }
8048            if Instant::now() >= deadline {
8049                return Err(failure(
8050                    NodeFailureCode::BackendBusy,
8051                    "managed resume did not settle before the bounded deadline",
8052                ));
8053            }
8054            sleep(Duration::from_millis(5)).await;
8055        }
8056    }
8057
8058    fn workspace_root(&self, workspace_id: &WorkspaceId) -> Result<String, NodeFailure> {
8059        self.workspaces
8060            .read()
8061            .unwrap_or_else(|poisoned| poisoned.into_inner())
8062            .get(workspace_id)
8063            .cloned()
8064            .ok_or_else(|| failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"))
8065    }
8066
8067    fn workspace_roots(&self) -> Vec<PathBuf> {
8068        self.workspaces
8069            .read()
8070            .unwrap_or_else(|poisoned| poisoned.into_inner())
8071            .values()
8072            .map(PathBuf::from)
8073            .collect()
8074    }
8075
8076    async fn inspect_workspace(
8077        &self,
8078        workspace_id: WorkspaceId,
8079    ) -> Result<WorkspaceInspection, NodeFailure> {
8080        let _permit = self
8081            .inspection_slots
8082            .clone()
8083            .try_acquire_owned()
8084            .map_err(|_| {
8085                tracing::warn!(
8086                    workspace_id = %workspace_id,
8087                    "workspace inspection rejected: inspection capacity is busy",
8088                );
8089                failure(
8090                    NodeFailureCode::BackendBusy,
8091                    "workspace inspection capacity is busy",
8092                )
8093            })?;
8094        let canonical_root = self.workspace_root(&workspace_id).map_err(|error| {
8095            tracing::warn!(
8096                workspace_id = %workspace_id,
8097                code = ?error.code,
8098                "workspace inspection rejected: workspace is not registered",
8099            );
8100            error
8101        })?;
8102        let tree_root = canonical_root.clone();
8103        let started_at = Instant::now();
8104        let time_budget_ms = workspace_inspection_time_budget_ms();
8105        let entry_cap = workspace_inspection_entry_cap();
8106        let deadline = started_at + Duration::from_millis(time_budget_ms);
8107        let (entries, tree_truncated, walk_budget) = tokio::task::spawn_blocking(move || {
8108            collect_workspace_entries(Path::new(&tree_root), deadline, entry_cap)
8109        })
8110        .await
8111        .map_err(|error| {
8112            tracing::warn!(
8113                workspace_id = %workspace_id,
8114                error = %error,
8115                "workspace inspection rejected: walk task failed",
8116            );
8117            failure(
8118                NodeFailureCode::BackendOperationFailed,
8119                &format!("workspace inspection task failed: {error}"),
8120            )
8121        })?;
8122        let (mut git, git_time_budget_exceeded) =
8123            inspect_git_workspace(&canonical_root, deadline).await;
8124        if !git.worktrees.is_empty() {
8125            let registered = self
8126                .workspaces
8127                .read()
8128                .unwrap_or_else(|poisoned| poisoned.into_inner())
8129                .iter()
8130                .map(|(workspace_id, root)| (workspace_id.clone(), root.clone()))
8131                .collect::<Vec<_>>();
8132            for worktree in &mut git.worktrees {
8133                worktree.workspace_id = registered
8134                    .iter()
8135                    .find(|(_, root)| {
8136                        worktree_paths_equal(root, windows_path_text(&worktree.path))
8137                    })
8138                    .map(|(workspace_id, _)| workspace_id.clone());
8139            }
8140        }
8141        if git.is_repository {
8142            git.managed_worktree = self
8143                .managed_worktrees
8144                .lock()
8145                .unwrap_or_else(|poisoned| poisoned.into_inner())
8146                .git_scope(&workspace_id, git.branch.as_deref());
8147        }
8148        let truncation = workspace_inspection_truncation(
8149            &walk_budget,
8150            git_time_budget_exceeded,
8151            started_at.elapsed(),
8152        );
8153        if let Some(truncation) = &truncation {
8154            tracing::info!(
8155                workspace_id = %workspace_id,
8156                walk_time_budget_exceeded = truncation.walk_time_budget_exceeded,
8157                walk_entry_cap_exceeded = truncation.walk_entry_cap_exceeded,
8158                git_time_budget_exceeded = truncation.git_time_budget_exceeded,
8159                entries_visited = truncation.entries_visited,
8160                elapsed_ms = truncation.elapsed_ms,
8161                "workspace inspection truncated by its inner budget",
8162            );
8163        }
8164        Ok(WorkspaceInspection {
8165            workspace_id,
8166            entries,
8167            tree_truncated,
8168            git,
8169            truncation,
8170        })
8171    }
8172
8173    async fn browse_host_directories(
8174        &self,
8175        directory: Option<OpaqueHostPath>,
8176        after: Option<OpaqueHostPath>,
8177    ) -> Result<HostDirectoryListing, NodeFailure> {
8178        let permit = self
8179            .inspection_slots
8180            .clone()
8181            .try_acquire_owned()
8182            .map_err(|_| {
8183                failure(
8184                    NodeFailureCode::BackendBusy,
8185                    "host directory browse capacity is busy",
8186                )
8187            })?;
8188        let task = tokio::task::spawn_blocking(move || {
8189            let _permit = permit;
8190            browse_host_directories(directory, after)
8191        });
8192        timeout(Duration::from_millis(HOST_DIRECTORY_BROWSE_TIMEOUT_MS), task)
8193            .await
8194            .map_err(|_| {
8195                failure(
8196                    NodeFailureCode::HostDirectoryReadTimedOut,
8197                    "host directory browse exceeded its bounded deadline",
8198                )
8199            })?
8200            .map_err(|_| {
8201                failure(
8202                    NodeFailureCode::HostDirectoryReadFailed,
8203                    "host directory browse task failed",
8204                )
8205            })?
8206            .map_err(host_directory_failure)
8207    }
8208
8209    async fn read_workspace_file(
8210        &self,
8211        workspace_id: WorkspaceId,
8212        path: RepositoryPath,
8213    ) -> Result<WorkspaceFileRead, NodeFailure> {
8214        let canonical_root = self.workspace_root(&workspace_id)?;
8215        let permit = self
8216            .inspection_slots
8217            .clone()
8218            .try_acquire_owned()
8219            .map_err(|_| {
8220                failure(
8221                    NodeFailureCode::BackendBusy,
8222                    "workspace file read capacity is busy",
8223                )
8224            })?;
8225        let path_to_read = path.clone();
8226        let task = tokio::task::spawn_blocking(move || {
8227            let _permit = permit;
8228            read_workspace_file_from_disk(Path::new(&canonical_root), &path_to_read)
8229        });
8230        let content = timeout(
8231            Duration::from_millis(WORKSPACE_FILE_READ_TIMEOUT_MS),
8232            task,
8233        )
8234        .await
8235        .map_err(|_| {
8236            failure(
8237                NodeFailureCode::RepositoryFileReadTimedOut,
8238                "repository file read exceeded its bounded deadline",
8239            )
8240        })?
8241        .map_err(|_| {
8242            failure(
8243                NodeFailureCode::RepositoryFileReadFailed,
8244                "repository file read task failed",
8245            )
8246        })?
8247        .map_err(workspace_file_failure)?;
8248        let content = match content {
8249            WorkspaceFileBytes::Utf8(text) => {
8250                let revision = Some(workspace_file_revision(text.as_bytes()));
8251                return Ok(WorkspaceFileRead {
8252                    workspace_id,
8253                    path,
8254                    content: WorkspaceFileContent::Utf8 {
8255                        byte_len: u32::try_from(text.len())
8256                            .expect("bounded workspace text length must fit u32"),
8257                        text,
8258                    },
8259                    revision,
8260                });
8261            }
8262            WorkspaceFileBytes::NonUtf8 { byte_length } => {
8263                WorkspaceFileContent::NonUtf8 {
8264                    byte_len: u32::try_from(byte_length)
8265                        .expect("bounded workspace file length must fit u32"),
8266                }
8267            }
8268            WorkspaceFileBytes::TooLarge => WorkspaceFileContent::TooLarge {
8269                limit_bytes: MAX_WORKSPACE_FILE_BYTES as u32,
8270            },
8271        };
8272        Ok(WorkspaceFileRead {
8273            workspace_id,
8274            path,
8275            content,
8276            revision: None,
8277        })
8278    }
8279
8280    async fn write_workspace_file(
8281        &self,
8282        workspace_id: WorkspaceId,
8283        path: RepositoryPath,
8284        expected_revision: WorkspaceFileRevision,
8285        text: String,
8286    ) -> Result<WorkspaceFileRead, NodeFailure> {
8287        let canonical_root = self.workspace_root(&workspace_id)?;
8288        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8289            failure(NodeFailureCode::BackendBusy, "workspace file write capacity is busy")
8290        })?;
8291        let path_to_write = path.clone();
8292        let expected = expected_revision.as_str().to_owned();
8293        let text_to_write = text.clone();
8294        let task = tokio::task::spawn_blocking(move || {
8295            let _permit = permit;
8296            write_workspace_file_to_disk(
8297                Path::new(&canonical_root),
8298                &path_to_write,
8299                &expected,
8300                &text_to_write,
8301            )
8302        });
8303        let revision = timeout(Duration::from_millis(WORKSPACE_FILE_WRITE_TIMEOUT_MS), task)
8304            .await
8305            .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteTimedOut, "repository file write exceeded its bounded deadline"))?
8306            .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteFailed, "repository file write task failed"))?
8307            .map_err(workspace_file_write_failure)?;
8308        Ok(WorkspaceFileRead {
8309            workspace_id,
8310            path,
8311            content: WorkspaceFileContent::Utf8 {
8312                byte_len: u32::try_from(text.len()).expect("bounded workspace text length must fit u32"),
8313                text,
8314            },
8315            revision: Some(WorkspaceFileRevision::new(revision)
8316                .expect("workspace writer returns a valid SHA-256 revision")),
8317        })
8318    }
8319
8320    async fn create_workspace_file(
8321        &self,
8322        workspace_id: WorkspaceId,
8323        path: RepositoryPath,
8324    ) -> Result<WorkspaceFileRead, NodeFailure> {
8325        let canonical_root = self.workspace_root(&workspace_id)?;
8326        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8327            failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
8328        })?;
8329        let path_to_create = path.clone();
8330        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
8331        let task_commit_state = Arc::clone(&commit_state);
8332        let task = tokio::task::spawn_blocking(move || {
8333            let _permit = permit;
8334            create_workspace_file_on_disk(
8335                Path::new(&canonical_root),
8336                &path_to_create,
8337                &task_commit_state,
8338            )
8339        });
8340        let revision = settle_workspace_entry_create(
8341            task,
8342            commit_state,
8343            Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
8344            "repository file create exceeded its bounded deadline",
8345            "repository file create task failed",
8346        )
8347        .await?;
8348        Ok(WorkspaceFileRead {
8349            workspace_id,
8350            path,
8351            content: WorkspaceFileContent::Utf8 {
8352                text: String::new(),
8353                byte_len: 0,
8354            },
8355            revision: Some(WorkspaceFileRevision::new(revision)
8356                .expect("workspace creator returns a valid SHA-256 revision")),
8357        })
8358    }
8359
8360    async fn create_workspace_directory(
8361        &self,
8362        workspace_id: WorkspaceId,
8363        path: RepositoryPath,
8364    ) -> Result<WorkspaceEntry, NodeFailure> {
8365        let canonical_root = self.workspace_root(&workspace_id)?;
8366        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8367            failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
8368        })?;
8369        let path_to_create = path.clone();
8370        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
8371        let task_commit_state = Arc::clone(&commit_state);
8372        let task = tokio::task::spawn_blocking(move || {
8373            let _permit = permit;
8374            create_workspace_directory_on_disk(
8375                Path::new(&canonical_root),
8376                &path_to_create,
8377                &task_commit_state,
8378            )
8379        });
8380        settle_workspace_entry_create(
8381            task,
8382            commit_state,
8383            Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
8384            "repository directory create exceeded its bounded deadline",
8385            "repository directory create task failed",
8386        )
8387        .await?;
8388        Ok(WorkspaceEntry {
8389            relative_path: path,
8390            kind: WorkspaceEntryKind::Directory,
8391        })
8392    }
8393
8394    async fn read_git_history(
8395        &self,
8396        workspace_id: WorkspaceId,
8397        path: Option<RepositoryPath>,
8398        before: Option<GitObjectId>,
8399        limit: u16,
8400    ) -> Result<GitHistoryPage, NodeFailure> {
8401        let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8402            failure(NodeFailureCode::BackendBusy, "git history capacity is busy")
8403        })?;
8404        let root = self.workspace_root(&workspace_id)?;
8405        read_git_history_bounded(&root, path.as_ref(), before.as_ref(), limit).await
8406    }
8407
8408    async fn read_git_diff(
8409        &self,
8410        workspace_id: WorkspaceId,
8411        request: GitDiffRequest,
8412    ) -> Result<GitDiff, NodeFailure> {
8413        let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8414            failure(NodeFailureCode::BackendBusy, "git diff capacity is busy")
8415        })?;
8416        let root = self.workspace_root(&workspace_id)?;
8417        read_git_diff_bounded(&root, request).await
8418    }
8419
8420    fn native_session_route_root(
8421        &self,
8422        route: &NativeSessionCatalogRoute,
8423    ) -> Result<Option<String>, NodeFailure> {
8424        route
8425            .validate()
8426            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
8427        match route.scope {
8428            gate4agent_types::NativeSessionCatalogScope::Workspace => route
8429                .workspace_id
8430                .as_ref()
8431                .map(|workspace_id| self.workspace_root(workspace_id))
8432                .transpose(),
8433            gate4agent_types::NativeSessionCatalogScope::Unregistered => Ok(None),
8434        }
8435    }
8436
8437    fn project_native_session_entries(
8438        &self,
8439        route: &NativeSessionCatalogRoute,
8440        entries: Vec<ScopedNativeSessionCatalogEntry>,
8441    ) -> Vec<crate::protocol::NativeSessionCatalogEntry> {
8442        let current_root = route
8443            .workspace_id
8444            .as_ref()
8445            .and_then(|workspace_id| self.workspace_root(workspace_id).ok());
8446        let records = self
8447            .session_records
8448            .lock()
8449            .unwrap_or_else(|poisoned| poisoned.into_inner());
8450        let mut seen_records = std::collections::HashSet::new();
8451        entries
8452            .into_iter()
8453            .map(|entry| {
8454                let record_id = route.workspace_id.as_ref().and_then(|workspace_id| {
8455                    let matches = records
8456                        .records
8457                        .values()
8458                        .filter(|record| {
8459                            record.provider == route.provider
8460                                && record.workspace_id == *workspace_id
8461                                && current_root.as_deref().is_some_and(|root| {
8462                                    platform::roots_equal(
8463                                        root,
8464                                        windows_path_text(&record.canonical_root),
8465                                    )
8466                                })
8467                                && record.provider_session.as_ref().is_some_and(|identity| {
8468                                    session_registry::same_provider_session(
8469                                        &record.provider,
8470                                        identity,
8471                                        &route.provider,
8472                                        &entry.provider_session,
8473                                    )
8474                                })
8475                        })
8476                        .map(|record| record.record_id.clone())
8477                        .collect::<Vec<_>>();
8478                    (matches.len() == 1
8479                        && seen_records.insert(matches[0].clone()))
8480                        .then(|| matches[0].clone())
8481                });
8482                let metadata = entry.metadata;
8483                crate::protocol::NativeSessionCatalogEntry {
8484                    selection_id: metadata.selection_id,
8485                    title: metadata.title,
8486                    modified_at_unix_ms: metadata.modified_at_unix_ms,
8487                    model: metadata.model,
8488                    message_count: metadata.message_count,
8489                    completed_turn_count: metadata.completed_turn_count,
8490                    external_group: entry.external_group,
8491                    record_id,
8492                }
8493            })
8494            .collect()
8495    }
8496
8497    async fn catalog_native_sessions(
8498        &self,
8499        route: NativeSessionCatalogRoute,
8500        limit: u16,
8501    ) -> Result<(
8502        Vec<crate::protocol::NativeSessionCatalogEntry>,
8503        crate::protocol::NativeSessionCatalogSummary,
8504    ), NodeFailure> {
8505        let canonical_root = self.native_session_route_root(&route)?;
8506        let registered_workspace_roots = self.workspace_roots();
8507        let provider = route.provider.clone();
8508        let scope = route.scope;
8509        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
8510            failure(
8511                NodeFailureCode::InvalidRequest,
8512                "native session catalog is not configured",
8513            )
8514        })?;
8515        let _permit = Arc::clone(&self.inspection_slots)
8516            .try_acquire_owned()
8517            .map_err(|_| {
8518                failure(
8519                    NodeFailureCode::BackendBusy,
8520                    "native session catalog capacity is busy",
8521                )
8522            })?;
8523        let page = run_native_session_catalog_operation(
8524            catalog,
8525            "native session catalog",
8526            move |catalog| {
8527                catalog.catalog_initial_for_scope(
8528                    &provider,
8529                    scope,
8530                    canonical_root.as_deref().map(Path::new),
8531                    &registered_workspace_roots,
8532                    limit,
8533                )
8534            },
8535        )
8536        .await?;
8537        let summary = crate::protocol::NativeSessionCatalogSummary {
8538            catalog_revision: page.revision,
8539            recent_cutoff_unix_ms: page.cutoff_unix_ms,
8540            recent_total_count: page.recent_total_count,
8541            older_total_count: page.older_total_count,
8542            recent_next_after_selection_id: page.next_after_selection_id,
8543            recent_has_more: page.remaining_count > 0,
8544        };
8545        Ok((self.project_native_session_entries(&route, page.entries), summary))
8546    }
8547
8548    async fn page_native_sessions(
8549        &self,
8550        route: NativeSessionCatalogRoute,
8551        window: crate::protocol::NativeSessionCatalogWindow,
8552        catalog_revision: u64,
8553        recent_cutoff_unix_ms: u64,
8554        after_selection_id: Option<String>,
8555        limit: u16,
8556    ) -> Result<crate::protocol::NativeSessionCatalogPage, NodeFailure> {
8557        let canonical_root = self.native_session_route_root(&route)?;
8558        let registered_workspace_roots = self.workspace_roots();
8559        let provider = route.provider.clone();
8560        let scope = route.scope;
8561        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
8562            failure(
8563                NodeFailureCode::InvalidRequest,
8564                "native session catalog is not configured",
8565            )
8566        })?;
8567        let _permit = Arc::clone(&self.inspection_slots)
8568            .try_acquire_owned()
8569            .map_err(|_| {
8570                failure(
8571                    NodeFailureCode::BackendBusy,
8572                    "native session catalog capacity is busy",
8573                )
8574            })?;
8575        let page = run_native_session_catalog_operation(
8576            catalog,
8577            "native session catalog page",
8578            move |catalog| {
8579                Ok::<_, std::convert::Infallible>(catalog.catalog_page_for_scope(
8580                    &provider,
8581                    scope,
8582                    canonical_root.as_deref().map(Path::new),
8583                    &registered_workspace_roots,
8584                    window,
8585                    catalog_revision,
8586                    recent_cutoff_unix_ms,
8587                    after_selection_id.as_deref(),
8588                    limit,
8589                ))
8590            },
8591        )
8592        .await?;
8593        let page = page.map_err(|error| match error {
8594            NativeSessionCatalogError::StaleCatalog => failure(
8595                NodeFailureCode::StaleNativeSessionCatalog,
8596                "native session catalog revision or cursor is stale",
8597            ),
8598            _ => failure(
8599                NodeFailureCode::BackendOperationFailed,
8600                "native session catalog page failed",
8601            ),
8602        })?;
8603        let entries = self.project_native_session_entries(&route, page.entries);
8604        Ok(crate::protocol::NativeSessionCatalogPage {
8605            window: page.window,
8606            revision: page.revision,
8607            entries,
8608            next_after_selection_id: page.next_after_selection_id,
8609            remaining_count: page.remaining_count,
8610            has_more: page.remaining_count > 0,
8611        })
8612    }
8613
8614    async fn preview_native_session(
8615        &self,
8616        selection: NativeSessionSelection,
8617        message_limit: u16,
8618    ) -> Result<crate::protocol::NativeSessionPreview, NodeFailure> {
8619        selection
8620            .validate()
8621            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
8622        let canonical_root = self.native_session_route_root(&selection.route)?;
8623        let registered_workspace_roots = self.workspace_roots();
8624        let provider = selection.route.provider.clone();
8625        let scope = selection.route.scope;
8626        let catalog_revision = selection.catalog_revision;
8627        let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
8628        let selection_id = selection.selection_id.clone();
8629        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
8630            failure(
8631                NodeFailureCode::InvalidRequest,
8632                "native session preview is not configured",
8633            )
8634        })?;
8635        let _permit = Arc::clone(&self.inspection_slots)
8636            .try_acquire_owned()
8637            .map_err(|_| {
8638                failure(
8639                    NodeFailureCode::BackendBusy,
8640                    "native session preview capacity is busy",
8641                )
8642            })?;
8643        let preview = run_native_session_catalog_operation(
8644            catalog,
8645            "native session preview",
8646            move |catalog| {
8647                Ok::<_, std::convert::Infallible>(catalog.preview_for_scope(
8648                    &provider,
8649                    scope,
8650                    canonical_root.as_deref().map(Path::new),
8651                    &registered_workspace_roots,
8652                    catalog_revision,
8653                    recent_cutoff_unix_ms,
8654                    &selection_id,
8655                    message_limit,
8656                ))
8657            },
8658        )
8659        .await?;
8660        preview
8661            .map(Into::into)
8662            .map_err(native_session_preview_failure)
8663    }
8664
8665    async fn index_native_session(
8666        &self,
8667        selection: NativeSessionSelection,
8668        display_name: String,
8669    ) -> Result<ManagedSessionRecord, NodeFailure> {
8670        selection
8671            .validate()
8672            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
8673        if selection.route.scope
8674            != gate4agent_types::NativeSessionCatalogScope::Workspace
8675        {
8676            return Err(failure(
8677                NodeFailureCode::WorkspaceRegistrationRequired,
8678                "register the external project as a workspace before indexing or resuming it",
8679            ));
8680        }
8681        let workspace_id = selection
8682            .route
8683            .workspace_id
8684            .clone()
8685            .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
8686        let canonical_root = self
8687            .native_session_route_root(&selection.route)?
8688            .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
8689        let registered_workspace_roots = self.workspace_roots();
8690        let provider = selection.route.provider.clone();
8691        let catalog_revision = selection.catalog_revision;
8692        let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
8693        let selection_id = selection.selection_id.clone();
8694        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
8695            failure(
8696                NodeFailureCode::InvalidRequest,
8697                "native session index is not configured",
8698            )
8699        })?;
8700        let _permit = Arc::clone(&self.inspection_slots)
8701            .try_acquire_owned()
8702            .map_err(|_| {
8703                failure(
8704                    NodeFailureCode::BackendBusy,
8705                    "native session index capacity is busy",
8706                )
8707            })?;
8708        let resolved = run_native_session_catalog_operation(
8709            catalog,
8710            "native session index resolution",
8711            move |catalog| {
8712                Ok::<_, std::convert::Infallible>(catalog.resolve_selection_for_scope(
8713                    &provider,
8714                    gate4agent_types::NativeSessionCatalogScope::Workspace,
8715                    Some(Path::new(&canonical_root)),
8716                    &registered_workspace_roots,
8717                    catalog_revision,
8718                    recent_cutoff_unix_ms,
8719                    &selection_id,
8720                ))
8721            },
8722        )
8723        .await?
8724        .map_err(native_session_preview_failure)?;
8725        self.index_provider_session_with_policy(
8726            workspace_id,
8727            selection.route.provider,
8728            resolved.identity,
8729            display_name,
8730            true,
8731        )
8732    }
8733
8734    async fn preview_native_session_by_session_id(
8735        &self,
8736        workspace_id: WorkspaceId,
8737        provider: AgentId,
8738        session_id: String,
8739        message_limit: u16,
8740    ) -> Result<gate4agent_types::NativeSessionPreview, NodeFailure> {
8741        let canonical_root = self.workspace_root(&workspace_id)?;
8742        let registered_workspace_roots = self.workspace_roots();
8743        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
8744            failure(
8745                NodeFailureCode::InvalidRequest,
8746                "native session preview is not configured",
8747            )
8748        })?;
8749        let _permit = Arc::clone(&self.inspection_slots)
8750            .try_acquire_owned()
8751            .map_err(|_| {
8752                failure(
8753                    NodeFailureCode::BackendBusy,
8754                    "native session preview capacity is busy",
8755                )
8756            })?;
8757        run_native_session_catalog_operation(
8758            catalog,
8759            "native session preview",
8760            move |catalog| {
8761                catalog.preview_session_id_for_workspace(
8762                    &provider,
8763                    Path::new(&canonical_root),
8764                    &registered_workspace_roots,
8765                    &session_id,
8766                    message_limit,
8767                )
8768            },
8769        )
8770        .await
8771    }
8772
8773    async fn register_workspace(
8774        &self,
8775        workspace_id: WorkspaceId,
8776        root: String,
8777    ) -> Result<WorkspaceSnapshot, NodeFailure> {
8778        validate_workspace_request_root(&root)?;
8779        let canonicalize_workspace_id = workspace_id.clone();
8780        let workspace = tokio::task::spawn_blocking(move || {
8781            WorkspaceConfig::new(canonicalize_workspace_id, root)
8782        })
8783        .await
8784        .map_err(|error| {
8785            failure(
8786                NodeFailureCode::BackendOperationFailed,
8787                &format!("workspace canonicalization task failed: {error}"),
8788            )
8789        })?
8790        .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
8791        let transaction = self
8792            .state_transaction
8793            .lock()
8794            .unwrap_or_else(|poisoned| poisoned.into_inner());
8795        let mut workspaces = self
8796            .workspaces
8797            .write()
8798            .unwrap_or_else(|poisoned| poisoned.into_inner());
8799        if workspaces.contains_key(&workspace_id) {
8800            return Err(failure(
8801                NodeFailureCode::DuplicateWorkspaceId,
8802                "workspace ID is already registered",
8803            ));
8804        }
8805        if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
8806            platform::roots_equal(existing_root, workspace.canonical_root())
8807        }) {
8808            return Err(failure(
8809                NodeFailureCode::DuplicateWorkspaceRoot,
8810                &format!("workspace root is already registered as '{existing_id}'"),
8811            ));
8812        }
8813        {
8814            let records = self
8815                .session_records
8816                .lock()
8817                .unwrap_or_else(|poisoned| poisoned.into_inner());
8818            if let Some(record) = records.records.values().find(|record| {
8819                record.workspace_id == workspace_id
8820                    && !record
8821                        .canonical_root
8822                        .as_utf8()
8823                        .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
8824            }) {
8825                return Err(failure(
8826                    NodeFailureCode::SessionRecordConflict,
8827                    &format!(
8828                        "workspace ID is retained by session record '{}' at another root",
8829                        record.record_id,
8830                    ),
8831                ));
8832            }
8833        }
8834        let snapshot = WorkspaceSnapshot {
8835            workspace_id: workspace_id.clone(),
8836            canonical_root: opaque_windows_path(workspace.canonical_root().to_owned()),
8837            sessions: Vec::new(),
8838            worktree_service_mode: Some(WorktreeServiceMode::Manual),
8839            managed_worktree_profiles: Some(WorktreeProfileInventory {
8840                profiles: Vec::new(),
8841            }),
8842        };
8843        workspaces.insert(workspace_id.clone(), workspace.canonical_root().to_owned());
8844        drop(workspaces);
8845        let previous_records = {
8846            let mut records = self
8847                .session_records
8848                .lock()
8849                .unwrap_or_else(|poisoned| poisoned.into_inner());
8850            let previous = records.records.clone();
8851            for record in records.records.values_mut().filter(|record| {
8852                record.workspace_id == workspace_id
8853                    && record
8854                        .canonical_root
8855                        .as_utf8()
8856                        .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
8857            }) {
8858                if record.active_session.is_none() {
8859                    record.state = detached_record_state(record);
8860                }
8861                if record.last_error.as_deref() == Some(WORKSPACE_UNAVAILABLE_ERROR) {
8862                    record.last_error = None;
8863                }
8864                record.updated_at_unix_ms = unix_time_ms();
8865            }
8866            previous
8867        };
8868        if let Err(error) = self.persist_state_locked() {
8869            self.workspaces
8870                .write()
8871                .unwrap_or_else(|poisoned| poisoned.into_inner())
8872                .remove(&workspace_id);
8873            self.session_records
8874                .lock()
8875                .unwrap_or_else(|poisoned| poisoned.into_inner())
8876                .records = previous_records;
8877            return Err(persistence_failure(error));
8878        }
8879        let restored = self
8880            .session_records
8881            .lock()
8882            .unwrap_or_else(|poisoned| poisoned.into_inner())
8883            .records
8884            .values()
8885            .filter(|record| record.workspace_id == workspace_id)
8886            .cloned()
8887            .collect::<Vec<_>>();
8888        drop(transaction);
8889        self.publish(NodeEvent::WorkspaceAdded {
8890            workspace: snapshot.clone(),
8891        });
8892        for record in restored {
8893            self.publish_record(record);
8894        }
8895        Ok(snapshot)
8896    }
8897
8898    async fn create_standalone_workspace(
8899        &self,
8900        workspace_id: WorkspaceId,
8901        root: String,
8902        initial_branch: Option<String>,
8903    ) -> Result<WorkspaceSnapshot, NodeFailure> {
8904        validate_workspace_request_root(&root)?;
8905        validate_git_revision("initial branch", initial_branch.as_deref())?;
8906        let preflight_workspace_id = workspace_id.clone();
8907        let preflight_root = root.clone();
8908        let canonical_candidate = tokio::task::spawn_blocking(move || {
8909            canonical_standalone_workspace_candidate(&preflight_workspace_id, &preflight_root)
8910        })
8911        .await
8912        .map_err(|_| failure(
8913            NodeFailureCode::BackendOperationFailed,
8914            "standalone workspace root preflight task failed",
8915        ))?
8916        .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
8917        {
8918            let workspaces = self
8919                .workspaces
8920                .read()
8921                .unwrap_or_else(|poisoned| poisoned.into_inner());
8922            if workspaces.contains_key(&workspace_id) {
8923                return Err(failure(
8924                    NodeFailureCode::DuplicateWorkspaceId,
8925                    "workspace ID is already registered",
8926                ));
8927            }
8928            if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
8929                platform::roots_equal(existing_root, &canonical_candidate)
8930            }) {
8931                return Err(failure(
8932                    NodeFailureCode::DuplicateWorkspaceRoot,
8933                    &format!("workspace root is already registered as '{existing_id}'"),
8934                ));
8935            }
8936        }
8937        let prepared = prepare_standalone_workspace(root, initial_branch.as_deref())
8938            .await
8939            .map_err(standalone_workspace_failure)?;
8940        prepared
8941            .verify_for_registration()
8942            .await
8943            .map_err(standalone_workspace_failure)?;
8944        match self
8945            .register_workspace(workspace_id, prepared.root().to_owned())
8946            .await
8947        {
8948            Ok(workspace) => Ok(workspace),
8949            Err(registration_error) => {
8950                if prepared.compensate_registration_failure().await {
8951                    Err(registration_error)
8952                } else {
8953                    Err(failure(
8954                        NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
8955                        "standalone workspace initialization succeeded but registration failed; recovery is required",
8956                    ))
8957                }
8958            }
8959        }
8960    }
8961
8962    fn unregister_workspace(&self, workspace_id: &WorkspaceId) -> Result<(), NodeFailure> {
8963        let bindings = self
8964            .session_bindings
8965            .lock()
8966            .unwrap_or_else(|poisoned| poisoned.into_inner());
8967        if bindings.values().any(|binding| &binding.workspace_id == workspace_id) {
8968            return Err(failure(
8969                NodeFailureCode::WorkspaceBusy,
8970                "workspace retains a bound session; remove every session before unregistering it",
8971            ));
8972        }
8973        drop(bindings);
8974        let transaction = self
8975            .state_transaction
8976            .lock()
8977            .unwrap_or_else(|poisoned| poisoned.into_inner());
8978        let mut workspaces = self
8979            .workspaces
8980            .write()
8981            .unwrap_or_else(|poisoned| poisoned.into_inner());
8982        if !workspaces.contains_key(workspace_id) {
8983            return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
8984        }
8985        if workspaces.len() == 1 {
8986            return Err(failure(
8987                NodeFailureCode::LastWorkspace,
8988                "node must retain at least one workspace",
8989            ));
8990        }
8991        let removed_root = workspaces
8992            .remove(workspace_id)
8993            .expect("workspace presence was validated");
8994        drop(workspaces);
8995        let previous_records = {
8996            let mut records = self
8997                .session_records
8998                .lock()
8999                .unwrap_or_else(|poisoned| poisoned.into_inner());
9000            let previous = records.records.clone();
9001            for record in records
9002                .records
9003                .values_mut()
9004                .filter(|record| &record.workspace_id == workspace_id)
9005            {
9006                record.active_session = None;
9007                record.state = ManagedSessionState::Unavailable;
9008                record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
9009                record.updated_at_unix_ms = unix_time_ms();
9010            }
9011            previous
9012        };
9013        if let Err(error) = self.persist_state_locked() {
9014            self.workspaces
9015                .write()
9016                .unwrap_or_else(|poisoned| poisoned.into_inner())
9017                .insert(workspace_id.clone(), removed_root);
9018            self.session_records
9019                .lock()
9020                .unwrap_or_else(|poisoned| poisoned.into_inner())
9021                .records = previous_records;
9022            return Err(persistence_failure(error));
9023        }
9024        let unavailable = self
9025            .session_records
9026            .lock()
9027            .unwrap_or_else(|poisoned| poisoned.into_inner())
9028            .records
9029            .values()
9030            .filter(|record| &record.workspace_id == workspace_id)
9031            .cloned()
9032            .collect::<Vec<_>>();
9033        drop(transaction);
9034        self.publish(NodeEvent::WorkspaceRemoved {
9035            workspace_id: workspace_id.clone(),
9036        });
9037        for record in unavailable {
9038            self.publish_record(record);
9039        }
9040        Ok(())
9041    }
9042
9043    async fn create_worktree(
9044        &self,
9045        source_workspace_id: WorkspaceId,
9046        workspace_id: WorkspaceId,
9047        target_root: String,
9048        branch: String,
9049        base: Option<String>,
9050    ) -> Result<(GitWorktreeSnapshot, WorkspaceSnapshot), NodeFailure> {
9051        self.require_worktree_service(&source_workspace_id)?;
9052        validate_workspace_request_root(&target_root)?;
9053        validate_git_revision("worktree base", base.as_deref())?;
9054        let source_root = self.workspace_root(&source_workspace_id)?;
9055        {
9056            let workspaces = self
9057                .workspaces
9058                .read()
9059                .unwrap_or_else(|poisoned| poisoned.into_inner());
9060            if workspaces.contains_key(&workspace_id) {
9061                return Err(failure(
9062                    NodeFailureCode::DuplicateWorkspaceId,
9063                    "workspace ID is already registered",
9064                ));
9065            }
9066            if let Some((existing_id, _)) = workspaces
9067                .iter()
9068                .find(|(_, root)| worktree_paths_equal(root, &target_root))
9069            {
9070                return Err(failure(
9071                    NodeFailureCode::DuplicateWorkspaceRoot,
9072                    &format!("workspace root is already registered as '{existing_id}'"),
9073                ));
9074            }
9075        }
9076        let mut worktree = create_git_worktree(
9077            &source_root,
9078            &target_root,
9079            &branch,
9080            base.as_deref(),
9081        )
9082        .await
9083        .map_err(git_worktree_failure)?;
9084        let workspace = match self
9085            .register_workspace(workspace_id, worktree.path.clone())
9086            .await
9087        {
9088            Ok(workspace) => workspace,
9089            Err(registration_error) => {
9090                if remove_git_worktree(&source_root, &worktree.path).await.is_err() {
9091                    return Err(failure(
9092                        NodeFailureCode::ManagedWorktreeRecoveryRequired,
9093                        "worktree registration failed and compensation requires recovery",
9094                    ));
9095                }
9096                return Err(failure(
9097                    registration_error.code,
9098                    &registration_error.message,
9099                ));
9100            }
9101        };
9102        worktree.workspace_id = Some(workspace.workspace_id.clone());
9103        Ok((protocol_worktree(worktree), workspace))
9104    }
9105
9106    async fn remove_worktree(
9107        &self,
9108        source_workspace_id: WorkspaceId,
9109        target_root: String,
9110    ) -> Result<Option<WorkspaceId>, NodeFailure> {
9111        self.require_worktree_service(&source_workspace_id)?;
9112        validate_workspace_request_root(&target_root)?;
9113        let source_root = self.workspace_root(&source_workspace_id)?;
9114        let target_root = normalize_worktree_removal_target(&target_root)
9115            .map_err(git_worktree_failure)?;
9116        let target = list_git_worktrees(&source_root)
9117            .await
9118            .map_err(git_worktree_failure)?
9119            .into_iter()
9120            .find(|worktree| worktree_paths_equal(&worktree.path, &target_root))
9121            .ok_or_else(|| {
9122                failure(
9123                    NodeFailureCode::WorktreeProtected,
9124                    "refusing to remove a path that is not in Git's worktree listing",
9125                )
9126            })?;
9127        let registered_workspace_id = {
9128            let workspaces = self
9129                .workspaces
9130                .read()
9131                .unwrap_or_else(|poisoned| poisoned.into_inner());
9132            let matched = workspaces
9133                .iter()
9134                .find(|(_, root)| worktree_paths_equal(root, &target.path))
9135                .map(|(workspace_id, _)| workspace_id.clone());
9136            if matched.is_some() && workspaces.len() == 1 {
9137                return Err(failure(
9138                    NodeFailureCode::LastWorkspace,
9139                    "node must retain at least one workspace",
9140                ));
9141            }
9142            matched
9143        };
9144        if let Some(workspace_id) = registered_workspace_id.as_ref() {
9145            let bindings = self
9146                .session_bindings
9147                .lock()
9148                .unwrap_or_else(|poisoned| poisoned.into_inner());
9149            if bindings
9150                .values()
9151                .any(|binding| &binding.workspace_id == workspace_id)
9152            {
9153                return Err(failure(
9154                    NodeFailureCode::WorkspaceBusy,
9155                    "worktree workspace retains a bound session; remove every session first",
9156                ));
9157            }
9158        }
9159        remove_git_worktree(&source_root, &target.path)
9160            .await
9161            .map_err(git_worktree_failure)?;
9162        if let Some(workspace_id) = registered_workspace_id.as_ref() {
9163            self.unregister_workspace(workspace_id)
9164                .map_err(|error| failure(error.code, &error.message))?;
9165        }
9166        Ok(registered_workspace_id)
9167    }
9168
9169    async fn cleanup_managed_worktree(
9170        &self,
9171        lease_id: &ManagedWorktreeLeaseId,
9172        explicit: bool,
9173    ) -> Result<ManagedWorktreeLeaseSnapshot, NodeFailure> {
9174        let lease = self.managed_worktrees.lock()
9175            .unwrap_or_else(|poisoned| poisoned.into_inner())
9176            .get(lease_id)
9177            .cloned()
9178            .ok_or_else(|| failure(
9179                NodeFailureCode::UnknownManagedWorktreeLease,
9180                "managed worktree lease does not exist",
9181            ))?;
9182        if lease.state == ManagedWorktreeLeaseState::Removed {
9183            return Ok(lease.snapshot());
9184        }
9185        if lease.has_holders() {
9186            return Err(failure(
9187                NodeFailureCode::ManagedWorktreeBusy,
9188                "managed worktree lease still has a session or durable-record holder",
9189            ));
9190        }
9191        if self
9192            .materializations
9193            .lock()
9194            .unwrap_or_else(|poisoned| poisoned.into_inner())
9195            .values()
9196            .any(|record| record.managed_lease_id() == Some(lease_id))
9197        {
9198            return Err(failure(
9199                NodeFailureCode::ManagedWorktreeRecoveryRequired,
9200                "managed worktree cleanup is blocked by session environment ownership",
9201            ));
9202        }
9203        if !explicit && lease.retention == ManagedWorktreeRetention::Retain {
9204            let snapshot = {
9205                let mut registry = self.managed_worktrees.lock()
9206                    .unwrap_or_else(|poisoned| poisoned.into_inner());
9207                let current = registry.get_mut(lease_id)
9208                    .expect("managed worktree lease remains present");
9209                current.state = ManagedWorktreeLeaseState::Retained;
9210                current.cleanup_failure = None;
9211                current.updated_at_unix_ms = unix_time_ms();
9212                current.snapshot()
9213            };
9214            if self.persist_state().is_err() {
9215                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
9216            }
9217            self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
9218            return Ok(snapshot);
9219        }
9220        let profile = self.managed_profile(&lease.source_workspace_id, &lease.profile_id)?;
9221        if profile.revision() != &lease.profile_revision {
9222            self.mark_managed_recovery_required(
9223                lease_id,
9224                ManagedWorktreeCleanupFailure::OwnershipConflict,
9225            );
9226            return Err(failure(
9227                NodeFailureCode::ManagedWorktreeRecoveryRequired,
9228                "managed worktree profile revision changed",
9229            ));
9230        }
9231        profile.validate_target_authority(&lease.lease_id, &lease.target_root).map_err(|message| {
9232            self.mark_managed_recovery_required(
9233                lease_id,
9234                ManagedWorktreeCleanupFailure::OwnershipConflict,
9235            );
9236            failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message)
9237        })?;
9238        let source_root = self.workspace_root(&lease.source_workspace_id)?;
9239        let listed = list_git_worktrees(&source_root).await.map_err(|error| {
9240            self.mark_managed_cleanup_blocked(lease_id, ManagedWorktreeCleanupFailure::Backend);
9241            managed_git_worktree_failure(error)
9242        })?;
9243        let target = listed.iter()
9244            .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
9245        if let Some(target) = target {
9246            if target.is_main || target.is_bare || !exact_owned_worktree(
9247                &lease,
9248                &target.path,
9249                target.branch.as_deref(),
9250                &target.head,
9251            ) {
9252                self.mark_managed_recovery_required(
9253                    lease_id,
9254                    ManagedWorktreeCleanupFailure::OwnershipConflict,
9255                );
9256                return Err(failure(
9257                    NodeFailureCode::ManagedWorktreeOwnershipConflict,
9258                    "Git worktree identity no longer matches the managed lease",
9259                ));
9260            }
9261            if target.prunable {
9262                self.mark_managed_cleanup_blocked(
9263                    lease_id,
9264                    ManagedWorktreeCleanupFailure::Prunable,
9265                );
9266                return Err(failure(
9267                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
9268                    "managed worktree is prunable and requires recovery",
9269                ));
9270            }
9271            if let Err(error) = remove_git_worktree(&source_root, &target.path).await {
9272                let failure_kind = match error.kind {
9273                    GitWorktreeErrorKind::Dirty => ManagedWorktreeCleanupFailure::Dirty,
9274                    GitWorktreeErrorKind::Locked => ManagedWorktreeCleanupFailure::Locked,
9275                    GitWorktreeErrorKind::Protected => ManagedWorktreeCleanupFailure::OwnershipConflict,
9276                    GitWorktreeErrorKind::Conflict => ManagedWorktreeCleanupFailure::Busy,
9277                    GitWorktreeErrorKind::Invalid | GitWorktreeErrorKind::NotRepository
9278                    | GitWorktreeErrorKind::Failed => ManagedWorktreeCleanupFailure::Backend,
9279                };
9280                if failure_kind == ManagedWorktreeCleanupFailure::OwnershipConflict {
9281                    self.mark_managed_recovery_required(lease_id, failure_kind);
9282                } else {
9283                    self.mark_managed_cleanup_blocked(lease_id, failure_kind);
9284                }
9285                return Err(managed_git_worktree_failure(error));
9286            }
9287        } else {
9288            match std::fs::symlink_metadata(&lease.target_root) {
9289                Err(error) if error.kind() == io::ErrorKind::NotFound => {}
9290                Ok(_) => {
9291                    self.mark_managed_recovery_required(
9292                        lease_id,
9293                        ManagedWorktreeCleanupFailure::OwnershipConflict,
9294                    );
9295                    return Err(failure(
9296                        NodeFailureCode::ManagedWorktreeOwnershipConflict,
9297                        "managed target exists but is not owned by Git",
9298                    ));
9299                }
9300                Err(_) => {
9301                    self.mark_managed_cleanup_blocked(
9302                        lease_id,
9303                        ManagedWorktreeCleanupFailure::Backend,
9304                    );
9305                    return Err(failure(
9306                        NodeFailureCode::BackendOperationFailed,
9307                        "managed target identity could not be inspected",
9308                    ));
9309                }
9310            }
9311        }
9312        let registered = self.workspaces.read()
9313            .unwrap_or_else(|poisoned| poisoned.into_inner())
9314            .contains_key(&lease.workspace_id);
9315        if registered {
9316            self.unregister_workspace(&lease.workspace_id)?;
9317        }
9318        let removed = {
9319            self.managed_worktrees.lock()
9320                .unwrap_or_else(|poisoned| poisoned.into_inner())
9321                .tombstone(lease_id, unix_time_ms())
9322                .expect("managed worktree lease remains present during cleanup")
9323        };
9324        self.persist_state().map_err(persistence_failure)?;
9325        self.publish(NodeEvent::ManagedWorktreeRemoved { lease_id: lease_id.clone() });
9326        Ok(removed.snapshot())
9327    }
9328
9329    fn mark_managed_cleanup_blocked(
9330        &self,
9331        lease_id: &ManagedWorktreeLeaseId,
9332        failure_kind: ManagedWorktreeCleanupFailure,
9333    ) {
9334        let snapshot = {
9335            let mut registry = self.managed_worktrees.lock()
9336                .unwrap_or_else(|poisoned| poisoned.into_inner());
9337            let Some(lease) = registry.get_mut(lease_id) else { return };
9338            lease.state = ManagedWorktreeLeaseState::CleanupBlocked;
9339            lease.cleanup_failure = Some(failure_kind);
9340            lease.updated_at_unix_ms = unix_time_ms();
9341            lease.snapshot()
9342        };
9343        let _ = self.persist_state();
9344        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
9345    }
9346
9347    fn require_worktree_service(
9348        &self,
9349        source_workspace_id: &WorkspaceId,
9350    ) -> Result<(), NodeFailure> {
9351        if self.managed_worktrees.lock()
9352            .unwrap_or_else(|poisoned| poisoned.into_inner())
9353            .lease_for_workspace(source_workspace_id)
9354            .is_some()
9355        {
9356            return Err(failure(
9357                NodeFailureCode::ManagedWorktreeOwnershipConflict,
9358                "managed worktree targets are internal-only workspaces",
9359            ));
9360        }
9361        match self
9362            .worktree_service_modes
9363            .get(source_workspace_id)
9364            .copied()
9365            .unwrap_or(WorktreeServiceMode::Manual)
9366        {
9367            WorktreeServiceMode::Manual => Ok(()),
9368            WorktreeServiceMode::Managed | WorktreeServiceMode::Off => Err(failure(
9369                NodeFailureCode::UnsupportedSpawnCapability,
9370                "Git worktree service mode is unavailable in this node build",
9371            )),
9372        }
9373    }
9374
9375    fn managed_reservation_conflict(
9376        &self,
9377        workspace_id: Option<&WorkspaceId>,
9378        root: Option<&str>,
9379    ) -> bool {
9380        self.managed_worktrees.lock()
9381            .unwrap_or_else(|poisoned| poisoned.into_inner())
9382            .active_records()
9383            .any(|lease| {
9384                workspace_id.is_some_and(|workspace_id| {
9385                    workspace_id == &lease.source_workspace_id
9386                        || workspace_id == &lease.workspace_id
9387                }) || root.is_some_and(|root| worktree_paths_equal(root, &lease.target_root))
9388            })
9389    }
9390
9391    fn reject_managed_reservation(
9392        &self,
9393        workspace_id: Option<&WorkspaceId>,
9394        root: Option<&str>,
9395    ) -> Result<(), NodeFailure> {
9396        if self.managed_reservation_conflict(workspace_id, root) {
9397            Err(failure(
9398                NodeFailureCode::ManagedWorktreeOwnershipConflict,
9399                "managed worktree ownership may only be mutated through its lease cleanup",
9400            ))
9401        } else {
9402            Ok(())
9403        }
9404    }
9405
9406    fn bound_workspace_root(&self, address: &SessionAddress) -> Result<String, NodeFailure> {
9407        self.validate_address(address)?;
9408        self.workspace_root(&address.workspace_id)
9409    }
9410
9411    /// `Resume`/`ResumeWithPrompt` are always a raw-PTY relaunch (see
9412    /// `require_policy`'s own doc on those two requirements), so they keep
9413    /// gating on `binding.runtime_policy`'s PTY-terminal-text-inference
9414    /// flags exactly as before; `pipe_transport`/`acp_transport` stay
9415    /// `false` on the `require_policy` call below for them because a PTY
9416    /// resume never needs those facts, not because this method cannot look
9417    /// them up.
9418    ///
9419    /// `SemanticPrompt` -- the requirement `Prompt`/`Paste` share -- reads
9420    /// the SAME `binding.runtime_policy` every other requirement reads here
9421    /// (one computed policy, read by every gate). That policy is no longer
9422    /// PTY-terminal-verification-shaped for an ACP session --
9423    /// `spawn_session_with_deadline` computes it BY TRANSPORT now, through
9424    /// `provider_runtime::policy_for_transport` -- so consulting it for
9425    /// `Prompt`/`Paste` on ACP is sound where it used to gate the wrong fact
9426    /// (this is what made an ACP `Prompt` come back `UnsupportedCapability`
9427    /// even though a prompt is an ACP session's only interface). Pipe keeps
9428    /// reading the same PTY-probe-derived value it always has --
9429    /// `policy_for_transport` passes Pipe through unchanged this pass (see
9430    /// its own doc for why).
9431    ///
9432    /// What this method still adds on top, for ACP/Pipe only, is a
9433    /// turn-in-flight PRECONDITION ahead of that policy check: a session
9434    /// takes one turn at a time, so a `Prompt`/`Paste` is refused by name
9435    /// (`NodeFailureCode::TurnInFlight`) while a turn is already running,
9436    /// read off the same live `SessionSnapshot` state `require_acp_session`
9437    /// already reads for the ACP control verbs -- `TurnStarted` observed
9438    /// with no matching `TurnCompleted` yet is exactly `ProviderSnapshot::
9439    /// lead_activity != Idle`. Sending a second prompt mid-turn would put
9440    /// its fate in vendor-specific behaviour this node cannot observe (one
9441    /// agent may queue it, another drop it, another interleave it), so this
9442    /// refuses it outright rather than gambling on that -- a rule with no
9443    /// PTY analogue, which is why PTY does not take this extra precondition.
9444    fn require_session_runtime_policy(
9445        &self,
9446        address: &SessionAddress,
9447        requirement: ProviderRuntimeRequirement,
9448    ) -> Result<ProviderRuntimePolicy, NodeFailure> {
9449        let runtime_policy = {
9450            let bindings = self
9451                .session_bindings
9452                .lock()
9453                .unwrap_or_else(|poisoned| poisoned.into_inner());
9454            let binding = bindings
9455                .get(&address.session.instance_id)
9456                .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
9457            if binding.workspace_id != address.workspace_id {
9458                return Err(failure(
9459                    NodeFailureCode::SessionWorkspaceMismatch,
9460                    "session belongs to another workspace",
9461                ));
9462            }
9463            if binding.generation != address.session.generation {
9464                return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
9465            }
9466            binding.runtime_policy
9467        };
9468        if requirement == ProviderRuntimeRequirement::SemanticPrompt {
9469            let snapshot = self.internal_session_snapshot(address)?;
9470            if matches!(snapshot.transport, TransportKind::Acp | TransportKind::Pipe)
9471                && snapshot.provider.lead_activity != ProviderActivity::Idle
9472            {
9473                return Err(failure(
9474                    NodeFailureCode::TurnInFlight,
9475                    "session already has a turn in flight; wait for it to complete before sending another prompt",
9476                ));
9477            }
9478        }
9479        require_policy(runtime_policy, false, false, requirement)
9480            .map(|()| runtime_policy)
9481            .map_err(|_| failure(
9482                NodeFailureCode::UnsupportedCapability,
9483                "session runtime policy does not admit this semantic operation",
9484            ))
9485    }
9486
9487    fn remove_binding(&self, address: &SessionAddress) -> Option<SessionBinding> {
9488        let mut bindings = self
9489            .session_bindings
9490            .lock()
9491            .unwrap_or_else(|poisoned| poisoned.into_inner());
9492        if bindings.get(&address.session.instance_id).is_some_and(|binding| {
9493            binding.workspace_id == address.workspace_id
9494                && binding.generation == address.session.generation
9495        }) {
9496            let removed = bindings.remove(&address.session.instance_id);
9497            // Exclusive dig2browser station profile lease is node-local; clear
9498            // on every session unbind / rollback. Never ImportSession/cookies.
9499            #[cfg(feature = "dig2-station-probe")]
9500            {
9501                crate::dig2_station_lease::release_lease_for_holder(
9502                    &self.browser_station_leases,
9503                    address.session.instance_id,
9504                );
9505            }
9506            self.clear_terminal_frame_watermark(address);
9507            if let Some(control) = self.native_launch_profile_control.as_ref() {
9508                control.clear_native_mcp_server_launch_overlay(
9509                    address.session.instance_id,
9510                );
9511            }
9512            if let Some(registry) = self.harness_mcp_registry.as_ref() {
9513                registry.revoke_session(address);
9514            }
9515            if removed
9516                .as_ref()
9517                .is_some_and(|binding| {
9518                    binding.environment_profile.is_some()
9519                        || binding.bundle.is_some()
9520                        || binding.context.is_some()
9521                })
9522            {
9523                if let Some(control) = self.native_launch_profile_control.as_ref() {
9524                    control.clear_native_launch_profile_selection(
9525                        address.session.instance_id,
9526                    );
9527                    control.clear_native_instance_launch_overlay(
9528                        address.session.instance_id,
9529                    );
9530                }
9531            }
9532            return removed;
9533        }
9534        None
9535    }
9536
9537    fn cleanup_session_owned_materialization(
9538        &self,
9539        address: &SessionAddress,
9540        binding: &SessionBinding,
9541    ) -> Result<(), NodeFailure> {
9542        let Some(id) = binding.materialization_id.as_ref() else {
9543            return Ok(());
9544        };
9545        let owner = self
9546            .materializations
9547            .lock()
9548            .unwrap_or_else(|poisoned| poisoned.into_inner())
9549            .get(id)
9550            .map(|record| record.owner().clone());
9551        match owner {
9552            None | Some(MaterializationOwner::Record { .. }) => Ok(()),
9553            Some(MaterializationOwner::Session {
9554                incarnation_id,
9555                instance_id,
9556                generation,
9557            }) if incarnation_id == self.incarnation_id
9558                && instance_id == address.session.instance_id
9559                && generation == address.session.generation =>
9560            {
9561                self.cleanup_materialization(id)
9562            }
9563            Some(MaterializationOwner::Session { .. }) => Err(failure(
9564                NodeFailureCode::BackendOperationFailed,
9565                "session environment ownership requires recovery",
9566            )),
9567        }
9568    }
9569
9570    fn clear_terminal_frame_watermark(&self, address: &SessionAddress) {
9571        let mut watermarks = self
9572            .terminal_frame_watermarks
9573            .lock()
9574            .unwrap_or_else(|poisoned| poisoned.into_inner());
9575        if watermarks
9576            .get(&address.session.instance_id)
9577            .is_some_and(|(current, _)| current == address)
9578        {
9579            watermarks.remove(&address.session.instance_id);
9580        }
9581    }
9582
9583    #[cfg(test)]
9584    fn address_for(
9585        &self,
9586        instance_id: AgentInstanceId,
9587        generation: SessionGeneration,
9588    ) -> Option<SessionAddress> {
9589        let binding = self.session_bindings
9590            .lock()
9591            .unwrap_or_else(|poisoned| poisoned.into_inner())
9592            .get(&instance_id)
9593            .cloned()?;
9594        (binding.generation == generation).then_some(SessionAddress {
9595            workspace_id: binding.workspace_id,
9596            session: SessionKey { instance_id, generation },
9597        })
9598    }
9599
9600    fn arm_resume(
9601        &self,
9602        address: &SessionAddress,
9603        command_id: CommandId,
9604        runtime_policy: ProviderRuntimePolicy,
9605    ) -> Result<(), NodeFailure> {
9606        let mut bindings = self
9607            .session_bindings
9608            .lock()
9609            .unwrap_or_else(|poisoned| poisoned.into_inner());
9610        let binding = bindings
9611            .get_mut(&address.session.instance_id)
9612            .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
9613        if binding.workspace_id != address.workspace_id {
9614            return Err(failure(
9615                NodeFailureCode::SessionWorkspaceMismatch,
9616                "session belongs to another workspace",
9617            ));
9618        }
9619        if binding.generation != address.session.generation {
9620            return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
9621        }
9622        binding.pending_resume = Some((binding.generation, command_id, runtime_policy));
9623        Ok(())
9624    }
9625
9626    fn clear_armed_resume(&self, address: &SessionAddress) {
9627        let mut bindings = self
9628            .session_bindings
9629            .lock()
9630            .unwrap_or_else(|poisoned| poisoned.into_inner());
9631        if let Some(binding) = bindings.get_mut(&address.session.instance_id) {
9632            if binding.workspace_id == address.workspace_id
9633                && binding.generation == address.session.generation
9634            {
9635                binding.pending_resume = None;
9636            }
9637        }
9638    }
9639
9640    fn publish_control(&self, event: ControlEvent) {
9641        let revokes_harness_mcp = matches!(
9642            &event.event,
9643            ControlEventKind::Exited { .. }
9644                | ControlEventKind::Failed { .. }
9645                | ControlEventKind::Removed
9646        );
9647        let (address, previous_address, record_id, identity_admitted, resume_command_rejected) = {
9648            let mut bindings = self
9649                .session_bindings
9650                .lock()
9651                .unwrap_or_else(|poisoned| poisoned.into_inner());
9652            let Some(binding) = bindings.get_mut(&event.instance_id) else {
9653                return;
9654            };
9655            let previous_address = SessionAddress {
9656                workspace_id: binding.workspace_id.clone(),
9657                session: SessionKey {
9658                    instance_id: event.instance_id,
9659                    generation: binding.generation,
9660                },
9661            };
9662            let mut resume_command_rejected = false;
9663            if binding.generation == event.generation {
9664                if let Some((pending_generation, pending_command_id, _)) = binding.pending_resume {
9665                    let rejected_resume = matches!(
9666                        &event.event,
9667                        ControlEventKind::CommandRejected { .. }
9668                    ) && event.command_id == Some(pending_command_id);
9669                    let resume_failed = matches!(
9670                        &event.event,
9671                        ControlEventKind::ResumeDenied { .. }
9672                            | ControlEventKind::ResumeFailed { .. }
9673                    );
9674                    if pending_generation == binding.generation
9675                        && (rejected_resume || resume_failed)
9676                    {
9677                        resume_command_rejected = rejected_resume;
9678                        binding.pending_resume = None;
9679                    }
9680                }
9681            } else {
9682                let expected = binding.generation.0.checked_add(1);
9683                let authorized = matches!(&event.event, ControlEventKind::ResumeAuthorized { .. });
9684                if !binding.pending_resume.is_some_and(|(generation, _, _)| {
9685                    generation == binding.generation
9686                })
9687                    || expected != Some(event.generation.0)
9688                    || !authorized
9689                {
9690                    return;
9691                }
9692                let (_, _, runtime_policy) = binding
9693                    .pending_resume
9694                    .expect("authorized resume retains its pending runtime policy");
9695                binding.generation = event.generation;
9696                binding.runtime_policy = runtime_policy;
9697                binding.pending_resume = None;
9698            }
9699            (
9700                SessionAddress {
9701                    workspace_id: binding.workspace_id.clone(),
9702                    session: SessionKey {
9703                        instance_id: event.instance_id,
9704                        generation: event.generation,
9705                    },
9706                },
9707                (previous_address.session.generation != event.generation)
9708                    .then_some(previous_address),
9709                binding.record_id.clone(),
9710                binding.runtime_policy.provider_session_identity,
9711                resume_command_rejected,
9712            )
9713        };
9714        if let Some(previous_address) = previous_address.as_ref() {
9715            self.clear_terminal_frame_watermark(previous_address);
9716        }
9717        if let Some(record_id) = record_id {
9718            self.reconcile_managed_record(
9719                &record_id,
9720                &address,
9721                &event,
9722                identity_admitted,
9723                resume_command_rejected,
9724            );
9725        }
9726        // Every state change a session's process lifecycle can reach gets a
9727        // line here: this is the single chokepoint every `ControlEventKind`
9728        // passes through regardless of transport (PTY/pipe/ACP) or whether it
9729        // came from a fresh spawn or a resume. Per-message provider chatter
9730        // (`ProviderEvent`/`ProviderGap`/terminal frames) is deliberately not
9731        // matched here -- that is exactly the per-frame noise this crate
9732        // family's level discipline forbids at INFO.
9733        match &event.event {
9734            ControlEventKind::Running { process_id } => {
9735                tracing::info!(
9736                    node_id = %self.node_id,
9737                    workspace_id = %address.workspace_id,
9738                    session = ?address.session,
9739                    pid = ?process_id,
9740                    "session process is running",
9741                );
9742            }
9743            ControlEventKind::Exited { exit_code, forced } => {
9744                tracing::info!(
9745                    node_id = %self.node_id,
9746                    workspace_id = %address.workspace_id,
9747                    session = ?address.session,
9748                    exit_code = ?exit_code,
9749                    forced = forced,
9750                    "session process exited",
9751                );
9752            }
9753            ControlEventKind::Failed { message } => {
9754                tracing::warn!(
9755                    node_id = %self.node_id,
9756                    workspace_id = %address.workspace_id,
9757                    session = ?address.session,
9758                    cause = %message,
9759                    "session failed",
9760                );
9761            }
9762            ControlEventKind::CommandRejected { message } => {
9763                tracing::warn!(
9764                    node_id = %self.node_id,
9765                    workspace_id = %address.workspace_id,
9766                    session = ?address.session,
9767                    cause = %message,
9768                    "session command rejected",
9769                );
9770            }
9771            ControlEventKind::Removed => {
9772                tracing::info!(
9773                    node_id = %self.node_id,
9774                    workspace_id = %address.workspace_id,
9775                    session = ?address.session,
9776                    "session removed",
9777                );
9778            }
9779            ControlEventKind::ResumeAuthorized { session: provider_session } => {
9780                tracing::info!(
9781                    node_id = %self.node_id,
9782                    workspace_id = %address.workspace_id,
9783                    session = ?address.session,
9784                    provider_session = ?provider_session,
9785                    "session resume authorized",
9786                );
9787            }
9788            ControlEventKind::Resumed { session: provider_session, process_id } => {
9789                tracing::info!(
9790                    node_id = %self.node_id,
9791                    workspace_id = %address.workspace_id,
9792                    session = ?address.session,
9793                    provider_session = ?provider_session,
9794                    pid = ?process_id,
9795                    "session resumed",
9796                );
9797            }
9798            ControlEventKind::ResumeDenied { reason } => {
9799                tracing::warn!(
9800                    node_id = %self.node_id,
9801                    workspace_id = %address.workspace_id,
9802                    session = ?address.session,
9803                    cause = %reason,
9804                    "session resume denied",
9805                );
9806            }
9807            ControlEventKind::ResumeFailed { message } => {
9808                tracing::warn!(
9809                    node_id = %self.node_id,
9810                    workspace_id = %address.workspace_id,
9811                    session = ?address.session,
9812                    cause = %message,
9813                    "session resume failed",
9814                );
9815            }
9816            _ => {}
9817        }
9818        let stream_chunk = agent_stream_chunk(&event);
9819        self.publish(NodeEvent::Control {
9820            address: address.clone(),
9821            event,
9822        });
9823        if let Some(chunk) = stream_chunk {
9824            self.publish_agent_stream_chunk(address.clone(), chunk);
9825        }
9826        if revokes_harness_mcp {
9827            if let Some(registry) = self.harness_mcp_registry.as_ref() {
9828                registry.revoke_session(&address);
9829            }
9830            if let Some(control) = self.native_launch_profile_control.as_ref() {
9831                control.clear_native_mcp_server_launch_overlay(
9832                    address.session.instance_id,
9833                );
9834            }
9835        }
9836    }
9837
9838    fn reconcile_managed_record(
9839        &self,
9840        record_id: &SessionRecordId,
9841        address: &SessionAddress,
9842        event: &ControlEvent,
9843        identity_admitted: bool,
9844        resume_command_rejected: bool,
9845    ) {
9846        let _transaction = self
9847            .state_transaction
9848            .lock()
9849            .unwrap_or_else(|poisoned| poisoned.into_inner());
9850        let observed_identity = match &event.event {
9851            ControlEventKind::ProviderEvent {
9852                event: gate4agent_types::ProviderEvent::SessionIdentityObserved { identity },
9853                ..
9854            } if identity_admitted => Some(identity.clone()),
9855            _ => None,
9856        };
9857        let identity_was_observed = observed_identity.is_some();
9858        let replacement_id = observed_identity
9859            .as_ref()
9860            .and_then(|_| self.allocate_record_id().ok());
9861        let mut upserts = Vec::new();
9862        let mut removals = Vec::new();
9863        let mut rebind = None;
9864        let original_records;
9865        let original_materializations = self
9866            .materializations
9867            .lock()
9868            .unwrap_or_else(|poisoned| poisoned.into_inner())
9869            .clone();
9870        {
9871            let mut records = self
9872                .session_records
9873                .lock()
9874                .unwrap_or_else(|poisoned| poisoned.into_inner());
9875            original_records = records.records.clone();
9876            let Some(current_snapshot) = records.records.get(record_id).cloned() else {
9877                return;
9878            };
9879            if let Some(identity) = observed_identity {
9880                let existing_id = records
9881                    .records
9882                    .values()
9883                    .find(|record| {
9884                        record.record_id != *record_id
9885                            && record.provider_session.as_ref().is_some_and(|record_identity| {
9886                                session_registry::same_provider_session(
9887                                    &record.provider,
9888                                    record_identity,
9889                                    &current_snapshot.provider,
9890                                    &identity,
9891                                )
9892                            })
9893                    })
9894                    .map(|record| record.record_id.clone());
9895                if let Some(existing_id) = existing_id {
9896                    let scope_matches = records.records.get(&existing_id).is_some_and(|record| {
9897                        record.mode == current_snapshot.mode
9898                            && record.workspace_id == current_snapshot.workspace_id
9899                            && record.environment_profile == current_snapshot.environment_profile
9900                            && record.bundle == current_snapshot.bundle
9901                            && record.context_id == current_snapshot.context_id
9902                            && record.context == current_snapshot.context
9903                            && record
9904                                .canonical_root
9905                                .as_utf8()
9906                                .zip(current_snapshot.canonical_root.as_utf8())
9907                                .is_some_and(|(left, right)| platform::roots_equal(left, right))
9908                    });
9909                    if !scope_matches {
9910                        if let Some(current) = records.records.get_mut(record_id) {
9911                            current.state = ManagedSessionState::Unavailable;
9912                            current.last_error =
9913                                Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR.to_owned());
9914                            current.updated_at_unix_ms = unix_time_ms();
9915                            upserts.push(current.clone());
9916                        }
9917                    } else {
9918                        let existing_is_active = records
9919                            .records
9920                            .get(&existing_id)
9921                            .is_some_and(|record| record.active_session.is_some());
9922                        if existing_is_active {
9923                            if let Some(current) = records.records.get_mut(record_id) {
9924                                current.state = ManagedSessionState::Unavailable;
9925                                current.last_error =
9926                                    Some(PROVIDER_SESSION_LIVE_CONFLICT_ERROR.to_owned());
9927                                current.updated_at_unix_ms = unix_time_ms();
9928                                upserts.push(current.clone());
9929                            }
9930                        } else {
9931                            if current_snapshot.provider_session.is_some() {
9932                                if let Some(current) = records.records.get_mut(record_id) {
9933                                    current.active_session = None;
9934                                    current.state = ManagedSessionState::Dormant;
9935                                    current.updated_at_unix_ms = unix_time_ms();
9936                                    upserts.push(current.clone());
9937                                }
9938                            } else {
9939                                records.records.remove(record_id);
9940                                removals.push(record_id.clone());
9941                            }
9942                            if let Some(existing) = records.records.get_mut(&existing_id) {
9943                                existing.provider_session = Some(identity.clone());
9944                                existing.active_session = Some(address.clone());
9945                                existing.state = ManagedSessionState::Live;
9946                                existing.updated_at_unix_ms = unix_time_ms();
9947                                existing.last_error = None;
9948                                upserts.push(existing.clone());
9949                            }
9950                            rebind = Some(existing_id);
9951                        }
9952                    }
9953                } else if current_snapshot
9954                    .provider_session
9955                    .as_ref()
9956                    .is_some_and(|current| {
9957                        !session_registry::same_provider_session(
9958                            &current_snapshot.provider,
9959                            current,
9960                            &current_snapshot.provider,
9961                            &identity,
9962                        )
9963                    })
9964                {
9965                    if let Some(current) = records.records.get_mut(record_id) {
9966                        current.active_session = None;
9967                        if current.bundle.is_some() || current.context.is_some() {
9968                            let had_bundle = current.bundle.is_some();
9969                            current.provider_session = None;
9970                            current.bundle = None;
9971                            current.context_id = None;
9972                            current.context = None;
9973                            current.state = ManagedSessionState::Unavailable;
9974                            current.last_error = Some(if had_bundle {
9975                                "bundle-unavailable".to_owned()
9976                            } else {
9977                                "context-unavailable".to_owned()
9978                            });
9979                        } else {
9980                            current.state = ManagedSessionState::Dormant;
9981                        }
9982                        current.updated_at_unix_ms = unix_time_ms();
9983                        upserts.push(current.clone());
9984                    }
9985                    if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
9986                        if let Some(current) = records.records.get_mut(record_id) {
9987                            current.state = ManagedSessionState::Unavailable;
9988                            current.last_error =
9989                                Some(MANAGED_SESSION_CAPACITY_ERROR.to_owned());
9990                            current.updated_at_unix_ms = unix_time_ms();
9991                            upserts.push(current.clone());
9992                        }
9993                    } else if let Some(new_record_id) = replacement_id {
9994                        let now = unix_time_ms();
9995                        let replacement = ManagedSessionRecord {
9996                            record_id: new_record_id.clone(),
9997                            display_name: format!(
9998                                "{} #{}",
9999                                current_snapshot.provider.as_str(),
10000                                records
10001                                    .records
10002                                    .values()
10003                                    .filter(|record| {
10004                                        record.provider == current_snapshot.provider
10005                                    })
10006                                    .count()
10007                                    .saturating_add(1),
10008                            ),
10009                            provider: current_snapshot.provider.clone(),
10010                            mode: current_snapshot.mode,
10011                            state: ManagedSessionState::Live,
10012                            workspace_id: current_snapshot.workspace_id,
10013                            canonical_root: current_snapshot.canonical_root,
10014                            provider_session: Some(identity),
10015                            active_session: Some(address.clone()),
10016                            environment_profile: current_snapshot.environment_profile,
10017                            bundle: current_snapshot.bundle,
10018                            context_id: current_snapshot.context_id,
10019                            context: current_snapshot.context,
10020                            // This is a fresh record_id representing the session under its
10021                            // newly-observed identity; it has not yet produced any clean
10022                            // exit of its own, so it starts with no exported pack (the old
10023                            // record, still addressable under its own record_id, keeps
10024                            // whatever exported_context it already had).
10025                            exported_context: None,
10026                            task_binding: current_snapshot.task_binding,
10027                            created_at_unix_ms: now,
10028                            updated_at_unix_ms: now,
10029                            last_error: None,
10030                        };
10031                        records
10032                            .records
10033                            .insert(new_record_id.clone(), replacement.clone());
10034                        upserts.push(replacement);
10035                        rebind = Some(new_record_id);
10036                    } else if let Some(current) = records.records.get_mut(record_id) {
10037                        current.state = ManagedSessionState::Unavailable;
10038                        current.last_error =
10039                            Some(PROVIDER_IDENTITY_ALLOCATION_ERROR.to_owned());
10040                        upserts.push(current.clone());
10041                    }
10042                } else if let Some(current) = records.records.get_mut(record_id) {
10043                    current.provider_session = Some(identity);
10044                    current.active_session = Some(address.clone());
10045                    current.state = ManagedSessionState::Live;
10046                    current.updated_at_unix_ms = unix_time_ms();
10047                    current.last_error = None;
10048                    upserts.push(current.clone());
10049                }
10050            } else if let Some(current) = records.records.get_mut(record_id) {
10051                if resume_command_rejected {
10052                    current.active_session = None;
10053                    current.state = detached_record_state(current);
10054                    current.updated_at_unix_ms = unix_time_ms();
10055                    current.last_error =
10056                        Some(PROVIDER_RESUME_REJECTED_ERROR.to_owned());
10057                    upserts.push(current.clone());
10058                } else {
10059                    match &event.event {
10060                    ControlEventKind::Running { .. }
10061                    | ControlEventKind::ResumeAuthorized { .. }
10062                    | ControlEventKind::Resumed { .. } => {
10063                        current.active_session = Some(address.clone());
10064                        current.state = active_record_state(
10065                            identity_admitted,
10066                            current.provider_session.is_some(),
10067                        );
10068                        current.updated_at_unix_ms = unix_time_ms();
10069                        current.last_error = None;
10070                        upserts.push(current.clone());
10071                    }
10072                    ControlEventKind::Exited { .. } | ControlEventKind::Removed => {
10073                        current.active_session = None;
10074                        current.state = detached_record_state(current);
10075                        current.updated_at_unix_ms = unix_time_ms();
10076                        upserts.push(current.clone());
10077                    }
10078                    ControlEventKind::Failed { message }
10079                    | ControlEventKind::ResumeFailed { message } => {
10080                        current.active_session = None;
10081                        current.state = detached_record_state(current);
10082                        current.updated_at_unix_ms = unix_time_ms();
10083                        current.last_error = Some(
10084                            session_registry::sanitized_record_error_summary(message),
10085                        );
10086                        upserts.push(current.clone());
10087                    }
10088                    ControlEventKind::ResumeDenied { reason } => {
10089                        current.active_session = None;
10090                        current.state = detached_record_state(current);
10091                        current.updated_at_unix_ms = unix_time_ms();
10092                        current.last_error = Some(
10093                            session_registry::sanitized_record_error_summary(reason),
10094                        );
10095                        upserts.push(current.clone());
10096                    }
10097                        _ => {}
10098                    }
10099                }
10100            }
10101        }
10102        let materialization_owner_record = identity_was_observed.then(|| {
10103            rebind.clone().unwrap_or_else(|| record_id.clone())
10104        }).and_then(|candidate| {
10105            self.session_records
10106                .lock()
10107                .unwrap_or_else(|poisoned| poisoned.into_inner())
10108                .records
10109                .get(&candidate)
10110                .filter(|record| {
10111                    record.provider_session.is_some()
10112                        && record.active_session.as_ref() == Some(address)
10113                })
10114                .map(|_| candidate)
10115        });
10116        if let Some(owner_record_id) = materialization_owner_record {
10117            let materialization_id = self
10118                .session_bindings
10119                .lock()
10120                .unwrap_or_else(|poisoned| poisoned.into_inner())
10121                .get(&address.session.instance_id)
10122                .and_then(|binding| binding.materialization_id.clone());
10123            if let Some(materialization_id) = materialization_id {
10124                let mut transfer_failed = false;
10125                {
10126                    let mut materializations = self
10127                        .materializations
10128                        .lock()
10129                        .unwrap_or_else(|poisoned| poisoned.into_inner());
10130                    if let Some(ownership) = materializations.get_mut(&materialization_id) {
10131                        match ownership.owner() {
10132                            MaterializationOwner::Session {
10133                                incarnation_id,
10134                                instance_id,
10135                                generation,
10136                            } if *incarnation_id == self.incarnation_id
10137                                && *instance_id == address.session.instance_id
10138                                && *generation == address.session.generation =>
10139                            {
10140                                transfer_failed = ownership
10141                                    .transfer_to_record(owner_record_id.clone(), unix_time_ms())
10142                                    .is_err();
10143                            }
10144                            MaterializationOwner::Record { record_id }
10145                                if record_id == &owner_record_id => {}
10146                            MaterializationOwner::Record { record_id } => {
10147                                let expected = record_id.clone();
10148                                transfer_failed = ownership
10149                                    .transfer_record_owner(
10150                                        &expected,
10151                                        owner_record_id.clone(),
10152                                        unix_time_ms(),
10153                                    )
10154                                    .is_err();
10155                            }
10156                            _ => transfer_failed = true,
10157                        }
10158                        if transfer_failed {
10159                            let _ = ownership.mark_recovery_required(unix_time_ms());
10160                        }
10161                    } else {
10162                        transfer_failed = true;
10163                    }
10164                }
10165                if transfer_failed {
10166                    if let Some(record) = self
10167                        .session_records
10168                        .lock()
10169                        .unwrap_or_else(|poisoned| poisoned.into_inner())
10170                        .records
10171                        .get_mut(&owner_record_id)
10172                    {
10173                        record.state = ManagedSessionState::Unavailable;
10174                        record.last_error = Some("environment-profile-unavailable".to_owned());
10175                        record.updated_at_unix_ms = unix_time_ms();
10176                        upserts.push(record.clone());
10177                    }
10178                }
10179            }
10180        }
10181        if let Some(new_record_id) = rebind.clone() {
10182            if let Some(binding) = self
10183                .session_bindings
10184                .lock()
10185                .unwrap_or_else(|poisoned| poisoned.into_inner())
10186                .get_mut(&address.session.instance_id)
10187            {
10188                binding.record_id = Some(new_record_id);
10189            }
10190        }
10191        if upserts.is_empty() && removals.is_empty() {
10192            return;
10193        }
10194        if self.persist_state_locked().is_err() {
10195            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
10196            let mut records = self
10197                .session_records
10198                .lock()
10199                .unwrap_or_else(|poisoned| poisoned.into_inner());
10200            records.records = original_records;
10201            *self
10202                .materializations
10203                .lock()
10204                .unwrap_or_else(|poisoned| poisoned.into_inner()) = original_materializations;
10205            if let Some(current) = records.records.get_mut(record_id) {
10206                current.state = ManagedSessionState::Unavailable;
10207                current.last_error = Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned());
10208                current.updated_at_unix_ms = unix_time_ms();
10209                upserts = vec![current.clone()];
10210            } else {
10211                upserts.clear();
10212            }
10213            removals.clear();
10214            drop(records);
10215            if let Some(binding) = self
10216                .session_bindings
10217                .lock()
10218                .unwrap_or_else(|poisoned| poisoned.into_inner())
10219                .get_mut(&address.session.instance_id)
10220            {
10221                binding.record_id = Some(record_id.clone());
10222            }
10223            eprintln!("gate4agent-node: {DURABLE_STATE_COMMIT_FAILED_ERROR}");
10224        }
10225        for record in upserts {
10226            self.publish_record(record);
10227        }
10228        for record_id in removals {
10229            self.publish(NodeEvent::SessionRecordRemoved { record_id });
10230        }
10231    }
10232
10233    fn snapshot(&self) -> NodeSnapshot {
10234        let control = self.handle.snapshot();
10235        let bindings = self
10236            .session_bindings
10237            .lock()
10238            .unwrap_or_else(|poisoned| poisoned.into_inner())
10239            .clone();
10240        let workspace_roots = self
10241            .workspaces
10242            .read()
10243            .unwrap_or_else(|poisoned| poisoned.into_inner())
10244            .clone();
10245        let environment_profiles = self
10246            .environment_profiles
10247            .read()
10248            .unwrap_or_else(|poisoned| poisoned.into_inner());
10249        let mut workspaces = workspace_roots
10250            .iter()
10251            .map(|(workspace_id, canonical_root)| {
10252                (
10253                    workspace_id.clone(),
10254                    WorkspaceSnapshot {
10255                        workspace_id: workspace_id.clone(),
10256                        canonical_root: opaque_windows_path(canonical_root.clone()),
10257                        sessions: Vec::new(),
10258                        worktree_service_mode: Some(
10259                            self.worktree_service_modes
10260                                .get(workspace_id)
10261                                .copied()
10262                                .unwrap_or(WorktreeServiceMode::Manual),
10263                        ),
10264                        managed_worktree_profiles: Some(WorktreeProfileInventory {
10265                            profiles: self
10266                                .managed_worktree_profiles
10267                                .get(workspace_id)
10268                                .into_iter()
10269                                .flat_map(BTreeMap::values)
10270                                .map(|profile| ManagedWorktreeProfileSummary {
10271                                    id: profile.profile_id().clone(),
10272                                    revision: profile.revision().clone(),
10273                                    retention: profile.retention(),
10274                                })
10275                                .collect(),
10276                        }),
10277                    },
10278                )
10279            })
10280            .collect::<BTreeMap<_, _>>();
10281        let mut agent_progress = Vec::new();
10282        for session in &control.sessions {
10283            let Some(binding) = bindings.get(&session.instance_id) else {
10284                continue;
10285            };
10286            if binding.generation != session.generation {
10287                continue;
10288            }
10289            if let Some(workspace) = workspaces.get_mut(&binding.workspace_id) {
10290                let address = SessionAddress {
10291                    workspace_id: binding.workspace_id.clone(),
10292                    session: SessionKey {
10293                        instance_id: session.instance_id,
10294                        generation: session.generation,
10295                    },
10296                };
10297                if let Some(progress) =
10298                    agent_progress_from_provider_snapshot(address, &session.provider)
10299                {
10300                    agent_progress.push(progress);
10301                }
10302                workspace.sessions.push(session.clone());
10303            }
10304        }
10305        NodeSnapshot {
10306            node_id: self.node_id.clone(),
10307            enabled_providers: self.enabled_providers.clone(),
10308            provider_runtime_statuses: {
10309                let updates = self
10310                    .provider_runtime_status_updates
10311                    .read()
10312                    .unwrap_or_else(|poisoned| poisoned.into_inner());
10313                ProviderRuntimeStatuses::new(
10314                    self.provider_runtime_statuses
10315                        .iter()
10316                        .filter(|status| !updates.contains_key(status.provider()))
10317                        .cloned()
10318                        .chain(updates.values().cloned()),
10319                )
10320                .expect("refreshed provider runtime inventory remains bounded and unique")
10321            },
10322            workspaces: workspaces.into_values().collect(),
10323            session_records: self
10324                .session_records
10325                .lock()
10326                .unwrap_or_else(|poisoned| poisoned.into_inner())
10327                .records
10328                .values()
10329                .cloned()
10330                .collect(),
10331            managed_worktrees: self.managed_worktrees
10332                .lock()
10333                .unwrap_or_else(|poisoned| poisoned.into_inner())
10334                .snapshots(),
10335            launch_inventory: Some(LaunchInventory {
10336                spawn_profiles: Some(
10337                    self.spawn_profiles
10338                        .iter()
10339                        .filter_map(|profile| {
10340                            resolved_spawn_profile_summary(profile, &environment_profiles)
10341                        })
10342                        .collect(),
10343                ),
10344                bundles: Some(
10345                    self.bundle_catalog
10346                        .read()
10347                        .unwrap_or_else(|poisoned| poisoned.into_inner())
10348                        .iter()
10349                        .map(NodeBundle::receipt)
10350                        .collect(),
10351                ),
10352                // Opaque station allowlist ids from empty-default catalog /
10353                // optional startup loader. Never secrets. Dig2 probe stays stub.
10354                network_allowlists: {
10355                    let catalog = self
10356                        .network_allowlist_catalog
10357                        .read()
10358                        .unwrap_or_else(|poisoned| poisoned.into_inner());
10359                    if catalog.is_empty() {
10360                        None
10361                    } else {
10362                        Some(catalog.ids().cloned().collect())
10363                    }
10364                },
10365            }),
10366            agent_progress,
10367        }
10368    }
10369
10370    fn publish(&self, event: NodeEvent) -> NodeEventEnvelope {
10371        assert!(
10372            !matches!(
10373                &event,
10374                NodeEvent::TerminalFrame { .. } | NodeEvent::AgentStream { .. }
10375            ),
10376            "terminal frame and agent stream events must use their own dedicated live channel",
10377        );
10378        let mut history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
10379        let sequence = history
10380            .last_sequence
10381            .checked_add(1)
10382            .expect("node event sequence exhausted");
10383        if history.events.len() == NODE_EVENT_HISTORY_MAX {
10384            if let Some(evicted) = history.events.pop_front() {
10385                history.replay_floor_sequence = evicted
10386                    .sequence
10387                    .checked_add(1)
10388                    .expect("node replay floor sequence exhausted");
10389                history.removed_record_providers.remove(&evicted.sequence);
10390            }
10391        }
10392        match &event {
10393            NodeEvent::HarnessMcpReadCall { .. } => {
10394                panic!("transient harness MCP calls must not enter durable event history")
10395            }
10396            NodeEvent::SessionRecordUpserted { record } => {
10397                history
10398                    .record_providers
10399                    .insert(record.record_id.clone(), record.provider.clone());
10400            }
10401            NodeEvent::SessionRecordRemoved { record_id } => {
10402                if let Some(provider) = history.record_providers.remove(record_id) {
10403                    history.removed_record_providers.insert(sequence, provider);
10404                }
10405            }
10406            NodeEvent::ManagedWorktreeUpserted { .. }
10407            | NodeEvent::ManagedWorktreeRemoved { .. }
10408            | NodeEvent::Control { .. }
10409            | NodeEvent::ControllerChanged { .. }
10410            | NodeEvent::SessionRecordHistorySummarized { .. }
10411            | NodeEvent::WorkspaceAdded { .. }
10412            | NodeEvent::WorkspaceRemoved { .. }
10413            | NodeEvent::ResyncRequired { .. }
10414            | NodeEvent::AgentStream { .. }
10415            | NodeEvent::TerminalFrame { .. } => {}
10416        }
10417        let envelope = NodeEventEnvelope { sequence, event };
10418        history.events.push_back(envelope.clone());
10419        history.last_sequence = sequence;
10420        let _ = self.event_tx.send(envelope.clone());
10421        drop(history);
10422        envelope
10423    }
10424
10425    /// Publishes a transient (never durable) `NodeEvent` -- today only
10426    /// `NodeEvent::HarnessMcpReadCall` -- onto the dedicated
10427    /// `harness_mcp_proxy_event_tx` broadcast (see that field's own doc for
10428    /// why this must never ride the shared `event_tx` bus). Returns whether
10429    /// the send reached at least one subscriber: `broadcast::Sender::send`
10430    /// fails in exactly one way, no active receiver at that instant, and
10431    /// the caller (`HarnessMcpProxyRegistry::begin_call`) uses a `false`
10432    /// return to fail the pending call immediately rather than leave it to
10433    /// expire silently at `LOCAL_READ_TIMEOUT`/
10434    /// `MAX_HARNESS_MCP_CALL_DEADLINE_MS`.
10435    fn publish_transient(&self, event: NodeEvent) -> bool {
10436        assert!(event.requires_harness_mcp_proxy_capability());
10437        assert!(event.harness_mcp_contract_is_valid_at(unix_time_ms()));
10438        let envelope = NodeEventEnvelope { sequence: 0, event };
10439        match self.harness_mcp_proxy_event_tx.send(Arc::new(vec![envelope])) {
10440            Ok(_) => true,
10441            Err(broadcast::error::SendError(undelivered)) => {
10442                if let Some(NodeEventEnvelope {
10443                    event: NodeEvent::HarnessMcpReadCall { call_id, request, .. },
10444                    ..
10445                }) = undelivered.first() {
10446                    // `request.content_type` only, never a per-tool label --
10447                    // this crate does not decode `request.body` and does not
10448                    // hold `hatchery-harness-api` (Nested Control Plane
10449                    // doctrine, Law 3; see this crate's dependency on
10450                    // `gate4agent-node-protocol`'s own `Forbidden:` line).
10451                    tracing::warn!(
10452                        call_id = call_id.as_str(),
10453                        content_type = ?request.content_type,
10454                        cause = "no-subscribers",
10455                        "harness MCP proxy event could not be published",
10456                    );
10457                }
10458                false
10459            }
10460        }
10461    }
10462
10463    fn publish_terminal_frames(&self) {
10464        let snapshot = self.handle.snapshot();
10465        let bindings = self
10466            .session_bindings
10467            .lock()
10468            .unwrap_or_else(|poisoned| poisoned.into_inner());
10469        let candidates = snapshot
10470            .sessions
10471            .iter()
10472            .filter_map(|session| {
10473                let binding = bindings.get(&session.instance_id)?;
10474                if binding.generation != session.generation {
10475                    return None;
10476                }
10477                let frame = session.terminal_frame.clone()?;
10478                Some((
10479                    SessionAddress {
10480                        workspace_id: binding.workspace_id.clone(),
10481                        session: SessionKey {
10482                            instance_id: session.instance_id,
10483                            generation: session.generation,
10484                        },
10485                    },
10486                    frame,
10487                ))
10488            })
10489            .collect();
10490        drop(bindings);
10491        self.publish_terminal_frame_candidates(candidates);
10492    }
10493
10494    fn publish_terminal_frame_candidates(
10495        &self,
10496        candidates: Vec<(SessionAddress, TerminalFrame)>,
10497    ) {
10498        let mut watermarks = self
10499            .terminal_frame_watermarks
10500            .lock()
10501            .unwrap_or_else(|poisoned| poisoned.into_inner());
10502        let mut events = Vec::with_capacity(candidates.len());
10503        for (address, frame) in candidates {
10504            let is_first_frame = !watermarks.contains_key(&address.session.instance_id);
10505            let advanced = watermarks
10506                .get(&address.session.instance_id)
10507                .map_or(true, |(current_address, sequence)| {
10508                    current_address != &address || frame.sequence > *sequence
10509                });
10510            if !advanced {
10511                continue;
10512            }
10513            if is_first_frame {
10514                // This is the first PTY output this node has observed for
10515                // this session -- a one-time event per spawn, not per-frame
10516                // chatter (the watermark makes sure of that). A session that
10517                // reaches this line with no controller attached anywhere on
10518                // the node is running unobserved: exactly how processes from
10519                // repeated spawn attempts went orphaned for hours without a
10520                // single line about it.
10521                if self.controller_state().is_none() {
10522                    tracing::warn!(
10523                        node_id = %self.node_id,
10524                        workspace_id = %address.workspace_id,
10525                        session = ?address.session,
10526                        terminal_size = ?frame.size,
10527                        "session producing output with no controller attached",
10528                    );
10529                } else {
10530                    tracing::info!(
10531                        node_id = %self.node_id,
10532                        workspace_id = %address.workspace_id,
10533                        session = ?address.session,
10534                        terminal_size = ?frame.size,
10535                        "session produced its first output",
10536                    );
10537                }
10538            }
10539            watermarks.insert(
10540                address.session.instance_id,
10541                (address.clone(), frame.sequence),
10542            );
10543            events.push(NodeEvent::TerminalFrame { address, frame });
10544        }
10545        drop(watermarks);
10546        if events.is_empty() {
10547            return;
10548        }
10549        let mut history = self
10550            .history
10551            .lock()
10552            .unwrap_or_else(|poisoned| poisoned.into_inner());
10553        let mut envelopes = Vec::with_capacity(events.len());
10554        for event in events {
10555            let sequence = history
10556                .last_sequence
10557                .checked_add(1)
10558                .expect("node event sequence exhausted");
10559            history.last_sequence = sequence;
10560            envelopes.push(NodeEventEnvelope { sequence, event });
10561        }
10562        let _ = self.terminal_event_tx.send(Arc::new(envelopes));
10563        drop(history);
10564    }
10565
10566    /// Publishes one `AgentStreamChunkV1` onto the dedicated agent-stream
10567    /// channel, mirroring `publish_terminal_frame_candidates` above: the
10568    /// envelope's `sequence` is drawn from the same `history.last_sequence`
10569    /// counter every other event kind shares, never a counter of its own --
10570    /// `queue_connection_event`/`queue_connection_event_batch` compare
10571    /// `envelope.sequence` against one `discard_events_through` watermark
10572    /// regardless of which broadcast channel it arrived on, so a
10573    /// per-channel counter would desynchronize that comparison.
10574    fn publish_agent_stream_chunk(&self, address: SessionAddress, chunk: AgentStreamChunkV1) {
10575        let mut history = self
10576            .history
10577            .lock()
10578            .unwrap_or_else(|poisoned| poisoned.into_inner());
10579        let sequence = history
10580            .last_sequence
10581            .checked_add(1)
10582            .expect("node event sequence exhausted");
10583        history.last_sequence = sequence;
10584        drop(history);
10585        // Debug, not info/warn -- this fires on every single chunk, but it
10586        // is exactly the counter the next drop-measurement pass needs: what
10587        // the node actually emitted (`source_sequence`, `kind`) versus what
10588        // a harness subscriber's own forwarder later logs having received
10589        // (`hatchery-harness-service::runtime`'s "forwarding agent stream
10590        // event"), so a gap between the two logs names itself instead of
10591        // needing a new counter added ad hoc.
10592        tracing::debug!(
10593            source_sequence = chunk.source_sequence,
10594            kind = agent_stream_chunk_kind_label(&chunk.kind),
10595            envelope_sequence = sequence,
10596            "publishing agent stream chunk",
10597        );
10598        let envelope = NodeEventEnvelope {
10599            sequence,
10600            event: NodeEvent::AgentStream { address, chunk },
10601        };
10602        let _ = self.agent_stream_event_tx.send(Arc::new(vec![envelope]));
10603    }
10604
10605    fn current_sequence(&self) -> u64 {
10606        self.history
10607            .lock()
10608            .unwrap_or_else(|poisoned| poisoned.into_inner())
10609            .last_sequence
10610    }
10611
10612    fn controller_state(&self) -> Option<ControllerState> {
10613        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
10614        let lease = controller.as_ref().copied()?;
10615        let remaining = lease.expires_at.saturating_duration_since(Instant::now());
10616        if remaining.is_zero() {
10617            *controller = None;
10618            return None;
10619        }
10620        Some(ControllerState {
10621            connection_id: lease.connection_id,
10622            lease_remaining_ms: remaining.as_millis().min(u64::MAX as u128) as u64,
10623        })
10624    }
10625
10626    fn acquire_controller(&self, connection_id: u64, role: ClientRole, lease_ms: u64) -> Result<ControllerState, NodeFailure> {
10627        if role != ClientRole::Operator {
10628            tracing::warn!(
10629                node_id = %self.node_id,
10630                connection_id,
10631                "controller lease rejected: observer connections cannot acquire control",
10632            );
10633            return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections cannot acquire control"));
10634        }
10635        let lease_ms = if lease_ms == 0 {
10636            DEFAULT_CONTROLLER_LEASE_MS
10637        } else {
10638            lease_ms.clamp(MIN_CONTROLLER_LEASE_MS, MAX_CONTROLLER_LEASE_MS)
10639        };
10640        let now = Instant::now();
10641        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
10642        if let Some(active) = controller.as_ref() {
10643            if active.connection_id != connection_id && active.expires_at > now {
10644                tracing::warn!(
10645                    node_id = %self.node_id,
10646                    connection_id,
10647                    holder_connection_id = active.connection_id,
10648                    "controller lease rejected: another operator holds it",
10649                );
10650                return Err(failure(NodeFailureCode::ControllerBusy, "another operator holds the controller lease"));
10651            }
10652        }
10653        // A reconnect (this connection already holds the lease, renewing
10654        // before expiry) happens on a periodic cadence; logging it at INFO
10655        // every renewal would be exactly the heartbeat chatter this crate
10656        // family's level discipline forbids. A genuine attach -- the
10657        // controller identity actually changing -- is a state change and
10658        // gets INFO; a same-connection renewal gets DEBUG.
10659        let is_new_attach = match controller.as_ref() {
10660            Some(active) => active.connection_id != connection_id,
10661            None => true,
10662        };
10663        *controller = Some(ControllerLease {
10664            connection_id,
10665            expires_at: now + Duration::from_millis(lease_ms),
10666        });
10667        drop(controller);
10668        let state = ControllerState { connection_id, lease_remaining_ms: lease_ms };
10669        if is_new_attach {
10670            tracing::info!(
10671                node_id = %self.node_id,
10672                connection_id,
10673                lease_ms,
10674                "controller lease acquired",
10675            );
10676        } else {
10677            tracing::debug!(
10678                node_id = %self.node_id,
10679                connection_id,
10680                lease_ms,
10681                "controller lease renewed",
10682            );
10683        }
10684        self.publish(NodeEvent::ControllerChanged { controller: Some(state.clone()) });
10685        Ok(state)
10686    }
10687
10688    fn release_controller(&self, connection_id: u64) -> bool {
10689        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
10690        let released = controller.as_ref().is_some_and(|active| active.connection_id == connection_id);
10691        if released {
10692            *controller = None;
10693        }
10694        drop(controller);
10695        if released {
10696            tracing::info!(
10697                node_id = %self.node_id,
10698                connection_id,
10699                "controller lease released",
10700            );
10701            self.publish(NodeEvent::ControllerChanged { controller: None });
10702        }
10703        released
10704    }
10705
10706    fn require_controller(&self, connection_id: u64, role: ClientRole) -> Result<(), NodeFailure> {
10707        if role != ClientRole::Operator {
10708            return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections are read-only"));
10709        }
10710        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
10711        let now = Instant::now();
10712        match controller.as_ref().copied() {
10713            Some(active) if active.connection_id == connection_id && active.expires_at > now => Ok(()),
10714            Some(active) if active.expires_at <= now => {
10715                *controller = None;
10716                Err(failure(NodeFailureCode::ControllerRequired, "controller lease expired"))
10717            }
10718            _ => Err(failure(NodeFailureCode::ControllerRequired, "controller lease is required")),
10719        }
10720    }
10721
10722    fn validate_address(&self, address: &SessionAddress) -> Result<AgentId, NodeFailure> {
10723        if !self
10724            .workspaces
10725            .read()
10726            .unwrap_or_else(|poisoned| poisoned.into_inner())
10727            .contains_key(&address.workspace_id)
10728        {
10729            return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
10730        }
10731        let binding = self
10732            .session_bindings
10733            .lock()
10734            .unwrap_or_else(|poisoned| poisoned.into_inner())
10735            .get(&address.session.instance_id)
10736            .cloned()
10737            .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10738        if binding.workspace_id != address.workspace_id {
10739            return Err(failure(
10740                NodeFailureCode::SessionWorkspaceMismatch,
10741                "session belongs to another workspace",
10742            ));
10743        }
10744        if binding.generation != address.session.generation {
10745            return Err(failure(
10746                NodeFailureCode::StaleGeneration,
10747                &format!(
10748                    "stale session generation: expected {}, received {}",
10749                    binding.generation.0,
10750                    address.session.generation.0,
10751                ),
10752            ));
10753        }
10754        let snapshot = self.handle.snapshot();
10755        let Some(current) = snapshot
10756            .sessions
10757            .iter()
10758            .find(|item| item.instance_id == address.session.instance_id)
10759        else {
10760            return Err(failure(NodeFailureCode::UnknownSession, "session instance does not exist"));
10761        };
10762        if current.generation != address.session.generation {
10763            return Err(failure(
10764                NodeFailureCode::StaleGeneration,
10765                &format!(
10766                    "stale session generation: expected {}, received {}",
10767                    current.generation.0,
10768                    address.session.generation.0,
10769                ),
10770            ));
10771        }
10772        Ok(current.agent_id.clone())
10773    }
10774
10775    fn dispatch(&self, command: ControlCommand) -> Result<CommandId, NodeFailure> {
10776        self.dispatch_envelope(self.prepare_command(command))
10777    }
10778
10779    fn prepare_command(&self, command: ControlCommand) -> CommandEnvelope {
10780        let id = self.next_command_id.fetch_add(1, Ordering::AcqRel);
10781        CommandEnvelope {
10782            id: CommandId(id),
10783            command,
10784        }
10785    }
10786
10787    fn dispatch_envelope(&self, envelope: CommandEnvelope) -> Result<CommandId, NodeFailure> {
10788        let command_id = envelope.id;
10789        self.handle
10790            .dispatch(envelope)
10791            .map_err(|error| match error {
10792                PortDispatchError::Full => failure(NodeFailureCode::BackendBusy, "backend command ingress is full"),
10793                PortDispatchError::Disconnected => failure(NodeFailureCode::BackendDisconnected, "backend command ingress is disconnected"),
10794            })?;
10795        Ok(command_id)
10796    }
10797
10798    /// Records the kernel's own reason for asynchronously refusing
10799    /// `command_id` -- see `rejected_command_reasons`'s doc for why a
10800    /// spawn-dispatch waiter otherwise has no way to learn it. Bounded by
10801    /// `REJECTED_COMMAND_REASON_MAX_ENTRIES`: expired entries are swept
10802    /// first, and if the map is still at capacity after that, the single
10803    /// oldest surviving entry is evicted -- `CommandId` is monotonically
10804    /// assigned (`next_command_id.fetch_add`), so the `BTreeMap`'s first key
10805    /// is always the oldest.
10806    fn record_rejected_command(
10807        &self,
10808        command_id: CommandId,
10809        message: String,
10810        unsupported_transport: bool,
10811    ) {
10812        let now = Instant::now();
10813        let mut reasons = self.rejected_command_reasons.lock()
10814            .unwrap_or_else(|poisoned| poisoned.into_inner());
10815        reasons.retain(|_, reason| reason.expires_at > now);
10816        if reasons.len() >= REJECTED_COMMAND_REASON_MAX_ENTRIES {
10817            if let Some(&oldest) = reasons.keys().next() {
10818                reasons.remove(&oldest);
10819            }
10820        }
10821        reasons.insert(
10822            command_id,
10823            RejectedCommandReason {
10824                message,
10825                unsupported_transport,
10826                expires_at: now + Duration::from_millis(REJECTED_COMMAND_REASON_TTL_MS),
10827            },
10828        );
10829    }
10830
10831    /// Removes and returns the kernel's reason for refusing `command_id`,
10832    /// if the drive loop has recorded one and it has not expired -- see
10833    /// `rejected_command_reasons`'s doc. Consumed at most once: a spawn
10834    /// wait loop calls this instead of blindly polling out its whole
10835    /// deadline when the command it is waiting on turns out to have been
10836    /// refused rather than merely slow.
10837    fn take_rejected_command_reason(&self, command_id: CommandId) -> Option<RejectedCommandReason> {
10838        let now = Instant::now();
10839        let mut reasons = self.rejected_command_reasons.lock()
10840            .unwrap_or_else(|poisoned| poisoned.into_inner());
10841        match reasons.remove(&command_id) {
10842            Some(reason) if reason.expires_at > now => Some(reason),
10843            _ => None,
10844        }
10845    }
10846
10847    async fn dispatch_bounded(
10848        &self,
10849        command: ControlCommand,
10850        timeout_duration: Duration,
10851    ) -> Result<CommandId, NodeFailure> {
10852        let deadline = Instant::now() + timeout_duration;
10853        loop {
10854            match self.dispatch(command.clone()) {
10855                Ok(command_id) => return Ok(command_id),
10856                Err(error) if error.code == NodeFailureCode::BackendBusy && Instant::now() < deadline => {
10857                    sleep(Duration::from_millis(2)).await;
10858                }
10859                Err(error) => return Err(error),
10860            }
10861        }
10862    }
10863
10864    fn internal_session_snapshot(
10865        &self,
10866        address: &SessionAddress,
10867    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
10868        self.validate_address(address)?;
10869        self.handle
10870            .snapshot()
10871            .sessions
10872            .clone()
10873            .into_iter()
10874            .find(|session| {
10875                session.instance_id == address.session.instance_id
10876                    && session.generation == address.session.generation
10877            })
10878            .ok_or_else(|| {
10879                failure(
10880                    NodeFailureCode::UnknownSession,
10881                    "session snapshot is unavailable",
10882                )
10883            })
10884    }
10885
10886    /// Confirms `address` names a live session spawned over the ACP
10887    /// transport and returns its current snapshot -- the gate every
10888    /// `acp-control-v1` verb shares. `require_session_runtime_policy`
10889    /// cannot answer this by itself: `ProviderRuntimePolicy` carries no
10890    /// transport bit for `Acp`/`Inline` (see that method's own doc), only
10891    /// the session's own recorded `TransportKind` does, and that lives on
10892    /// the live snapshot, not the binding.
10893    fn require_acp_session(
10894        &self,
10895        address: &SessionAddress,
10896    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
10897        let snapshot = self.internal_session_snapshot(address)?;
10898        if snapshot.transport != TransportKind::Acp {
10899            return Err(failure(
10900                NodeFailureCode::UnsupportedTransport,
10901                "this verb requires a session spawned over the ACP transport",
10902            ));
10903        }
10904        Ok(snapshot)
10905    }
10906
10907    async fn dispatch_history_bounded(
10908        &self,
10909        address: &SessionAddress,
10910        command: ControlCommand,
10911        operation: HistoryOperation,
10912    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
10913        self.validate_address(address)?;
10914        let started_after = self.current_sequence();
10915        let command_id = self
10916            .dispatch_bounded(
10917                command,
10918                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
10919            )
10920            .await?;
10921        let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
10922        let mut scan_after = started_after;
10923        let mut accepted_after = None;
10924        loop {
10925            let events = self
10926                .history
10927                .lock()
10928                .unwrap_or_else(|poisoned| poisoned.into_inner())
10929                .events
10930                .iter()
10931                .filter(|envelope| envelope.sequence > scan_after)
10932                .cloned()
10933                .collect::<Vec<_>>();
10934            for envelope in events {
10935                scan_after = scan_after.max(envelope.sequence);
10936                let NodeEvent::Control {
10937                    address: event_address,
10938                    event,
10939                } = envelope.event
10940                else {
10941                    continue;
10942                };
10943                if event_address != *address {
10944                    continue;
10945                }
10946                if event.command_id == Some(command_id) {
10947                    match &event.event {
10948                        ControlEventKind::CommandRejected { .. } => {
10949                            return Err(failure(
10950                                NodeFailureCode::InvalidRequest,
10951                                "history request was rejected",
10952                            ));
10953                        }
10954                        ControlEventKind::HistoryRequested {
10955                            operation: requested,
10956                            ..
10957                        } if requested == &operation => {
10958                            accepted_after = Some(envelope.sequence);
10959                            continue;
10960                        }
10961                        _ => {}
10962                    }
10963                }
10964                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
10965                    match (&operation, &event.event) {
10966                        (
10967                            HistoryOperation::Discover { .. },
10968                            ControlEventKind::HistoryDiscovered { .. },
10969                        )
10970                        | (
10971                            HistoryOperation::Load { .. },
10972                            ControlEventKind::HistoryLoaded { .. },
10973                        ) => return self.internal_session_snapshot(address),
10974                        (_, ControlEventKind::HistoryFailed { .. }) => {
10975                            return Err(failure(
10976                                NodeFailureCode::BackendOperationFailed,
10977                                "history operation failed",
10978                            ));
10979                        }
10980                        _ => {}
10981                    }
10982                }
10983            }
10984            if Instant::now() >= deadline {
10985                return Err(failure(
10986                    NodeFailureCode::BackendBusy,
10987                    if accepted_after.is_some() {
10988                        "history operation did not settle before the bounded deadline"
10989                    } else {
10990                        "history operation was not accepted before the bounded deadline"
10991                    },
10992                ));
10993            }
10994            sleep(Duration::from_millis(2)).await;
10995        }
10996    }
10997
10998    async fn discover_history(
10999        &self,
11000        address: &SessionAddress,
11001        limit: u16,
11002    ) -> Result<Vec<HistoryCandidateSummary>, NodeFailure> {
11003        let query = HistoryQuery {
11004            working_directory: Some(self.workspace_root(&address.workspace_id)?),
11005            limit,
11006        };
11007        query
11008            .validate()
11009            .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history query"))?;
11010        let snapshot = self
11011            .dispatch_history_bounded(
11012                address,
11013                ControlCommand::DiscoverHistory {
11014                    instance_id: address.session.instance_id,
11015                    query: query.clone(),
11016                },
11017                HistoryOperation::Discover { query },
11018            )
11019            .await?;
11020        if snapshot.history.pending.is_some() || snapshot.history.loaded.is_some() {
11021            return Err(failure(
11022                NodeFailureCode::BackendOperationFailed,
11023                "history discovery settled into an invalid state",
11024            ));
11025        }
11026        Ok(snapshot.history.candidates)
11027    }
11028
11029    async fn load_history(
11030        &self,
11031        address: &SessionAddress,
11032        candidate_id: String,
11033    ) -> Result<HistorySessionRecord, NodeFailure> {
11034        validate_candidate_id(&candidate_id)
11035            .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history candidate"))?;
11036        let snapshot = self
11037            .dispatch_history_bounded(
11038                address,
11039                ControlCommand::LoadHistory {
11040                    instance_id: address.session.instance_id,
11041                    candidate_id: candidate_id.clone(),
11042                },
11043                HistoryOperation::Load {
11044                    candidate_id: candidate_id.clone(),
11045                },
11046            )
11047            .await?;
11048        if snapshot.history.pending.is_some()
11049            || snapshot.history.loaded_candidate_id.as_ref() != Some(&candidate_id)
11050        {
11051            return Err(failure(
11052                NodeFailureCode::BackendOperationFailed,
11053                "history load settled into an invalid state",
11054            ));
11055        }
11056        snapshot.history.loaded.ok_or_else(|| {
11057            failure(
11058                NodeFailureCode::BackendOperationFailed,
11059                "history load produced no bounded session",
11060            )
11061        })
11062    }
11063
11064    /// `allow_clean_detachment`: `false` for the on-demand
11065    /// `NodeRequest::ExportContextPackForSessionRecord` API path (the
11066    /// managed record must stay untouched, still live, for the whole call);
11067    /// `true` only for the reactive auto-export-at-exit path (`§2.1`), which
11068    /// reacts to the very clean-exit event that detaches the record out from
11069    /// under this same call — see `session_record_export_target_matches`.
11070    async fn export_context_pack_for_session_record(
11071        &self,
11072        record_id: &SessionRecordId,
11073        session: &SessionAddress,
11074        allow_clean_detachment: bool,
11075    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11076        // C2 replaces a relayed failure's prose with a canned per-code
11077        // string (`C2NodeFailure`'s own `From`), so this path's dozen
11078        // distinct refusals are indistinguishable to anyone downstream --
11079        // and a refusal here expires a continuation, which keeps no field
11080        // for why. The prose stays on the node, where it was written.
11081        let outcome = self
11082            .export_context_pack_for_session_record_inner(record_id, session, allow_clean_detachment)
11083            .await;
11084        if let Err(failure) = &outcome {
11085            tracing::warn!(
11086                record = %record_id,
11087                code = ?failure.code,
11088                reason = %failure.message,
11089                "session-record context-pack export refused",
11090            );
11091        }
11092        outcome
11093    }
11094
11095    async fn export_context_pack_for_session_record_inner(
11096        &self,
11097        record_id: &SessionRecordId,
11098        session: &SessionAddress,
11099        allow_clean_detachment: bool,
11100    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11101        // Several steps here refuse with the same BackendOperationFailed
11102        // code and no prose of their own, so which step failed is
11103        // otherwise unrecoverable -- and a refusal here expires a
11104        // continuation.
11105        let step = |name: &'static str| move |failure: NodeFailure| {
11106            tracing::warn!(step = name, code = ?failure.code, "context-pack export step refused");
11107            failure
11108        };
11109        let record = self.record(record_id).map_err(step("record-lookup"))?;
11110        // `provider_session` is `Some` only for a provider whose resolved
11111        // runtime policy admits live identity verification (raw PTY plus a
11112        // semantic PTY adapter -- `provider_runtime::policy_from_capability_flags`).
11113        // The PTY-sidecar-observation shape (no fleet provider today, but
11114        // capability-keyed rather than vendor-locked) hardcodes
11115        // `provider_session_identity: false` in `provider_runtime::evaluate`,
11116        // so its records go straight to `Live` with `provider_session: None`
11117        // and stay that way for the record's entire life -- that is this
11118        // shape's permanent resting state, not a race with observation, and
11119        // `discover_history`/`load_history` below do not need an identity to
11120        // do their job for it. `IdentityPending` is the actual race (an
11121        // identity-admitted provider whose observation has not landed yet):
11122        // it keeps refusing here rather than falling back, because that
11123        // provider's own history the fallback would otherwise trust is not
11124        // guaranteed to be exact without the identity it is still waiting on.
11125        let identity = match record.provider_session.clone() {
11126            Some(identity) => {
11127                if identity.key != ProviderSessionKey::SessionId {
11128                    return Err(step("provider-identity-key")(failure(
11129                        NodeFailureCode::BackendOperationFailed,
11130                        "managed session provider identity cannot bind history exactly",
11131                    )));
11132                }
11133                Some(identity)
11134            }
11135            None if record.state == ManagedSessionState::IdentityPending => {
11136                return Err(step("provider-session-identity")(failure(
11137                    NodeFailureCode::BackendOperationFailed,
11138                    "managed session has not yet observed its provider session identity",
11139                )));
11140            }
11141            None => None,
11142        };
11143        self.revalidate_session_record_context_export(
11144            &record,
11145            identity.as_ref(),
11146            session,
11147            allow_clean_detachment,
11148        ).map_err(step("revalidate-before-discovery"))?;
11149        let candidates = self
11150            .discover_history(session, HISTORY_DISCOVERY_LIMIT_MAX)
11151            .await
11152            .map_err(step("discover-history"))?;
11153        // With no verified identity to filter by, the discovered set must
11154        // still resolve to exactly one candidate: the same ambiguity refusal
11155        // an identity filter would otherwise produce is the only safety
11156        // property left available for this shape.
11157        let candidate_id = select_history_candidate(
11158            &candidates,
11159            identity.as_ref().map(|identity| identity.id.as_str()),
11160        ).map_err(step("unique-candidate"))?;
11161        let loaded = self.load_history(session, candidate_id.clone()).await
11162            .map_err(step("load-history"))?;
11163        if let Some(identity) = identity.as_ref() {
11164            if loaded.session_id != identity.id {
11165                return Err(failure(
11166                    NodeFailureCode::SessionRecordConflict,
11167                    "loaded history identity does not match the managed session record",
11168                ));
11169            }
11170        }
11171        self.revalidate_session_record_context_export(
11172            &record,
11173            identity.as_ref(),
11174            session,
11175            allow_clean_detachment,
11176        )?;
11177        self.revalidate_loaded_history(session, &candidate_id, &loaded)
11178            .map_err(step("revalidate-loaded-history"))?;
11179        let pack = self.materialize_context_pack(session).await
11180            .map_err(step("materialize-context-pack"))?;
11181        self.commit_context_pack_for_session_record(
11182            &record,
11183            identity.as_ref(),
11184            session,
11185            &candidate_id,
11186            &loaded,
11187            pack,
11188            allow_clean_detachment,
11189        )
11190    }
11191
11192    async fn export_context_pack(
11193        &self,
11194        address: &SessionAddress,
11195    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11196        let pack = self.materialize_context_pack(address).await?;
11197        self.commit_context_pack(pack)
11198    }
11199
11200    async fn materialize_context_pack(
11201        &self,
11202        address: &SessionAddress,
11203    ) -> Result<NodeContextPack, NodeFailure> {
11204        let snapshot = self.internal_session_snapshot(address)?;
11205        if !context_pack_source_status_is_usable(&snapshot.status) {
11206            return Err(failure(
11207                NodeFailureCode::InvalidRequest,
11208                "context export requires a running or successfully exited source session",
11209            ));
11210        }
11211        if snapshot.history.pending.is_some()
11212            || snapshot.history.loaded_candidate_id.is_none()
11213        {
11214            return Err(failure(
11215                NodeFailureCode::InvalidRequest,
11216                "context export requires settled loaded history",
11217            ));
11218        }
11219        let loaded = snapshot.history.loaded.clone().ok_or_else(|| {
11220            failure(
11221                NodeFailureCode::InvalidRequest,
11222                "context export requires settled loaded history",
11223            )
11224        })?;
11225        let source_provider = snapshot.agent_id.clone();
11226        let loaded_candidate_id = snapshot.history.loaded_candidate_id.clone();
11227        let repository = self
11228            .context_pack_repository(&address.workspace_id)
11229            .await?;
11230        let current = self.internal_session_snapshot(address)?;
11231        if !context_pack_source_status_is_usable(&current.status)
11232            || current.agent_id != source_provider
11233            || current.history.pending.is_some()
11234            || current.history.loaded_candidate_id != loaded_candidate_id
11235            || current.history.loaded.as_ref() != Some(&loaded)
11236        {
11237            return Err(failure(
11238                NodeFailureCode::ContextPackMaterializationFailed,
11239                "context source changed during bounded repository capture",
11240            ));
11241        }
11242        NodeContextPack::export_with_repository(
11243            ContextPackLineageReceipt {
11244                source_node_id: self.node_id.clone(),
11245                source_session: address.clone(),
11246                source_provider,
11247            },
11248            &loaded,
11249            Some(repository),
11250        )
11251        .map_err(|_| {
11252            failure(
11253                NodeFailureCode::ContextPackMaterializationFailed,
11254                "bounded context pack export failed",
11255            )
11256        })
11257    }
11258
11259    /// Write-through to the durable [`ContextPackStore`], if one is
11260    /// configured (`state_path` is `Some`). A node with no durable state
11261    /// path — fixtures, tests, ephemeral runs — has no `ContextPackStore`
11262    /// open at all and this is a no-op, matching `persist_state`'s own
11263    /// "durability optional, degrade gracefully when unconfigured" idiom.
11264    fn commit_context_pack_durably(&self, pack: &NodeContextPack) -> Result<(), NodeFailure> {
11265        let mut store = self
11266            .context_pack_store
11267            .lock()
11268            .unwrap_or_else(|poisoned| poisoned.into_inner());
11269        let Some(store) = store.as_mut() else {
11270            return Ok(());
11271        };
11272        store.commit(pack).map_err(|_| {
11273            failure(
11274                NodeFailureCode::ContextPackMaterializationFailed,
11275                "durable context pack commit failed",
11276            )
11277        })
11278    }
11279
11280    fn commit_context_pack(
11281        &self,
11282        pack: NodeContextPack,
11283    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11284        self.commit_context_pack_durably(&pack)?;
11285        self.context_catalog
11286            .write()
11287            .unwrap_or_else(|poisoned| poisoned.into_inner())
11288            .insert(pack)
11289            .map_err(|_| {
11290                failure(
11291                    NodeFailureCode::ContextPackMaterializationFailed,
11292                    "context pack catalog is full",
11293                )
11294            })
11295    }
11296
11297    fn commit_context_pack_for_session_record(
11298        &self,
11299        expected: &ManagedSessionRecord,
11300        identity: Option<&ProviderSessionIdentity>,
11301        session: &SessionAddress,
11302        candidate_id: &str,
11303        loaded: &HistorySessionRecord,
11304        pack: NodeContextPack,
11305        allow_clean_detachment: bool,
11306    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11307        let current_root = self.workspace_root(&expected.workspace_id)?;
11308        let _transaction = self
11309            .state_transaction
11310            .lock()
11311            .unwrap_or_else(|poisoned| poisoned.into_inner());
11312        let records = self
11313            .session_records
11314            .lock()
11315            .unwrap_or_else(|poisoned| poisoned.into_inner());
11316        let current = records.records.get(&expected.record_id).ok_or_else(|| {
11317            failure(
11318                NodeFailureCode::SessionRecordConflict,
11319                "managed session record disappeared during context export",
11320            )
11321        })?;
11322        if !session_record_export_target_matches(expected, current, session, allow_clean_detachment) {
11323            return Err(failure(
11324                NodeFailureCode::SessionRecordConflict,
11325                "managed session record changed during context export",
11326            ));
11327        }
11328        let bindings = self
11329            .session_bindings
11330            .lock()
11331            .unwrap_or_else(|poisoned| poisoned.into_inner());
11332        let binding = bindings.get(&session.session.instance_id).ok_or_else(|| {
11333            failure(
11334                NodeFailureCode::SessionRecordConflict,
11335                "managed session runtime binding disappeared during context export",
11336            )
11337        })?;
11338        let runtime = self
11339            .handle
11340            .snapshot()
11341            .sessions
11342            .iter()
11343            .find(|snapshot| {
11344                snapshot.instance_id == session.session.instance_id
11345                    && snapshot.generation == session.session.generation
11346            })
11347            .cloned()
11348            .ok_or_else(|| {
11349                failure(
11350                    NodeFailureCode::SessionRecordConflict,
11351                    "managed session runtime disappeared during context export",
11352                )
11353            })?;
11354        let receipt = pack.receipt();
11355        if !session_record_context_export_binding_is_exact(
11356            expected,
11357            current,
11358            identity,
11359            session,
11360            binding,
11361            &runtime.agent_id,
11362            &current_root,
11363            allow_clean_detachment,
11364        ) || !session_record_context_export_source_is_usable(
11365            current,
11366            &runtime.status,
11367            allow_clean_detachment,
11368        ) || runtime.history.pending.is_some()
11369            || runtime.history.loaded_candidate_id.as_deref() != Some(candidate_id)
11370            || runtime.history.loaded.as_ref() != Some(loaded)
11371            || receipt.lineage.source_node_id != self.node_id
11372            || receipt.lineage.source_session != *session
11373            || receipt.lineage.source_provider != current.provider
11374            || receipt.source_message_count != loaded.message_count
11375        {
11376            return Err(failure(
11377                NodeFailureCode::SessionRecordConflict,
11378                "managed session source changed before context catalog commit",
11379            ));
11380        }
11381        self.commit_context_pack_durably(&pack)?;
11382        self.context_catalog
11383            .write()
11384            .unwrap_or_else(|poisoned| poisoned.into_inner())
11385            .insert(pack)
11386            .map_err(|_| {
11387                failure(
11388                    NodeFailureCode::ContextPackMaterializationFailed,
11389                    "context pack catalog is full",
11390                )
11391            })
11392    }
11393
11394    async fn context_pack_repository(
11395        &self,
11396        workspace_id: &WorkspaceId,
11397    ) -> Result<ContextPackRepository, NodeFailure> {
11398        let first = self
11399            .context_pack_repository_observation(workspace_id)
11400            .await?;
11401        let second = self
11402            .context_pack_repository_observation(workspace_id)
11403            .await?;
11404        stable_context_pack_repository(first, second)
11405    }
11406
11407    async fn context_pack_repository_observation(
11408        &self,
11409        workspace_id: &WorkspaceId,
11410    ) -> Result<ContextPackRepositoryObservation, NodeFailure> {
11411        let git = self.inspect_workspace(workspace_id.clone()).await?.git;
11412        let head = if git.is_repository {
11413            let root = self.workspace_root(workspace_id)?;
11414            match resolve_base_commit_with_timeout(
11415                &root,
11416                "HEAD",
11417                GIT_COMMAND_TIMEOUT_MS,
11418            )
11419            .await
11420            {
11421                Ok(commit) => ContextPackRepositoryHead::Commit(commit),
11422                Err(error) if error.kind == GitWorktreeErrorKind::Conflict => {
11423                    ContextPackRepositoryHead::Unborn
11424                }
11425                Err(_) => {
11426                    return Err(failure(
11427                        NodeFailureCode::ContextPackMaterializationFailed,
11428                        "context pack HEAD observation failed",
11429                    ));
11430                }
11431            }
11432        } else {
11433            ContextPackRepositoryHead::NotRepository
11434        };
11435        let mut files = Vec::with_capacity(CONTEXT_PACK_SELECTED_FILES.len());
11436        for selected_path in CONTEXT_PACK_SELECTED_FILES {
11437            let path = RepositoryPath::utf8(selected_path.to_owned()).map_err(|_| {
11438                failure(
11439                    NodeFailureCode::InvalidRepositoryPath,
11440                    "context pack selected file path is invalid",
11441                )
11442            })?;
11443            match self.read_workspace_file(workspace_id.clone(), path).await {
11444                Ok(WorkspaceFileRead {
11445                    content: WorkspaceFileContent::Utf8 { text, byte_len },
11446                    ..
11447                }) => files.push(ContextPackRepositoryFileSource::utf8(
11448                    selected_path,
11449                    text,
11450                    byte_len,
11451                )),
11452                Ok(WorkspaceFileRead {
11453                    content: WorkspaceFileContent::NonUtf8 { byte_len },
11454                    ..
11455                }) => files.push(ContextPackRepositoryFileSource::skipped(
11456                    selected_path,
11457                    ContextPackSelectedFileSkipReason::NonUtf8,
11458                    Some(byte_len),
11459                )),
11460                Ok(WorkspaceFileRead {
11461                    content: WorkspaceFileContent::TooLarge { .. },
11462                    ..
11463                }) => files.push(ContextPackRepositoryFileSource::skipped(
11464                    selected_path,
11465                    ContextPackSelectedFileSkipReason::TooLarge,
11466                    None,
11467                )),
11468                Err(error) => {
11469                    let reason = if matches!(
11470                        error.code,
11471                        NodeFailureCode::InvalidRepositoryPath
11472                            | NodeFailureCode::RepositoryFileNotRegular
11473                            | NodeFailureCode::RepositoryPathUnsafe
11474                    ) {
11475                        ContextPackSelectedFileSkipReason::Unsafe
11476                    } else {
11477                        ContextPackSelectedFileSkipReason::Unavailable
11478                    };
11479                    files.push(ContextPackRepositoryFileSource::skipped(
11480                        selected_path,
11481                        reason,
11482                        None,
11483                    ));
11484                }
11485            }
11486        }
11487        Ok(ContextPackRepositoryObservation { head, git, files })
11488    }
11489
11490    fn forget_context_pack(&self, context_id: &SpawnContextId) -> Result<(), NodeFailure> {
11491        let referenced_by_binding = self
11492            .session_bindings
11493            .lock()
11494            .unwrap_or_else(|poisoned| poisoned.into_inner())
11495            .values()
11496            .any(|binding| {
11497                binding.context.as_ref().map(|receipt| &receipt.id) == Some(context_id)
11498            });
11499        let referenced_by_record = self
11500            .session_records
11501            .lock()
11502            .unwrap_or_else(|poisoned| poisoned.into_inner())
11503            .records
11504            .values()
11505            .any(|record| record.context_id.as_ref() == Some(context_id));
11506        let referenced_by_materialization = self
11507            .materializations
11508            .lock()
11509            .unwrap_or_else(|poisoned| poisoned.into_inner())
11510            .values()
11511            .any(|ownership| {
11512                ownership.context().map(|receipt| &receipt.id) == Some(context_id)
11513            });
11514        if referenced_by_binding
11515            || referenced_by_record
11516            || referenced_by_materialization
11517        {
11518            return Err(failure(
11519                NodeFailureCode::ContextPackBusy,
11520                "context pack is still referenced",
11521            ));
11522        }
11523        let mut catalog = self.context_catalog
11524            .write()
11525            .unwrap_or_else(|poisoned| poisoned.into_inner());
11526        if catalog.get(context_id).is_none() {
11527            return Err(failure(
11528                NodeFailureCode::UnknownContextPack,
11529                "context pack is unavailable",
11530            ));
11531        }
11532        self.spawn_idempotency
11533            .lock()
11534            .unwrap_or_else(|poisoned| poisoned.into_inner())
11535            .entries
11536            .retain(|_, entry| !entry.value.references_context(context_id));
11537        let removed = catalog.remove(context_id);
11538        debug_assert!(removed.is_some(), "validated context pack remains present");
11539        Ok(())
11540    }
11541
11542    async fn dispatch_input_bounded(
11543        &self,
11544        address: &SessionAddress,
11545        action: InputAction,
11546    ) -> Result<(), NodeFailure> {
11547        let started_after = self.current_sequence();
11548        let command_id = self
11549            .dispatch_bounded(
11550                ControlCommand::SendInput {
11551                    instance_id: address.session.instance_id,
11552                    action,
11553                },
11554                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
11555            )
11556            .await?;
11557        // A semantic prompt may spend the provider's full readiness budget before
11558        // the runtime emits InputCompleted. Keep the node request deadline bounded,
11559        // but strictly beyond every readiness policy in this node's catalog.
11560        let deadline = Instant::now() + Duration::from_millis(self.input_settle_timeout_ms);
11561        let mut scan_after = started_after;
11562        let mut accepted_after = None;
11563        loop {
11564            let events = self
11565                .history
11566                .lock()
11567                .unwrap_or_else(|poisoned| poisoned.into_inner())
11568                .events
11569                .iter()
11570                .filter(|envelope| envelope.sequence > scan_after)
11571                .cloned()
11572                .collect::<Vec<_>>();
11573            for envelope in events {
11574                scan_after = scan_after.max(envelope.sequence);
11575                let NodeEvent::Control {
11576                    address: event_address,
11577                    event,
11578                } = envelope.event
11579                else {
11580                    continue;
11581                };
11582                if event_address != *address {
11583                    continue;
11584                }
11585                if event.command_id == Some(command_id) {
11586                    match &event.event {
11587                        ControlEventKind::CommandRejected { message } => {
11588                            return Err(failure(NodeFailureCode::InvalidRequest, message));
11589                        }
11590                        ControlEventKind::InputRequested { .. } => {
11591                            accepted_after = Some(envelope.sequence);
11592                            continue;
11593                        }
11594                        _ => {}
11595                    }
11596                }
11597                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
11598                    match &event.event {
11599                        ControlEventKind::InputCompleted { .. } => return Ok(()),
11600                        ControlEventKind::InputFailed { message, .. } => {
11601                            return Err(failure(
11602                                NodeFailureCode::BackendOperationFailed,
11603                                message,
11604                            ));
11605                        }
11606                        _ => {}
11607                    }
11608                }
11609            }
11610            if Instant::now() >= deadline {
11611                return Err(failure(
11612                    NodeFailureCode::BackendBusy,
11613                    if accepted_after.is_some() {
11614                        "input was accepted but did not settle before the bounded deadline"
11615                    } else {
11616                        "input was not accepted before the bounded deadline"
11617                    },
11618                ));
11619            }
11620            sleep(Duration::from_millis(2)).await;
11621        }
11622    }
11623
11624    async fn dispatch_resize_bounded(
11625        &self,
11626        address: &SessionAddress,
11627        size: gate4agent_types::TerminalSize,
11628    ) -> Result<(), NodeFailure> {
11629        let started_after = self.current_sequence();
11630        let command_id = self
11631            .dispatch_bounded(
11632                ControlCommand::Resize {
11633                    instance_id: address.session.instance_id,
11634                    size,
11635                },
11636                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
11637            )
11638            .await?;
11639        let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
11640        let mut scan_after = started_after;
11641        let mut accepted_after = None;
11642        loop {
11643            let events = self
11644                .history
11645                .lock()
11646                .unwrap_or_else(|poisoned| poisoned.into_inner())
11647                .events
11648                .iter()
11649                .filter(|envelope| envelope.sequence > scan_after)
11650                .cloned()
11651                .collect::<Vec<_>>();
11652            for envelope in events {
11653                scan_after = scan_after.max(envelope.sequence);
11654                let NodeEvent::Control {
11655                    address: event_address,
11656                    event,
11657                } = envelope.event
11658                else {
11659                    continue;
11660                };
11661                if event_address != *address {
11662                    continue;
11663                }
11664                if event.command_id == Some(command_id) {
11665                    match &event.event {
11666                        ControlEventKind::CommandRejected { message } => {
11667                            return Err(failure(NodeFailureCode::InvalidRequest, message));
11668                        }
11669                        ControlEventKind::ResizeRequested {
11670                            size: requested_size,
11671                            ..
11672                        } if *requested_size == size => {
11673                            accepted_after = Some(envelope.sequence);
11674                            continue;
11675                        }
11676                        _ => {}
11677                    }
11678                }
11679                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
11680                    match &event.event {
11681                        ControlEventKind::Resized { size: settled_size }
11682                            if *settled_size == size =>
11683                        {
11684                            return Ok(());
11685                        }
11686                        ControlEventKind::ResizeFailed { message } => {
11687                            return Err(failure(
11688                                NodeFailureCode::BackendOperationFailed,
11689                                message,
11690                            ));
11691                        }
11692                        _ => {}
11693                    }
11694                }
11695            }
11696            if Instant::now() >= deadline {
11697                return Err(failure(
11698                    NodeFailureCode::BackendBusy,
11699                    if accepted_after.is_some() {
11700                        "resize was accepted but did not settle before the bounded deadline"
11701                    } else {
11702                        "resize was not accepted before the bounded deadline"
11703                    },
11704                ));
11705            }
11706            sleep(Duration::from_millis(2)).await;
11707        }
11708    }
11709
11710    async fn spawn_session(
11711        &self,
11712        workspace_id: WorkspaceId,
11713        provider: AgentId,
11714        mode: SessionMode,
11715        terminal_size: gate4agent_types::TerminalSize,
11716        initial_prompt: Option<String>,
11717    ) -> Result<SessionAddress, NodeFailure> {
11718        if self.managed_worktrees.lock()
11719            .unwrap_or_else(|poisoned| poisoned.into_inner())
11720            .lease_for_workspace(&workspace_id)
11721            .is_some()
11722        {
11723            return Err(failure(
11724                NodeFailureCode::ManagedWorktreeOwnershipConflict,
11725                "managed worktree targets may only be spawned through their lease request",
11726            ));
11727        }
11728        self
11729            .spawn_session_with_deadline(
11730                workspace_id,
11731                provider,
11732                mode,
11733                terminal_size,
11734                initial_prompt,
11735                None,
11736                None,
11737                None,
11738                None,
11739                None,
11740                SpawnRecordPolicy::ProviderIdentityOnly,
11741                None,
11742                &[],
11743                None,
11744                // `NodeRequest::Spawn` (this method's only caller) carries no
11745                // approval-level selection of its own -- see
11746                // `HarnessOperatorRequestV1::SpawnSession`'s own field for
11747                // the one operator-wire path that can choose something other
11748                // than the axis default.
11749                ApprovalLevel::default(),
11750                None,
11751                None,
11752            )
11753            .await
11754            .map(|(session, _runtime_policy)| session)
11755    }
11756
11757    async fn spawn_session_with_deadline(
11758        &self,
11759        workspace_id: WorkspaceId,
11760        provider: AgentId,
11761        mode: SessionMode,
11762        terminal_size: gate4agent_types::TerminalSize,
11763        initial_prompt: Option<String>,
11764        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
11765        bundle: Option<ResolvedBundleReceipt>,
11766        context: Option<ResolvedContextPackReceipt>,
11767        managed_authority: Option<ManagedWorktreeLeaseId>,
11768        admitted_runtime_policy: Option<ProviderRuntimePolicy>,
11769        record_policy: SpawnRecordPolicy,
11770        deadline: Option<Instant>,
11771        required_capabilities: &[ProviderRuntimeCapability],
11772        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
11773        approval_level: ApprovalLevel,
11774        network_allowlist: Option<crate::protocol::SpawnNetworkAllowlistId>,
11775        // Opaque dig2browser station profile id (ids only). Feature
11776        // `dig2-station-probe`: after probe at resolve, spawn takes an
11777        // exclusive node-local lease; cleared on session end. Never
11778        // ImportSession / cookies on C2. Feature off: ignored (stub echo
11779        // already happened on the receipt path).
11780        browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
11781    ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
11782        let reserved_lease = self.managed_worktrees.lock()
11783            .unwrap_or_else(|poisoned| poisoned.into_inner())
11784            .lease_for_workspace(&workspace_id);
11785        match (reserved_lease.as_ref(), managed_authority.as_ref()) {
11786            (Some(expected), Some(provided)) if expected == provided => {}
11787            (None, None) => {}
11788            _ => {
11789                return Err(failure(
11790                    NodeFailureCode::ManagedWorktreeOwnershipConflict,
11791                    "spawn lacks exact managed worktree lease authority",
11792                ));
11793            }
11794        }
11795        // Refuse concurrent distinct environment profiles (incl. ProviderHome
11796        // bindings) on the same workspace cwd. Parallel path: managed worktree
11797        // spawn so each profile gets its own workspace_id.
11798        self.ensure_workspace_environment_profile_compatible(
11799            &workspace_id,
11800            environment_profile.as_ref(),
11801        )?;
11802        let runtime_requirement = match (mode, initial_prompt.is_some()) {
11803            (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
11804            (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
11805            (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
11806            (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
11807        };
11808        // This session's own transport -- computed once, here, from `mode`,
11809        // and reused both to admit spawn (below) and, further down, to
11810        // compute the final `runtime_policy` this session actually operates
11811        // under (`provider_runtime::policy_for_transport`). Registration
11812        // reuses this exact value rather than recomputing it a second time.
11813        let transport = match mode {
11814            SessionMode::Pty => TransportKind::Pty,
11815            SessionMode::Inline => TransportKind::Pipe,
11816            SessionMode::Acp => TransportKind::Acp,
11817        };
11818        let pipe_transport = self.provider_supports_pipe_transport(&provider);
11819        let acp_transport = self.provider_supports_acp_transport(&provider);
11820        let runtime_policy = if let Some(runtime_policy) = admitted_runtime_policy {
11821            require_policy(runtime_policy, pipe_transport, acp_transport, runtime_requirement).map_err(|_| failure(
11822                NodeFailureCode::UnsupportedSpawnCapability,
11823                "pre-admitted provider runtime no longer satisfies spawn requirements",
11824            ))?;
11825            runtime_policy
11826        } else if let Some(deadline) = deadline {
11827            let remaining = spawn_deadline_remaining(deadline)?;
11828            timeout(
11829                remaining,
11830                self.admit_provider_runtime(&provider, runtime_requirement),
11831            )
11832            .await
11833            .map_err(|_| {
11834                failure(
11835                    NodeFailureCode::SpawnDeadlineExceeded,
11836                    "provider admission exceeded the spawn deadline",
11837                )
11838            })??
11839        } else {
11840            self.admit_provider_runtime(&provider, runtime_requirement).await?
11841        };
11842        let runtime_policy = self.effective_spawn_runtime_policy(runtime_policy);
11843        // The single point where this session's `runtime_policy` becomes
11844        // transport-correct: `admit_provider_runtime` (and the pre-admitted
11845        // branch above) only ever compute a PTY-terminal-verification-shaped
11846        // value, sound outright only for `TransportKind::Pty` (Pipe still
11847        // relies on that same value downstream -- see `policy_for_transport`'s
11848        // own doc for why). `policy_for_transport` is the explicit,
11849        // exhaustive match Item 1 asks for -- this is the ONE place its
11850        // output becomes the session's `runtime_policy`, so every later
11851        // reader (`ControlCommand::Start`, `session_bindings`, and every
11852        // engine gate that reads `state.runtime_policy`) sees the same,
11853        // correctly-computed value.
11854        let runtime_policy =
11855            crate::provider_runtime::policy_for_transport(transport, runtime_policy);
11856        if required_capabilities
11857            .iter()
11858            .any(|capability| !runtime_policy.admits(*capability))
11859        {
11860            return Err(failure(
11861                NodeFailureCode::UnsupportedSpawnCapability,
11862                "provider runtime does not admit a required spawn capability",
11863            ));
11864        }
11865        if let Some(deadline) = deadline {
11866            spawn_deadline_remaining(deadline)?;
11867        }
11868        self.ensure_binding_capacity()?;
11869        let working_directory = self.workspace_root(&workspace_id)?;
11870        let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
11871        let session = SessionKey {
11872            instance_id,
11873            generation: SessionGeneration(1),
11874        };
11875        let address = SessionAddress {
11876            workspace_id: workspace_id.clone(),
11877            session,
11878        };
11879        // This is the request-accepted checkpoint: admission (managed
11880        // worktree authority, runtime capability, dispatch deadline) already
11881        // passed and a concrete session identity is minted. It is a distinct
11882        // state from the process actually running (`ControlEventKind::Running`,
11883        // logged in `publish_control`) or the terminal outcome (`Exited`/
11884        // `Failed`, logged there too) -- this line exists so that gap is no
11885        // longer silent.
11886        tracing::info!(
11887            node_id = %self.node_id,
11888            workspace_id = %workspace_id,
11889            provider = %provider,
11890            environment_profile = ?environment_profile.as_ref().map(|receipt| &receipt.profile_id),
11891            session = ?address.session,
11892            "session spawn request accepted",
11893        );
11894        // Track A follow-on: exclusive node-local BrowserStationLease after
11895        // dig2 probe (feature dig2-station-probe). Opaque profile id only —
11896        // never ImportSession / cookies on C2. Linux/feature-off: probe
11897        // already refused or stub; this block is cfg'd out when feature off.
11898        #[cfg(feature = "dig2-station-probe")]
11899        let mut browser_station_lease_guard = if let Some(profile_id) = browser_profile_id {
11900            use crate::dig2_station_lease::{acquire_lease, BrowserStationLeaseError};
11901            match acquire_lease(
11902                &self.browser_station_leases,
11903                profile_id,
11904                instance_id,
11905                unix_time_ms(),
11906            ) {
11907                Ok(guard) => Some(guard),
11908                Err(BrowserStationLeaseError::ProfileBusy { .. }) => {
11909                    return Err(failure(
11910                        NodeFailureCode::BrowserStationProfileBusy,
11911                        "dig2browser station profile is already leased by another session",
11912                    ));
11913                }
11914                Err(BrowserStationLeaseError::CapacityExceeded) => {
11915                    return Err(failure(
11916                        NodeFailureCode::BrowserStationProfileBusy,
11917                        "dig2browser station profile lease capacity exhausted",
11918                    ));
11919                }
11920                Err(BrowserStationLeaseError::HolderAlreadyLeased) => {
11921                    return Err(failure(
11922                        NodeFailureCode::BrowserStationProfileBusy,
11923                        "session already holds a dig2browser station profile lease",
11924                    ));
11925                }
11926            }
11927        } else {
11928            None
11929        };
11930        #[cfg(not(feature = "dig2-station-probe"))]
11931        let _ = browser_profile_id;
11932        let mut harness_mcp_overlay = if let Some(prepared) = harness_mcp {
11933            // Slice A(ii) of gate4agent-arc-mailbox-and-task-layer: the door
11934            // exists for PTY (env vars) and ACP (`session/new.mcpServers`,
11935            // `gate4agent-shell-native`'s `mcp_server_acp_entry`) alike --
11936            // both read the identical overlay resolved below, one as child
11937            // environment, the other translated into one stdio MCP server
11938            // entry. `Inline`/`Pipe` stays refused: this arc's proof is ACP
11939            // only, and nothing downstream reads a harness-MCP overlay for
11940            // that transport yet.
11941            if !matches!(mode, SessionMode::Pty | SessionMode::Acp) || prepared.provider != provider
11942            {
11943                return Err(failure(
11944                    NodeFailureCode::BindingMismatch,
11945                    "harness MCP reservation does not match the exact PTY/ACP provider",
11946                ));
11947            }
11948            prepared.verify_helper_program().map_err(harness_mcp_failure)?;
11949            let control = self.native_launch_profile_control.clone().ok_or_else(|| {
11950                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP launch control is unavailable")
11951            })?;
11952            // Every name below is the caller's, carried in the reservation's
11953            // launch description: which environment variables receive the
11954            // endpoint, token and helper program, what the MCP server is called,
11955            // which argv it gets and which stale variables are erased.
11956            let launch = prepared.launch();
11957            let mut env_entries = vec![
11958                (
11959                    OsString::from(launch.endpoint_env.as_str()),
11960                    prepared.endpoint().as_os_str().to_os_string(),
11961                ),
11962                (
11963                    OsString::from(launch.token_env.as_str()),
11964                    prepared.token().expose().into(),
11965                ),
11966                (
11967                    OsString::from(launch.program_env.as_str()),
11968                    prepared.helper_program().as_os_str().to_os_string(),
11969                ),
11970            ];
11971            if let Some(trace_entry) = harness_mcp_trace_env_entry(launch) {
11972                env_entries.push(trace_entry);
11973            }
11974            let overlay = NativeMcpServerLaunchOverlay::new(
11975                provider.clone(),
11976                launch.server_name.as_str(),
11977                prepared.helper_program().as_os_str().to_os_string(),
11978                launch.args.iter().map(OsString::from).collect(),
11979                env_entries,
11980                launch.scrub_env.iter().map(OsString::from).collect(),
11981            ).map_err(|_| failure(
11982                NodeFailureCode::HarnessMcpUnavailable,
11983                "harness MCP launch overlay is unavailable",
11984            ))?;
11985            control
11986                .install_native_mcp_server_launch_overlay(instance_id, overlay)
11987                .map_err(|_| failure(
11988                    NodeFailureCode::HarnessMcpUnavailable,
11989                    "harness MCP launch overlay could not be installed",
11990                ))?;
11991            // State-change line: the overlay is installed and will be read
11992            // back by the PTY child's environment or, for ACP,
11993            // `gate4agent-shell-native`'s `mcp_server_acp_entry` at spawn
11994            // time -- no secrets here, only which instance/provider/mode
11995            // now carries a harness-MCP door.
11996            tracing::info!(
11997                node_id = %self.node_id,
11998                instance_id = ?instance_id,
11999                provider = %provider,
12000                mode = ?mode,
12001                "harness MCP overlay installed for spawn",
12002            );
12003            Some(NativeHarnessMcpOverlayGuard { control, instance_id })
12004        } else {
12005            None
12006        };
12007        let prepared_materialization = self.prepare_session_materialization(
12008            &address,
12009            &provider,
12010            mode,
12011            environment_profile.as_ref(),
12012            bundle.as_ref(),
12013            context.as_ref(),
12014            managed_authority.clone(),
12015        )?;
12016        let (environment_overlay, mut materialization_guard) = match prepared_materialization {
12017            Some((overlay, guard)) => (Some(overlay), Some(guard)),
12018            None => (None, None),
12019        };
12020        let materialization_id = materialization_guard
12021            .as_ref()
12022            .and_then(SessionMaterializationGuard::id)
12023            .cloned();
12024        let mut environment_selection = self.select_environment_profile(
12025            instance_id,
12026            &provider,
12027            mode,
12028            environment_profile.as_ref(),
12029        )?;
12030        // Station network overlays: Codex `-c network_access` and/or Claude
12031        // ProviderHome `--settings` sidecar (permits → allowedDomains).
12032        let provider_home = materialization_guard
12033            .as_ref()
12034            .and_then(SessionMaterializationGuard::provider_home);
12035        let network_overlay_args = self.resolve_network_allowlist_overlay_args(
12036            &provider,
12037            mode,
12038            approval_level,
12039            network_allowlist.as_ref(),
12040            provider_home,
12041        )?;
12042        let environment_overlay = match (environment_overlay.flatten(), network_overlay_args) {
12043            (prepared, args) if args.is_empty() => prepared,
12044            (Some(PreparedNativeLaunchOverlay::Instance(mut overlay)), args) => {
12045                overlay.append_extra_args(args).map_err(|_| {
12046                    failure(
12047                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
12048                        "network allowlist overlay could not merge with instance launch args",
12049                    )
12050                })?;
12051                Some(PreparedNativeLaunchOverlay::Instance(overlay))
12052            }
12053            (Some(PreparedNativeLaunchOverlay::Environment(overlay)), args) => {
12054                Some(PreparedNativeLaunchOverlay::Instance(
12055                    overlay
12056                        .into_instance_with_extra_args(args)
12057                        .map_err(|_| {
12058                            failure(
12059                                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
12060                                "network allowlist overlay could not merge with environment overlay",
12061                            )
12062                        })?,
12063                ))
12064            }
12065            (None, args) => Some(PreparedNativeLaunchOverlay::Instance(
12066                NativeInstanceLaunchOverlay::new(
12067                    provider.clone(),
12068                    match mode {
12069                        SessionMode::Pty => TransportKind::Pty,
12070                        SessionMode::Acp => TransportKind::Acp,
12071                        SessionMode::Inline => TransportKind::Pipe,
12072                    },
12073                    Vec::new(),
12074                    args,
12075                )
12076                .map_err(|_| {
12077                    failure(
12078                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
12079                        "network allowlist overlay is invalid for this transport",
12080                    )
12081                })?,
12082            )),
12083        };
12084        let mut instance_overlay = environment_overlay
12085            .map(|overlay| self.install_prepared_launch_overlay(instance_id, overlay))
12086            .transpose()?
12087            .flatten();
12088        let dispatch_timeout = spawn_dispatch_timeout(deadline)?;
12089        let record_id = self.bind_spawn_session_with_materialization(
12090            &address,
12091            provider.clone(),
12092            mode,
12093            runtime_policy,
12094            record_policy,
12095            environment_profile,
12096            bundle,
12097            context,
12098            materialization_id,
12099        )?;
12100        if let Some(selection) = environment_selection.take() {
12101            selection.retain();
12102        }
12103        if let Some(overlay) = instance_overlay.take() {
12104            overlay.retain();
12105        }
12106        if let Some(guard) = materialization_guard.take() {
12107            guard.retain();
12108        }
12109        // `transport` was already computed above, alongside this session's
12110        // `runtime_policy`; reused here for `Register` rather than derived a
12111        // second time.
12112        let agent_id = provider;
12113        let register_command_id = match self.dispatch_bounded(
12114            ControlCommand::Register {
12115                instance_id,
12116                agent_id,
12117                transport,
12118            },
12119            dispatch_timeout,
12120        )
12121        .await {
12122            Ok(command_id) => command_id,
12123            Err(error) => {
12124                if let Some(binding) = self.remove_binding(&address) {
12125                    self.cleanup_session_owned_materialization(&address, &binding)?;
12126                }
12127                if let Some(record_id) = record_id.as_ref() {
12128                    self.discard_record(record_id)?;
12129                }
12130                return Err(spawn_dispatch_error(error, deadline));
12131            }
12132        };
12133        let start_timeout = match spawn_dispatch_timeout(deadline) {
12134            Ok(timeout) => timeout,
12135            Err(error) => {
12136                let recovery = self
12137                    .rollback_spawn(
12138                        &address,
12139                        Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12140                    )
12141                    .await;
12142                return match recovery {
12143                    Ok(()) => {
12144                        if let Some(record_id) = record_id.as_ref() {
12145                            self.discard_record(record_id)?;
12146                        }
12147                        Err(error)
12148                    }
12149                    Err(recovery_error) => {
12150                        if let Some(record_id) = record_id.as_ref() {
12151                            self.mark_record_error(
12152                                record_id,
12153                                "spawn deadline elapsed and registration recovery failed",
12154                            )?;
12155                        }
12156                        Err(recovery_error)
12157                    }
12158                };
12159            }
12160        };
12161        if let Err(start_error) = self.verify_harness_mcp_before_start(harness_mcp) {
12162            if let Err(recovery_error) = self
12163                .rollback_spawn(&address, Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS))
12164                .await
12165            {
12166                return Err(recovery_error);
12167            }
12168            if let Some(record_id) = record_id.as_ref() {
12169                self.discard_record(record_id)?;
12170            }
12171            return Err(start_error);
12172        }
12173        let start_result = self
12174            .dispatch_bounded(
12175                ControlCommand::Start {
12176                    instance_id,
12177                    runtime_policy,
12178                    request: StartRequest {
12179                        working_directory,
12180                        terminal_size,
12181                        initial_prompt,
12182                        session_options: None,
12183                        approval_level,
12184                    },
12185                },
12186                start_timeout,
12187            )
12188            .await;
12189        let start_command_id = match start_result {
12190            Ok(command_id) => command_id,
12191            Err(start_error) => {
12192                let start_error = spawn_dispatch_error(start_error, deadline);
12193                let recovery = self
12194                    .rollback_spawn(
12195                        &address,
12196                        Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12197                    )
12198                    .await;
12199                return match recovery {
12200                    Ok(()) => {
12201                        if let Some(record_id) = record_id.as_ref() {
12202                            self.discard_record(record_id)?;
12203                        }
12204                        Err(start_error)
12205                    }
12206                    Err(recovery_error) => {
12207                        let diagnostic = format!(
12208                            "start failed and registration recovery failed: {}",
12209                            recovery_error.message,
12210                        );
12211                        if let Some(record_id) = record_id.as_ref() {
12212                            self.mark_record_error(record_id, &diagnostic)?;
12213                        }
12214                        Err(failure(recovery_error.code, &diagnostic))
12215                    }
12216                };
12217            }
12218        };
12219
12220        let commit_deadline = deadline.unwrap_or_else(|| Instant::now() + dispatch_timeout);
12221        loop {
12222            // `Register`/`Start` only fail synchronously for a port-level
12223            // ingress problem (see `dispatch_bounded`); the kernel refusing
12224            // either command's content -- an unsupported transport, an
12225            // unknown agent -- lands one drive-loop tick later, and no
12226            // session ever appears in `self.handle.snapshot()` for it to
12227            // notice below. Check for that named reason before falling
12228            // through to the blind presence poll, so a rejected spawn
12229            // reports why instead of just timing out.
12230            if let Some(reason) = self.take_rejected_command_reason(register_command_id)
12231                .or_else(|| self.take_rejected_command_reason(start_command_id))
12232            {
12233                self.rollback_spawn(
12234                    &address,
12235                    Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12236                ).await.map_err(|error| {
12237                        failure(
12238                            error.code,
12239                            &format!(
12240                                "spawn was rejected ({}) and registration recovery failed: {}",
12241                                reason.message, error.message
12242                            ),
12243                        )
12244                    })?;
12245                if let Some(record_id) = record_id.as_ref() {
12246                    self.discard_record(record_id)?;
12247                }
12248                let code = if reason.unsupported_transport {
12249                    NodeFailureCode::UnsupportedTransport
12250                } else {
12251                    NodeFailureCode::BackendOperationFailed
12252                };
12253                return Err(failure(code, &reason.message));
12254            }
12255            if let Some(current) = self
12256                .handle
12257                .snapshot()
12258                .sessions
12259                .iter()
12260                .find(|current| current.instance_id == instance_id)
12261            {
12262                if current.generation != session.generation {
12263                    let divergence = failure(
12264                        NodeFailureCode::StaleGeneration,
12265                        "spawn generation diverged",
12266                    );
12267                    return match self
12268                        .rollback_spawn(
12269                            &address,
12270                            Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12271                        )
12272                        .await
12273                    {
12274                        Ok(()) => {
12275                            if let Some(record_id) = record_id.as_ref() {
12276                                self.discard_record(record_id)?;
12277                            }
12278                            Err(divergence)
12279                        }
12280                        Err(recovery_error) => {
12281                            if let Some(record_id) = record_id.as_ref() {
12282                                self.mark_record_error(
12283                                    record_id,
12284                                    "spawn generation diverged and recovery failed",
12285                                )?;
12286                            }
12287                            Err(recovery_error)
12288                        }
12289                    };
12290                }
12291                if !matches!(current.status, gate4agent_types::SessionStatus::Registered) {
12292                    if let Some(overlay) = harness_mcp_overlay.take() {
12293                        overlay.retain();
12294                    }
12295                    #[cfg(feature = "dig2-station-probe")]
12296                    if let Some(guard) = browser_station_lease_guard.take() {
12297                        guard.retain();
12298                    }
12299                    return Ok((address, runtime_policy));
12300                }
12301            }
12302            if Instant::now() >= commit_deadline {
12303                self.rollback_spawn(
12304                    &address,
12305                    Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12306                ).await.map_err(|error| {
12307                        failure(
12308                            error.code,
12309                            &format!(
12310                                "start did not commit and registration recovery failed: {}",
12311                                error.message
12312                            ),
12313                        )
12314                    })?;
12315                if let Some(record_id) = record_id.as_ref() {
12316                    self.discard_record(record_id)?;
12317                }
12318                return Err(failure(
12319                    if deadline.is_some() {
12320                        NodeFailureCode::SpawnDeadlineExceeded
12321                    } else {
12322                        NodeFailureCode::BackendBusy
12323                    },
12324                    "start did not commit before the bounded deadline; registration was removed",
12325                ));
12326            }
12327            sleep(Duration::from_millis(2)).await;
12328        }
12329    }
12330
12331    fn effective_spawn_runtime_policy(
12332        &self,
12333        runtime_policy: ProviderRuntimePolicy,
12334    ) -> ProviderRuntimePolicy {
12335        #[cfg(feature = "fixture")]
12336        let runtime_policy = if self.fixture_semantic_hook_policy {
12337            ProviderRuntimePolicy::new(true, true, true, true, false, true)
12338                .expect("monitoring Hook fixture policy is internally valid")
12339        } else {
12340            runtime_policy
12341        };
12342        runtime_policy
12343    }
12344
12345    fn verify_harness_mcp_before_start(
12346        &self,
12347        prepared: Option<&PreparedHarnessMcpSpawn>,
12348    ) -> Result<(), NodeFailure> {
12349        prepared
12350            .map(PreparedHarnessMcpSpawn::verify_helper_program)
12351            .transpose()
12352            .map(|_| ())
12353            .map_err(harness_mcp_failure)
12354    }
12355
12356    async fn rollback_spawn(
12357        &self,
12358        address: &SessionAddress,
12359        commit_timeout: Duration,
12360    ) -> Result<(), NodeFailure> {
12361        self.dispatch_bounded(
12362            ControlCommand::Remove {
12363                instance_id: address.session.instance_id,
12364            },
12365            commit_timeout,
12366        )
12367        .await?;
12368        self.wait_until_removed(address, commit_timeout).await?;
12369        if let Some(binding) = self.remove_binding(address) {
12370            self.cleanup_session_owned_materialization(address, &binding)?;
12371        }
12372        Ok(())
12373    }
12374
12375    async fn remove_session(&self, address: &SessionAddress) -> Result<(), NodeFailure> {
12376        let commit_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
12377        self.dispatch_bounded(
12378            ControlCommand::Remove {
12379                instance_id: address.session.instance_id,
12380            },
12381            commit_timeout,
12382        )
12383            .await?;
12384        self.wait_until_removed(address, commit_timeout).await?;
12385        let removed = self.remove_binding(address);
12386        if let Some(binding) = removed.as_ref() {
12387            self.cleanup_session_owned_materialization(address, binding)?;
12388        }
12389        if let Some(lease_id) = removed.and_then(|binding| binding.managed_worktree_lease_id) {
12390            let snapshot = self.managed_worktrees.lock()
12391                .unwrap_or_else(|poisoned| poisoned.into_inner())
12392                .release_session(
12393                    &lease_id,
12394                    self.incarnation_id,
12395                    address.session.instance_id,
12396                    address.session.generation,
12397                    unix_time_ms(),
12398                );
12399            if self.persist_state().is_err() {
12400                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
12401            }
12402            if let Some(snapshot) = snapshot {
12403                self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
12404            }
12405            let _ = self.cleanup_managed_worktree(&lease_id, false).await;
12406        }
12407        Ok(())
12408    }
12409
12410    /// Polls until `address`'s session leaves the engine's own session map,
12411    /// or the bounded deadline expires.
12412    ///
12413    /// A `Remove` sent while the session is not yet in a terminal status
12414    /// (e.g. still `Stopping`, mid-effect right after a `Stop`) is rejected
12415    /// once by the engine (`SessionStatus::allows_remove`) and
12416    /// then discarded -- nothing re-issues it on its own, so the removal
12417    /// that would satisfy this wait never arrives and the caller was
12418    /// guaranteed to time out even though the session settles microseconds
12419    /// later. So this re-dispatches on every poll tick: a session that
12420    /// settles mid-wait -- the common case right after a `Stop` -- still
12421    /// gets removed inside the same bounded deadline.
12422    ///
12423    /// It re-dispatches only once the status will ACCEPT the command. A
12424    /// rejected re-dispatch is not the harmless no-op this loop first
12425    /// assumed: every rejection publishes a `ControlEventKind::Command
12426    /// Rejected` to every subscriber and writes a WARN line. Ungated, one
12427    /// ordinary teardown produced 241 of them in 1.6 seconds -- the whole
12428    /// `Stopping` window at one known-doomed dispatch per 2ms tick, fanned
12429    /// out to the harness and every attached client. Gated, the same
12430    /// teardown sends exactly one `Remove`, at the first tick the engine
12431    /// can act on it.
12432    ///
12433    /// The status is read from the snapshot this loop already takes, so
12434    /// the gate costs nothing extra. It races -- the status can move
12435    /// between the read and the dispatch -- and that is fine: a rare lost
12436    /// race is one rejection, and the next tick retries.
12437    ///
12438    /// Re-dispatch is separately rate-limited to
12439    /// [`REMOVE_REDISPATCH_INTERVAL_MS`], because the status gate alone
12440    /// leaves a second burst behind it. Once the session settles, every
12441    /// 2ms tick sent another `Remove` while the accepted one was still in
12442    /// flight, and each of those landed on an instance the engine had
12443    /// already dropped -- `agent instance … is not registered`, observed
12444    /// live as a tail of rejections immediately after the `session
12445    /// removed` line. The presence poll stays at 2ms so the wait still
12446    /// exits promptly; only the sending slows down.
12447    ///
12448    /// Rate-limited rather than once-only on purpose: the status gate
12449    /// covers the status precondition, but `Gate4AgentEngine::remove` has
12450    /// others (a pending operation, an in-flight capability probe or
12451    /// history request) that clear on their own. A single shot would turn
12452    /// those into a guaranteed `BackendBusy` timeout; at 10Hz they still
12453    /// get their retries, just twenty of them across the deadline instead
12454    /// of a thousand.
12455    async fn wait_until_removed(
12456        &self,
12457        address: &SessionAddress,
12458        commit_timeout: Duration,
12459    ) -> Result<(), NodeFailure> {
12460        let deadline = Instant::now() + commit_timeout;
12461        let mut next_dispatch = Instant::now();
12462        loop {
12463            let snapshot = self.handle.snapshot();
12464            let status = snapshot
12465                .sessions
12466                .iter()
12467                .find(|session| session.instance_id == address.session.instance_id)
12468                .map(|session| session.status.clone());
12469            let Some(status) = status else {
12470                return Ok(());
12471            };
12472            let now = Instant::now();
12473            if now >= deadline {
12474                return Err(failure(
12475                    NodeFailureCode::BackendBusy,
12476                    "remove did not commit before the bounded deadline; session binding was retained",
12477                ));
12478            }
12479            if status.allows_remove() && now >= next_dispatch {
12480                let _ = self.dispatch(ControlCommand::Remove {
12481                    instance_id: address.session.instance_id,
12482                });
12483                next_dispatch = now + Duration::from_millis(REMOVE_REDISPATCH_INTERVAL_MS);
12484            }
12485            sleep(Duration::from_millis(2)).await;
12486        }
12487    }
12488
12489    fn resync(&self, after_sequence: u64) -> NodeResponse {
12490        let history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
12491        let event_sequence = history.last_sequence;
12492        let oldest_available_sequence = history.replay_floor_sequence;
12493        let events = history
12494            .events
12495            .iter()
12496            .filter(|event| event.sequence > after_sequence)
12497            .cloned()
12498            .collect();
12499        drop(history);
12500        NodeResponse::Resync {
12501            event_sequence,
12502            oldest_available_sequence,
12503            snapshot: self.snapshot(),
12504            events,
12505        }
12506    }
12507}
12508
12509/// Coarse label for one `AgentStreamChunkKindV1`, used only by
12510/// `NodeShared::publish_agent_stream_chunk`'s own debug log -- mirrors
12511/// `hatchery-harness-service`'s `agent_stream_event_kind_label` (same
12512/// "name what crossed this boundary" reasoning, one layer upstream of it).
12513fn agent_stream_chunk_kind_label(kind: &AgentStreamChunkKindV1) -> &'static str {
12514    match kind {
12515        AgentStreamChunkKindV1::Text { .. } => "text",
12516        AgentStreamChunkKindV1::Thinking { .. } => "thinking",
12517        AgentStreamChunkKindV1::InteractionPrompt { .. } => "interaction-prompt",
12518        AgentStreamChunkKindV1::ModeCatalog { .. } => "mode-catalog",
12519        AgentStreamChunkKindV1::ConfigOptions { .. } => "config-options",
12520        AgentStreamChunkKindV1::ModelCatalog { .. } => "model-catalog",
12521        AgentStreamChunkKindV1::Blocked { .. } => "blocked",
12522    }
12523}
12524
12525/// Serves this wire on the local endpoint, and -- when `call_home` names a
12526/// relay -- over an outbound connection to it as well.
12527///
12528/// The two live in one future rather than a fourth arm of `run`'s own
12529/// `select!` because they are one concern: this is where the node's wire
12530/// is served, and the direction a given connection was opened in does not
12531/// change what happens on it. It also means the existing graceful drain
12532/// below covers both without being duplicated.
12533///
12534/// Either side finishing ends the pair, which is correct: the listener
12535/// only returns on shutdown or a bind failure, and `call_home_loop` only
12536/// returns on shutdown -- a failed relay connection is retried inside it,
12537/// never propagated out.
12538async fn accept_connections(
12539    endpoint: &str,
12540    call_home: Option<std::net::SocketAddr>,
12541    shared: Arc<NodeShared>,
12542) -> Result<(), NodeServerError> {
12543    let mut connections = JoinSet::new();
12544    let result = match call_home {
12545        None => accept_connections_inner(endpoint, shared, &mut connections).await,
12546        Some(relay) => {
12547            let listener =
12548                accept_connections_inner(endpoint, Arc::clone(&shared), &mut connections);
12549            let dialler = call_home_loop(relay, Arc::clone(&shared));
12550            tokio::pin!(listener);
12551            tokio::pin!(dialler);
12552            tokio::select! {
12553                result = &mut listener => result,
12554                result = &mut dialler => result,
12555            }
12556        }
12557    };
12558    let graceful_deadline = Instant::now() + Duration::from_millis(CONNECTION_SHUTDOWN_GRACE_MS);
12559    while !connections.is_empty() {
12560        let remaining = graceful_deadline.saturating_duration_since(Instant::now());
12561        if remaining.is_zero()
12562            || timeout(remaining, connections.join_next()).await.is_err()
12563        {
12564            break;
12565        }
12566    }
12567    connections.abort_all();
12568    while connections.join_next().await.is_some() {}
12569    result
12570}
12571
12572/// Backoff ladder for a relay that will not answer, capped rather than
12573/// unbounded: a node whose relay is down is not broken and should keep
12574/// trying forever, but it should not spend a core doing it.
12575const CALL_HOME_BACKOFFS_MS: [u64; 5] = [500, 1_000, 2_000, 4_000, 8_000];
12576
12577/// Dials the relay, serves this wire over the connection, and does it
12578/// again when the connection ends.
12579///
12580/// The node is the SERVER on the socket it just opened. That inversion is
12581/// the whole point: `serve_connection` is the same function the local
12582/// listener feeds, and it neither knows nor cares which end placed the
12583/// call. The one thing this direction adds is the preface -- the relay
12584/// accepted a socket and cannot know whose it is, so the node says its
12585/// name before the handshake it would otherwise begin with.
12586///
12587/// Never returns an error. A relay that is down, refusing, or speaking a
12588/// different protocol is a condition to retry, not a reason to take the
12589/// node's local endpoint down with it; the only way out is shutdown.
12590async fn call_home_loop(
12591    relay: std::net::SocketAddr,
12592    shared: Arc<NodeShared>,
12593) -> Result<(), NodeServerError> {
12594    let mut failures = 0_usize;
12595    loop {
12596        if shared.shutdown.load(Ordering::Acquire) {
12597            return Ok(());
12598        }
12599        let permit = tokio::select! {
12600            permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
12601                permit.map_err(|_| NodeServerError::PreauthClosed)?
12602            }
12603            _ = shared.shutdown_notify.notified() => return Ok(()),
12604        };
12605        match dial_relay_and_serve(relay, Arc::clone(&shared), permit).await {
12606            Ok(()) => {
12607                // A clean end is the relay hanging up, which it does on
12608                // its own restart. Reconnect immediately rather than
12609                // serving the backoff a peer that is plainly there.
12610                failures = 0;
12611                tracing::info!(node_id = %shared.node_id, %relay, "call-home connection closed");
12612            }
12613            Err(error) => {
12614                failures = failures.saturating_add(1);
12615                tracing::warn!(
12616                    node_id = %shared.node_id,
12617                    %relay,
12618                    cause = %error,
12619                    attempt = failures,
12620                    "call-home connection failed",
12621                );
12622                let backoff = CALL_HOME_BACKOFFS_MS
12623                    [failures.saturating_sub(1).min(CALL_HOME_BACKOFFS_MS.len() - 1)];
12624                tokio::select! {
12625                    _ = sleep(Duration::from_millis(backoff)) => {}
12626                    _ = shared.shutdown_notify.notified() => return Ok(()),
12627                }
12628            }
12629        }
12630    }
12631}
12632
12633/// One call-home attempt: connect, name this node, then serve.
12634async fn dial_relay_and_serve(
12635    relay: std::net::SocketAddr,
12636    shared: Arc<NodeShared>,
12637    preauth_permit: OwnedSemaphorePermit,
12638) -> Result<(), NodeServerError> {
12639    let mut stream = tokio::net::TcpStream::connect(relay)
12640        .await
12641        .map_err(NodeServerError::CallHomeTransport)?;
12642    // Nagle off for the same reason the rest of this wire wants it off:
12643    // the traffic is small request/response frames, and coalescing them
12644    // adds latency to every one.
12645    let _ = stream.set_nodelay(true);
12646    gate4agent_node_wire::write_call_home_announce(&mut stream, &shared.node_id)
12647        .await
12648        .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
12649    tracing::info!(node_id = %shared.node_id, %relay, "announced this node to the relay");
12650    serve_connection(stream, shared, preauth_permit).await
12651}
12652
12653async fn accept_connections_inner(
12654    endpoint: &str,
12655    shared: Arc<NodeShared>,
12656    connections: &mut JoinSet<Result<(), NodeServerError>>,
12657) -> Result<(), NodeServerError> {
12658    let mut listener = OwnerOnlyLocalListener::bind(endpoint).await?;
12659    loop {
12660        while connections.try_join_next().is_some() {}
12661        if shared.shutdown.load(Ordering::Acquire) {
12662            return Ok(());
12663        }
12664        let preauth_permit = tokio::select! {
12665            permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
12666                permit.map_err(|_| NodeServerError::PreauthClosed)?
12667            }
12668            _ = shared.shutdown_notify.notified() => return Ok(()),
12669        };
12670        let server = tokio::select! {
12671            result = listener.accept() => result?,
12672            _ = shared.shutdown_notify.notified() => return Ok(()),
12673        };
12674        let connection_shared = Arc::clone(&shared);
12675        connections.spawn(async move {
12676            serve_connection(server, connection_shared, preauth_permit).await
12677        });
12678        if shared.shutdown.load(Ordering::Acquire) {
12679            return Ok(());
12680        }
12681    }
12682}
12683
12684/// Serves one connection, whichever end opened it.
12685///
12686/// Generic over the stream rather than typed to `LocalServerStream`
12687/// because a call-home connection arrives on a `TcpStream` the node itself
12688/// dialled. Nothing in the body ever depended on the concrete type -- it
12689/// reads and writes framed JSON and then `tokio::io::split`s -- so this is
12690/// the whole of what welding "who dials" to "who serves" was costing.
12691async fn serve_connection<S>(
12692    mut pipe: S,
12693    shared: Arc<NodeShared>,
12694    preauth_permit: OwnedSemaphorePermit,
12695) -> Result<(), NodeServerError>
12696where
12697    S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
12698{
12699    let hello_frame = timeout(
12700        Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
12701        read_json_frame_limited_body_timeout(
12702            &mut pipe,
12703            MAX_NODE_HELLO_FRAME_BYTES,
12704            Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
12705        ),
12706    )
12707    .await
12708    .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
12709    let ClientFrame::Hello(hello) = hello_frame else {
12710        return Err(NodeServerError::Handshake("first frame must be hello".to_owned()));
12711    };
12712    if hello.build_stamp != BUILD_STAMP {
12713        return Err(NodeServerError::Handshake(format!(
12714            "build stamp mismatch: local={BUILD_STAMP} remote={}",
12715            hello.build_stamp,
12716        )));
12717    }
12718    let compatibility = match hello.compatibility.as_ref() {
12719        Some(offer) => {
12720            let mut selected = node_compatibility_support(&shared)?
12721                .negotiate(offer)
12722                .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
12723            project_negotiated_provider_ids(&mut selected);
12724            Some(selected)
12725        }
12726        None => None,
12727    };
12728    let server_nonce = random_nonce().map_err(NodeServerError::Authentication)?;
12729    let server_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
12730        (Some(offer), Some(selected)) => negotiated_auth_proof(
12731            shared.access_token.as_bytes(),
12732            AuthDirection::Server,
12733            hello.role,
12734            &hello.client_nonce,
12735            &server_nonce,
12736            offer,
12737            selected,
12738        ),
12739        (None, None) => auth_proof(
12740            shared.access_token.as_bytes(),
12741            AuthDirection::Server,
12742            hello.role,
12743            &hello.client_nonce,
12744            &server_nonce,
12745        ),
12746        _ => Err("node compatibility authentication state is inconsistent".to_owned()),
12747    }
12748    .map_err(NodeServerError::Authentication)?;
12749    write_json_frame_limited(
12750        &mut pipe,
12751        &ServerFrame::Challenge(ServerChallenge {
12752            build_stamp: BUILD_STAMP.to_owned(),
12753            server_nonce,
12754            server_proof,
12755            compatibility: compatibility.clone(),
12756        }),
12757        MAX_NODE_HELLO_FRAME_BYTES,
12758    )
12759    .await?;
12760    let authentication = timeout(
12761        Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
12762        read_json_frame_limited_body_timeout(
12763            &mut pipe,
12764            MAX_NODE_HELLO_FRAME_BYTES,
12765            Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
12766        ),
12767    )
12768    .await
12769    .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
12770    let ClientFrame::Authenticate(authentication) = authentication else {
12771        return Err(NodeServerError::Handshake("second frame must authenticate the challenge".to_owned()));
12772    };
12773    let expected_client_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
12774        (Some(offer), Some(selected)) => negotiated_auth_proof(
12775            shared.access_token.as_bytes(),
12776            AuthDirection::Client,
12777            hello.role,
12778            &hello.client_nonce,
12779            &server_nonce,
12780            offer,
12781            selected,
12782        ),
12783        (None, None) => auth_proof(
12784            shared.access_token.as_bytes(),
12785            AuthDirection::Client,
12786            hello.role,
12787            &hello.client_nonce,
12788            &server_nonce,
12789        ),
12790        _ => Err("node compatibility authentication state is inconsistent".to_owned()),
12791    }
12792    .map_err(NodeServerError::Authentication)?;
12793    if !proofs_match(&authentication.client_proof, &expected_client_proof) {
12794        return Err(NodeServerError::Handshake("access denied".to_owned()));
12795    }
12796    let selected_capabilities = compatibility
12797        .as_ref()
12798        .map(|selected| selected.capabilities.clone())
12799        .unwrap_or_default();
12800    let include_provider_runtime_status = selected_capabilities.iter().any(|capability| {
12801        capability.as_str() == NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY
12802    });
12803    let include_open_provider_ids = selected_capabilities.iter().any(|capability| {
12804        capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
12805    });
12806    let include_terminal_frame_events = selected_capabilities.iter().any(|capability| {
12807        capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
12808    });
12809    let include_agent_stream_events = selected_capabilities.iter().any(|capability| {
12810        capability.as_str() == NODE_AGENT_STREAM_EVENTS_CAPABILITY
12811    });
12812    let include_spawn_profiles = selected_capabilities.iter().any(|capability| {
12813        capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
12814    });
12815    let include_managed_worktrees = selected_capabilities.iter().any(|capability| {
12816        capability.as_str() == NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY
12817    }) && selected_capabilities.iter().any(|capability| {
12818        capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
12819    });
12820    let include_child_environment_profiles = selected_capabilities.iter().any(|capability| {
12821        capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
12822    });
12823    let include_session_bundles = selected_capabilities.iter().any(|capability| {
12824        capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
12825    });
12826    let include_history_context_packs = selected_capabilities.iter().any(|capability| {
12827        capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
12828    });
12829    let include_agent_progress = selected_capabilities.iter().any(|capability| {
12830        capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
12831    });
12832    let include_session_task_correlation = selected_capabilities.iter().any(|capability| {
12833        capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
12834    });
12835    let include_harness_mcp_proxy = selected_capabilities.iter().any(|capability| {
12836        capability.as_str() == NODE_HARNESS_MCP_READ_PROXY_CAPABILITY
12837    });
12838    let authenticated_permit = Arc::clone(&shared.authenticated_slots)
12839        .try_acquire_owned()
12840        .map_err(|_| NodeServerError::AuthenticatedConnectionLimit)?;
12841    let connection_id = shared.next_connection_id.fetch_add(1, Ordering::AcqRel);
12842    let _release_guard = ControllerReleaseGuard {
12843        shared: Arc::clone(&shared),
12844        connection_id,
12845    };
12846    drop(preauth_permit);
12847    let _authenticated_permit = authenticated_permit;
12848    let mut event_rx = shared.event_tx.subscribe();
12849    let mut terminal_event_rx = terminal_event_subscription(
12850        &shared,
12851        include_terminal_frame_events,
12852    );
12853    let mut agent_stream_event_rx = agent_stream_event_subscription(
12854        &shared,
12855        include_agent_stream_events,
12856    );
12857    let mut harness_mcp_proxy_event_rx = harness_mcp_proxy_event_subscription(
12858        &shared,
12859        include_harness_mcp_proxy,
12860    );
12861    write_json_frame(
12862        &mut pipe,
12863        &ServerFrame::Hello(NodeHello {
12864            build_stamp: BUILD_STAMP.to_owned(),
12865            incarnation_id: shared.incarnation_id,
12866            connection_id,
12867            role: hello.role,
12868            event_sequence: shared.current_sequence(),
12869            controller: shared.controller_state(),
12870            snapshot: snapshot_for_wire(
12871                &shared,
12872                include_provider_runtime_status,
12873                include_open_provider_ids,
12874                include_spawn_profiles,
12875                include_managed_worktrees,
12876                include_child_environment_profiles,
12877                include_session_bundles,
12878                include_history_context_packs,
12879                include_agent_progress,
12880                include_session_task_correlation,
12881            ),
12882            compatibility,
12883        }),
12884    )
12885    .await?;
12886    let (mut reader, mut writer) = tokio::io::split(pipe);
12887    let (frame_tx, mut frame_rx) = mpsc::channel(16);
12888    let reader_task = tokio::spawn(async move {
12889        loop {
12890            let frame = read_json_frame_limited_body_timeout::<_, ClientFrame>(
12891                &mut reader,
12892                MAX_NODE_CLIENT_FRAME_BYTES,
12893                Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
12894            )
12895            .await;
12896            let terminal = frame.is_err();
12897            if frame_tx.send(frame).await.is_err() || terminal {
12898                break;
12899            }
12900        }
12901    });
12902    let _reader_abort = AbortTaskOnDrop(reader_task.abort_handle());
12903    let mut pending_events = Vec::new();
12904    let mut resync_required = false;
12905    let mut discard_events_through = 0_u64;
12906    loop {
12907        // One tick of the serve loop, whether or not it had anything to do.
12908        // Against `connection_events_sent` this separates "busy" from
12909        // "spinning": a loop turning over thousands of times a second while
12910        // sending nothing is not serving, it is burning a core.
12911        shared.connection_loop_iterations.fetch_add(1, Ordering::Relaxed);
12912        let durable_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
12913            .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
12914        for _ in 0..durable_drain_budget {
12915            match event_rx.try_recv() {
12916                Ok(event) => queue_connection_event(
12917                    &mut pending_events,
12918                    event,
12919                    discard_events_through,
12920                ),
12921                Err(broadcast::error::TryRecvError::Empty) => break,
12922                Err(broadcast::error::TryRecvError::Lagged(_)) => {
12923                    resync_required = true;
12924                    break;
12925                }
12926                Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
12927            }
12928        }
12929        if let Some(receiver) = terminal_event_rx.as_mut() {
12930            match receiver.try_recv() {
12931                Ok(events) => queue_connection_event_batch(
12932                    &mut pending_events,
12933                    &events,
12934                    discard_events_through,
12935                ),
12936                Err(broadcast::error::TryRecvError::Empty) => {}
12937                Err(broadcast::error::TryRecvError::Lagged(_)) => {
12938                    resync_required = true;
12939                }
12940                Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
12941            }
12942        }
12943        if let Some(receiver) = agent_stream_event_rx.as_mut() {
12944            // Loops up to the same remaining-room budget the durable
12945            // `event_rx` drain above uses, rather than one `try_recv` per
12946            // tick: each successful receive here is itself only ONE
12947            // `NodeEventEnvelope` batch (`publish_agent_stream_chunk` always
12948            // sends `vec![envelope]`, never more), so a single-shot drain
12949            // caps this connection's agent-stream throughput at one chunk
12950            // per outer-loop tick regardless of how many are already queued
12951            // -- exactly the kind of self-inflicted stall this crate's own
12952            // durable-event budget exists to avoid.
12953            let agent_stream_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
12954                .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
12955            for _ in 0..agent_stream_drain_budget {
12956                match receiver.try_recv() {
12957                    Ok(events) => queue_connection_event_batch(
12958                        &mut pending_events,
12959                        &events,
12960                        discard_events_through,
12961                    ),
12962                    Err(broadcast::error::TryRecvError::Empty) => break,
12963                    Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
12964                        tracing::warn!(
12965                            connection_id,
12966                            dropped,
12967                            "agent stream event subscription lagged; forcing a resync",
12968                        );
12969                        resync_required = true;
12970                        break;
12971                    }
12972                    Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
12973                }
12974            }
12975        }
12976        if let Some(receiver) = harness_mcp_proxy_event_rx.as_mut() {
12977            // Mirrors the agent-stream drain immediately above: each
12978            // successful receive is one single-envelope batch
12979            // (`publish_transient` always sends `vec![envelope]`), so this
12980            // loops up to the same remaining-room budget rather than
12981            // stopping after one `try_recv`.
12982            let harness_mcp_proxy_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
12983                .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
12984            for _ in 0..harness_mcp_proxy_drain_budget {
12985                match receiver.try_recv() {
12986                    Ok(events) => queue_connection_event_batch(
12987                        &mut pending_events,
12988                        &events,
12989                        discard_events_through,
12990                    ),
12991                    Err(broadcast::error::TryRecvError::Empty) => break,
12992                    Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
12993                        tracing::warn!(
12994                            connection_id,
12995                            dropped,
12996                            "harness MCP proxy event subscription lagged; forcing a resync",
12997                        );
12998                        resync_required = true;
12999                        break;
13000                    }
13001                    Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13002                }
13003            }
13004        }
13005        if resync_required {
13006            let event = resync_required_event(&shared);
13007            apply_connection_resync_watermark(
13008                &mut pending_events,
13009                &mut discard_events_through,
13010                event.sequence,
13011            );
13012            write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
13013            resync_required = false;
13014            continue;
13015        }
13016        if !pending_events.is_empty() {
13017            pending_events.sort_unstable_by_key(|event| event.sequence);
13018            let burst_len = connection_event_burst_len(pending_events.len());
13019            shared
13020                .connection_events_sent
13021                .fetch_add(burst_len as u64, Ordering::Relaxed);
13022            for event in pending_events.drain(..burst_len) {
13023                let event = if include_managed_worktrees {
13024                    Some(event)
13025                } else {
13026                    project_event_without_managed_worktrees(event)
13027                };
13028                let event = event.and_then(|event| if include_open_provider_ids {
13029                    Some(event)
13030                } else {
13031                    project_event_legacy_provider_ids(&shared, event)
13032                });
13033                let event = event.map(|event| if include_child_environment_profiles {
13034                    event
13035                } else {
13036                    project_event_without_child_environment_profile(event)
13037                });
13038                let event = event.map(|event| if include_session_bundles {
13039                    event
13040                } else {
13041                    project_event_without_session_bundle(event)
13042                });
13043                let event = event.map(|event| {
13044                    project_event_history_for_wire(event, include_history_context_packs)
13045                });
13046                let event = event.map(|event| if include_history_context_packs {
13047                    event
13048                } else {
13049                    project_event_without_context_pack(event)
13050                });
13051                let event = event.map(|event| if include_session_task_correlation {
13052                    event
13053                } else {
13054                    project_event_without_session_task_binding(event)
13055                });
13056                let exact_harness_controller = hello.role == ClientRole::Operator
13057                    && shared.controller_state().is_some_and(|controller| {
13058                        controller.connection_id == connection_id
13059                    });
13060                let event = event.and_then(|event| {
13061                    if include_harness_mcp_proxy && exact_harness_controller {
13062                        Some(event)
13063                    } else {
13064                        project_event_without_harness_mcp_proxy(event)
13065                    }
13066                });
13067                if let Some(event) = event {
13068                    write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
13069                }
13070            }
13071        }
13072        tokio::select! {
13073            biased;
13074            frame = frame_rx.recv() => {
13075                let frame = match frame {
13076                    Some(frame) => frame,
13077                    None => break,
13078                };
13079                let frame = match frame {
13080                    Ok(frame) => frame,
13081                    Err(FrameError::Io(error)) if matches!(error.kind(), io::ErrorKind::BrokenPipe | io::ErrorKind::UnexpectedEof) => break,
13082                    Err(error) => return Err(error.into()),
13083                };
13084                let ClientFrame::Request(request) = frame else {
13085                    return Err(NodeServerError::Handshake("hello may only be sent once".to_owned()));
13086                };
13087                #[cfg(feature = "fixture")]
13088                let probe_spawn_managed_worktree_v2 = matches!(
13089                    &request.request,
13090                    NodeRequest::SpawnManagedWorktreeV2 { .. }
13091                );
13092                let requires_open_provider_ids =
13093                    request_requires_open_provider_ids(&shared, &request.request);
13094                let requires_child_environment_profile =
13095                    request_requires_child_environment_profile(&shared, &request.request);
13096                let requires_session_bundle =
13097                    request_requires_session_bundle(&shared, &request.request);
13098                let requires_history_context_pack =
13099                    request_requires_history_context_pack(&shared, &request.request);
13100                let mut reply = if requires_open_provider_ids && !include_open_provider_ids {
13101                    ResponseEnvelope {
13102                        request_id: request.request_id,
13103                        result: Err(failure(
13104                            NodeFailureCode::UnsupportedCapability,
13105                            "open provider IDs were not negotiated",
13106                        )),
13107                    }
13108                } else if requires_child_environment_profile
13109                    && !include_child_environment_profiles
13110                {
13111                    ResponseEnvelope {
13112                        request_id: request.request_id,
13113                        result: Err(failure(
13114                            NodeFailureCode::UnsupportedCapability,
13115                            "child environment profiles were not negotiated",
13116                        )),
13117                    }
13118                } else if requires_session_bundle && !include_session_bundles {
13119                    ResponseEnvelope {
13120                        request_id: request.request_id,
13121                        result: Err(failure(
13122                            NodeFailureCode::UnsupportedCapability,
13123                            "session bundles were not negotiated",
13124                        )),
13125                    }
13126                } else if requires_history_context_pack && !include_history_context_packs {
13127                    ResponseEnvelope {
13128                        request_id: request.request_id,
13129                        result: Err(failure(
13130                            NodeFailureCode::UnsupportedCapability,
13131                            "history context packs were not negotiated",
13132                        )),
13133                    }
13134                } else if request_uses_unnegotiated_capability(
13135                    &request.request,
13136                    &selected_capabilities,
13137                )
13138                {
13139                    ResponseEnvelope {
13140                        request_id: request.request_id,
13141                        result: Err(failure(
13142                            NodeFailureCode::UnsupportedCapability,
13143                            "request capability was not negotiated",
13144                        )),
13145                    }
13146                } else {
13147                    process_request(&shared, connection_id, hello.role, request).await
13148                };
13149                #[cfg(feature = "fixture")]
13150                if probe_spawn_managed_worktree_v2 {
13151                    shared
13152                        .fixture_spawn_managed_worktree_v2_failure_probe
13153                        .record(reply.result.as_ref().err().map(|failure| failure.code));
13154                }
13155                if !include_provider_runtime_status {
13156                    clear_response_provider_runtime_status(&mut reply);
13157                }
13158                if !include_managed_worktrees {
13159                    project_response_without_managed_worktrees(&mut reply);
13160                }
13161                if !include_open_provider_ids {
13162                    project_response_legacy_provider_ids(&shared, &mut reply);
13163                }
13164                if !include_child_environment_profiles {
13165                    project_response_without_child_environment_profile(&mut reply);
13166                }
13167                if !include_session_bundles {
13168                    project_response_without_session_bundle(&mut reply);
13169                }
13170                project_response_history_for_wire(
13171                    &mut reply,
13172                    include_history_context_packs,
13173                );
13174                if !include_history_context_packs {
13175                    project_response_without_context_pack(&mut reply);
13176                }
13177                if !include_agent_progress {
13178                    project_response_without_agent_progress(&mut reply);
13179                }
13180                if !include_session_task_correlation {
13181                    project_response_without_session_task_binding(&mut reply);
13182                }
13183                write_json_frame(&mut writer, &ServerFrame::Reply(reply)).await?;
13184            }
13185            _ = tokio::task::yield_now(), if !pending_events.is_empty() => {}
13186            event = event_rx.recv() => {
13187                match event {
13188                    Ok(event) => queue_connection_event(
13189                        &mut pending_events,
13190                        event,
13191                        discard_events_through,
13192                    ),
13193                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13194                    Err(broadcast::error::RecvError::Closed) => break,
13195                }
13196            }
13197            events = receive_terminal_event_batch(&mut terminal_event_rx) => {
13198                match events {
13199                    Ok(events) => queue_connection_event_batch(
13200                        &mut pending_events,
13201                        &events,
13202                        discard_events_through,
13203                    ),
13204                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13205                    Err(broadcast::error::RecvError::Closed) => break,
13206                }
13207            }
13208            events = receive_terminal_event_batch(&mut agent_stream_event_rx) => {
13209                match events {
13210                    Ok(events) => queue_connection_event_batch(
13211                        &mut pending_events,
13212                        &events,
13213                        discard_events_through,
13214                    ),
13215                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13216                    Err(broadcast::error::RecvError::Closed) => break,
13217                }
13218            }
13219            events = receive_terminal_event_batch(&mut harness_mcp_proxy_event_rx) => {
13220                match events {
13221                    Ok(events) => queue_connection_event_batch(
13222                        &mut pending_events,
13223                        &events,
13224                        discard_events_through,
13225                    ),
13226                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13227                    Err(broadcast::error::RecvError::Closed) => break,
13228                }
13229            }
13230            _ = shared.shutdown_notify.notified() => break,
13231        }
13232    }
13233    Ok(())
13234}
13235
13236async fn receive_terminal_event_batch(
13237    receiver: &mut Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>>,
13238) -> Result<Arc<Vec<NodeEventEnvelope>>, broadcast::error::RecvError> {
13239    match receiver {
13240        Some(receiver) => receiver.recv().await,
13241        None => std::future::pending().await,
13242    }
13243}
13244
13245fn terminal_event_subscription(
13246    shared: &NodeShared,
13247    enabled: bool,
13248) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13249    enabled.then(|| shared.terminal_event_tx.subscribe())
13250}
13251
13252/// Mirrors `terminal_event_subscription` exactly, over the agent stream's
13253/// own channel -- see `NodeShared::agent_stream_event_tx`'s own doc for why
13254/// this needs a dedicated channel rather than a flag on the terminal one.
13255fn agent_stream_event_subscription(
13256    shared: &NodeShared,
13257    enabled: bool,
13258) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13259    enabled.then(|| shared.agent_stream_event_tx.subscribe())
13260}
13261
13262/// Mirrors `agent_stream_event_subscription` exactly, over the harness MCP
13263/// proxy's own channel -- see `NodeShared::harness_mcp_proxy_event_tx`'s
13264/// own doc for why this needs a dedicated channel rather than a ride on
13265/// the durable `event_tx`.
13266fn harness_mcp_proxy_event_subscription(
13267    shared: &NodeShared,
13268    enabled: bool,
13269) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13270    enabled.then(|| shared.harness_mcp_proxy_event_tx.subscribe())
13271}
13272
13273fn queue_connection_event(
13274    pending: &mut Vec<NodeEventEnvelope>,
13275    event: NodeEventEnvelope,
13276    discard_through: u64,
13277) {
13278    if event.sequence <= discard_through
13279        && !event.event.requires_harness_mcp_proxy_capability()
13280    {
13281        return;
13282    }
13283    if let NodeEvent::TerminalFrame { address, .. } = &event.event {
13284        if let Some(index) = pending.iter().position(|current| {
13285            matches!(
13286                &current.event,
13287                NodeEvent::TerminalFrame {
13288                    address: current_address,
13289                    ..
13290                } if current_address == address
13291            )
13292        }) {
13293            pending.remove(index);
13294        }
13295    }
13296    pending.push(event);
13297}
13298
13299fn project_event_without_harness_mcp_proxy(
13300    envelope: NodeEventEnvelope,
13301) -> Option<NodeEventEnvelope> {
13302    (!envelope.event.requires_harness_mcp_proxy_capability()).then_some(envelope)
13303}
13304
13305fn queue_connection_event_batch(
13306    pending: &mut Vec<NodeEventEnvelope>,
13307    events: &[NodeEventEnvelope],
13308    discard_through: u64,
13309) {
13310    for event in events {
13311        queue_connection_event(pending, event.clone(), discard_through);
13312    }
13313}
13314
13315fn apply_connection_resync_watermark(
13316    pending: &mut Vec<NodeEventEnvelope>,
13317    discard_through: &mut u64,
13318    marker_sequence: u64,
13319) {
13320    *discard_through = (*discard_through).max(marker_sequence);
13321    pending.retain(|event| event.sequence > *discard_through);
13322}
13323
13324fn connection_event_burst_len(pending_len: usize) -> usize {
13325    pending_len.min(NODE_CONNECTION_EVENT_BURST_MAX)
13326}
13327
13328fn resync_required_event(shared: &NodeShared) -> NodeEventEnvelope {
13329    let history = shared
13330        .history
13331        .lock()
13332        .unwrap_or_else(|poisoned| poisoned.into_inner());
13333    let sequence = history.last_sequence;
13334    let oldest_available_sequence = history.replay_floor_sequence;
13335    NodeEventEnvelope {
13336        sequence,
13337        event: NodeEvent::ResyncRequired {
13338            oldest_available_sequence,
13339        },
13340    }
13341}
13342
13343fn node_compatibility_support(
13344    shared: &NodeShared,
13345) -> Result<NodeCompatibilitySupport, NodeServerError> {
13346    let mut support = node_compatibility_support_for_manifest(
13347        &shared.provider_contracts,
13348        &shared.provider_adapter_contracts,
13349    )?;
13350    if shared
13351        .delivery_store
13352        .lock()
13353        .unwrap_or_else(|poisoned| poisoned.into_inner())
13354        .is_some()
13355    {
13356        support.capabilities.push(
13357            CapabilityId::new(NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY)
13358                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13359        );
13360    }
13361    if shared.harness_mcp_registry.is_some() {
13362        support.capabilities.push(
13363            CapabilityId::new(NODE_HARNESS_MCP_READ_PROXY_CAPABILITY)
13364                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13365        );
13366    }
13367    Ok(support)
13368}
13369
13370fn node_compatibility_support_for_manifest(
13371    provider_contracts: &[ProviderContractSupport],
13372    provider_adapter_contracts: &[ProviderAdapterContractSupport],
13373) -> Result<NodeCompatibilitySupport, NodeServerError> {
13374    Ok(NodeCompatibilitySupport {
13375        build_stamp: BUILD_STAMP.to_owned(),
13376        capabilities: baseline_capabilities()?,
13377        host: platform::host_descriptor().map_err(NodeServerError::Handshake)?,
13378        path_semantics: platform::path_semantics(),
13379        local_transport: platform::local_transport(),
13380        state_schema: StateSchemaSupport {
13381            versions: ProtocolRange::new(NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10)
13382                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13383        },
13384        provider_contracts: provider_contracts.to_vec(),
13385        provider_adapter_contracts: provider_adapter_contracts.to_vec(),
13386    })
13387}
13388
13389fn baseline_capabilities() -> Result<Vec<CapabilityId>, NodeServerError> {
13390    let capabilities = [
13391        NODE_COMPATIBILITY_METADATA_CAPABILITY,
13392        CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
13393        NODE_REPOSITORY_PATH_CAPABILITY,
13394        NODE_WORKSPACE_FILE_READ_CAPABILITY,
13395        NODE_WORKSPACE_FILE_WRITE_CAPABILITY,
13396        NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
13397        NODE_GIT_READ_CAPABILITY,
13398        NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY,
13399        NODE_PROVIDER_ID_OPEN_CAPABILITY,
13400        NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
13401        NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY,
13402        NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
13403        NODE_AGENT_STREAM_EVENTS_CAPABILITY,
13404        NODE_ACP_CONTROL_CAPABILITY,
13405        NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
13406        NODE_SPAWN_PROFILE_REVISION_CAPABILITY,
13407        NODE_WORKTREE_SELECTION_CAPABILITY,
13408        NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
13409        NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
13410        NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
13411        NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
13412        NODE_HISTORY_CONTEXT_PACK_CAPABILITY,
13413        NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
13414        NODE_NATIVE_SESSION_CATALOG_CAPABILITY,
13415        NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
13416        NODE_NATIVE_SESSION_INDEX_CAPABILITY,
13417        NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
13418        NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
13419        NODE_SESSION_TASK_CORRELATION_CAPABILITY,
13420    ]
13421        .into_iter()
13422        .map(|capability| {
13423            CapabilityId::new(capability)
13424                .map_err(|error| NodeServerError::Handshake(error.to_string()))
13425        })
13426        .collect::<Result<Vec<_>, _>>()?;
13427    #[cfg(windows)]
13428    let capabilities = {
13429        let mut capabilities = capabilities;
13430        capabilities.push(
13431            CapabilityId::new(NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY)
13432                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13433        );
13434        capabilities
13435    };
13436    Ok(capabilities)
13437}
13438
13439fn request_uses_unnegotiated_capability(
13440    request: &NodeRequest,
13441    selected_capabilities: &[CapabilityId],
13442) -> bool {
13443    let selected = |required: &str| {
13444        selected_capabilities
13445            .iter()
13446            .any(|capability| capability.as_str() == required)
13447    };
13448    request
13449        .required_capability()
13450        .is_some_and(|required| !selected(required))
13451        || (request.requires_worktree_selection_capability()
13452            && !selected(NODE_WORKTREE_SELECTION_CAPABILITY))
13453        || (request.requires_spawn_spec_defaults_overrides_capability()
13454            && !selected(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY))
13455        || (request.requires_spawn_profile_revision_capability()
13456            && !selected(NODE_SPAWN_PROFILE_REVISION_CAPABILITY))
13457        || (request.requires_child_environment_profile_capability()
13458            && !selected(NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY))
13459        || (request.requires_session_bundle_materialization_capability()
13460            && !selected(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY))
13461        || (request.requires_history_context_pack_capability()
13462            && !selected(NODE_HISTORY_CONTEXT_PACK_CAPABILITY))
13463}
13464
13465fn snapshot_for_wire(
13466    shared: &NodeShared,
13467    include_provider_runtime_status: bool,
13468    include_open_provider_ids: bool,
13469    include_spawn_profiles: bool,
13470    include_managed_worktrees: bool,
13471    include_child_environment_profiles: bool,
13472    include_session_bundles: bool,
13473    include_history_context_packs: bool,
13474    include_agent_progress: bool,
13475    include_session_task_correlation: bool,
13476) -> NodeSnapshot {
13477    let mut snapshot = shared.snapshot();
13478    if !include_provider_runtime_status {
13479        snapshot.provider_runtime_statuses.clear();
13480    }
13481    if !include_open_provider_ids {
13482        project_snapshot_legacy_provider_ids(&mut snapshot);
13483    }
13484    if !include_managed_worktrees {
13485        snapshot.managed_worktrees.clear();
13486        for workspace in &mut snapshot.workspaces {
13487            workspace.managed_worktree_profiles = None;
13488        }
13489    }
13490    if let Some(inventory) = snapshot.launch_inventory.as_mut() {
13491        if !include_spawn_profiles {
13492            inventory.spawn_profiles = None;
13493        }
13494        if !include_session_bundles {
13495            inventory.bundles = None;
13496        }
13497        if inventory.spawn_profiles.is_none()
13498            && inventory.bundles.is_none()
13499            && inventory.network_allowlists.is_none()
13500        {
13501            snapshot.launch_inventory = None;
13502        }
13503    }
13504    if !include_child_environment_profiles {
13505        clear_snapshot_child_environment_profiles(&mut snapshot);
13506    }
13507    if !include_session_bundles {
13508        clear_snapshot_session_bundles(&mut snapshot);
13509    }
13510    project_snapshot_history_for_wire(&mut snapshot, include_history_context_packs);
13511    if !include_history_context_packs {
13512        clear_snapshot_context_packs(&mut snapshot);
13513    }
13514    if !include_agent_progress {
13515        snapshot.agent_progress.clear();
13516    }
13517    if !include_session_task_correlation {
13518        clear_snapshot_session_task_bindings(&mut snapshot);
13519    }
13520    snapshot
13521}
13522
13523fn clear_snapshot_session_task_bindings(snapshot: &mut NodeSnapshot) {
13524    for record in &mut snapshot.session_records {
13525        record.task_binding = None;
13526    }
13527}
13528
13529fn project_event_without_session_task_binding(
13530    mut envelope: NodeEventEnvelope,
13531) -> NodeEventEnvelope {
13532    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
13533        record.task_binding = None;
13534    }
13535    envelope
13536}
13537
13538fn project_response_without_session_task_binding(reply: &mut ResponseEnvelope) {
13539    let Ok(response) = reply.result.as_mut() else { return };
13540    match response {
13541        NodeResponse::Snapshot { snapshot, .. } => {
13542            clear_snapshot_session_task_bindings(snapshot);
13543        }
13544        NodeResponse::Resync { snapshot, events, .. } => {
13545            clear_snapshot_session_task_bindings(snapshot);
13546            for event in events {
13547                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
13548                    record.task_binding = None;
13549                }
13550            }
13551        }
13552        NodeResponse::SessionRecordUpdated { record }
13553        | NodeResponse::ProviderSessionIndexed { record }
13554        | NodeResponse::NativeSessionIndexed { record, .. }
13555        | NodeResponse::SessionRecordResumed { record, .. } => {
13556            record.task_binding = None;
13557        }
13558        _ => {}
13559    }
13560}
13561
13562fn project_response_without_agent_progress(reply: &mut ResponseEnvelope) {
13563    let Ok(response) = reply.result.as_mut() else { return };
13564    match response {
13565        NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
13566            snapshot.agent_progress.clear();
13567        }
13568        _ => {}
13569    }
13570}
13571
13572fn project_event_without_managed_worktrees(
13573    mut envelope: NodeEventEnvelope,
13574) -> Option<NodeEventEnvelope> {
13575    if matches!(
13576        envelope.event,
13577        NodeEvent::ManagedWorktreeUpserted { .. }
13578            | NodeEvent::ManagedWorktreeRemoved { .. }
13579    ) {
13580        return None;
13581    }
13582    if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
13583        workspace.managed_worktree_profiles = None;
13584    }
13585    Some(envelope)
13586}
13587
13588fn project_response_without_managed_worktrees(reply: &mut ResponseEnvelope) {
13589    if matches!(
13590        reply.result,
13591        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { .. })
13592            | Ok(NodeResponse::ManagedWorktreeCleanup { .. })
13593    ) {
13594        reply.result = Err(failure(
13595            NodeFailureCode::UnsupportedCapability,
13596            "managed worktree capability was not negotiated",
13597        ));
13598        return;
13599    }
13600    let Ok(response) = reply.result.as_mut() else { return };
13601    match response {
13602        NodeResponse::Snapshot { snapshot, .. } => {
13603            snapshot.managed_worktrees.clear();
13604            for workspace in &mut snapshot.workspaces {
13605                workspace.managed_worktree_profiles = None;
13606            }
13607        }
13608        NodeResponse::Resync { snapshot, events, .. } => {
13609            snapshot.managed_worktrees.clear();
13610            for workspace in &mut snapshot.workspaces {
13611                workspace.managed_worktree_profiles = None;
13612            }
13613            *events = std::mem::take(events)
13614                .into_iter()
13615                .filter_map(project_event_without_managed_worktrees)
13616                .collect();
13617        }
13618        NodeResponse::WorkspaceRegistered { workspace }
13619        | NodeResponse::StandaloneWorkspaceCreated { workspace }
13620        | NodeResponse::WorktreeCreated { workspace, .. } => {
13621            workspace.managed_worktree_profiles = None;
13622        }
13623        NodeResponse::WorkspaceInspected { inspection } => {
13624            inspection.git.managed_worktree = None;
13625        }
13626        _ => {}
13627    }
13628}
13629
13630fn project_negotiated_provider_ids(compatibility: &mut NegotiatedNodeCompatibility) {
13631    let includes_open_provider_ids = compatibility.capabilities.iter().any(|capability| {
13632        capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
13633    });
13634    if includes_open_provider_ids {
13635        return;
13636    }
13637    compatibility
13638        .provider_contracts
13639        .retain(|contract| provider_id_is_legacy(&contract.provider));
13640    compatibility
13641        .provider_adapter_contracts
13642        .retain(|contract| provider_id_is_legacy(&contract.provider));
13643}
13644
13645fn request_requires_open_provider_ids(shared: &NodeShared, request: &NodeRequest) -> bool {
13646    if let Some(spec) = match request {
13647        NodeRequest::SpawnSpec { spec }
13648        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
13649        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
13650        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
13651        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
13652        _ => None,
13653    } {
13654        return shared
13655            .resolve_spawn_spec(spec)
13656            .is_ok_and(|resolved| !provider_id_is_legacy(&resolved.provider));
13657    }
13658    request_requires_open_provider_ids_with(
13659        request,
13660        |session| shared.validate_address(session).ok(),
13661        |record_id| shared.record(record_id).ok().map(|record| record.provider),
13662    )
13663}
13664
13665fn request_requires_child_environment_profile(
13666    shared: &NodeShared,
13667    request: &NodeRequest,
13668) -> bool {
13669    if let Some(spec) = match request {
13670        NodeRequest::SpawnSpec { spec }
13671        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
13672        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
13673        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
13674        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
13675        _ => None,
13676    } {
13677        return shared
13678            .resolve_spawn_spec(spec)
13679            .is_ok_and(|resolved| resolved.environment_profile_id.is_some());
13680    }
13681    match request {
13682        NodeRequest::Resume { session, .. }
13683        | NodeRequest::Prompt { session, .. }
13684        | NodeRequest::Paste { session, .. }
13685        | NodeRequest::Input { session, .. }
13686        | NodeRequest::TerminalBytes { session, .. }
13687        | NodeRequest::TerminalControl { session, .. }
13688        | NodeRequest::Resize { session, .. }
13689        | NodeRequest::Interrupt { session }
13690        | NodeRequest::Stop { session, .. }
13691        | NodeRequest::Remove { session }
13692        | NodeRequest::ResolveInteraction { session, .. }
13693        | NodeRequest::SetSessionMode { session, .. }
13694        | NodeRequest::SetSessionConfigOption { session, .. }
13695        | NodeRequest::SetSessionModel { session, .. }
13696        | NodeRequest::DiscoverHistory { session, .. }
13697        | NodeRequest::LoadHistory { session, .. }
13698        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
13699        | NodeRequest::ExportContextPack { session } => shared
13700            .session_bindings
13701            .lock()
13702            .unwrap_or_else(|poisoned| poisoned.into_inner())
13703            .get(&session.session.instance_id)
13704            .filter(|binding| {
13705                binding.workspace_id == session.workspace_id
13706                    && binding.generation == session.session.generation
13707            })
13708            .map_or(true, |binding| binding.environment_profile.is_some()),
13709        NodeRequest::RenameSessionRecord { record_id, .. }
13710        | NodeRequest::SetSessionTask { record_id, .. }
13711        | NodeRequest::ResumeSessionRecord { record_id, .. }
13712        | NodeRequest::ForgetSessionRecord { record_id } => shared
13713            .record(record_id)
13714            .map_or(true, |record| record.environment_profile.is_some()),
13715        NodeRequest::ArmHarnessMcpReservation { .. }
13716        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
13717        | NodeRequest::ActivateHarnessMcpReservation { .. }
13718        | NodeRequest::AbortHarnessMcpReservation { .. }
13719        | NodeRequest::PutHarnessMcpReplyChunk { .. }
13720        | NodeRequest::RejectHarnessMcpCall { .. }
13721        | NodeRequest::Snapshot
13722        | NodeRequest::Resync { .. }
13723        | NodeRequest::BeginDeliveryStage { .. }
13724        | NodeRequest::PutDeliveryBlobChunk { .. }
13725        | NodeRequest::CommitDeliveryStage { .. }
13726        | NodeRequest::AbortDeliveryStage { .. }
13727        | NodeRequest::BrowseHostDirectories { .. }
13728        | NodeRequest::InspectWorkspace { .. }
13729        | NodeRequest::ReadWorkspaceFile { .. }
13730        | NodeRequest::WriteWorkspaceFile { .. }
13731        | NodeRequest::CreateWorkspaceFile { .. }
13732        | NodeRequest::CreateWorkspaceDirectory { .. }
13733        | NodeRequest::ReadGitHistory { .. }
13734        | NodeRequest::ReadGitDiff { .. }
13735        | NodeRequest::AcquireController { .. }
13736        | NodeRequest::ReleaseController
13737        | NodeRequest::Spawn { .. }
13738        | NodeRequest::SpawnSpec { .. }
13739        | NodeRequest::RegisterWorkspace { .. }
13740        | NodeRequest::CreateStandaloneWorkspace { .. }
13741        | NodeRequest::UnregisterWorkspace { .. }
13742        | NodeRequest::CreateWorktree { .. }
13743        | NodeRequest::RemoveWorktree { .. }
13744        | NodeRequest::SpawnManagedWorktree { .. }
13745        | NodeRequest::SpawnManagedWorktreeV2 { .. }
13746        | NodeRequest::CleanupManagedWorktree { .. }
13747        | NodeRequest::IndexProviderSession { .. }
13748        | NodeRequest::IndexNativeSession { .. }
13749        | NodeRequest::CatalogNativeSessions { .. }
13750        | NodeRequest::PageNativeSessions { .. }
13751        | NodeRequest::PreviewNativeSession { .. }
13752        | NodeRequest::PreviewSessionRecord { .. }
13753        | NodeRequest::ForgetContextPack { .. }
13754        | NodeRequest::ResolveDurableContextPack { .. }
13755        | NodeRequest::ReadContextPack { .. }
13756        | NodeRequest::Shutdown => false,
13757    }
13758}
13759
13760fn request_requires_session_bundle(shared: &NodeShared, request: &NodeRequest) -> bool {
13761    if let Some(spec) = match request {
13762        NodeRequest::SpawnSpec { spec }
13763        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
13764        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
13765        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
13766        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
13767        _ => None,
13768    } {
13769        return shared
13770            .resolve_spawn_spec(spec)
13771            .is_ok_and(|resolved| resolved.bundle_id.is_some());
13772    }
13773    match request {
13774        NodeRequest::Resume { session, .. }
13775        | NodeRequest::Prompt { session, .. }
13776        | NodeRequest::Paste { session, .. }
13777        | NodeRequest::Input { session, .. }
13778        | NodeRequest::TerminalBytes { session, .. }
13779        | NodeRequest::TerminalControl { session, .. }
13780        | NodeRequest::Resize { session, .. }
13781        | NodeRequest::Interrupt { session }
13782        | NodeRequest::Stop { session, .. }
13783        | NodeRequest::Remove { session }
13784        | NodeRequest::ResolveInteraction { session, .. }
13785        | NodeRequest::SetSessionMode { session, .. }
13786        | NodeRequest::SetSessionConfigOption { session, .. }
13787        | NodeRequest::SetSessionModel { session, .. }
13788        | NodeRequest::DiscoverHistory { session, .. }
13789        | NodeRequest::LoadHistory { session, .. }
13790        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
13791        | NodeRequest::ExportContextPack { session } => shared
13792            .session_bindings
13793            .lock()
13794            .unwrap_or_else(|poisoned| poisoned.into_inner())
13795            .get(&session.session.instance_id)
13796            .filter(|binding| {
13797                binding.workspace_id == session.workspace_id
13798                    && binding.generation == session.session.generation
13799            })
13800            .map_or(true, |binding| binding.bundle.is_some()),
13801        NodeRequest::RenameSessionRecord { record_id, .. }
13802        | NodeRequest::SetSessionTask { record_id, .. }
13803        | NodeRequest::ResumeSessionRecord { record_id, .. }
13804        | NodeRequest::ForgetSessionRecord { record_id } => shared
13805            .record(record_id)
13806            .map_or(true, |record| record.bundle.is_some()),
13807        NodeRequest::ArmHarnessMcpReservation { .. }
13808        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
13809        | NodeRequest::ActivateHarnessMcpReservation { .. }
13810        | NodeRequest::AbortHarnessMcpReservation { .. }
13811        | NodeRequest::PutHarnessMcpReplyChunk { .. }
13812        | NodeRequest::RejectHarnessMcpCall { .. }
13813        | NodeRequest::Snapshot
13814        | NodeRequest::Resync { .. }
13815        | NodeRequest::BeginDeliveryStage { .. }
13816        | NodeRequest::PutDeliveryBlobChunk { .. }
13817        | NodeRequest::CommitDeliveryStage { .. }
13818        | NodeRequest::AbortDeliveryStage { .. }
13819        | NodeRequest::BrowseHostDirectories { .. }
13820        | NodeRequest::InspectWorkspace { .. }
13821        | NodeRequest::ReadWorkspaceFile { .. }
13822        | NodeRequest::WriteWorkspaceFile { .. }
13823        | NodeRequest::CreateWorkspaceFile { .. }
13824        | NodeRequest::CreateWorkspaceDirectory { .. }
13825        | NodeRequest::ReadGitHistory { .. }
13826        | NodeRequest::ReadGitDiff { .. }
13827        | NodeRequest::AcquireController { .. }
13828        | NodeRequest::ReleaseController
13829        | NodeRequest::Spawn { .. }
13830        | NodeRequest::SpawnSpec { .. }
13831        | NodeRequest::RegisterWorkspace { .. }
13832        | NodeRequest::CreateStandaloneWorkspace { .. }
13833        | NodeRequest::UnregisterWorkspace { .. }
13834        | NodeRequest::CreateWorktree { .. }
13835        | NodeRequest::RemoveWorktree { .. }
13836        | NodeRequest::SpawnManagedWorktree { .. }
13837        | NodeRequest::SpawnManagedWorktreeV2 { .. }
13838        | NodeRequest::CleanupManagedWorktree { .. }
13839        | NodeRequest::IndexProviderSession { .. }
13840        | NodeRequest::IndexNativeSession { .. }
13841        | NodeRequest::CatalogNativeSessions { .. }
13842        | NodeRequest::PageNativeSessions { .. }
13843        | NodeRequest::PreviewNativeSession { .. }
13844        | NodeRequest::PreviewSessionRecord { .. }
13845        | NodeRequest::ForgetContextPack { .. }
13846        | NodeRequest::ResolveDurableContextPack { .. }
13847        | NodeRequest::ReadContextPack { .. }
13848        | NodeRequest::Shutdown => false,
13849    }
13850}
13851
13852fn request_requires_history_context_pack(shared: &NodeShared, request: &NodeRequest) -> bool {
13853    if request.requires_history_context_pack_capability() {
13854        return true;
13855    }
13856    if let Some(spec) = match request {
13857        NodeRequest::SpawnSpec { spec }
13858        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
13859        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
13860        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
13861        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
13862        _ => None,
13863    } {
13864        return shared
13865            .resolve_spawn_spec(spec)
13866            .map_or(true, |resolved| resolved.context_id.is_some());
13867    }
13868    match request {
13869        NodeRequest::Resume { session, .. }
13870        | NodeRequest::Prompt { session, .. }
13871        | NodeRequest::Paste { session, .. }
13872        | NodeRequest::Input { session, .. }
13873        | NodeRequest::TerminalBytes { session, .. }
13874        | NodeRequest::TerminalControl { session, .. }
13875        | NodeRequest::Resize { session, .. }
13876        | NodeRequest::Interrupt { session }
13877        | NodeRequest::Stop { session, .. }
13878        | NodeRequest::Remove { session }
13879        | NodeRequest::ResolveInteraction { session, .. }
13880        | NodeRequest::SetSessionMode { session, .. }
13881        | NodeRequest::SetSessionConfigOption { session, .. }
13882        | NodeRequest::SetSessionModel { session, .. } => shared
13883            .session_bindings
13884            .lock()
13885            .unwrap_or_else(|poisoned| poisoned.into_inner())
13886            .get(&session.session.instance_id)
13887            .filter(|binding| {
13888                binding.workspace_id == session.workspace_id
13889                    && binding.generation == session.session.generation
13890            })
13891            .map_or(true, |binding| binding.context.is_some()),
13892        NodeRequest::RenameSessionRecord { record_id, .. }
13893        | NodeRequest::SetSessionTask { record_id, .. }
13894        | NodeRequest::ResumeSessionRecord { record_id, .. }
13895        | NodeRequest::ForgetSessionRecord { record_id } => shared
13896            .record(record_id)
13897            .map_or(true, |record| record.context.is_some()),
13898        NodeRequest::ArmHarnessMcpReservation { .. }
13899        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
13900        | NodeRequest::ActivateHarnessMcpReservation { .. }
13901        | NodeRequest::AbortHarnessMcpReservation { .. }
13902        | NodeRequest::PutHarnessMcpReplyChunk { .. }
13903        | NodeRequest::RejectHarnessMcpCall { .. }
13904        | NodeRequest::Snapshot
13905        | NodeRequest::Resync { .. }
13906        | NodeRequest::BeginDeliveryStage { .. }
13907        | NodeRequest::PutDeliveryBlobChunk { .. }
13908        | NodeRequest::CommitDeliveryStage { .. }
13909        | NodeRequest::AbortDeliveryStage { .. }
13910        | NodeRequest::BrowseHostDirectories { .. }
13911        | NodeRequest::InspectWorkspace { .. }
13912        | NodeRequest::ReadWorkspaceFile { .. }
13913        | NodeRequest::WriteWorkspaceFile { .. }
13914        | NodeRequest::CreateWorkspaceFile { .. }
13915        | NodeRequest::CreateWorkspaceDirectory { .. }
13916        | NodeRequest::ReadGitHistory { .. }
13917        | NodeRequest::ReadGitDiff { .. }
13918        | NodeRequest::AcquireController { .. }
13919        | NodeRequest::ReleaseController
13920        | NodeRequest::Spawn { .. }
13921        | NodeRequest::SpawnSpec { .. }
13922        | NodeRequest::RegisterWorkspace { .. }
13923        | NodeRequest::CreateStandaloneWorkspace { .. }
13924        | NodeRequest::UnregisterWorkspace { .. }
13925        | NodeRequest::CreateWorktree { .. }
13926        | NodeRequest::RemoveWorktree { .. }
13927        | NodeRequest::SpawnManagedWorktree { .. }
13928        | NodeRequest::SpawnManagedWorktreeV2 { .. }
13929        | NodeRequest::CleanupManagedWorktree { .. }
13930        | NodeRequest::IndexProviderSession { .. }
13931        | NodeRequest::IndexNativeSession { .. }
13932        | NodeRequest::CatalogNativeSessions { .. }
13933        | NodeRequest::PageNativeSessions { .. }
13934        | NodeRequest::PreviewNativeSession { .. }
13935        | NodeRequest::PreviewSessionRecord { .. }
13936        | NodeRequest::DiscoverHistory { .. }
13937        | NodeRequest::LoadHistory { .. }
13938        | NodeRequest::ExportContextPackForSessionRecord { .. }
13939        | NodeRequest::ExportContextPack { .. }
13940        | NodeRequest::ForgetContextPack { .. }
13941        | NodeRequest::ResolveDurableContextPack { .. }
13942        | NodeRequest::ReadContextPack { .. }
13943        | NodeRequest::Shutdown => false,
13944    }
13945}
13946
13947fn request_requires_open_provider_ids_with(
13948    request: &NodeRequest,
13949    provider_for_session: impl Fn(&SessionAddress) -> Option<AgentId>,
13950    provider_for_record: impl Fn(&SessionRecordId) -> Option<AgentId>,
13951) -> bool {
13952    match request {
13953        NodeRequest::Spawn { provider, .. } => !provider_id_is_legacy(provider),
13954        NodeRequest::IndexProviderSession { provider, .. } => !provider_id_is_legacy(provider),
13955        NodeRequest::CatalogNativeSessions { route, .. }
13956        | NodeRequest::PageNativeSessions { route, .. } => {
13957            !provider_id_is_legacy(&route.provider)
13958        }
13959        NodeRequest::PreviewNativeSession { selection, .. }
13960        | NodeRequest::IndexNativeSession { selection, .. } => {
13961            !provider_id_is_legacy(&selection.route.provider)
13962        }
13963        NodeRequest::SpawnSpec { .. }
13964        | NodeRequest::SpawnSpecWithHarnessMcp { .. } => false,
13965        NodeRequest::SpawnManagedWorktree { .. }
13966        | NodeRequest::SpawnManagedWorktreeV2 { .. } => false,
13967        NodeRequest::Resume { session, .. }
13968        | NodeRequest::Prompt { session, .. }
13969        | NodeRequest::Paste { session, .. }
13970        | NodeRequest::Input { session, .. }
13971        | NodeRequest::TerminalBytes { session, .. }
13972        | NodeRequest::TerminalControl { session, .. }
13973        | NodeRequest::Resize { session, .. }
13974        | NodeRequest::Interrupt { session }
13975        | NodeRequest::Stop { session, .. }
13976        | NodeRequest::Remove { session }
13977        | NodeRequest::ResolveInteraction { session, .. }
13978        | NodeRequest::SetSessionMode { session, .. }
13979        | NodeRequest::SetSessionConfigOption { session, .. }
13980        | NodeRequest::SetSessionModel { session, .. }
13981        | NodeRequest::DiscoverHistory { session, .. }
13982        | NodeRequest::LoadHistory { session, .. }
13983        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
13984        | NodeRequest::ExportContextPack { session } => provider_for_session(session)
13985            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
13986        NodeRequest::RenameSessionRecord { record_id, .. }
13987        | NodeRequest::SetSessionTask { record_id, .. }
13988        | NodeRequest::ResumeSessionRecord { record_id, .. }
13989        | NodeRequest::ForgetSessionRecord { record_id } => provider_for_record(record_id)
13990            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
13991        NodeRequest::PreviewSessionRecord { record_id, .. } => provider_for_record(record_id)
13992            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
13993        NodeRequest::ArmHarnessMcpReservation { .. }
13994        | NodeRequest::ActivateHarnessMcpReservation { .. }
13995        | NodeRequest::AbortHarnessMcpReservation { .. }
13996        | NodeRequest::PutHarnessMcpReplyChunk { .. }
13997        | NodeRequest::RejectHarnessMcpCall { .. }
13998        | NodeRequest::Snapshot
13999        | NodeRequest::Resync { .. }
14000        | NodeRequest::BeginDeliveryStage { .. }
14001        | NodeRequest::PutDeliveryBlobChunk { .. }
14002        | NodeRequest::CommitDeliveryStage { .. }
14003        | NodeRequest::AbortDeliveryStage { .. }
14004        | NodeRequest::BrowseHostDirectories { .. }
14005        | NodeRequest::InspectWorkspace { .. }
14006        | NodeRequest::ReadWorkspaceFile { .. }
14007        | NodeRequest::WriteWorkspaceFile { .. }
14008        | NodeRequest::CreateWorkspaceFile { .. }
14009        | NodeRequest::CreateWorkspaceDirectory { .. }
14010        | NodeRequest::ReadGitHistory { .. }
14011        | NodeRequest::ReadGitDiff { .. }
14012        | NodeRequest::AcquireController { .. }
14013        | NodeRequest::ReleaseController
14014        | NodeRequest::RegisterWorkspace { .. }
14015        | NodeRequest::CreateStandaloneWorkspace { .. }
14016        | NodeRequest::UnregisterWorkspace { .. }
14017        | NodeRequest::CreateWorktree { .. }
14018        | NodeRequest::RemoveWorktree { .. }
14019        | NodeRequest::CleanupManagedWorktree { .. }
14020        | NodeRequest::Shutdown => false,
14021        NodeRequest::ForgetContextPack { .. } => true,
14022        // No session/record to cheaply resolve a provider from here either;
14023        // the resolved receipt can carry an open-id provider, so require the
14024        // same capability as ForgetContextPack unconditionally. `ReadContextPack`
14025        // is the same digest-keyed lookup one step further into the same
14026        // catalog, so it inherits the same conservative answer.
14027        NodeRequest::ResolveDurableContextPack { .. }
14028        | NodeRequest::ReadContextPack { .. } => true,
14029    }
14030}
14031
14032fn project_snapshot_legacy_provider_ids(snapshot: &mut NodeSnapshot) {
14033    snapshot.enabled_providers.retain(provider_id_is_legacy);
14034    snapshot.provider_runtime_statuses = ProviderRuntimeStatuses::new(
14035        snapshot
14036            .provider_runtime_statuses
14037            .as_slice()
14038            .iter()
14039            .filter(|status| provider_id_is_legacy(status.provider()))
14040            .cloned(),
14041    )
14042    .expect("a filtered provider runtime projection remains valid");
14043    snapshot
14044        .session_records
14045        .retain(|record| provider_id_is_legacy(&record.provider));
14046    for workspace in &mut snapshot.workspaces {
14047        workspace
14048            .sessions
14049            .retain(|session| provider_id_is_legacy(&session.agent_id));
14050    }
14051}
14052
14053fn clear_snapshot_child_environment_profiles(snapshot: &mut NodeSnapshot) {
14054    for record in &mut snapshot.session_records {
14055        record.environment_profile = None;
14056    }
14057    if let Some(profiles) = snapshot
14058        .launch_inventory
14059        .as_mut()
14060        .and_then(|inventory| inventory.spawn_profiles.as_mut())
14061    {
14062        for profile in profiles {
14063            profile.environment_profile = None;
14064        }
14065    }
14066}
14067
14068fn project_event_without_child_environment_profile(
14069    mut envelope: NodeEventEnvelope,
14070) -> NodeEventEnvelope {
14071    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14072        record.environment_profile = None;
14073    }
14074    envelope
14075}
14076
14077fn project_response_without_child_environment_profile(reply: &mut ResponseEnvelope) {
14078    let contains_environment_profile = match reply.result.as_ref() {
14079        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
14080            receipt.environment_profile.is_some()
14081        }
14082        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14083            receipt.spawn.environment_profile.is_some()
14084        }
14085        _ => false,
14086    };
14087    if contains_environment_profile {
14088        reply.result = Err(failure(
14089            NodeFailureCode::UnsupportedCapability,
14090            "child environment profiles were not negotiated",
14091        ));
14092        return;
14093    }
14094    let Ok(response) = reply.result.as_mut() else {
14095        return;
14096    };
14097    match response {
14098        NodeResponse::Snapshot { snapshot, .. } => {
14099            clear_snapshot_child_environment_profiles(snapshot);
14100        }
14101        NodeResponse::Resync { snapshot, events, .. } => {
14102            clear_snapshot_child_environment_profiles(snapshot);
14103            for event in events {
14104                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14105                    record.environment_profile = None;
14106                }
14107            }
14108        }
14109        NodeResponse::SessionRecordUpdated { record }
14110        | NodeResponse::ProviderSessionIndexed { record }
14111        | NodeResponse::NativeSessionIndexed { record, .. }
14112        | NodeResponse::SessionRecordResumed { record, .. } => {
14113            record.environment_profile = None;
14114        }
14115        NodeResponse::Armed { .. }
14116        | NodeResponse::Spawned { .. }
14117        | NodeResponse::Activated { .. }
14118        | NodeResponse::Aborted { .. }
14119        | NodeResponse::ReplyChunkAccepted { .. }
14120        | NodeResponse::CallRejected { .. }
14121        | NodeResponse::DeliveryStageBegun { .. }
14122        | NodeResponse::DeliveryBlobChunkAccepted { .. }
14123        | NodeResponse::DeliveryCommitted { .. }
14124        | NodeResponse::DeliveryStageAborted { .. }
14125        | NodeResponse::WorkspaceInspected { .. }
14126        | NodeResponse::HostDirectoriesBrowsed { .. }
14127        | NodeResponse::WorkspaceFileRead { .. }
14128        | NodeResponse::WorkspaceFileWritten { .. }
14129        | NodeResponse::WorkspaceFileCreated { .. }
14130        | NodeResponse::WorkspaceDirectoryCreated { .. }
14131        | NodeResponse::GitHistoryRead { .. }
14132        | NodeResponse::GitDiffRead { .. }
14133        | NodeResponse::Controller { .. }
14134        | NodeResponse::SpawnAccepted { .. }
14135        | NodeResponse::SpawnSpecAccepted { .. }
14136        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14137        | NodeResponse::ManagedWorktreeCleanup { .. }
14138        | NodeResponse::NativeSessionsCataloged { .. }
14139        | NodeResponse::NativeSessionsPaged { .. }
14140        | NodeResponse::NativeSessionPreviewed { .. }
14141        | NodeResponse::SessionRecordPreviewed { .. }
14142        | NodeResponse::HistoryDiscovered { .. }
14143        | NodeResponse::HistoryLoaded { .. }
14144        | NodeResponse::ContextPackForSessionRecordExported { .. }
14145        | NodeResponse::ContextPackExported { .. }
14146        | NodeResponse::ContextPackForgotten { .. }
14147        | NodeResponse::DurableContextPackResolved { .. }
14148        | NodeResponse::ContextPackBytesRead { .. }
14149        | NodeResponse::SessionRecordForgotten { .. }
14150        | NodeResponse::WorkspaceRegistered { .. }
14151        | NodeResponse::StandaloneWorkspaceCreated { .. }
14152        | NodeResponse::WorkspaceUnregistered { .. }
14153        | NodeResponse::WorktreeCreated { .. }
14154        | NodeResponse::WorktreeRemoved { .. }
14155        | NodeResponse::Accepted
14156        | NodeResponse::ShuttingDown => {}
14157    }
14158}
14159
14160fn clear_snapshot_session_bundles(snapshot: &mut NodeSnapshot) {
14161    for record in &mut snapshot.session_records {
14162        record.bundle = None;
14163    }
14164}
14165
14166fn project_event_without_session_bundle(
14167    mut envelope: NodeEventEnvelope,
14168) -> NodeEventEnvelope {
14169    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14170        record.bundle = None;
14171    }
14172    envelope
14173}
14174
14175fn project_response_without_session_bundle(reply: &mut ResponseEnvelope) {
14176    let contains_bundle = match reply.result.as_ref() {
14177        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.bundle.is_some(),
14178        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14179            receipt.spawn.bundle.is_some()
14180        }
14181        _ => false,
14182    };
14183    if contains_bundle {
14184        reply.result = Err(failure(
14185            NodeFailureCode::UnsupportedCapability,
14186            "session bundles were not negotiated",
14187        ));
14188        return;
14189    }
14190    let Ok(response) = reply.result.as_mut() else {
14191        return;
14192    };
14193    match response {
14194        NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_session_bundles(snapshot),
14195        NodeResponse::Resync { snapshot, events, .. } => {
14196            clear_snapshot_session_bundles(snapshot);
14197            for event in events {
14198                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14199                    record.bundle = None;
14200                }
14201            }
14202        }
14203        NodeResponse::SessionRecordUpdated { record }
14204        | NodeResponse::ProviderSessionIndexed { record }
14205        | NodeResponse::NativeSessionIndexed { record, .. }
14206        | NodeResponse::SessionRecordResumed { record, .. } => record.bundle = None,
14207        NodeResponse::Armed { .. }
14208        | NodeResponse::Spawned { .. }
14209        | NodeResponse::Activated { .. }
14210        | NodeResponse::Aborted { .. }
14211        | NodeResponse::ReplyChunkAccepted { .. }
14212        | NodeResponse::CallRejected { .. }
14213        | NodeResponse::DeliveryStageBegun { .. }
14214        | NodeResponse::DeliveryBlobChunkAccepted { .. }
14215        | NodeResponse::DeliveryCommitted { .. }
14216        | NodeResponse::DeliveryStageAborted { .. }
14217        | NodeResponse::WorkspaceInspected { .. }
14218        | NodeResponse::HostDirectoriesBrowsed { .. }
14219        | NodeResponse::WorkspaceFileRead { .. }
14220        | NodeResponse::WorkspaceFileWritten { .. }
14221        | NodeResponse::WorkspaceFileCreated { .. }
14222        | NodeResponse::WorkspaceDirectoryCreated { .. }
14223        | NodeResponse::GitHistoryRead { .. }
14224        | NodeResponse::GitDiffRead { .. }
14225        | NodeResponse::Controller { .. }
14226        | NodeResponse::SpawnAccepted { .. }
14227        | NodeResponse::SpawnSpecAccepted { .. }
14228        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14229        | NodeResponse::ManagedWorktreeCleanup { .. }
14230        | NodeResponse::NativeSessionsCataloged { .. }
14231        | NodeResponse::NativeSessionsPaged { .. }
14232        | NodeResponse::NativeSessionPreviewed { .. }
14233        | NodeResponse::SessionRecordPreviewed { .. }
14234        | NodeResponse::HistoryDiscovered { .. }
14235        | NodeResponse::HistoryLoaded { .. }
14236        | NodeResponse::ContextPackForSessionRecordExported { .. }
14237        | NodeResponse::ContextPackExported { .. }
14238        | NodeResponse::ContextPackForgotten { .. }
14239        | NodeResponse::DurableContextPackResolved { .. }
14240        | NodeResponse::ContextPackBytesRead { .. }
14241        | NodeResponse::SessionRecordForgotten { .. }
14242        | NodeResponse::WorkspaceRegistered { .. }
14243        | NodeResponse::StandaloneWorkspaceCreated { .. }
14244        | NodeResponse::WorkspaceUnregistered { .. }
14245        | NodeResponse::WorktreeCreated { .. }
14246        | NodeResponse::WorktreeRemoved { .. }
14247        | NodeResponse::Accepted
14248        | NodeResponse::ShuttingDown => {}
14249    }
14250}
14251
14252fn project_snapshot_history_for_wire(
14253    snapshot: &mut NodeSnapshot,
14254    include_history_metadata: bool,
14255) {
14256    for workspace in &mut snapshot.workspaces {
14257        for session in &mut workspace.sessions {
14258            if include_history_metadata {
14259                session.history.loaded = None;
14260            } else {
14261                session.history = Default::default();
14262            }
14263        }
14264    }
14265}
14266
14267fn project_event_history_for_wire(
14268    mut envelope: NodeEventEnvelope,
14269    include_history_metadata: bool,
14270) -> NodeEventEnvelope {
14271    if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
14272        for session in &mut workspace.sessions {
14273            if include_history_metadata {
14274                session.history.loaded = None;
14275            } else {
14276                session.history = Default::default();
14277            }
14278        }
14279    }
14280    envelope
14281}
14282
14283fn project_response_history_for_wire(
14284    reply: &mut ResponseEnvelope,
14285    include_history_metadata: bool,
14286) {
14287    let Ok(response) = reply.result.as_mut() else {
14288        return;
14289    };
14290    match response {
14291        NodeResponse::Snapshot { snapshot, .. } => {
14292            project_snapshot_history_for_wire(snapshot, include_history_metadata);
14293        }
14294        NodeResponse::Resync {
14295            snapshot, events, ..
14296        } => {
14297            project_snapshot_history_for_wire(snapshot, include_history_metadata);
14298            for event in events {
14299                *event = project_event_history_for_wire(
14300                    event.clone(),
14301                    include_history_metadata,
14302                );
14303            }
14304        }
14305        NodeResponse::WorkspaceRegistered { workspace }
14306        | NodeResponse::StandaloneWorkspaceCreated { workspace }
14307        | NodeResponse::WorktreeCreated { workspace, .. } => {
14308            for session in &mut workspace.sessions {
14309                if include_history_metadata {
14310                    session.history.loaded = None;
14311                } else {
14312                    session.history = Default::default();
14313                }
14314            }
14315        }
14316        _ => {}
14317    }
14318}
14319
14320fn clear_snapshot_context_packs(snapshot: &mut NodeSnapshot) {
14321    for record in &mut snapshot.session_records {
14322        record.context_id = None;
14323        record.context = None;
14324        record.exported_context = None;
14325    }
14326}
14327
14328fn project_event_without_context_pack(
14329    mut envelope: NodeEventEnvelope,
14330) -> NodeEventEnvelope {
14331    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14332        record.context_id = None;
14333        record.context = None;
14334        record.exported_context = None;
14335    }
14336    envelope
14337}
14338
14339fn project_response_without_context_pack(reply: &mut ResponseEnvelope) {
14340    let contains_context = match reply.result.as_ref() {
14341        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.context.is_some(),
14342        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14343            receipt.spawn.context.is_some()
14344        }
14345        Ok(NodeResponse::HistoryDiscovered { .. })
14346        | Ok(NodeResponse::HistoryLoaded { .. })
14347        | Ok(NodeResponse::ContextPackForSessionRecordExported { .. })
14348        | Ok(NodeResponse::ContextPackExported { .. })
14349        | Ok(NodeResponse::ContextPackForgotten { .. })
14350        | Ok(NodeResponse::DurableContextPackResolved { .. }) => true,
14351        _ => false,
14352    };
14353    if contains_context {
14354        reply.result = Err(failure(
14355            NodeFailureCode::UnsupportedCapability,
14356            "history context packs were not negotiated",
14357        ));
14358        return;
14359    }
14360    let Ok(response) = reply.result.as_mut() else {
14361        return;
14362    };
14363    match response {
14364        NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_context_packs(snapshot),
14365        NodeResponse::Resync {
14366            snapshot, events, ..
14367        } => {
14368            clear_snapshot_context_packs(snapshot);
14369            for event in events {
14370                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14371                    record.context_id = None;
14372                    record.context = None;
14373                    record.exported_context = None;
14374                }
14375            }
14376        }
14377        NodeResponse::SessionRecordUpdated { record }
14378        | NodeResponse::ProviderSessionIndexed { record }
14379        | NodeResponse::NativeSessionIndexed { record, .. }
14380        | NodeResponse::SessionRecordResumed { record, .. } => {
14381            record.context_id = None;
14382            record.context = None;
14383            record.exported_context = None;
14384        }
14385        _ => {}
14386    }
14387}
14388
14389fn project_response_legacy_provider_ids(shared: &NodeShared, reply: &mut ResponseEnvelope) {
14390    let contains_open_record = match reply.result.as_ref() {
14391        Ok(NodeResponse::SessionRecordUpdated { record })
14392        | Ok(NodeResponse::ProviderSessionIndexed { record })
14393        | Ok(NodeResponse::SessionRecordResumed { record, .. }) => {
14394            !provider_id_is_legacy(&record.provider)
14395        }
14396        Ok(NodeResponse::NativeSessionIndexed { selection, record }) => {
14397            !provider_id_is_legacy(&selection.route.provider)
14398                || !provider_id_is_legacy(&record.provider)
14399        }
14400        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
14401            !provider_id_is_legacy(&receipt.provider)
14402        }
14403        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14404            !provider_id_is_legacy(&receipt.spawn.provider)
14405        }
14406        Ok(NodeResponse::NativeSessionsCataloged { route, .. })
14407        | Ok(NodeResponse::NativeSessionsPaged { route, .. }) => {
14408            !provider_id_is_legacy(&route.provider)
14409        }
14410        Ok(NodeResponse::NativeSessionPreviewed { selection, .. }) => {
14411            !provider_id_is_legacy(&selection.route.provider)
14412        }
14413        _ => false,
14414    };
14415    if contains_open_record {
14416        reply.result = Err(failure(
14417            NodeFailureCode::UnsupportedCapability,
14418            "open provider IDs were not negotiated",
14419        ));
14420        return;
14421    }
14422    let Ok(response) = reply.result.as_mut() else {
14423        return;
14424    };
14425    match response {
14426        NodeResponse::Snapshot { snapshot, .. } => {
14427            project_snapshot_legacy_provider_ids(snapshot);
14428        }
14429        NodeResponse::Resync { snapshot, events, .. } => {
14430            project_snapshot_legacy_provider_ids(snapshot);
14431            *events = std::mem::take(events)
14432                .into_iter()
14433                .filter_map(|event| project_event_legacy_provider_ids(shared, event))
14434                .collect();
14435        }
14436        NodeResponse::WorkspaceRegistered { workspace }
14437        | NodeResponse::StandaloneWorkspaceCreated { workspace }
14438        | NodeResponse::WorktreeCreated { workspace, .. } => {
14439            workspace
14440                .sessions
14441                .retain(|session| provider_id_is_legacy(&session.agent_id));
14442        }
14443        NodeResponse::Armed { .. }
14444        | NodeResponse::Spawned { .. }
14445        | NodeResponse::Activated { .. }
14446        | NodeResponse::Aborted { .. }
14447        | NodeResponse::ReplyChunkAccepted { .. }
14448        | NodeResponse::CallRejected { .. }
14449        | NodeResponse::DeliveryStageBegun { .. }
14450        | NodeResponse::DeliveryBlobChunkAccepted { .. }
14451        | NodeResponse::DeliveryCommitted { .. }
14452        | NodeResponse::DeliveryStageAborted { .. }
14453        | NodeResponse::WorkspaceInspected { .. }
14454        | NodeResponse::HostDirectoriesBrowsed { .. }
14455        | NodeResponse::WorkspaceFileRead { .. }
14456        | NodeResponse::WorkspaceFileWritten { .. }
14457        | NodeResponse::WorkspaceFileCreated { .. }
14458        | NodeResponse::WorkspaceDirectoryCreated { .. }
14459        | NodeResponse::GitHistoryRead { .. }
14460        | NodeResponse::GitDiffRead { .. }
14461        | NodeResponse::Controller { .. }
14462        | NodeResponse::SpawnAccepted { .. }
14463        | NodeResponse::SpawnSpecAccepted { .. }
14464        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14465        | NodeResponse::ManagedWorktreeCleanup { .. }
14466        | NodeResponse::NativeSessionsCataloged { .. }
14467        | NodeResponse::NativeSessionsPaged { .. }
14468        | NodeResponse::NativeSessionPreviewed { .. }
14469        | NodeResponse::SessionRecordPreviewed { .. }
14470        | NodeResponse::HistoryDiscovered { .. }
14471        | NodeResponse::HistoryLoaded { .. }
14472        | NodeResponse::ContextPackForSessionRecordExported { .. }
14473        | NodeResponse::ContextPackExported { .. }
14474        | NodeResponse::ContextPackForgotten { .. }
14475        | NodeResponse::DurableContextPackResolved { .. }
14476        | NodeResponse::ContextPackBytesRead { .. }
14477        | NodeResponse::SessionRecordUpdated { .. }
14478        | NodeResponse::ProviderSessionIndexed { .. }
14479        | NodeResponse::NativeSessionIndexed { .. }
14480        | NodeResponse::SessionRecordResumed { .. }
14481        | NodeResponse::SessionRecordForgotten { .. }
14482        | NodeResponse::WorkspaceUnregistered { .. }
14483        | NodeResponse::WorktreeRemoved { .. }
14484        | NodeResponse::Accepted
14485        | NodeResponse::ShuttingDown => {}
14486    }
14487}
14488
14489fn project_event_legacy_provider_ids(
14490    shared: &NodeShared,
14491    mut envelope: NodeEventEnvelope,
14492) -> Option<NodeEventEnvelope> {
14493    let include = match &mut envelope.event {
14494        NodeEvent::HarnessMcpReadCall { .. } => true,
14495        NodeEvent::Control { address, .. }
14496        | NodeEvent::AgentStream { address, .. }
14497        | NodeEvent::TerminalFrame { address, .. } => shared
14498            .validate_address(address)
14499            .is_ok_and(|provider| provider_id_is_legacy(&provider)),
14500        NodeEvent::WorkspaceAdded { workspace } => {
14501            workspace
14502                .sessions
14503                .retain(|session| provider_id_is_legacy(&session.agent_id));
14504            true
14505        }
14506        NodeEvent::SessionRecordUpserted { record } => {
14507            provider_id_is_legacy(&record.provider)
14508        }
14509        NodeEvent::SessionRecordHistorySummarized { .. } => false,
14510        NodeEvent::SessionRecordRemoved { .. } => shared
14511            .history
14512            .lock()
14513            .unwrap_or_else(|poisoned| poisoned.into_inner())
14514            .removed_record_providers
14515            .get(&envelope.sequence)
14516            .is_some_and(provider_id_is_legacy),
14517        NodeEvent::ControllerChanged { .. }
14518        | NodeEvent::ManagedWorktreeUpserted { .. }
14519        | NodeEvent::ManagedWorktreeRemoved { .. }
14520        | NodeEvent::WorkspaceRemoved { .. }
14521        | NodeEvent::ResyncRequired { .. } => true,
14522    };
14523    include.then_some(envelope)
14524}
14525
14526fn clear_response_provider_runtime_status(reply: &mut ResponseEnvelope) {
14527    let Ok(response) = reply.result.as_mut() else {
14528        return;
14529    };
14530    match response {
14531        NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
14532            snapshot.provider_runtime_statuses.clear();
14533        }
14534        NodeResponse::Armed { .. }
14535        | NodeResponse::Spawned { .. }
14536        | NodeResponse::Activated { .. }
14537        | NodeResponse::Aborted { .. }
14538        | NodeResponse::ReplyChunkAccepted { .. }
14539        | NodeResponse::CallRejected { .. }
14540        | NodeResponse::DeliveryStageBegun { .. }
14541        | NodeResponse::DeliveryBlobChunkAccepted { .. }
14542        | NodeResponse::DeliveryCommitted { .. }
14543        | NodeResponse::DeliveryStageAborted { .. }
14544        | NodeResponse::WorkspaceInspected { .. }
14545        | NodeResponse::HostDirectoriesBrowsed { .. }
14546        | NodeResponse::WorkspaceFileRead { .. }
14547        | NodeResponse::WorkspaceFileWritten { .. }
14548        | NodeResponse::WorkspaceFileCreated { .. }
14549        | NodeResponse::WorkspaceDirectoryCreated { .. }
14550        | NodeResponse::GitHistoryRead { .. }
14551        | NodeResponse::GitDiffRead { .. }
14552        | NodeResponse::Controller { .. }
14553        | NodeResponse::SpawnAccepted { .. }
14554        | NodeResponse::SpawnSpecAccepted { .. }
14555        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14556        | NodeResponse::ManagedWorktreeCleanup { .. }
14557        | NodeResponse::NativeSessionsCataloged { .. }
14558        | NodeResponse::NativeSessionsPaged { .. }
14559        | NodeResponse::NativeSessionPreviewed { .. }
14560        | NodeResponse::SessionRecordPreviewed { .. }
14561        | NodeResponse::HistoryDiscovered { .. }
14562        | NodeResponse::HistoryLoaded { .. }
14563        | NodeResponse::ContextPackForSessionRecordExported { .. }
14564        | NodeResponse::ContextPackExported { .. }
14565        | NodeResponse::ContextPackForgotten { .. }
14566        | NodeResponse::DurableContextPackResolved { .. }
14567        | NodeResponse::ContextPackBytesRead { .. }
14568        | NodeResponse::SessionRecordUpdated { .. }
14569        | NodeResponse::ProviderSessionIndexed { .. }
14570        | NodeResponse::NativeSessionIndexed { .. }
14571        | NodeResponse::SessionRecordResumed { .. }
14572        | NodeResponse::SessionRecordForgotten { .. }
14573        | NodeResponse::WorkspaceRegistered { .. }
14574        | NodeResponse::StandaloneWorkspaceCreated { .. }
14575        | NodeResponse::WorkspaceUnregistered { .. }
14576        | NodeResponse::WorktreeCreated { .. }
14577        | NodeResponse::WorktreeRemoved { .. }
14578        | NodeResponse::Accepted
14579        | NodeResponse::ShuttingDown => {}
14580    }
14581}
14582
14583struct ControllerReleaseGuard {
14584    shared: Arc<NodeShared>,
14585    connection_id: u64,
14586}
14587
14588impl Drop for ControllerReleaseGuard {
14589    fn drop(&mut self) {
14590        self.shared.release_controller(self.connection_id);
14591    }
14592}
14593
14594struct AbortTaskOnDrop(AbortHandle);
14595
14596impl Drop for AbortTaskOnDrop {
14597    fn drop(&mut self) {
14598        self.0.abort();
14599    }
14600}
14601
14602/// Reads an unsigned env-config override, following the pattern of every
14603/// other bounded knob in this module: missing/unparsable/zero falls back to
14604/// `default`, and the resolved value is always clamped to `max` so a bad
14605/// override cannot reopen a bound this budget exists to enforce.
14606fn env_config_bounded(name: &str, default: u64, max: u64) -> u64 {
14607    std::env::var(name)
14608        .ok()
14609        .and_then(|value| value.parse::<u64>().ok())
14610        .filter(|value| *value > 0)
14611        .unwrap_or(default)
14612        .min(max)
14613}
14614
14615fn workspace_inspection_time_budget_ms() -> u64 {
14616    env_config_bounded(
14617        "GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS",
14618        WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT,
14619        WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX,
14620    )
14621}
14622
14623fn workspace_inspection_entry_cap() -> usize {
14624    env_config_bounded(
14625        "GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP",
14626        WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT as u64,
14627        WORKSPACE_INSPECTION_ENTRY_CAP_MAX as u64,
14628    ) as usize
14629}
14630
14631/// Tracks the shared walk-side budget across the whole recursive walk: a
14632/// wall-clock deadline (shared with the git phase that runs after the walk)
14633/// and a cap on directory entries visited, whether or not they were pushed
14634/// to the response. Cheap to check per entry — one `Instant::now()` and one
14635/// integer compare.
14636struct WorkspaceWalkBudget {
14637    deadline: Instant,
14638    entry_cap: usize,
14639    entries_visited: u64,
14640    time_budget_exceeded: bool,
14641    entry_cap_exceeded: bool,
14642}
14643
14644impl WorkspaceWalkBudget {
14645    fn new(deadline: Instant, entry_cap: usize) -> Self {
14646        Self {
14647            deadline,
14648            entry_cap,
14649            entries_visited: 0,
14650            time_budget_exceeded: false,
14651            entry_cap_exceeded: false,
14652        }
14653    }
14654
14655    fn is_exhausted(&self) -> bool {
14656        self.time_budget_exceeded || self.entry_cap_exceeded
14657    }
14658
14659    /// Records one visited directory entry (pushed to the response or not)
14660    /// and reports whether the walk must stop now.
14661    fn record_visit(&mut self) -> bool {
14662        self.entries_visited += 1;
14663        if self.entries_visited as usize >= self.entry_cap {
14664            self.entry_cap_exceeded = true;
14665        }
14666        if Instant::now() >= self.deadline {
14667            self.time_budget_exceeded = true;
14668        }
14669        self.is_exhausted()
14670    }
14671}
14672
14673fn collect_workspace_entries(
14674    root: &Path,
14675    deadline: Instant,
14676    entry_cap: usize,
14677) -> (Vec<WorkspaceEntry>, bool, WorkspaceWalkBudget) {
14678    let mut entries = Vec::new();
14679    let mut truncated = false;
14680    let mut budget = WorkspaceWalkBudget::new(deadline, entry_cap);
14681    walk_workspace_directory(root, None, 0, &mut entries, &mut truncated, &mut budget);
14682    // DFS pre-order emits `x/child` between prefix-named siblings `x` and
14683    // `x-y` ('-' sorts below '/'), while the bounded operator projections
14684    // require strict global byte order over relative paths.
14685    entries.sort_by(|left, right| left.relative_path.as_utf8().cmp(&right.relative_path.as_utf8()));
14686    (entries, truncated, budget)
14687}
14688
14689/// Builds the additive `WorkspaceInspectionTruncationV1` for an
14690/// `inspect_workspace` response — `None` once the walk and git phase both
14691/// completed inside their shared budget.
14692fn workspace_inspection_truncation(
14693    walk_budget: &WorkspaceWalkBudget,
14694    git_time_budget_exceeded: bool,
14695    elapsed: Duration,
14696) -> Option<WorkspaceInspectionTruncationV1> {
14697    if !walk_budget.time_budget_exceeded
14698        && !walk_budget.entry_cap_exceeded
14699        && !git_time_budget_exceeded
14700    {
14701        return None;
14702    }
14703    Some(WorkspaceInspectionTruncationV1 {
14704        walk_time_budget_exceeded: walk_budget.time_budget_exceeded,
14705        walk_entry_cap_exceeded: walk_budget.entry_cap_exceeded,
14706        git_time_budget_exceeded,
14707        entries_visited: walk_budget.entries_visited,
14708        elapsed_ms: u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX),
14709    })
14710}
14711
14712fn walk_workspace_directory(
14713    directory: &Path,
14714    relative_directory: Option<&RepositoryPath>,
14715    depth: usize,
14716    entries: &mut Vec<WorkspaceEntry>,
14717    truncated: &mut bool,
14718    budget: &mut WorkspaceWalkBudget,
14719) {
14720    if budget.is_exhausted() {
14721        *truncated = true;
14722        return;
14723    }
14724    let read_dir = match std::fs::read_dir(directory) {
14725        Ok(read_dir) => read_dir,
14726        Err(_) => {
14727            *truncated = true;
14728            return;
14729        }
14730    };
14731    let mut children = Vec::new();
14732    for child in read_dir {
14733        match child {
14734            Ok(child) => children.push(child),
14735            Err(_) => *truncated = true,
14736        }
14737    }
14738    children.sort_by_key(|child| child.file_name());
14739    for child in children {
14740        if budget.record_visit() {
14741            *truncated = true;
14742            return;
14743        }
14744        if entries.len() >= WORKSPACE_TREE_MAX_ENTRIES {
14745            *truncated = true;
14746            return;
14747        }
14748        let file_type = match child.file_type() {
14749            Ok(file_type) => file_type,
14750            Err(_) => {
14751                *truncated = true;
14752                continue;
14753            }
14754        };
14755        if file_type.is_symlink() {
14756            continue;
14757        }
14758        let name = match child.file_name().into_string() {
14759            Ok(name) => name,
14760            Err(_) => {
14761                *truncated = true;
14762                continue;
14763            }
14764        };
14765        if file_type.is_dir() && is_skipped_workspace_directory(&name, &child.path()) {
14766            continue;
14767        }
14768        let relative_path = match relative_directory {
14769            None => windows_repository_path(name),
14770            Some(parent) => parent
14771                .as_utf8()
14772                .and_then(|parent| windows_repository_path(format!("{parent}/{name}"))),
14773        };
14774        let Some(relative_path) = relative_path else {
14775            *truncated = true;
14776            continue;
14777        };
14778        let kind = if file_type.is_dir() {
14779            WorkspaceEntryKind::Directory
14780        } else if file_type.is_file() {
14781            WorkspaceEntryKind::File
14782        } else {
14783            continue;
14784        };
14785        entries.push(WorkspaceEntry {
14786            relative_path: relative_path.clone(),
14787            kind,
14788        });
14789        if file_type.is_dir() {
14790            if depth + 1 < WORKSPACE_TREE_MAX_DEPTH {
14791                walk_workspace_directory(
14792                    &child.path(),
14793                    Some(&relative_path),
14794                    depth + 1,
14795                    entries,
14796                    truncated,
14797                    budget,
14798                );
14799            } else {
14800                *truncated = true;
14801            }
14802        }
14803    }
14804}
14805
14806fn windows_repository_path(value: String) -> Option<RepositoryPath> {
14807    if value.contains('\\') {
14808        return None;
14809    }
14810    RepositoryPath::utf8(value).ok()
14811}
14812
14813fn windows_repository_path_from_bytes(value: &[u8]) -> Option<RepositoryPath> {
14814    windows_repository_path(std::str::from_utf8(value).ok()?.to_owned())
14815}
14816
14817/// Excludes a directory from the workspace tree walk. The three literal
14818/// names are the common convention-based cases; the `CACHEDIR.TAG` check
14819/// beneath them catches every build-output directory regardless of name
14820/// (cargo writes `CACHEDIR.TAG` into every `--target-dir` it creates, so
14821/// this covers `target-a2`, `target-a2-tui`, and any future renamed build
14822/// directory without maintaining a name heuristic list). The name check
14823/// runs first and returns early so a directory rejected by name never pays
14824/// for the extra `CACHEDIR.TAG` stat.
14825fn is_skipped_workspace_directory(name: &str, path: &Path) -> bool {
14826    if name.eq_ignore_ascii_case(".git")
14827        || name.eq_ignore_ascii_case("target")
14828        || name.eq_ignore_ascii_case("node_modules")
14829    {
14830        return true;
14831    }
14832    path.join("CACHEDIR.TAG").is_file()
14833}
14834
14835fn context_pack_source_status_is_usable(status: &SessionStatus) -> bool {
14836    matches!(
14837        status,
14838        SessionStatus::Running | SessionStatus::Exited { exit_code: Some(0) }
14839    )
14840}
14841
14842#[derive(Clone, Debug, Eq, PartialEq)]
14843enum ContextPackRepositoryHead {
14844    NotRepository,
14845    Unborn,
14846    Commit(String),
14847}
14848
14849#[derive(Clone, Debug, Eq, PartialEq)]
14850struct ContextPackRepositoryObservation {
14851    head: ContextPackRepositoryHead,
14852    git: GitSnapshot,
14853    files: Vec<ContextPackRepositoryFileSource>,
14854}
14855
14856fn stable_context_pack_repository(
14857    first: ContextPackRepositoryObservation,
14858    second: ContextPackRepositoryObservation,
14859) -> Result<ContextPackRepository, NodeFailure> {
14860    if first != second {
14861        return Err(failure(
14862            NodeFailureCode::ContextPackMaterializationFailed,
14863            "context repository changed during bounded capture",
14864        ));
14865    }
14866    Ok(ContextPackRepository::from_sources(
14867        &second.git,
14868        second.files,
14869    ))
14870}
14871
14872/// Appends a "skipped: budget exhausted" diagnostic and returns `true` once
14873/// (and only once) the shared walk+git `deadline` has already passed. Every
14874/// remaining phase in `inspect_git_workspace` checks this immediately
14875/// before issuing its own git call, so a budget spent by the walk (or by an
14876/// earlier git call) skips the rest of the chain instead of paying for it
14877/// anyway.
14878fn git_budget_exhausted(snapshot: &mut GitSnapshot, deadline: Instant, stage: &str) -> bool {
14879    if Instant::now() < deadline {
14880        return false;
14881    }
14882    append_git_diagnostic(
14883        snapshot,
14884        &format!("{stage} skipped: workspace inspection time budget exhausted"),
14885    );
14886    true
14887}
14888
14889/// Deadline-aware sibling of `run_git_bounded`, used only by
14890/// `inspect_git_workspace`'s six-call chain: caps the per-call timeout to
14891/// whatever remains of the shared `deadline` (never more than
14892/// `GIT_COMMAND_TIMEOUT_MS`), so a slow walk phase that already spent most
14893/// of the budget cannot still hand a full per-call timeout to every git
14894/// probe behind it. `inspect_git_workspace` already guards each call site
14895/// with `git_budget_exhausted`, so `deadline` is guaranteed not to have
14896/// passed yet here.
14897async fn run_git_bounded_with_deadline(
14898    root: &str,
14899    arguments: &[&str],
14900    output_limit: usize,
14901    deadline: Instant,
14902) -> io::Result<GitCommandOutput> {
14903    let remaining = deadline.checked_duration_since(Instant::now());
14904    let timeout_ms = remaining
14905        .and_then(|remaining| u64::try_from(remaining.as_millis()).ok())
14906        .unwrap_or(0)
14907        .clamp(1, GIT_COMMAND_TIMEOUT_MS);
14908    run_git_read_bounded(root, arguments, output_limit, timeout_ms).await
14909}
14910
14911/// Runs the bounded six-call git probe chain used by `inspect_workspace`.
14912/// `deadline` is the same absolute instant the preceding directory walk was
14913/// bounded by, so time already spent walking counts against what remains
14914/// for git enrichment. Returns the resulting `GitSnapshot` plus whether the
14915/// shared budget (not an individual per-call timeout) is what cut the git
14916/// phase short.
14917async fn inspect_git_workspace(root: &str, deadline: Instant) -> (GitSnapshot, bool) {
14918    let mut snapshot = GitSnapshot {
14919        is_repository: false,
14920        branch: None,
14921        status: Vec::new(),
14922        recent_commits: Vec::new(),
14923        worktrees: Vec::new(),
14924        managed_worktree: None,
14925        truncated: false,
14926        diagnostic: None,
14927    };
14928    if git_budget_exhausted(&mut snapshot, deadline, "git repository probe") {
14929        return (snapshot, true);
14930    }
14931    let repository = match run_git_bounded_with_deadline(
14932        root,
14933        &["rev-parse", "--is-inside-work-tree"],
14934        4 * 1_024,
14935        deadline,
14936    )
14937    .await
14938    {
14939        Ok(output) => output,
14940        Err(error) => {
14941            append_git_diagnostic(
14942                &mut snapshot,
14943                &format!("git inspection unavailable: {error}"),
14944            );
14945            return (snapshot, false);
14946        }
14947    };
14948    snapshot.truncated |= repository.truncated;
14949    if repository.timed_out {
14950        append_git_diagnostic(&mut snapshot, "git repository probe timed out");
14951        return (snapshot, false);
14952    }
14953    if !repository.success
14954        || String::from_utf8_lossy(&repository.stdout).trim() != "true"
14955    {
14956        let stderr = String::from_utf8_lossy(&repository.stderr);
14957        if !stderr.to_ascii_lowercase().contains("not a git repository") {
14958            append_git_diagnostic(&mut snapshot, stderr.trim());
14959        }
14960        return (snapshot, false);
14961    }
14962    snapshot.is_repository = true;
14963
14964    if git_budget_exhausted(&mut snapshot, deadline, "git branch query") {
14965        return (snapshot, true);
14966    }
14967    match run_git_bounded_with_deadline(root, &["branch", "--show-current"], 4 * 1_024, deadline).await {
14968        Ok(output) => {
14969            snapshot.truncated |= output.truncated;
14970            if output.timed_out {
14971                append_git_diagnostic(&mut snapshot, "git branch query timed out");
14972            } else if output.success {
14973                let branch = String::from_utf8_lossy(&output.stdout).trim().to_owned();
14974                if !branch.is_empty() {
14975                    snapshot.branch = Some(truncate_git_field(
14976                        &branch,
14977                        MAX_REPOSITORY_PATH_BYTES,
14978                        &mut snapshot.truncated,
14979                    ));
14980                }
14981            }
14982        }
14983        Err(error) => append_git_diagnostic(
14984            &mut snapshot,
14985            &format!("git branch query failed: {error}"),
14986        ),
14987    }
14988    if snapshot.branch.is_none() && !git_budget_exhausted(&mut snapshot, deadline, "git detached-HEAD probe") {
14989        if let Ok(output) = run_git_bounded_with_deadline(root, &["rev-parse", "--short", "HEAD"], 4 * 1_024, deadline).await {
14990            snapshot.truncated |= output.truncated;
14991            if output.success && !output.timed_out {
14992                let head = String::from_utf8_lossy(&output.stdout).trim().to_owned();
14993                if !head.is_empty() {
14994                    snapshot.branch = Some(format!("detached:{head}"));
14995                }
14996            }
14997        }
14998    }
14999
15000    if git_budget_exhausted(&mut snapshot, deadline, "git status") {
15001        return (snapshot, true);
15002    }
15003    match run_git_bounded_with_deadline(
15004        root,
15005        &[
15006            "status",
15007            "--porcelain=v1",
15008            "-z",
15009            "--renames",
15010            "--untracked-files=normal",
15011            "--",
15012            ".",
15013        ],
15014        GIT_OUTPUT_MAX_BYTES,
15015        deadline,
15016    )
15017    .await
15018    {
15019        Ok(output) => {
15020            snapshot.truncated |= output.truncated;
15021            if output.timed_out {
15022                append_git_diagnostic(&mut snapshot, "git status timed out");
15023            } else if output.success {
15024                parse_git_status(&output.stdout, &mut snapshot);
15025            } else {
15026                append_git_diagnostic(
15027                    &mut snapshot,
15028                    String::from_utf8_lossy(&output.stderr).trim(),
15029                );
15030            }
15031        }
15032        Err(error) => append_git_diagnostic(
15033            &mut snapshot,
15034            &format!("git status failed: {error}"),
15035        ),
15036    }
15037
15038    if git_budget_exhausted(&mut snapshot, deadline, "git log") {
15039        return (snapshot, true);
15040    }
15041    let log_limit = (GIT_COMMIT_MAX_ENTRIES + 1).to_string();
15042    match run_git_bounded_with_deadline(
15043        root,
15044        &["log", "-n", &log_limit, "--pretty=format:%H%x1f%s", "--", "."],
15045        16 * 1_024,
15046        deadline,
15047    )
15048    .await
15049    {
15050        Ok(output) => {
15051            snapshot.truncated |= output.truncated;
15052            if output.timed_out {
15053                append_git_diagnostic(&mut snapshot, "git log timed out");
15054            } else if output.success {
15055                parse_git_commits(&output.stdout, &mut snapshot);
15056            } else {
15057                let stderr = String::from_utf8_lossy(&output.stderr);
15058                if !stderr.to_ascii_lowercase().contains("does not have any commits") {
15059                    append_git_diagnostic(&mut snapshot, stderr.trim());
15060                }
15061            }
15062        }
15063        Err(error) => append_git_diagnostic(
15064            &mut snapshot,
15065            &format!("git log failed: {error}"),
15066        ),
15067    }
15068
15069    if git_budget_exhausted(&mut snapshot, deadline, "git worktree list") {
15070        return (snapshot, true);
15071    }
15072    match list_git_worktrees_with_deadline(root, deadline).await {
15073        Ok(worktrees) => {
15074            snapshot.worktrees = worktrees.into_iter().map(protocol_worktree).collect()
15075        }
15076        Err(error) => append_git_diagnostic(
15077            &mut snapshot,
15078            &format!("git worktree list failed: {}", error.message),
15079        ),
15080    }
15081    (snapshot, false)
15082}
15083
15084fn parse_git_status(output: &[u8], snapshot: &mut GitSnapshot) {
15085    let mut remaining = output;
15086    while !remaining.is_empty() {
15087        if snapshot.status.len() >= GIT_STATUS_MAX_ENTRIES {
15088            snapshot.truncated = true;
15089            break;
15090        }
15091        let Some(record_end) = remaining.iter().position(|byte| *byte == 0) else {
15092            snapshot.truncated = true;
15093            break;
15094        };
15095        let record = &remaining[..record_end];
15096        remaining = &remaining[record_end + 1..];
15097        let has_previous_path = record
15098            .get(..2)
15099            .is_some_and(|status| status.iter().any(|byte| matches!(*byte, b'R' | b'C')));
15100        let previous_path_bytes = if has_previous_path {
15101            let Some(previous_end) = remaining.iter().position(|byte| *byte == 0) else {
15102                snapshot.truncated = true;
15103                break;
15104            };
15105            let previous = &remaining[..previous_end];
15106            remaining = &remaining[previous_end + 1..];
15107            Some(previous)
15108        } else {
15109            None
15110        };
15111        if record.len() < 4
15112            || record[2] != b' '
15113            || !is_git_status_code(record[0])
15114            || !is_git_status_code(record[1])
15115        {
15116            snapshot.truncated = true;
15117            continue;
15118        }
15119        let Some(path) = windows_repository_path_from_bytes(&record[3..]) else {
15120            snapshot.truncated = true;
15121            continue;
15122        };
15123        let previous_path = match previous_path_bytes {
15124            Some(previous) => {
15125                let Some(previous) = windows_repository_path_from_bytes(previous) else {
15126                    snapshot.truncated = true;
15127                    continue;
15128                };
15129                Some(previous)
15130            }
15131            None => None,
15132        };
15133        snapshot.status.push(GitStatusEntry {
15134            index_status: (record[0] as char).to_string(),
15135            worktree_status: (record[1] as char).to_string(),
15136            path,
15137            previous_path,
15138        });
15139    }
15140}
15141
15142fn is_git_status_code(byte: u8) -> bool {
15143    matches!(byte, b' ' | b'M' | b'T' | b'A' | b'D' | b'R' | b'C' | b'U' | b'?' | b'!')
15144}
15145
15146fn parse_git_commits(output: &[u8], snapshot: &mut GitSnapshot) {
15147    for line in String::from_utf8_lossy(output).lines() {
15148        let Some((id, summary)) = line.split_once('\u{1f}') else {
15149            continue;
15150        };
15151        if snapshot.recent_commits.len() >= GIT_COMMIT_MAX_ENTRIES {
15152            snapshot.truncated = true;
15153            break;
15154        }
15155        let id = truncate_git_field(id, 64, &mut snapshot.truncated);
15156        let summary = truncate_git_field(summary, 512, &mut snapshot.truncated);
15157        snapshot.recent_commits.push(GitCommitSummary { id, summary });
15158    }
15159}
15160
15161fn truncate_git_field(value: &str, max_bytes: usize, truncated: &mut bool) -> String {
15162    if value.len() <= max_bytes {
15163        return value.to_owned();
15164    }
15165    *truncated = true;
15166    let mut boundary = max_bytes;
15167    while boundary > 0 && !value.is_char_boundary(boundary) {
15168        boundary -= 1;
15169    }
15170    value[..boundary].to_owned()
15171}
15172
15173fn append_git_diagnostic(snapshot: &mut GitSnapshot, message: &str) {
15174    if message.is_empty() {
15175        return;
15176    }
15177    let normalized = message
15178        .chars()
15179        .map(|character| if character.is_control() { ' ' } else { character })
15180        .collect::<String>();
15181    let mut diagnostic = snapshot.diagnostic.take().unwrap_or_default();
15182    if !diagnostic.is_empty() {
15183        diagnostic.push_str("; ");
15184    }
15185    diagnostic.push_str(normalized.trim());
15186    snapshot.diagnostic = Some(truncate_git_field(
15187        &diagnostic,
15188        GIT_DIAGNOSTIC_MAX_BYTES,
15189        &mut snapshot.truncated,
15190    ));
15191}
15192
15193async fn run_git_bounded(
15194    root: &str,
15195    arguments: &[&str],
15196    output_limit: usize,
15197) -> io::Result<GitCommandOutput> {
15198    run_git_read_bounded(root, arguments, output_limit, GIT_COMMAND_TIMEOUT_MS).await
15199}
15200
15201async fn read_git_history_bounded(
15202    root: &str,
15203    path: Option<&RepositoryPath>,
15204    before: Option<&GitObjectId>,
15205    limit: u16,
15206) -> Result<GitHistoryPage, NodeFailure> {
15207    let path = path
15208        .map(|path| {
15209            path.as_utf8().ok_or_else(|| {
15210                failure(
15211                    NodeFailureCode::InvalidRepositoryPath,
15212                    "git history path must use UTF-8",
15213                )
15214            })
15215        })
15216        .transpose()?
15217        .unwrap_or(".");
15218    let count = (usize::from(limit) + 1).to_string();
15219    let start = before
15220        .map(|revision| format!("{}^", revision.as_str()))
15221        .unwrap_or_else(|| "HEAD".to_owned());
15222    let arguments = [
15223        "--no-pager",
15224        "log",
15225        "--no-decorate",
15226        "--date=iso-strict",
15227        "--pretty=format:%H%x1f%P%x1f%s%x1f%an%x1f%ae%x1f%aI%x1f%cn%x1f%ce%x1f%cI%x1e",
15228        "-n",
15229        count.as_str(),
15230        start.as_str(),
15231        "--",
15232        path,
15233    ];
15234    let output = run_git_bounded(root, &arguments, 256 * 1_024)
15235        .await
15236        .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git history command failed"))?;
15237    if output.timed_out {
15238        return Err(failure(NodeFailureCode::GitReadTimedOut, "git history exceeded its bounded deadline"));
15239    }
15240    if !output.success {
15241        return Err(failure(NodeFailureCode::GitReadFailed, "git history could not be read"));
15242    }
15243    let mut commits = parse_git_history(&output.stdout);
15244    let has_more = commits.len() > usize::from(limit) || output.truncated;
15245    commits.truncate(usize::from(limit));
15246    let next_before = has_more.then(|| commits.last().map(|commit| commit.id.clone())).flatten();
15247    Ok(GitHistoryPage { commits, next_before, truncated: output.truncated })
15248}
15249
15250fn parse_git_history(output: &[u8]) -> Vec<GitCommitDetails> {
15251    String::from_utf8_lossy(output)
15252        .split('\u{1e}')
15253        .filter_map(|record| {
15254            let fields = record.trim_matches(['\r', '\n']).split('\u{1f}').collect::<Vec<_>>();
15255            if fields.len() != 9 {
15256                return None;
15257            }
15258            let id = GitObjectId::new(fields[0].to_owned()).ok()?;
15259            let parents = if fields[1].is_empty() {
15260                Vec::new()
15261            } else {
15262                fields[1]
15263                    .split(' ')
15264                    .map(|value| GitObjectId::new(value.to_owned()))
15265                    .collect::<Result<Vec<_>, _>>()
15266                    .ok()?
15267            };
15268            Some(GitCommitDetails {
15269                id,
15270                parents,
15271                subject: bounded_git_text(fields[2], 512),
15272                author_name: bounded_git_text(fields[3], 256),
15273                author_email: bounded_git_text(fields[4], 320),
15274                authored_at: bounded_git_text(fields[5], 64),
15275                committer_name: bounded_git_text(fields[6], 256),
15276                committer_email: bounded_git_text(fields[7], 320),
15277                committed_at: bounded_git_text(fields[8], 64),
15278                signature_status: GitSignatureStatus::NoSignature,
15279                signer: None,
15280            })
15281        })
15282        .take(usize::from(MAX_GIT_HISTORY_COMMITS) + 1)
15283        .collect()
15284}
15285
15286fn bounded_git_text(value: &str, max_bytes: usize) -> String {
15287    let normalized = value
15288        .chars()
15289        .map(|character| if character.is_control() { ' ' } else { character })
15290        .collect::<String>();
15291    if normalized.len() <= max_bytes {
15292        return normalized;
15293    }
15294    let mut boundary = max_bytes;
15295    while boundary > 0 && !normalized.is_char_boundary(boundary) {
15296        boundary -= 1;
15297    }
15298    normalized[..boundary].to_owned()
15299}
15300
15301async fn read_git_diff_bounded(root: &str, request: GitDiffRequest) -> Result<GitDiff, NodeFailure> {
15302    let path = request.path.as_ref().map(|path| {
15303        path.as_utf8()
15304            .ok_or_else(|| failure(NodeFailureCode::InvalidRepositoryPath, "git diff path must use UTF-8"))
15305    }).transpose()?;
15306    let mut arguments = match &request.mode {
15307        GitDiffMode::Working => vec!["--no-pager", "-c", "diff.external=", "diff", "--no-ext-diff", "--no-textconv"],
15308        GitDiffMode::Staged => vec!["--no-pager", "-c", "diff.external=", "diff", "--cached", "--no-ext-diff", "--no-textconv"],
15309        GitDiffMode::Commit { .. } => vec!["--no-pager", "-c", "diff.external=", "show", "--format=fuller", "--no-ext-diff", "--no-textconv"],
15310    };
15311    if let GitDiffMode::Commit { revision } = &request.mode {
15312        arguments.push(revision.as_str());
15313    }
15314    arguments.push("--");
15315    if let Some(path) = path {
15316        arguments.push(path);
15317    }
15318    let output = run_git_bounded(root, &arguments, MAX_GIT_DIFF_BYTES)
15319        .await
15320        .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git diff command failed"))?;
15321    if output.timed_out {
15322        return Err(failure(NodeFailureCode::GitReadTimedOut, "git diff exceeded its bounded deadline"));
15323    }
15324    if !output.success {
15325        return Err(failure(NodeFailureCode::GitReadFailed, "git diff could not be read"));
15326    }
15327    Ok(GitDiff {
15328        mode: request.mode,
15329        path: request.path,
15330        text: String::from_utf8_lossy(&output.stdout).into_owned(),
15331        truncated: output.truncated,
15332    })
15333}
15334
15335async fn process_request(shared: &NodeShared, connection_id: u64, role: ClientRole, envelope: RequestEnvelope) -> ResponseEnvelope {
15336    let result = process_request_inner(shared, connection_id, role, envelope.request).await;
15337    ResponseEnvelope { request_id: envelope.request_id, result }
15338}
15339
15340async fn process_request_inner(shared: &NodeShared, connection_id: u64, role: ClientRole, request: NodeRequest) -> Result<NodeResponse, NodeFailure> {
15341    if !request.harness_mcp_contract_is_valid_at(unix_time_ms()) {
15342        return Err(failure(NodeFailureCode::InvalidRequest, "harness MCP request is invalid or stale"));
15343    }
15344    let read_only = matches!(
15345        &request,
15346        NodeRequest::Snapshot
15347            | NodeRequest::Resync { .. }
15348            | NodeRequest::BrowseHostDirectories { .. }
15349            | NodeRequest::InspectWorkspace { .. }
15350            | NodeRequest::ReadWorkspaceFile { .. }
15351            | NodeRequest::ReadGitHistory { .. }
15352            | NodeRequest::ReadGitDiff { .. }
15353            | NodeRequest::CatalogNativeSessions { .. }
15354            | NodeRequest::PageNativeSessions { .. }
15355            | NodeRequest::PreviewNativeSession { .. }
15356            | NodeRequest::PreviewSessionRecord { .. }
15357    );
15358    let _mutation_guard = if read_only {
15359        None
15360    } else {
15361        let guard = shared.mutation_gate.lock().await;
15362        if shared.shutdown.load(Ordering::Acquire) {
15363            return Err(failure(NodeFailureCode::ShuttingDown, "node shutdown has begun"));
15364        }
15365        Some(guard)
15366    };
15367    match request {
15368        NodeRequest::Snapshot => Ok(NodeResponse::Snapshot {
15369            event_sequence: shared.current_sequence(),
15370            controller: shared.controller_state(),
15371            snapshot: shared.snapshot(),
15372        }),
15373        NodeRequest::Resync { after_sequence } => Ok(shared.resync(after_sequence)),
15374        NodeRequest::ArmHarnessMcpReservation {
15375            reservation_id,
15376            activation_digest,
15377            spawn_spec,
15378            launch,
15379            expires_at_unix_ms,
15380        } => {
15381            shared.require_controller(connection_id, role)?;
15382            let registry = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15383                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15384            })?;
15385            let expires_at_unix_ms = registry
15386                .arm(reservation_id.clone(), activation_digest.clone(), spawn_spec, launch, expires_at_unix_ms)
15387                .await
15388                .map_err(harness_mcp_failure)?;
15389            Ok(NodeResponse::Armed { reservation_id, activation_digest, expires_at_unix_ms })
15390        }
15391        NodeRequest::SpawnSpecWithHarnessMcp {
15392            reservation_id,
15393            activation_digest,
15394            spec,
15395            deadline_unix_ms,
15396        } => {
15397            shared.require_controller(connection_id, role)?;
15398            let receipt = shared
15399                .spawn_from_spec_with_harness_mcp(
15400                    reservation_id.clone(), activation_digest.clone(), spec, deadline_unix_ms,
15401                )
15402                .await?;
15403            Ok(NodeResponse::Spawned { reservation_id, activation_digest, receipt })
15404        }
15405        NodeRequest::ActivateHarnessMcpReservation {
15406            reservation_id,
15407            activation_digest,
15408            record_id,
15409            session,
15410        } => {
15411            shared.require_controller(connection_id, role)?;
15412            let provider_root_pid = shared.handle.snapshot().sessions.iter().find(|current| {
15413                current.instance_id == session.session.instance_id
15414                    && current.generation == session.session.generation
15415            }).and_then(|current| current.process_id).ok_or_else(|| {
15416                failure(NodeFailureCode::BindingMismatch, "provider root process is unavailable")
15417            })?;
15418            shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15419                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15420            })?.activate(
15421                &reservation_id, &activation_digest, &record_id, &session, provider_root_pid,
15422            ).map_err(harness_mcp_failure)?;
15423            Ok(NodeResponse::Activated { reservation_id, activation_digest, record_id, session })
15424        }
15425        NodeRequest::AbortHarnessMcpReservation { reservation_id, activation_digest } => {
15426            shared.require_controller(connection_id, role)?;
15427            let instance_id = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15428                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15429            })?.abort(&reservation_id, &activation_digest).map_err(harness_mcp_failure)?;
15430            if let (Some(control), Some(instance_id)) =
15431                (shared.native_launch_profile_control.as_ref(), instance_id)
15432            {
15433                control.clear_native_mcp_server_launch_overlay(instance_id);
15434            }
15435            Ok(NodeResponse::Aborted { reservation_id, activation_digest })
15436        }
15437        NodeRequest::PutHarnessMcpReplyChunk {
15438            reservation_id, activation_digest, record_id, session, call_id,
15439            offset, final_chunk, chunk_hex,
15440        } => {
15441            shared.require_controller(connection_id, role)?;
15442            let (next_offset, completed) = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15443                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15444            })?.put_reply_chunk(
15445                &reservation_id, &activation_digest, &record_id, &session, &call_id,
15446                offset, final_chunk, &chunk_hex,
15447            ).map_err(harness_mcp_failure)?;
15448            Ok(NodeResponse::ReplyChunkAccepted {
15449                reservation_id, activation_digest, record_id, session, call_id,
15450                next_offset, completed,
15451            })
15452        }
15453        NodeRequest::RejectHarnessMcpCall {
15454            reservation_id, activation_digest, record_id, session, call_id, reason,
15455        } => {
15456            shared.require_controller(connection_id, role)?;
15457            shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15458                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15459            })?.reject_call(
15460                &reservation_id, &activation_digest, &record_id, &session, &call_id, reason,
15461            ).map_err(harness_mcp_failure)?;
15462            Ok(NodeResponse::CallRejected {
15463                reservation_id, activation_digest, record_id, session, call_id,
15464            })
15465        }
15466        NodeRequest::BeginDeliveryStage { manifest } => {
15467            shared.require_controller(connection_id, role)?;
15468            shared.begin_delivery_stage(manifest)
15469        }
15470        NodeRequest::PutDeliveryBlobChunk {
15471            stage_id,
15472            blob_digest,
15473            offset,
15474            chunk_hex,
15475        } => {
15476            shared.require_controller(connection_id, role)?;
15477            shared.put_delivery_blob_chunk(stage_id, blob_digest, offset, chunk_hex)
15478        }
15479        NodeRequest::CommitDeliveryStage { stage_id } => {
15480            shared.require_controller(connection_id, role)?;
15481            let receipt = shared.commit_delivery_stage(stage_id)?;
15482            Ok(NodeResponse::DeliveryCommitted { receipt })
15483        }
15484        NodeRequest::AbortDeliveryStage { stage_id } => {
15485            shared.require_controller(connection_id, role)?;
15486            shared.abort_delivery_stage(&stage_id)?;
15487            Ok(NodeResponse::DeliveryStageAborted { stage_id })
15488        }
15489        NodeRequest::BrowseHostDirectories { directory, after } => {
15490            let listing = shared.browse_host_directories(directory, after).await?;
15491            Ok(NodeResponse::HostDirectoriesBrowsed { listing })
15492        }
15493        NodeRequest::InspectWorkspace { workspace_id } => {
15494            let inspection = shared.inspect_workspace(workspace_id).await?;
15495            Ok(NodeResponse::WorkspaceInspected { inspection })
15496        }
15497        NodeRequest::ReadWorkspaceFile { workspace_id, path } => {
15498            let file = shared.read_workspace_file(workspace_id, path).await?;
15499            Ok(NodeResponse::WorkspaceFileRead { file })
15500        }
15501        NodeRequest::WriteWorkspaceFile { workspace_id, path, expected_revision, text } => {
15502            shared.require_controller(connection_id, role)?;
15503            let file = shared
15504                .write_workspace_file(workspace_id, path, expected_revision, text)
15505                .await?;
15506            Ok(NodeResponse::WorkspaceFileWritten { file })
15507        }
15508        NodeRequest::CreateWorkspaceFile { workspace_id, path } => {
15509            shared.require_controller(connection_id, role)?;
15510            let file = shared.create_workspace_file(workspace_id, path).await?;
15511            Ok(NodeResponse::WorkspaceFileCreated { file })
15512        }
15513        NodeRequest::CreateWorkspaceDirectory { workspace_id, path } => {
15514            shared.require_controller(connection_id, role)?;
15515            let entry = shared
15516                .create_workspace_directory(workspace_id.clone(), path)
15517                .await?;
15518            Ok(NodeResponse::WorkspaceDirectoryCreated { workspace_id, entry })
15519        }
15520        NodeRequest::ReadGitHistory { workspace_id, path, before, limit } => {
15521            if role != ClientRole::Operator {
15522                return Err(failure(NodeFailureCode::ObserverReadOnly, "git history requires an operator connection"));
15523            }
15524            if !(1..=MAX_GIT_HISTORY_COMMITS).contains(&limit) {
15525                return Err(failure(NodeFailureCode::InvalidRequest, "git history limit is invalid"));
15526            }
15527            let page = shared
15528                .read_git_history(workspace_id.clone(), path, before, limit)
15529                .await?;
15530            Ok(NodeResponse::GitHistoryRead { workspace_id, page })
15531        }
15532        NodeRequest::ReadGitDiff { workspace_id, request } => {
15533            if role != ClientRole::Operator {
15534                return Err(failure(NodeFailureCode::ObserverReadOnly, "git diff requires an operator connection"));
15535            }
15536            let diff = shared.read_git_diff(workspace_id.clone(), request).await?;
15537            Ok(NodeResponse::GitDiffRead { workspace_id, diff })
15538        }
15539        NodeRequest::CatalogNativeSessions { route, limit } => {
15540            if role != ClientRole::Operator {
15541                return Err(failure(
15542                    NodeFailureCode::ObserverReadOnly,
15543                    "native session catalog requires an operator connection",
15544                ));
15545            }
15546            if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit) {
15547                return Err(failure(
15548                    NodeFailureCode::InvalidRequest,
15549                    "native session catalog limit is invalid",
15550                ));
15551            }
15552            let (entries, summary) = shared
15553                .catalog_native_sessions(route.clone(), limit)
15554                .await?;
15555            Ok(NodeResponse::NativeSessionsCataloged {
15556                route,
15557                entries,
15558                summary: Some(summary),
15559            })
15560        }
15561        NodeRequest::PageNativeSessions {
15562            route,
15563            window,
15564            catalog_revision,
15565            recent_cutoff_unix_ms,
15566            after_selection_id,
15567            limit,
15568        } => {
15569            if role != ClientRole::Operator {
15570                return Err(failure(
15571                    NodeFailureCode::ObserverReadOnly,
15572                    "native session catalog paging requires an operator connection",
15573                ));
15574            }
15575            if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit)
15576                || after_selection_id
15577                    .as_deref()
15578                    .is_some_and(|cursor| validate_candidate_id(cursor).is_err())
15579            {
15580                return Err(failure(
15581                    NodeFailureCode::InvalidRequest,
15582                    "native session catalog page request is invalid",
15583                ));
15584            }
15585            let page = shared
15586                .page_native_sessions(
15587                    route.clone(),
15588                    window,
15589                    catalog_revision,
15590                    recent_cutoff_unix_ms,
15591                    after_selection_id,
15592                    limit,
15593                )
15594                .await?;
15595            Ok(NodeResponse::NativeSessionsPaged {
15596                route,
15597                page,
15598            })
15599        }
15600        NodeRequest::PreviewNativeSession {
15601            selection,
15602            message_limit,
15603        } => {
15604            if role != ClientRole::Operator {
15605                return Err(failure(
15606                    NodeFailureCode::ObserverReadOnly,
15607                    "native session preview requires an operator connection",
15608                ));
15609            }
15610            if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
15611                .contains(&message_limit)
15612                || selection.validate().is_err()
15613            {
15614                return Err(failure(
15615                    NodeFailureCode::InvalidRequest,
15616                    "native session preview request is invalid",
15617                ));
15618            }
15619            let preview = shared
15620                .preview_native_session(selection.clone(), message_limit)
15621                .await?;
15622            Ok(NodeResponse::NativeSessionPreviewed {
15623                selection,
15624                preview,
15625            })
15626        }
15627        NodeRequest::IndexNativeSession {
15628            selection,
15629            display_name,
15630        } => {
15631            shared.require_controller(connection_id, role)?;
15632            let record = shared
15633                .index_native_session(selection.clone(), display_name)
15634                .await?;
15635            Ok(NodeResponse::NativeSessionIndexed { selection, record })
15636        }
15637        NodeRequest::PreviewSessionRecord {
15638            record_id,
15639            message_limit,
15640        } => {
15641            if role != ClientRole::Operator {
15642                return Err(failure(
15643                    NodeFailureCode::ObserverReadOnly,
15644                    "native session preview requires an operator connection",
15645                ));
15646            }
15647            if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
15648                .contains(&message_limit)
15649            {
15650                return Err(failure(
15651                    NodeFailureCode::InvalidRequest,
15652                    "native session preview request is invalid",
15653                ));
15654            }
15655            let record = shared.record(&record_id)?;
15656            let working_directory = shared.workspace_root(&record.workspace_id)?;
15657            if !platform::roots_equal(
15658                &working_directory,
15659                windows_path_text(&record.canonical_root),
15660            ) {
15661                return Err(failure(
15662                    NodeFailureCode::SessionRecordConflict,
15663                    "managed session workspace root changed; refusing to preview another directory",
15664                ));
15665            }
15666            let identity = record.provider_session.clone().ok_or_else(|| {
15667                failure(
15668                    NodeFailureCode::BackendOperationFailed,
15669                    "native session preview is unavailable for this session record",
15670                )
15671            })?;
15672            if identity.key != ProviderSessionKey::SessionId {
15673                return Err(failure(
15674                    NodeFailureCode::BackendOperationFailed,
15675                    "native session preview is unavailable for this session identity",
15676                ));
15677            }
15678            let preview = shared
15679                .preview_native_session_by_session_id(
15680                    record.workspace_id.clone(), record.provider.clone(), identity.id.clone(), message_limit,
15681                )
15682                .await?;
15683            if preview.session_id != identity.id {
15684                return Err(failure(
15685                    NodeFailureCode::SessionRecordConflict,
15686                    "native session preview identity changed while loading",
15687                ));
15688            }
15689            shared.revalidate_session_record_preview(&record, &identity)?;
15690            let preview: gate4agent_types::SessionRecordPreview = preview.into();
15691            shared.publish(NodeEvent::SessionRecordHistorySummarized {
15692                record_id: record_id.clone(),
15693                summary: SessionHistorySummaryV1::from(&preview),
15694            });
15695            Ok(NodeResponse::SessionRecordPreviewed {
15696                record_id,
15697                preview,
15698            })
15699        }
15700        NodeRequest::AcquireController { lease_ms } => {
15701            let controller = shared.acquire_controller(connection_id, role, lease_ms)?;
15702            Ok(NodeResponse::Controller { controller: Some(controller) })
15703        }
15704        NodeRequest::ReleaseController => {
15705            shared.release_controller(connection_id);
15706            Ok(NodeResponse::Controller { controller: shared.controller_state() })
15707        }
15708        NodeRequest::RegisterWorkspace { workspace_id, root } => {
15709            shared.require_controller(connection_id, role)?;
15710            let root = require_windows_path(root)?;
15711            shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
15712            let workspace = shared.register_workspace(workspace_id, root).await?;
15713            Ok(NodeResponse::WorkspaceRegistered { workspace })
15714        }
15715        NodeRequest::CreateStandaloneWorkspace {
15716            workspace_id,
15717            root,
15718            initial_branch,
15719        } => {
15720            shared.require_controller(connection_id, role)?;
15721            let root = require_windows_path(root)?;
15722            shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
15723            let workspace = shared
15724                .create_standalone_workspace(workspace_id, root, initial_branch)
15725                .await?;
15726            Ok(NodeResponse::StandaloneWorkspaceCreated { workspace })
15727        }
15728        NodeRequest::UnregisterWorkspace { workspace_id } => {
15729            shared.require_controller(connection_id, role)?;
15730            shared.reject_managed_reservation(Some(&workspace_id), None)?;
15731            shared.unregister_workspace(&workspace_id)?;
15732            Ok(NodeResponse::WorkspaceUnregistered { workspace_id })
15733        }
15734        NodeRequest::CreateWorktree {
15735            source_workspace_id,
15736            workspace_id,
15737            target_root,
15738            branch,
15739            base,
15740        } => {
15741            shared.require_controller(connection_id, role)?;
15742            let target_root = require_windows_path(target_root)?;
15743            shared.reject_managed_reservation(Some(&workspace_id), Some(&target_root))?;
15744            let (worktree, workspace) = shared
15745                .create_worktree(
15746                    source_workspace_id,
15747                    workspace_id,
15748                    target_root,
15749                    branch,
15750                    base,
15751                )
15752                .await?;
15753            Ok(NodeResponse::WorktreeCreated { worktree, workspace })
15754        }
15755        NodeRequest::RemoveWorktree {
15756            source_workspace_id,
15757            target_root,
15758        } => {
15759            shared.require_controller(connection_id, role)?;
15760            let native_target_root = require_windows_path(target_root.clone())?;
15761            shared.reject_managed_reservation(None, Some(&native_target_root))?;
15762            let workspace_id = shared
15763                .remove_worktree(source_workspace_id, native_target_root)
15764                .await?;
15765            Ok(NodeResponse::WorktreeRemoved {
15766                target_root,
15767                workspace_id,
15768            })
15769        }
15770        NodeRequest::Spawn { workspace_id, provider, mode, terminal_size, initial_prompt } => {
15771            shared.require_controller(connection_id, role)?;
15772            if !terminal_size.is_valid() {
15773                return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
15774            }
15775            if let Some(prompt) = initial_prompt.as_deref() {
15776                validate_node_text("spawn initial prompt", prompt)?;
15777            }
15778            let session = shared
15779                .spawn_session(workspace_id, provider, mode, terminal_size, initial_prompt)
15780                .await?;
15781            Ok(NodeResponse::SpawnAccepted { session })
15782        }
15783        NodeRequest::SpawnSpec { spec } => {
15784            shared.require_controller(connection_id, role)?;
15785            let receipt = shared.spawn_from_spec(spec).await?;
15786            Ok(NodeResponse::SpawnSpecAccepted { receipt })
15787        }
15788        NodeRequest::SpawnManagedWorktree { request } => {
15789            shared.require_controller(connection_id, role)?;
15790            let receipt = shared.spawn_managed_worktree(request).await?;
15791            Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
15792        }
15793        NodeRequest::SpawnManagedWorktreeV2 { request } => {
15794            shared.require_controller(connection_id, role)?;
15795            let receipt = shared.spawn_managed_worktree_v2(request).await?;
15796            Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
15797        }
15798        NodeRequest::CleanupManagedWorktree { lease_id } => {
15799            shared.require_controller(connection_id, role)?;
15800            let lease = shared.cleanup_managed_worktree(&lease_id, true).await?;
15801            Ok(NodeResponse::ManagedWorktreeCleanup { lease })
15802        }
15803        NodeRequest::DiscoverHistory { session, limit } => {
15804            controlled_session(shared, connection_id, role, &session)?;
15805            let candidates = shared.discover_history(&session, limit).await?;
15806            Ok(NodeResponse::HistoryDiscovered {
15807                session,
15808                candidates,
15809            })
15810        }
15811        NodeRequest::LoadHistory {
15812            session,
15813            candidate_id,
15814        } => {
15815            controlled_session(shared, connection_id, role, &session)?;
15816            let loaded = shared
15817                .load_history(&session, candidate_id)
15818                .await?;
15819            Ok(NodeResponse::HistoryLoaded {
15820                session,
15821                session_id: loaded.session_id,
15822                message_count: loaded.message_count,
15823                completed_turn_count: loaded.completed_turn_count,
15824            })
15825        }
15826        NodeRequest::ExportContextPackForSessionRecord { record_id, session } => {
15827            shared.require_controller(connection_id, role)?;
15828            let context = shared
15829                .export_context_pack_for_session_record(&record_id, &session, false)
15830                .await?;
15831            Ok(NodeResponse::ContextPackForSessionRecordExported {
15832                record_id,
15833                session,
15834                context,
15835            })
15836        }
15837        NodeRequest::ExportContextPack { session } => {
15838            controlled_session(shared, connection_id, role, &session)?;
15839            let context = shared.export_context_pack(&session).await?;
15840            Ok(NodeResponse::ContextPackExported { context })
15841        }
15842        NodeRequest::ForgetContextPack { context_id } => {
15843            shared.require_controller(connection_id, role)?;
15844            shared.forget_context_pack(&context_id)?;
15845            Ok(NodeResponse::ContextPackForgotten { context_id })
15846        }
15847        NodeRequest::ResolveDurableContextPack { context_id } => {
15848            // Read-only, idempotent, no controller lease required: this lets
15849            // a caller preflight "is this durably exported pack still here"
15850            // without holding the Node's mutation controller.
15851            let context = shared
15852                .context_catalog
15853                .read()
15854                .unwrap_or_else(|poisoned| poisoned.into_inner())
15855                .get(&context_id)
15856                .map(|pack| pack.receipt().clone())
15857                .ok_or_else(|| {
15858                    failure(
15859                        NodeFailureCode::UnknownContextPack,
15860                        "context pack is not present",
15861                    )
15862                })?;
15863            Ok(NodeResponse::DurableContextPackResolved { context })
15864        }
15865        NodeRequest::ReadContextPack { digest } => {
15866            // Read-only fetch of the pack's own bytes, keyed by content digest
15867            // rather than the caller's `SpawnContextId`: same no-controller-lease
15868            // rationale as `ResolveDurableContextPack` above, one step further
15869            // down the same catalog.
15870            let (id, byte_len, bytes) = shared
15871                .context_catalog
15872                .read()
15873                .unwrap_or_else(|poisoned| poisoned.into_inner())
15874                .get_by_digest(&digest)
15875                .map(|pack| {
15876                    (pack.receipt().id.clone(), pack.receipt().byte_len, pack.bytes().to_vec())
15877                })
15878                .ok_or_else(|| {
15879                    failure(
15880                        NodeFailureCode::UnknownContextPack,
15881                        &format!("context pack {digest} not on this node"),
15882                    )
15883                })?;
15884            tracing::debug!(
15885                digest = digest.as_str(),
15886                byte_len,
15887                "read context pack bytes",
15888            );
15889            Ok(NodeResponse::ContextPackBytesRead {
15890                pack: ContextPackBytesRead {
15891                    digest,
15892                    id,
15893                    byte_len,
15894                    bytes,
15895                },
15896            })
15897        }
15898        NodeRequest::Resume { session, terminal_size, initial_prompt } => {
15899            let provider = controlled_session(shared, connection_id, role, &session)?;
15900            if let Some(prompt) = initial_prompt.as_deref() {
15901                validate_node_text("resume initial prompt", prompt)?;
15902            }
15903            let runtime_requirement = if initial_prompt.is_some() {
15904                ProviderRuntimeRequirement::ResumeWithPrompt
15905            } else {
15906                ProviderRuntimeRequirement::Resume
15907            };
15908            shared.require_session_runtime_policy(&session, runtime_requirement)?;
15909            let runtime_policy = shared
15910                .admit_provider_runtime(&provider, runtime_requirement)
15911                .await?;
15912            let working_directory = shared.bound_workspace_root(&session)?;
15913            let request = ResumeLaunchRequest {
15914                working_directory,
15915                terminal_size,
15916                initial_prompt,
15917            };
15918            request
15919                .validate()
15920                .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
15921            let command = shared.prepare_command(ControlCommand::Resume {
15922                instance_id: session.session.instance_id,
15923                target: ResumeTarget::CurrentProvider,
15924                runtime_policy,
15925                request,
15926            });
15927            shared.arm_resume(&session, command.id, runtime_policy)?;
15928            let dispatch = shared.dispatch_envelope(command);
15929            if let Err(error) = dispatch {
15930                shared.clear_armed_resume(&session);
15931                return Err(error);
15932            }
15933            Ok(NodeResponse::Accepted)
15934        }
15935        NodeRequest::RenameSessionRecord {
15936            record_id,
15937            display_name,
15938        } => {
15939            shared.require_controller(connection_id, role)?;
15940            let record = shared.rename_session_record(&record_id, display_name)?;
15941            Ok(NodeResponse::SessionRecordUpdated { record })
15942        }
15943        NodeRequest::SetSessionTask {
15944            record_id,
15945            expected_revision,
15946            target,
15947        } => {
15948            shared.require_controller(connection_id, role)?;
15949            let record = shared.set_session_task(&record_id, expected_revision, target)?;
15950            Ok(NodeResponse::SessionRecordUpdated { record })
15951        }
15952        NodeRequest::IndexProviderSession {
15953            workspace_id,
15954            provider,
15955            identity,
15956            display_name,
15957        } => {
15958            shared.require_controller(connection_id, role)?;
15959            let record = shared.index_provider_session(
15960                workspace_id,
15961                provider,
15962                identity,
15963                display_name,
15964            )?;
15965            Ok(NodeResponse::ProviderSessionIndexed { record })
15966        }
15967        NodeRequest::ResumeSessionRecord {
15968            record_id,
15969            terminal_size,
15970            initial_prompt,
15971        } => {
15972            shared.require_controller(connection_id, role)?;
15973            if let Some(prompt) = initial_prompt.as_deref() {
15974                validate_node_text("managed resume initial prompt", prompt)?;
15975            }
15976            let (record, session) = shared
15977                .resume_session_record(
15978                    &record_id,
15979                    terminal_size,
15980                    initial_prompt,
15981                )
15982                .await?;
15983            Ok(NodeResponse::SessionRecordResumed { record, session })
15984        }
15985        NodeRequest::ForgetSessionRecord { record_id } => {
15986            shared.require_controller(connection_id, role)?;
15987            shared.forget_session_record(&record_id).await?;
15988            Ok(NodeResponse::SessionRecordForgotten { record_id })
15989        }
15990        NodeRequest::Prompt { session, text } => {
15991            let agent_id = controlled_session(shared, connection_id, role, &session)?;
15992            validate_node_text("prompt", &text)?;
15993            shared.require_session_runtime_policy(
15994                &session,
15995                ProviderRuntimeRequirement::SemanticPrompt,
15996            )?;
15997            let framing = prompt_framing(&agent_id);
15998            shared
15999                .dispatch_input_bounded(
16000                    &session,
16001                    InputAction::SubmitPrompt(PromptPayload { text, framing }),
16002                )
16003                .await?;
16004            Ok(NodeResponse::Accepted)
16005        }
16006        NodeRequest::Paste { session, text } => {
16007            controlled_session(shared, connection_id, role, &session)?;
16008            validate_node_text("paste", &text)?;
16009            shared.require_session_runtime_policy(
16010                &session,
16011                ProviderRuntimeRequirement::SemanticPrompt,
16012            )?;
16013            shared
16014                .dispatch_input_bounded(
16015                    &session,
16016                    InputAction::InsertDraft(PromptPayload {
16017                        text,
16018                        framing: PromptFraming::BracketedPaste,
16019                    }),
16020                )
16021                .await?;
16022            Ok(NodeResponse::Accepted)
16023        }
16024        NodeRequest::Input { session, text } => {
16025            controlled_session(shared, connection_id, role, &session)?;
16026            validate_node_text("terminal input", &text)?;
16027            shared
16028                .dispatch_input_bounded(
16029                    &session,
16030                    InputAction::TerminalText(TerminalText { text }),
16031                )
16032                .await?;
16033            Ok(NodeResponse::Accepted)
16034        }
16035        NodeRequest::TerminalBytes { session, bytes } => {
16036            controlled_session(shared, connection_id, role, &session)?;
16037            let action = terminal_bytes_action(bytes)?;
16038            shared
16039                .dispatch_input_bounded(&session, action)
16040                .await?;
16041            Ok(NodeResponse::Accepted)
16042        }
16043        NodeRequest::TerminalControl { session, control } => {
16044            controlled_session(shared, connection_id, role, &session)?;
16045            shared
16046                .dispatch_input_bounded(&session, InputAction::TerminalControl(control))
16047                .await?;
16048            Ok(NodeResponse::Accepted)
16049        }
16050        NodeRequest::Resize { session, size } => {
16051            controlled_session(shared, connection_id, role, &session)?;
16052            if !size.is_valid() {
16053                return Err(failure(NodeFailureCode::InvalidRequest, "terminal size is invalid"));
16054            }
16055            shared.dispatch_resize_bounded(&session, size).await?;
16056            Ok(NodeResponse::Accepted)
16057        }
16058        NodeRequest::Interrupt { session } => {
16059            controlled_session(shared, connection_id, role, &session)?;
16060            shared
16061                .dispatch_input_bounded(
16062                    &session,
16063                    InputAction::TerminalControl(TerminalControl::Interrupt),
16064                )
16065                .await?;
16066            Ok(NodeResponse::Accepted)
16067        }
16068        NodeRequest::Stop { session, force } => {
16069            controlled_session(shared, connection_id, role, &session)?;
16070            shared.dispatch(ControlCommand::Stop { instance_id: session.session.instance_id, force })?;
16071            Ok(NodeResponse::Accepted)
16072        }
16073        NodeRequest::Remove { session } => {
16074            controlled_session(shared, connection_id, role, &session)?;
16075            shared.remove_session(&session).await?;
16076            Ok(NodeResponse::Accepted)
16077        }
16078        NodeRequest::ResolveInteraction { session, correlation_id, response } => {
16079            shared.require_controller(connection_id, role)?;
16080            let snapshot = shared.require_acp_session(&session)?;
16081            let interaction = snapshot
16082                .provider
16083                .interactions
16084                .iter()
16085                .find(|interaction| {
16086                    interaction_correlation(
16087                        session.session.instance_id,
16088                        session.session.generation,
16089                        interaction.id.0,
16090                    ) == correlation_id
16091                })
16092                .ok_or_else(|| failure(
16093                    NodeFailureCode::InvalidRequest,
16094                    "unknown interaction correlation",
16095                ))?;
16096            if interaction.status != ProviderInteractionStatus::Pending {
16097                return Err(failure(
16098                    NodeFailureCode::BackendOperationFailed,
16099                    "interaction has already been resolved",
16100                ));
16101            }
16102            response
16103                .validate_for(interaction.interaction_kind)
16104                .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
16105            let interaction_id = interaction.id;
16106            let response_kind = response.kind();
16107            let response_outcome = response.outcome();
16108            shared.dispatch(ControlCommand::ResolveInteraction {
16109                instance_id: session.session.instance_id,
16110                generation: session.session.generation,
16111                interaction_id,
16112                response,
16113            })?;
16114            // The one line that lets an operator compare what THIS node
16115            // recorded against what the provider itself reports doing --
16116            // measured live on a kimi run where our side resolved `true`
16117            // while the agent reported "rejected" and neither layer had a
16118            // line to line up against the other.
16119            tracing::info!(
16120                node_id = %shared.node_id,
16121                session = ?session.session,
16122                correlation_id = %correlation_id,
16123                interaction_id = interaction_id.0,
16124                response_kind = ?response_kind,
16125                outcome = ?response_outcome,
16126                "interaction resolved",
16127            );
16128            Ok(NodeResponse::Accepted)
16129        }
16130        NodeRequest::SetSessionMode { session, mode_id } => {
16131            shared.require_controller(connection_id, role)?;
16132            shared.require_acp_session(&session)?;
16133            shared.dispatch(ControlCommand::SetSessionMode {
16134                instance_id: session.session.instance_id,
16135                mode_id,
16136            })?;
16137            Ok(NodeResponse::Accepted)
16138        }
16139        NodeRequest::SetSessionConfigOption {
16140            session,
16141            option_id,
16142            value_json,
16143        } => {
16144            shared.require_controller(connection_id, role)?;
16145            shared.require_acp_session(&session)?;
16146            shared.dispatch(ControlCommand::SetSessionConfigOption {
16147                instance_id: session.session.instance_id,
16148                option_id,
16149                value_json,
16150            })?;
16151            Ok(NodeResponse::Accepted)
16152        }
16153        NodeRequest::SetSessionModel { session, model_id } => {
16154            shared.require_controller(connection_id, role)?;
16155            shared.require_acp_session(&session)?;
16156            shared.dispatch(ControlCommand::SetSessionModel {
16157                instance_id: session.session.instance_id,
16158                model_id,
16159            })?;
16160            Ok(NodeResponse::Accepted)
16161        }
16162        NodeRequest::Shutdown => {
16163            shared.require_controller(connection_id, role)?;
16164            shared.begin_shutdown_locked().await?;
16165            Ok(NodeResponse::ShuttingDown)
16166        }
16167    }
16168}
16169
16170fn controlled_session(shared: &NodeShared, connection_id: u64, role: ClientRole, session: &SessionAddress) -> Result<AgentId, NodeFailure> {
16171    shared.require_controller(connection_id, role)?;
16172    shared.validate_address(session)
16173}
16174
16175/// Selects the sole history candidate matching `provider_session_id` when
16176/// the managed record carries a verified identity, or the sole candidate in
16177/// the discovered set at all when it does not (see
16178/// `export_context_pack_for_session_record_inner`'s identity match). Either
16179/// way, more than one match refuses instead of guessing which one is exact.
16180fn select_history_candidate(
16181    candidates: &[HistoryCandidateSummary],
16182    provider_session_id: Option<&str>,
16183) -> Result<String, NodeFailure> {
16184    let mut matches = candidates.iter().filter(|candidate| match provider_session_id {
16185        Some(id) => candidate.session_id_hint == id,
16186        None => true,
16187    });
16188    let candidate = matches.next().ok_or_else(|| {
16189        failure(
16190            NodeFailureCode::SessionRecordConflict,
16191            if provider_session_id.is_some() {
16192                "no history candidate matches the managed provider session"
16193            } else {
16194                "no history candidate exists for the managed session"
16195            },
16196        )
16197    })?;
16198    if matches.next().is_some() {
16199        return Err(failure(
16200            NodeFailureCode::SessionRecordConflict,
16201            if provider_session_id.is_some() {
16202                "history identity is ambiguous for the managed provider session"
16203            } else {
16204                "history is ambiguous without a verified provider session identity"
16205            },
16206        ));
16207    }
16208    Ok(candidate.id.clone())
16209}
16210
16211fn resolved_spawn_profile_summary(
16212    profile: &SpawnProfileDefaults,
16213    environment_profiles: &BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>,
16214) -> Option<SpawnProfileSummary> {
16215    let environment_profile = match profile.environment_profile_id.as_ref() {
16216        None => None,
16217        Some(profile_id) => {
16218            let binding = environment_profiles.get(profile_id)?;
16219            if binding.provider != profile.provider
16220                || binding.native_profile_id(profile.mode).is_none()
16221            {
16222                return None;
16223            }
16224            Some(ResolvedEnvironmentProfileReceipt {
16225                profile_id: binding.id.clone(),
16226                profile_revision: binding.revision.clone(),
16227                network_allowlist: None,
16228                browser_profile_id: None,
16229            })
16230        }
16231    };
16232    Some(SpawnProfileSummary {
16233        id: profile.profile_id.clone(),
16234        revision: profile.revision.clone(),
16235        environment_profile,
16236    })
16237}
16238
16239/// True if `current` is an acceptable version of `expected` at export
16240/// revalidation/commit time.
16241///
16242/// When `allow_clean_detachment` is `false` (the on-demand,
16243/// `NodeRequest::ExportContextPackForSessionRecord` API path), `current` must
16244/// be byte-identical to `expected` and still actively bound to `session` —
16245/// the original, unchanged invariant.
16246///
16247/// When `true` (the reactive auto-export-at-exit path, `§2.1`), `current` may
16248/// ALSO be the exact Live -> Dormant transition that the same clean exit this
16249/// export reacts to produces on its own: `state` Live -> Dormant,
16250/// `active_session` `Some(session)` -> `None`, `updated_at_unix_ms`
16251/// advancing. Every other field — including `provider_session`, `context`,
16252/// `bundle`, `task_binding` — must still match exactly. A record that changed
16253/// in any other way (rebind, a different active session, an identity
16254/// conflict, capacity eviction) is rejected either way; this widens nothing
16255/// about which sessions are eligible, only how the record's own expected
16256/// detachment is tolerated mid-export.
16257fn session_record_export_target_matches(
16258    expected: &ManagedSessionRecord,
16259    current: &ManagedSessionRecord,
16260    session: &SessionAddress,
16261    allow_clean_detachment: bool,
16262) -> bool {
16263    if current == expected && current.active_session.as_ref() == Some(session) {
16264        return current.state == ManagedSessionState::Live;
16265    }
16266    allow_clean_detachment
16267        && current.state == ManagedSessionState::Dormant
16268        && current.active_session.is_none()
16269        && current.record_id == expected.record_id
16270        && current.display_name == expected.display_name
16271        && current.provider == expected.provider
16272        && current.mode == expected.mode
16273        && current.workspace_id == expected.workspace_id
16274        && current.canonical_root == expected.canonical_root
16275        && current.provider_session == expected.provider_session
16276        && current.environment_profile == expected.environment_profile
16277        && current.bundle == expected.bundle
16278        && current.context_id == expected.context_id
16279        && current.context == expected.context
16280        && current.exported_context == expected.exported_context
16281        && current.task_binding == expected.task_binding
16282        && current.created_at_unix_ms == expected.created_at_unix_ms
16283        && current.last_error == expected.last_error
16284}
16285
16286fn session_record_context_export_binding_is_exact(
16287    expected: &ManagedSessionRecord,
16288    current: &ManagedSessionRecord,
16289    identity: Option<&ProviderSessionIdentity>,
16290    session: &SessionAddress,
16291    binding: &SessionBinding,
16292    provider: &AgentId,
16293    current_root: &str,
16294    allow_clean_detachment: bool,
16295) -> bool {
16296    session_record_export_target_matches(expected, current, session, allow_clean_detachment)
16297        && current.provider_session.as_ref() == identity
16298        && current.workspace_id == session.workspace_id
16299        && provider == &current.provider
16300        && binding.workspace_id == session.workspace_id
16301        && binding.generation == session.session.generation
16302        && binding.record_id.as_ref() == Some(&current.record_id)
16303        && platform::roots_equal(
16304            current_root,
16305            windows_path_text(&current.canonical_root),
16306        )
16307}
16308
16309fn session_record_context_export_source_is_usable(
16310    record: &ManagedSessionRecord,
16311    status: &SessionStatus,
16312    allow_clean_detachment: bool,
16313) -> bool {
16314    (record.state == ManagedSessionState::Live
16315        || (allow_clean_detachment && record.state == ManagedSessionState::Dormant))
16316        && context_pack_source_status_is_usable(status)
16317}
16318
16319fn prompt_framing(_agent_id: &AgentId) -> PromptFraming {
16320    PromptFraming::BracketedPaste
16321}
16322
16323fn validate_node_text(field: &str, text: &str) -> Result<(), NodeFailure> {
16324    if text.len() > MAX_NODE_TEXT_BYTES {
16325        return Err(failure(
16326            NodeFailureCode::InvalidRequest,
16327            &format!(
16328                "{field} length {} exceeds the {MAX_NODE_TEXT_BYTES}-byte node limit",
16329                text.len(),
16330            ),
16331        ));
16332    }
16333    Ok(())
16334}
16335
16336fn validate_terminal_bytes(bytes: &[u8]) -> Result<(), NodeFailure> {
16337    if bytes.is_empty() || bytes.len() > MAX_NODE_TERMINAL_BYTES {
16338        return Err(failure(
16339            NodeFailureCode::InvalidRequest,
16340            &format!(
16341                "terminal byte sequence must contain 1..={MAX_NODE_TERMINAL_BYTES} bytes; received {}",
16342                bytes.len(),
16343            ),
16344        ));
16345    }
16346    Ok(())
16347}
16348
16349fn terminal_bytes_action(bytes: Vec<u8>) -> Result<InputAction, NodeFailure> {
16350    validate_terminal_bytes(&bytes)?;
16351    Ok(InputAction::TerminalBytes(bytes))
16352}
16353
16354fn validate_workspace_request_root(root: &str) -> Result<(), NodeFailure> {
16355    if root.is_empty()
16356        || root.len() > MAX_WORKSPACE_ROOT_BYTES
16357        || root.chars().any(char::is_control)
16358    {
16359        return Err(failure(
16360            NodeFailureCode::InvalidWorkspaceRoot,
16361            &format!(
16362                "workspace root must contain 1..={MAX_WORKSPACE_ROOT_BYTES} bytes and no control characters",
16363            ),
16364        ));
16365    }
16366    Ok(())
16367}
16368
16369fn canonical_standalone_workspace_candidate(
16370    workspace_id: &WorkspaceId,
16371    root: &str,
16372) -> Result<String, NodeServerError> {
16373    let path = Path::new(root);
16374    match std::fs::symlink_metadata(path) {
16375        Ok(_) => WorkspaceConfig::new(workspace_id.clone(), path)
16376            .map(|workspace| workspace.canonical_root),
16377        Err(error) if error.kind() == io::ErrorKind::NotFound => {
16378            if !path.is_absolute() {
16379                return Err(NodeServerError::InvalidWorkspaceRoot {
16380                    workspace_id: workspace_id.clone(),
16381                    path: root.to_owned(),
16382                    message: "path is not absolute".to_owned(),
16383                });
16384            }
16385            let name = path.file_name().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
16386                workspace_id: workspace_id.clone(),
16387                path: root.to_owned(),
16388                message: "path has no standalone directory name".to_owned(),
16389            })?;
16390            let parent = path.parent().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
16391                workspace_id: workspace_id.clone(),
16392                path: root.to_owned(),
16393                message: "path has no parent directory".to_owned(),
16394            })?;
16395            let canonical = std::fs::canonicalize(parent)
16396                .map(|parent| parent.join(name))
16397                .map_err(|error| NodeServerError::InvalidWorkspaceRoot {
16398                    workspace_id: workspace_id.clone(),
16399                    path: root.to_owned(),
16400                    message: error.to_string(),
16401                })?;
16402            let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
16403                NodeServerError::InvalidWorkspaceRoot {
16404                    workspace_id: workspace_id.clone(),
16405                    path: path.to_string_lossy().into_owned(),
16406                    message: "canonical path is not valid Unicode".to_owned(),
16407                }
16408            })?;
16409            let canonical_root = platform::normalize_canonical_root(canonical_root);
16410            validate_workspace_root(workspace_id, &canonical_root)?;
16411            Ok(canonical_root)
16412        }
16413        Err(error) => Err(NodeServerError::InvalidWorkspaceRoot {
16414            workspace_id: workspace_id.clone(),
16415            path: root.to_owned(),
16416            message: error.to_string(),
16417        }),
16418    }
16419}
16420
16421fn validate_git_revision(field: &str, revision: Option<&str>) -> Result<(), NodeFailure> {
16422    let Some(revision) = revision else {
16423        return Ok(());
16424    };
16425    if revision.is_empty()
16426        || revision.len() > MAX_REPOSITORY_PATH_BYTES
16427        || revision.chars().any(char::is_control)
16428    {
16429        return Err(failure(
16430            NodeFailureCode::InvalidRequest,
16431            &format!(
16432                "{field} must contain 1..={MAX_REPOSITORY_PATH_BYTES} bytes and no control characters",
16433            ),
16434        ));
16435    }
16436    Ok(())
16437}
16438
16439fn git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
16440    let code = match error.kind {
16441        GitWorktreeErrorKind::Invalid => NodeFailureCode::InvalidRequest,
16442        GitWorktreeErrorKind::NotRepository => NodeFailureCode::NotGitRepository,
16443        GitWorktreeErrorKind::Conflict => NodeFailureCode::WorktreeConflict,
16444        GitWorktreeErrorKind::Protected => NodeFailureCode::WorktreeProtected,
16445        GitWorktreeErrorKind::Dirty => NodeFailureCode::WorktreeDirty,
16446        GitWorktreeErrorKind::Locked => NodeFailureCode::WorktreeLocked,
16447        GitWorktreeErrorKind::Failed => NodeFailureCode::BackendOperationFailed,
16448    };
16449    failure(code, &error.message)
16450}
16451
16452fn standalone_workspace_failure(error: StandaloneWorkspaceError) -> NodeFailure {
16453    let code = match error.kind {
16454        StandaloneWorkspaceErrorKind::InvalidRoot => NodeFailureCode::InvalidWorkspaceRoot,
16455        StandaloneWorkspaceErrorKind::InvalidInitialBranch => NodeFailureCode::InvalidRequest,
16456        StandaloneWorkspaceErrorKind::GitInitializationFailed => {
16457            NodeFailureCode::BackendOperationFailed
16458        }
16459        StandaloneWorkspaceErrorKind::RecoveryRequired => {
16460            NodeFailureCode::StandaloneWorkspaceRecoveryRequired
16461        }
16462    };
16463    failure(code, error.message)
16464}
16465
16466fn managed_git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
16467    let (code, message) = match error.kind {
16468        GitWorktreeErrorKind::Invalid => (
16469            NodeFailureCode::InvalidRequest,
16470            "managed Git input is invalid",
16471        ),
16472        GitWorktreeErrorKind::NotRepository => (
16473            NodeFailureCode::NotGitRepository,
16474            "managed source is not a Git repository",
16475        ),
16476        GitWorktreeErrorKind::Conflict => (
16477            NodeFailureCode::WorktreeConflict,
16478            "managed Git identity conflicts with existing repository state",
16479        ),
16480        GitWorktreeErrorKind::Protected => (
16481            NodeFailureCode::ManagedWorktreeOwnershipConflict,
16482            "managed worktree ownership validation failed",
16483        ),
16484        GitWorktreeErrorKind::Dirty => (
16485            NodeFailureCode::WorktreeDirty,
16486            "managed worktree contains uncommitted changes",
16487        ),
16488        GitWorktreeErrorKind::Locked => (
16489            NodeFailureCode::WorktreeLocked,
16490            "managed worktree is locked",
16491        ),
16492        GitWorktreeErrorKind::Failed => (
16493            NodeFailureCode::BackendOperationFailed,
16494            "managed Git operation failed",
16495        ),
16496    };
16497    NodeFailure { code, message: message.to_owned() }
16498}
16499
16500fn managed_spawn_receipt(
16501    resolved: &ResolvedSpawnSpec,
16502    incarnation_id: NodeIncarnationId,
16503    session: SessionAddress,
16504    lease: ManagedWorktreeLeaseSnapshot,
16505    environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
16506    bundle: Option<ResolvedBundleReceipt>,
16507    context: Option<ResolvedContextPackReceipt>,
16508) -> ManagedWorktreeSpawnReceipt {
16509    let mut managed = resolved.clone();
16510    managed.target.worktree_id = Some(lease.workspace_id.clone());
16511    ManagedWorktreeSpawnReceipt {
16512        spawn: managed.receipt_with_materialization(
16513            incarnation_id,
16514            session,
16515            environment_profile,
16516            bundle,
16517            context,
16518        ),
16519        lease,
16520    }
16521}
16522
16523fn validate_selected_worktree(
16524    source_root: &str,
16525    selected_root: &str,
16526    worktrees: &[NativeGitWorktreeSnapshot],
16527) -> Result<(), NodeFailure> {
16528    if !worktrees
16529        .iter()
16530        .any(|worktree| worktree_paths_equal(&worktree.path, source_root) && !worktree.is_bare)
16531    {
16532        return Err(failure(
16533            NodeFailureCode::WorktreeProtected,
16534            "source workspace is not an authoritative non-bare Git worktree root",
16535        ));
16536    }
16537    if !worktrees.iter().any(|worktree| {
16538        worktree_paths_equal(&worktree.path, selected_root)
16539            && !worktree.is_main
16540            && !worktree.is_bare
16541    }) {
16542        return Err(failure(
16543            NodeFailureCode::WorktreeProtected,
16544            "selected workspace is not an eligible Git worktree in the source repository",
16545        ));
16546    }
16547    Ok(())
16548}
16549
16550fn workspace_file_failure(error: WorkspaceFileReadError) -> NodeFailure {
16551    let code = match error.kind() {
16552        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
16553        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
16554            NodeFailureCode::RepositoryPathUnsafe
16555        }
16556        WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
16557        WorkspaceFileReadErrorKind::NotRegularFile => {
16558            NodeFailureCode::RepositoryFileNotRegular
16559        }
16560        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
16561            NodeFailureCode::RepositoryFileReadFailed
16562        }
16563        WorkspaceFileReadErrorKind::RevisionConflict => {
16564            NodeFailureCode::RepositoryFileRevisionConflict
16565        }
16566        WorkspaceFileReadErrorKind::AlreadyExists
16567        | WorkspaceFileReadErrorKind::ParentNotFound
16568        | WorkspaceFileReadErrorKind::ParentNotDirectory
16569        | WorkspaceFileReadErrorKind::Canceled => {
16570            NodeFailureCode::RepositoryFileReadFailed
16571        }
16572    };
16573    failure(code, "repository file read failed")
16574}
16575
16576fn workspace_file_write_failure(error: WorkspaceFileReadError) -> NodeFailure {
16577    let code = match error.kind() {
16578        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
16579        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
16580            NodeFailureCode::RepositoryPathUnsafe
16581        }
16582        WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
16583        WorkspaceFileReadErrorKind::NotRegularFile => {
16584            NodeFailureCode::RepositoryFileNotRegular
16585        }
16586        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
16587            NodeFailureCode::RepositoryFileWriteFailed
16588        }
16589        WorkspaceFileReadErrorKind::RevisionConflict => {
16590            NodeFailureCode::RepositoryFileRevisionConflict
16591        }
16592        WorkspaceFileReadErrorKind::AlreadyExists
16593        | WorkspaceFileReadErrorKind::ParentNotFound
16594        | WorkspaceFileReadErrorKind::ParentNotDirectory
16595        | WorkspaceFileReadErrorKind::Canceled => {
16596            NodeFailureCode::RepositoryFileWriteFailed
16597        }
16598    };
16599    failure(code, "repository file write failed")
16600}
16601
16602async fn settle_workspace_entry_create<T>(
16603    mut task: tokio::task::JoinHandle<Result<T, WorkspaceFileReadError>>,
16604    commit_state: Arc<AtomicU8>,
16605    deadline: Duration,
16606    timeout_message: &'static str,
16607    task_failure_message: &'static str,
16608) -> Result<T, NodeFailure> {
16609    let joined = match timeout(deadline, &mut task).await {
16610        Ok(joined) => joined,
16611        Err(_) => {
16612            if commit_state
16613                .compare_exchange(
16614                    WORKSPACE_ENTRY_CREATE_PENDING,
16615                    WORKSPACE_ENTRY_CREATE_CANCELED,
16616                    Ordering::AcqRel,
16617                    Ordering::Acquire,
16618                )
16619                .is_ok()
16620            {
16621                return Err(failure(
16622                    NodeFailureCode::RepositoryEntryCreateTimedOut,
16623                    timeout_message,
16624                ));
16625            }
16626            debug_assert_eq!(
16627                commit_state.load(Ordering::Acquire),
16628                WORKSPACE_ENTRY_CREATE_COMMITTING,
16629            );
16630            task.await
16631        }
16632    };
16633    joined
16634        .map_err(|_| {
16635            failure(
16636                NodeFailureCode::RepositoryEntryCreateFailed,
16637                task_failure_message,
16638            )
16639        })?
16640        .map_err(workspace_entry_create_failure)
16641}
16642
16643fn workspace_entry_create_failure(error: WorkspaceFileReadError) -> NodeFailure {
16644    let code = match error.kind() {
16645        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
16646        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
16647            NodeFailureCode::RepositoryPathUnsafe
16648        }
16649        WorkspaceFileReadErrorKind::AlreadyExists => {
16650            NodeFailureCode::RepositoryEntryAlreadyExists
16651        }
16652        WorkspaceFileReadErrorKind::ParentNotFound | WorkspaceFileReadErrorKind::NotFound => {
16653            NodeFailureCode::RepositoryParentNotFound
16654        }
16655        WorkspaceFileReadErrorKind::ParentNotDirectory
16656        | WorkspaceFileReadErrorKind::NotRegularFile => {
16657            NodeFailureCode::RepositoryParentNotDirectory
16658        }
16659        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
16660            NodeFailureCode::RepositoryEntryCreateFailed
16661        }
16662        WorkspaceFileReadErrorKind::RevisionConflict => {
16663            NodeFailureCode::RepositoryEntryCreateFailed
16664        }
16665        WorkspaceFileReadErrorKind::Canceled => {
16666            NodeFailureCode::RepositoryEntryCreateTimedOut
16667        }
16668    };
16669    failure(code, "repository entry create failed")
16670}
16671
16672fn workspace_file_revision(bytes: &[u8]) -> WorkspaceFileRevision {
16673    let value = digest(&SHA256, bytes)
16674        .as_ref()
16675        .iter()
16676        .map(|byte| format!("{byte:02x}"))
16677        .collect();
16678    WorkspaceFileRevision::new(value).expect("SHA-256 produces a valid workspace revision")
16679}
16680
16681fn managed_spawn_request_digest_v2(
16682    request: &ManagedWorktreeSpawnRequestV2,
16683) -> Result<String, NodeFailure> {
16684    let encoded = serde_json::to_vec(request).map_err(|_| {
16685        failure(
16686            NodeFailureCode::InvalidRequest,
16687            "managed worktree V2 request could not be canonicalized",
16688        )
16689    })?;
16690    let mut value = String::with_capacity(71);
16691    value.push_str("sha256:");
16692    for byte in digest(&SHA256, &encoded).as_ref() {
16693        use std::fmt::Write as _;
16694        write!(&mut value, "{byte:02x}").expect("writing to a String cannot fail");
16695    }
16696    Ok(value)
16697}
16698
16699fn native_session_preview_failure(error: NativeSessionPreviewError) -> NodeFailure {
16700    let code = match error {
16701        NativeSessionPreviewError::InvalidLimit => NodeFailureCode::InvalidRequest,
16702        NativeSessionPreviewError::WorkspaceUnavailable => NodeFailureCode::UnknownWorkspace,
16703        NativeSessionPreviewError::StaleCatalog => NodeFailureCode::StaleNativeSessionCatalog,
16704        NativeSessionPreviewError::UnsupportedProvider
16705        | NativeSessionPreviewError::PreviewUnavailable
16706        | NativeSessionPreviewError::SessionNotFound
16707        | NativeSessionPreviewError::AmbiguousSession => {
16708            NodeFailureCode::BackendOperationFailed
16709        }
16710    };
16711    failure(code, "native session preview failed")
16712}
16713
16714fn host_directory_failure(error: HostDirectoryBrowseError) -> NodeFailure {
16715    let code = match error.kind() {
16716        HostDirectoryBrowseErrorKind::Invalid => NodeFailureCode::HostDirectoryInvalid,
16717        HostDirectoryBrowseErrorKind::ReadFailed => NodeFailureCode::HostDirectoryReadFailed,
16718    };
16719    failure(code, "host directory browse failed")
16720}
16721
16722fn delivery_failure(error: DeliveryStoreError) -> NodeFailure {
16723    let code = match error {
16724        DeliveryStoreError::InvalidManifest => NodeFailureCode::DeliveryManifestInvalid,
16725        DeliveryStoreError::UnknownStage => NodeFailureCode::UnknownDeliveryStage,
16726        DeliveryStoreError::Capacity | DeliveryStoreError::StageConflict => {
16727            NodeFailureCode::DeliveryStageConflict
16728        }
16729        DeliveryStoreError::UnexpectedBlob => NodeFailureCode::DeliveryBlobUnexpected,
16730        DeliveryStoreError::ChunkOutOfOrder | DeliveryStoreError::ChunkOverflow => {
16731            NodeFailureCode::DeliveryChunkOutOfOrder
16732        }
16733        DeliveryStoreError::BlobDigestMismatch => {
16734            NodeFailureCode::DeliveryBlobDigestMismatch
16735        }
16736        DeliveryStoreError::BundleDigestMismatch => {
16737            NodeFailureCode::DeliveryBundleDigestMismatch
16738        }
16739        DeliveryStoreError::StageIncomplete => NodeFailureCode::DeliveryStageIncomplete,
16740        DeliveryStoreError::Unavailable
16741        | DeliveryStoreError::Corrupt
16742        | DeliveryStoreError::Storage(_) => NodeFailureCode::DeliveryStageStorageFailed,
16743    };
16744    failure(code, "delivery operation failed")
16745}
16746
16747fn validate_workspace_root(
16748    workspace_id: &WorkspaceId,
16749    canonical_root: &str,
16750) -> Result<(), NodeServerError> {
16751    #[cfg(windows)]
16752    if canonical_root.starts_with(r"\\") {
16753        return Err(NodeServerError::InvalidWorkspaceRoot {
16754            workspace_id: workspace_id.clone(),
16755            path: canonical_root.to_owned(),
16756            message: "UNC workspace roots are unsupported by Windows PTY providers".to_owned(),
16757        });
16758    }
16759    if !platform::workspace_root_supported(canonical_root)
16760        || canonical_root.len() > WORKING_DIRECTORY_MAX_BYTES
16761    {
16762        return Err(NodeServerError::InvalidWorkspaceRoot {
16763            workspace_id: workspace_id.clone(),
16764            path: canonical_root.to_owned(),
16765            message: "canonical path must be absolute, bounded, and free of control characters"
16766                .to_owned(),
16767        });
16768    }
16769    Ok(())
16770}
16771
16772fn failure(code: NodeFailureCode, _message: &str) -> NodeFailure {
16773    NodeFailure {
16774        code,
16775        message: node_failure_category(code).to_owned(),
16776    }
16777}
16778
16779fn harness_mcp_failure(error: HarnessMcpProxyError) -> NodeFailure {
16780    let code = match error {
16781        HarnessMcpProxyError::Unavailable => NodeFailureCode::HarnessMcpUnavailable,
16782        HarnessMcpProxyError::NotFound => NodeFailureCode::ReservationNotFound,
16783        HarnessMcpProxyError::Conflict => NodeFailureCode::ReservationConflict,
16784        HarnessMcpProxyError::Expired => NodeFailureCode::ReservationExpired,
16785        HarnessMcpProxyError::BindingMismatch => NodeFailureCode::BindingMismatch,
16786        HarnessMcpProxyError::NotActivated => NodeFailureCode::NotActivated,
16787        HarnessMcpProxyError::CallNotFound => NodeFailureCode::CallNotFound,
16788        HarnessMcpProxyError::ChunkOutOfOrder => NodeFailureCode::ChunkOutOfOrder,
16789        HarnessMcpProxyError::ResponseTooLarge => NodeFailureCode::ResponseTooLarge,
16790    };
16791    failure(code, "harness MCP proxy operation failed")
16792}
16793
16794/// Numbers each harness-MCP overlay this process constructs, in
16795/// construction order, so two overlays built back to back in one node process
16796/// still get distinct trace files.
16797static NEXT_HARNESS_MCP_TRACE_SEQUENCE: AtomicU64 = AtomicU64::new(1);
16798
16799/// Builds the optional trace environment entry of one harness-MCP overlay.
16800/// The caller's launch description names both the entry and the node-process
16801/// variable that opts in: when that variable holds a non-empty directory, the
16802/// entry names a trace file under it. Returns `None` when the launch has no
16803/// trace, or the directory is unset, empty, or not valid Unicode. Never creates
16804/// the directory; that is the operator's job.
16805fn harness_mcp_trace_env_entry(launch: &HarnessMcpLaunchV1) -> Option<(OsString, OsString)> {
16806    let trace = launch.trace.as_ref()?;
16807    let trace_dir = std::env::var(&trace.dir_env).ok()?;
16808    if trace_dir.is_empty() {
16809        return None;
16810    }
16811    let sequence = NEXT_HARNESS_MCP_TRACE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
16812    let path = std::path::Path::new(&trace_dir).join(format!("harness-mcp-{sequence}.trace"));
16813    Some((OsString::from(trace.env.as_str()), path.into_os_string()))
16814}
16815
16816fn spawn_deadline_remaining(deadline: Instant) -> Result<Duration, NodeFailure> {
16817    let remaining = deadline.saturating_duration_since(Instant::now());
16818    if remaining.is_zero() {
16819        Err(failure(
16820            NodeFailureCode::SpawnDeadlineExceeded,
16821            "spawn deadline elapsed",
16822        ))
16823    } else {
16824        Ok(remaining)
16825    }
16826}
16827
16828fn spawn_dispatch_timeout(deadline: Option<Instant>) -> Result<Duration, NodeFailure> {
16829    let bounded = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
16830    match deadline {
16831        Some(deadline) => Ok(spawn_deadline_remaining(deadline)?.min(bounded)),
16832        None => Ok(bounded),
16833    }
16834}
16835
16836fn spawn_dispatch_error(error: NodeFailure, deadline: Option<Instant>) -> NodeFailure {
16837    if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
16838        failure(
16839            NodeFailureCode::SpawnDeadlineExceeded,
16840            "spawn dispatch exceeded the spawn deadline",
16841        )
16842    } else {
16843        error
16844    }
16845}
16846
16847fn spawn_runtime_capabilities(
16848    capabilities: &SpawnRequiredCapabilities,
16849) -> Result<Vec<ProviderRuntimeCapability>, NodeFailure> {
16850    capabilities
16851        .iter()
16852        .map(|capability| match capability.as_str() {
16853            SPAWN_RUNTIME_RAW_PTY_LIFECYCLE => Ok(ProviderRuntimeCapability::RawPtyLifecycle),
16854            SPAWN_RUNTIME_SEMANTIC_READINESS => Ok(ProviderRuntimeCapability::SemanticReadiness),
16855            SPAWN_RUNTIME_STRUCTURED_PROMPT => Ok(ProviderRuntimeCapability::StructuredPrompt),
16856            SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY => {
16857                Ok(ProviderRuntimeCapability::ProviderSessionIdentity)
16858            }
16859            SPAWN_RUNTIME_SEMANTIC_RESUME => Ok(ProviderRuntimeCapability::SemanticResume),
16860            _ => Err(failure(
16861                NodeFailureCode::UnsupportedSpawnCapability,
16862                "spawn required capability is unknown",
16863            )),
16864        })
16865        .collect()
16866}
16867
16868fn persistence_failure(_error: io::Error) -> NodeFailure {
16869    NodeFailure {
16870        code: NodeFailureCode::BackendOperationFailed,
16871        message: DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned(),
16872    }
16873}
16874
16875fn node_failure_category(code: NodeFailureCode) -> &'static str {
16876    match code {
16877        NodeFailureCode::InvalidRequest => "invalid-request",
16878        NodeFailureCode::UnsupportedCapability => "unsupported-capability",
16879        NodeFailureCode::HarnessMcpUnavailable => "harness-mcp-unavailable",
16880        NodeFailureCode::ReservationNotFound => "reservation-not-found",
16881        NodeFailureCode::ReservationConflict => "reservation-conflict",
16882        NodeFailureCode::ReservationExpired => "reservation-expired",
16883        NodeFailureCode::BindingMismatch => "binding-mismatch",
16884        NodeFailureCode::NotActivated => "not-activated",
16885        NodeFailureCode::CallNotFound => "call-not-found",
16886        NodeFailureCode::ChunkOutOfOrder => "chunk-out-of-order",
16887        NodeFailureCode::ResponseTooLarge => "response-too-large",
16888        NodeFailureCode::DeliveryManifestInvalid => "delivery-manifest-invalid",
16889        NodeFailureCode::UnknownDeliveryStage => "unknown-delivery-stage",
16890        NodeFailureCode::DeliveryStageConflict => "delivery-stage-conflict",
16891        NodeFailureCode::DeliveryBlobUnexpected => "delivery-blob-unexpected",
16892        NodeFailureCode::DeliveryChunkOutOfOrder => "delivery-chunk-out-of-order",
16893        NodeFailureCode::DeliveryBlobDigestMismatch => "delivery-blob-digest-mismatch",
16894        NodeFailureCode::DeliveryBundleDigestMismatch => "delivery-bundle-digest-mismatch",
16895        NodeFailureCode::DeliveryStageIncomplete => "delivery-stage-incomplete",
16896        NodeFailureCode::DeliveryStageStorageFailed => "delivery-stage-storage-failed",
16897        NodeFailureCode::Unauthorized => "unauthorized",
16898        NodeFailureCode::ObserverReadOnly => "observer-read-only",
16899        NodeFailureCode::ControllerBusy => "controller-busy",
16900        NodeFailureCode::ControllerRequired => "controller-required",
16901        NodeFailureCode::UnknownWorkspace => "unknown-workspace",
16902        NodeFailureCode::HostDirectoryInvalid => "host-directory-invalid",
16903        NodeFailureCode::HostDirectoryReadFailed => "host-directory-read-failed",
16904        NodeFailureCode::HostDirectoryReadTimedOut => "host-directory-read-timed-out",
16905        NodeFailureCode::InvalidRepositoryPath => "invalid-repository-path",
16906        NodeFailureCode::RepositoryFileNotFound => "repository-file-not-found",
16907        NodeFailureCode::RepositoryFileNotRegular => "repository-file-not-regular",
16908        NodeFailureCode::RepositoryPathUnsafe => "repository-path-unsafe",
16909        NodeFailureCode::RepositoryFileReadTimedOut => "repository-file-read-timed-out",
16910        NodeFailureCode::RepositoryFileReadFailed => "repository-file-read-failed",
16911        NodeFailureCode::RepositoryFileWriteTimedOut => "repository-file-write-timed-out",
16912        NodeFailureCode::RepositoryFileWriteFailed => "repository-file-write-failed",
16913        NodeFailureCode::RepositoryFileRevisionConflict => "repository-file-revision-conflict",
16914        NodeFailureCode::RepositoryEntryAlreadyExists => "repository-entry-already-exists",
16915        NodeFailureCode::RepositoryParentNotFound => "repository-parent-not-found",
16916        NodeFailureCode::RepositoryParentNotDirectory => "repository-parent-not-directory",
16917        NodeFailureCode::RepositoryEntryCreateTimedOut => "repository-entry-create-timed-out",
16918        NodeFailureCode::RepositoryEntryCreateFailed => "repository-entry-create-failed",
16919        NodeFailureCode::GitReadTimedOut => "git-read-timed-out",
16920        NodeFailureCode::GitReadFailed => "git-read-failed",
16921        NodeFailureCode::UnknownSpawnProfile => "unknown-spawn-profile",
16922        NodeFailureCode::SpawnProfileRevisionMismatch => "spawn-profile-revision-mismatch",
16923        NodeFailureCode::SpawnTargetMismatch => "spawn-target-mismatch",
16924        NodeFailureCode::SpawnIdempotencyConflict => "spawn-idempotency-conflict",
16925        NodeFailureCode::SpawnIdempotencyCapacity => "spawn-idempotency-capacity",
16926        NodeFailureCode::SpawnDeadlineExceeded => "spawn-deadline-exceeded",
16927        NodeFailureCode::UnsupportedSpawnCapability => "unsupported-spawn-capability",
16928        NodeFailureCode::UnsupportedTransport => "unsupported-transport",
16929        NodeFailureCode::TurnInFlight => "turn-in-flight",
16930        NodeFailureCode::UnknownEnvironmentProfile => "unknown-environment-profile",
16931        NodeFailureCode::UnknownNetworkAllowlist => "unknown-network-allowlist",
16932        NodeFailureCode::UnsupportedNetworkAllowlistMapping => {
16933            "unsupported-network-allowlist-mapping"
16934        }
16935        NodeFailureCode::BrowserStationProbeUnavailable => {
16936            "browser-station-probe-unavailable"
16937        }
16938        NodeFailureCode::BrowserStationUnreachable => "browser-station-unreachable",
16939        NodeFailureCode::BrowserStationProfileBusy => "browser-station-profile-busy",
16940        NodeFailureCode::EnvironmentProfileBindingMismatch => {
16941            "environment-profile-binding-mismatch"
16942        }
16943        NodeFailureCode::UnknownBundle => "unknown-bundle",
16944        NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
16945        NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
16946        NodeFailureCode::UnknownContextPack => "unknown-context-pack",
16947        NodeFailureCode::ContextPackBusy => "context-pack-busy",
16948        NodeFailureCode::ContextPackMaterializationFailed => {
16949            "context-pack-materialization-failed"
16950        }
16951        NodeFailureCode::InvalidWorkspaceRoot => "invalid-workspace-root",
16952        NodeFailureCode::DuplicateWorkspaceId => "duplicate-workspace-id",
16953        NodeFailureCode::DuplicateWorkspaceRoot => "duplicate-workspace-root",
16954        NodeFailureCode::WorkspaceBusy => "workspace-busy",
16955        NodeFailureCode::LastWorkspace => "last-workspace",
16956        NodeFailureCode::NotGitRepository => "not-git-repository",
16957        NodeFailureCode::WorktreeConflict => "worktree-conflict",
16958        NodeFailureCode::WorktreeProtected => "worktree-protected",
16959        NodeFailureCode::WorktreeDirty => "worktree-dirty",
16960        NodeFailureCode::WorktreeLocked => "worktree-locked",
16961        NodeFailureCode::UnknownManagedWorktreeLease => "unknown-managed-worktree-lease",
16962        NodeFailureCode::ManagedWorktreeBusy => "managed-worktree-busy",
16963        NodeFailureCode::ManagedWorktreeOwnershipConflict => "managed-worktree-ownership-conflict",
16964        NodeFailureCode::ManagedWorktreeProfileRevisionMismatch => {
16965            "managed-worktree-profile-revision-mismatch"
16966        }
16967        NodeFailureCode::ManagedWorktreeRecoveryRequired => "managed-worktree-recovery-required",
16968        NodeFailureCode::StandaloneWorkspaceRecoveryRequired => {
16969            "standalone-workspace-recovery-required"
16970        }
16971        NodeFailureCode::UnknownSession => "unknown-session",
16972        NodeFailureCode::UnknownSessionRecord => "unknown-session-record",
16973        NodeFailureCode::SessionRecordNotResumable => "session-record-not-resumable",
16974        NodeFailureCode::SessionRecordBusy => "session-record-busy",
16975        NodeFailureCode::SessionRecordConflict => "session-record-conflict",
16976        NodeFailureCode::SessionWorkspaceMismatch => "session-workspace-mismatch",
16977        NodeFailureCode::WorkspaceRegistrationRequired => {
16978            "workspace-registration-required"
16979        }
16980        NodeFailureCode::StaleNativeSessionCatalog => "stale-native-session-catalog",
16981        NodeFailureCode::StaleGeneration => "stale-generation",
16982        NodeFailureCode::BackendBusy => "backend-busy",
16983        NodeFailureCode::BackendDisconnected => "backend-disconnected",
16984        NodeFailureCode::BackendOperationFailed => "backend-operation-failed",
16985        NodeFailureCode::ShuttingDown => "shutting-down",
16986    }
16987}
16988
16989fn durable_state_server_error(error: io::Error, category: &'static str) -> NodeServerError {
16990    NodeServerError::DurableState(io::Error::new(error.kind(), category))
16991}
16992
16993fn durable_state_load_error(error: io::Error) -> NodeServerError {
16994    let category = match session_registry::state_load_refusal(&error) {
16995        Some(session_registry::StateLoadRefusal::UnsupportedSchema) => {
16996            DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR
16997        }
16998        Some(session_registry::StateLoadRefusal::PathSemanticsUnsupported) => {
16999            DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR
17000        }
17001        Some(session_registry::StateLoadRefusal::NodeIdentityMismatch) => {
17002            DURABLE_STATE_CONFLICT_ERROR
17003        }
17004        _ => DURABLE_STATE_LOAD_FAILED_ERROR,
17005    };
17006    durable_state_server_error(error, category)
17007}
17008
17009fn unix_time_ms() -> u64 {
17010    std::time::SystemTime::now()
17011        .duration_since(std::time::UNIX_EPOCH)
17012        .unwrap_or_default()
17013        .as_millis()
17014        .min(u64::MAX as u128) as u64
17015}
17016
17017fn detached_record_state(record: &ManagedSessionRecord) -> ManagedSessionState {
17018    if record.provider_session.is_some() {
17019        ManagedSessionState::Dormant
17020    } else {
17021        ManagedSessionState::Unavailable
17022    }
17023}
17024
17025fn active_record_state(
17026    identity_admitted: bool,
17027    provider_session_is_known: bool,
17028) -> ManagedSessionState {
17029    if identity_admitted && !provider_session_is_known {
17030        ManagedSessionState::IdentityPending
17031    } else {
17032        ManagedSessionState::Live
17033    }
17034}
17035
17036fn managed_worktree_record_holders(
17037    records: &[ManagedSessionRecord],
17038) -> Vec<(SessionRecordId, WorkspaceId)> {
17039    records
17040        .iter()
17041        .filter(|record| record.provider_session.is_some())
17042        .map(|record| (record.record_id.clone(), record.workspace_id.clone()))
17043        .collect()
17044}
17045
17046fn managed_spawn_record_ownership(
17047    binding: &SessionBinding,
17048    effective_runtime_policy: ProviderRuntimePolicy,
17049) -> (Option<SessionRecordId>, Option<SessionRecordId>) {
17050    debug_assert_eq!(binding.runtime_policy, effective_runtime_policy);
17051    if effective_runtime_policy.provider_session_identity {
17052        (binding.record_id.clone(), None)
17053    } else {
17054        (None, binding.record_id.clone())
17055    }
17056}
17057
17058#[derive(Debug, Error)]
17059pub enum NodeServerError {
17060    #[error("node endpoint must be a bounded absolute local transport path")]
17061    InvalidEndpoint,
17062    #[error("node access token must contain 1..=4096 bytes")]
17063    InvalidAccessToken,
17064    #[error("node HTTP observer must listen on a loopback address: {0}")]
17065    InvalidApiListen(std::net::SocketAddr),
17066    #[error("node browser bridge must listen on a loopback address: {0}")]
17067    InvalidBridgeListen(std::net::SocketAddr),
17068    #[error("node browser bridge token must contain 1..=4096 bytes")]
17069    InvalidBridgeToken,
17070    #[error("bridge underlay requires --bridge-listen (local HTTP+WS door)")]
17071    BridgeUnderlayRequiresBridgeListen,
17072    #[error("bridge underlay requires GATE4AGENT_BRIDGE_TOKEN (application barrier)")]
17073    BridgeUnderlayRequiresBridgeToken,
17074    #[error("mesh underlay auth token must contain 1..=4096 bytes")]
17075    InvalidBridgeUnderlayToken,
17076    #[error("mesh underlay transport key must be 32 bytes (64 hex digits)")]
17077    InvalidBridgeUnderlayKey,
17078    #[error("node call-home relay must be a loopback address with a nonzero port: {0}")]
17079    InvalidCallHome(std::net::SocketAddr),
17080    #[error("node could not reach its call-home relay: {0}")]
17081    CallHomeTransport(#[source] std::io::Error),
17082    #[error("node durable state path must be an absolute file path: {0}")]
17083    InvalidStatePath(String),
17084    #[error("harness MCP helper must be an exact reviewed absolute regular file")]
17085    InvalidHarnessMcpHelper,
17086    #[error("network allowlist catalog path must be an absolute regular file: {0}")]
17087    InvalidNetworkAllowlistCatalogPath(String),
17088    #[error("network allowlist catalog could not be read: {0}")]
17089    NetworkAllowlistCatalogIo(#[source] std::io::Error),
17090    #[error("network allowlist catalog line {line}: {message}")]
17091    InvalidNetworkAllowlistCatalogEntry { line: usize, message: String },
17092    #[error("network allowlist catalog: {0}")]
17093    InvalidNetworkAllowlistCatalog(String),
17094    #[error("network allowlist catalog exceeds the {max}-entry limit")]
17095    NetworkAllowlistCatalogCapacity { max: usize },
17096    #[error("the local state directory is unavailable; supply an explicit state path")]
17097    LocalStateDirectoryUnavailable,
17098    #[error("the local runtime directory is unavailable; supply an explicit endpoint")]
17099    LocalRuntimeDirectoryUnavailable,
17100    #[error("node durable state failed: {0}")]
17101    DurableState(io::Error),
17102    #[error("node requires at least one configured workspace")]
17103    NoWorkspaces,
17104    #[error("workspace '{workspace_id}' root '{path}' is invalid: {message}")]
17105    InvalidWorkspaceRoot {
17106        workspace_id: WorkspaceId,
17107        path: String,
17108        message: String,
17109    },
17110    #[error("duplicate workspace ID: {0}")]
17111    DuplicateWorkspaceId(WorkspaceId),
17112    #[error("workspaces '{first}' and '{second}' resolve to the same root '{root}'")]
17113    DuplicateWorkspaceRoot {
17114        first: WorkspaceId,
17115        second: WorkspaceId,
17116        root: String,
17117    },
17118    #[error("workspace '{0}' uses managed worktrees but has no local profile")]
17119    ManagedWorktreeProfileRequired(WorkspaceId),
17120    #[error("workspace '{0}' has managed worktree profiles but is not in managed mode")]
17121    ManagedWorktreeProfileModeMismatch(WorkspaceId),
17122    #[error("workspace '{workspace_id}' has duplicate managed worktree profile '{profile_id}'")]
17123    DuplicateManagedWorktreeProfile {
17124        workspace_id: WorkspaceId,
17125        profile_id: WorktreeProfileId,
17126    },
17127    #[error("workspace '{workspace_id}' exceeds the {max}-profile managed worktree limit")]
17128    ManagedWorktreeProfileCapacity {
17129        workspace_id: WorkspaceId,
17130        max: usize,
17131    },
17132    #[error("workspace '{workspace_id}' managed worktree profile '{profile_id}' is invalid: {message}")]
17133    InvalidManagedWorktreeProfile {
17134        workspace_id: WorkspaceId,
17135        profile_id: WorktreeProfileId,
17136        message: String,
17137    },
17138    #[error("duplicate node environment profile: {0}")]
17139    DuplicateEnvironmentProfile(SpawnEnvironmentProfileId),
17140    #[error("node environment profile provider is unavailable: {0}")]
17141    EnvironmentProfileProviderUnavailable(AgentId),
17142    #[error("node environment profile capacity is {max}")]
17143    EnvironmentProfileCapacity { max: usize },
17144    #[error("node environment profiles reuse native profile ID '{0}'")]
17145    DuplicateNativeEnvironmentProfile(String),
17146    #[error("node native environment profile is invalid: {0}")]
17147    NativeEnvironmentProfile(String),
17148    #[error("node session-environment materialization root must be absolute")]
17149    InvalidSessionEnvironmentRoot,
17150    #[error("node session-environment materializer is unavailable")]
17151    SessionEnvironmentMaterializerRequired,
17152    #[error("node session-environment materializer failed to initialize")]
17153    SessionEnvironmentMaterializer,
17154    #[error("node bundle catalog is invalid: {0}")]
17155    BundleCatalog(String),
17156    #[error("node delivery store failed to initialize: {0}")]
17157    DeliveryStore(String),
17158    #[error("node context pack store failed to initialize")]
17159    ContextPackStore,
17160    #[error("active agent registry failed: {0}")]
17161    Registry(String),
17162    #[error("node provider contract manifest is invalid: {0}")]
17163    ProviderContractManifest(String),
17164    #[error("named pipe I/O failed: {0}")]
17165    Io(#[from] io::Error),
17166    #[error("node HTTP observer failed: {0}")]
17167    HttpApi(io::Error),
17168    #[error("node browser bridge failed: {0}")]
17169    BridgeApi(io::Error),
17170    #[error(transparent)]
17171    Frame(#[from] FrameError),
17172    #[error("node handshake failed: {0}")]
17173    Handshake(String),
17174    #[error("node authentication frame was not received before the bounded deadline")]
17175    AuthenticationTimedOut,
17176    #[error("node authentication primitive failed: {0}")]
17177    Authentication(String),
17178    #[error("node incarnation identity generation failed: {0}")]
17179    IncarnationIdentity(String),
17180    #[error("node pre-authentication limiter is closed")]
17181    PreauthClosed,
17182    #[error("node authenticated connection limit was reached")]
17183    AuthenticatedConnectionLimit,
17184    #[error("node Ctrl+C/CtrlBreak signal stream closed")]
17185    SignalStreamClosed,
17186    #[error("node stop-all dispatch failed: {0:?}")]
17187    ShutdownDispatch(NodeFailure),
17188    #[error("node shutdown timed out with {active_native_sessions} physical sessions retained")]
17189    ShutdownTimedOut { active_native_sessions: usize },
17190}
17191
17192impl From<crate::network_allowlist_catalog::NetworkAllowlistCatalogError> for NodeServerError {
17193    fn from(error: crate::network_allowlist_catalog::NetworkAllowlistCatalogError) -> Self {
17194        use crate::network_allowlist_catalog::NetworkAllowlistCatalogError as E;
17195        match error {
17196            E::InvalidPath(path) => Self::InvalidNetworkAllowlistCatalogPath(path),
17197            E::Io(error) => Self::NetworkAllowlistCatalogIo(error),
17198            E::InvalidEntry { line, message } => {
17199                Self::InvalidNetworkAllowlistCatalogEntry { line, message }
17200            }
17201            E::InvalidCatalog(message) => Self::InvalidNetworkAllowlistCatalog(message),
17202            E::Capacity { max } => Self::NetworkAllowlistCatalogCapacity { max },
17203        }
17204    }
17205}
17206
17207#[cfg(test)]
17208mod agent_stream_blocked_tests {
17209    use super::*;
17210    use gate4agent_types::{
17211        AdapterId, AdapterVerification, HostRequestOutcome as ProviderHostRequestOutcome,
17212        ProviderSource,
17213    };
17214
17215    fn provider_source(family: AdapterFamily, adapter: &str) -> ProviderSource {
17216        ProviderSource {
17217            family,
17218            binding: AdapterBinding::new(
17219                AdapterId::new(adapter).unwrap(),
17220                "fixture/v1",
17221                AdapterVerification::SyntheticFixture,
17222            )
17223            .unwrap(),
17224        }
17225    }
17226
17227    fn provider_control_event_at(
17228        family: AdapterFamily,
17229        adapter: &str,
17230        source_sequence: u64,
17231        provider_event: ProviderEvent,
17232    ) -> ControlEvent {
17233        ControlEvent {
17234            sequence: 41,
17235            command_id: None,
17236            instance_id: AgentInstanceId(7),
17237            generation: SessionGeneration(3),
17238            event: ControlEventKind::ProviderEvent {
17239                sequence: 8,
17240                source: provider_source(family, adapter),
17241                source_sequence,
17242                event: provider_event,
17243            },
17244        }
17245    }
17246
17247    fn provider_control_event(family: AdapterFamily, provider_event: ProviderEvent) -> ControlEvent {
17248        provider_control_event_at(family, "fixture", 9, provider_event)
17249    }
17250
17251    fn denied_host_request(
17252        by: ProviderHostDecisionAuthority,
17253        reason: Option<String>,
17254    ) -> ControlEvent {
17255        provider_control_event_at(
17256            AdapterFamily::Acp,
17257            "claude-code",
17258            2,
17259            ProviderEvent::HostRequestObserved {
17260                method: "terminal/create".to_owned(),
17261                params_json: String::new(),
17262                decision: ProviderHostRequestDecision::Denied { by },
17263                outcome: ProviderHostRequestOutcome::Executed,
17264                reason,
17265            },
17266        )
17267    }
17268
17269    fn blocked(
17270        event: &ControlEvent,
17271    ) -> (
17272        Option<String>,
17273        String,
17274        BlockAuthorityV1,
17275        Option<String>,
17276        String,
17277        Option<String>,
17278    ) {
17279        let chunk = agent_stream_chunk(event).expect("expected a Blocked chunk");
17280        chunk.validate().expect("minted chunk must validate");
17281        let AgentStreamChunkKindV1::Blocked {
17282            correlation_id,
17283            tool_class,
17284            authority,
17285            reason_kind,
17286            reason,
17287            help,
17288        } = chunk.kind
17289        else {
17290            panic!("expected AgentStreamChunkKindV1::Blocked");
17291        };
17292        (correlation_id, tool_class, authority, reason_kind, reason, help)
17293    }
17294
17295    /// Pins the fleet and every adapter each member declares: the fleet is
17296    /// exactly what `active_registry` says, and no member declares a hook
17297    /// adapter (lifecycle hooks are retired).
17298    #[test]
17299    fn the_enabled_provider_matrix_is_exact() {
17300        let registry = active_registry().unwrap();
17301        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
17302        assert_eq!(
17303            providers
17304                .iter()
17305                .map(|contract| contract.provider.as_str())
17306                .collect::<Vec<_>>(),
17307            vec!["claude", "codex", "grok", "kimi"],
17308        );
17309        assert!(adapters.iter().all(|contract| {
17310            !matches!(contract.family, AdapterFamily::Hook | AdapterFamily::ManagedHook)
17311        }));
17312    }
17313
17314    /// A gate-denied host request, WITH its reason text carried all the way
17315    /// from the ACP host, mints a `Blocked` chunk naming the gate.
17316    #[test]
17317    fn gate_denied_host_request_mints_a_blocked_chunk() {
17318        let gate_text =
17319            "blocked by dangerous-command gate: rule=filesystem-wipe, argument=rm -rf /";
17320        let event = denied_host_request(
17321            ProviderHostDecisionAuthority::Gate,
17322            Some(gate_text.to_owned()),
17323        );
17324        let chunk = agent_stream_chunk(&event).expect("expected a Blocked chunk");
17325        assert_eq!(chunk.source_sequence, 2);
17326        let (correlation_id, tool_class, authority, reason_kind, reason, help) = blocked(&event);
17327        assert_eq!(authority, BlockAuthorityV1::HostGate);
17328        assert_eq!(reason, gate_text);
17329        assert_eq!(correlation_id, None);
17330        assert_eq!(reason_kind, None);
17331        assert_eq!(help, None);
17332        assert_eq!(tool_class, "Write");
17333    }
17334
17335    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.";
17336
17337    /// The owner's classifier sample, verbatim, arriving as a
17338    /// `tool_call_update`-derived `ProviderEvent::ToolCompleted` with
17339    /// `non_execution_kind: "permission-rule"` -- mints a `Blocked` chunk with
17340    /// `authority: ProviderPermissionRule`, the `Reason:` sentence as `reason`
17341    /// and the guidance tail as `help`.
17342    #[test]
17343    fn classifier_sample_permission_rule_mints_a_blocked_chunk() {
17344        let event = provider_control_event(
17345            AdapterFamily::Acp,
17346            ProviderEvent::ToolCompleted {
17347                id: "toolu_9".to_owned(),
17348                output: CLASSIFIER_SAMPLE.to_owned(),
17349                is_error: true,
17350                duration_ms: None,
17351                agent_id: None,
17352                non_execution_kind: Some("permission-rule".to_owned()),
17353            },
17354        );
17355        let (correlation_id, _, authority, reason_kind, reason, help) = blocked(&event);
17356        assert_eq!(authority, BlockAuthorityV1::ProviderPermissionRule);
17357        assert_eq!(reason, "Blocked by classifier");
17358        assert_eq!(reason_kind, Some("permission-rule".to_owned()));
17359        assert!(help.as_deref().is_some_and(|help| help.starts_with("If you have other tasks")));
17360        assert!(correlation_id.is_some(), "a tool-correlated block must carry a correlation id");
17361    }
17362
17363    /// A `session/prompt` RPC error with `data.codexErrorInfo:
17364    /// "usageLimitExceeded"` (measured live 2026-09-05) mints a `Blocked` chunk
17365    /// with `authority: ProviderQuota`, the message verbatim as `reason` and the
17366    /// vendor code as `reason_kind`.
17367    #[test]
17368    fn codex_quota_rpc_error_mints_a_blocked_chunk() {
17369        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.";
17370        let event = provider_control_event(
17371            AdapterFamily::Acp,
17372            ProviderEvent::SessionEnded {
17373                result: "JSON-RPC error -32603: Internal error".to_owned(),
17374                cost_usd: None,
17375                is_error: true,
17376                stop_reason: Some(ProviderStopReason::ProviderError {
17377                    code: -32603,
17378                    message: message.to_owned(),
17379                    vendor_code: Some("usageLimitExceeded".to_owned()),
17380                }),
17381            },
17382        );
17383        let (_, tool_class, authority, reason_kind, reason, _) = blocked(&event);
17384        assert_eq!(authority, BlockAuthorityV1::ProviderQuota);
17385        assert_eq!(reason, message);
17386        assert_eq!(reason_kind, Some("usageLimitExceeded".to_owned()));
17387        assert_eq!(tool_class, "Session");
17388    }
17389
17390    /// ACP's `stopReason: "refusal"` mints a `Blocked` chunk with
17391    /// `authority: ProviderRefusal`.
17392    #[test]
17393    fn session_end_stop_reason_refusal_mints_a_blocked_chunk() {
17394        let event = provider_control_event(
17395            AdapterFamily::Acp,
17396            ProviderEvent::SessionEnded {
17397                result: "refusal".to_owned(),
17398                cost_usd: None,
17399                is_error: true,
17400                stop_reason: Some(ProviderStopReason::Refusal),
17401            },
17402        );
17403        let (_, _, authority, _, _, _) = blocked(&event);
17404        assert_eq!(authority, BlockAuthorityV1::ProviderRefusal);
17405    }
17406
17407    /// A failed tool completion with no typed `non_execution_kind` -- or one
17408    /// that only says the turn stopped -- is a plain execution failure, never a
17409    /// block; nor is an agent's own narrative claiming one.
17410    #[test]
17411    fn failures_and_narrative_without_a_typed_block_mint_no_blocked_chunk() {
17412        for non_execution_kind in [None, Some("interrupted".to_owned()), Some("cancelled".to_owned())] {
17413            let event = provider_control_event(
17414                AdapterFamily::Acp,
17415                ProviderEvent::ToolCompleted {
17416                    id: "toolu_1".to_owned(),
17417                    output: "command exited with status 1".to_owned(),
17418                    is_error: true,
17419                    duration_ms: Some(12),
17420                    agent_id: None,
17421                    non_execution_kind,
17422                },
17423            );
17424            assert!(agent_stream_chunk(&event).is_none());
17425        }
17426        let narrative = provider_control_event(
17427            AdapterFamily::Acp,
17428            ProviderEvent::ToolCompleted {
17429                id: "toolu_2".to_owned(),
17430                output: "the harness blocked the write, approval needed".to_owned(),
17431                is_error: false,
17432                duration_ms: Some(3),
17433                agent_id: None,
17434                non_execution_kind: None,
17435            },
17436        );
17437        assert!(agent_stream_chunk(&narrative).is_none());
17438    }
17439
17440    /// `Granted`/`Deferred` decisions never mint a `Blocked` chunk, whatever the
17441    /// execution outcome: a granted request that failed while running was
17442    /// still authorized.
17443    #[test]
17444    fn granted_and_deferred_host_requests_never_mint_a_blocked_chunk() {
17445        for (decision, outcome) in [
17446            (
17447                ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
17448                ProviderHostRequestOutcome::Executed,
17449            ),
17450            (ProviderHostRequestDecision::Deferred, ProviderHostRequestOutcome::Executed),
17451            (
17452                ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
17453                ProviderHostRequestOutcome::Failed {
17454                    error: "terminal/create spawn failed: os error 3".to_owned(),
17455                },
17456            ),
17457        ] {
17458            let event = provider_control_event_at(
17459                AdapterFamily::Acp,
17460                "claude-code",
17461                3,
17462                ProviderEvent::HostRequestObserved {
17463                    method: "fs/read_text_file".to_owned(),
17464                    params_json: String::new(),
17465                    decision: decision.clone(),
17466                    outcome,
17467                    reason: None,
17468                },
17469            );
17470            assert!(agent_stream_chunk(&event).is_none(), "{decision:?}");
17471        }
17472    }
17473
17474    /// A deferred `session/request_permission` resolved by an operator or an
17475    /// expired deadline carries no reason text -- the chunk falls back to a
17476    /// categorical sentence naming the authority rather than inventing detail.
17477    #[test]
17478    fn denied_host_request_with_no_reason_falls_back_to_a_categorical_sentence() {
17479        for (by, expected_authority, expected_reason) in [
17480            (
17481                ProviderHostDecisionAuthority::Operator,
17482                BlockAuthorityV1::Operator,
17483                "declined by the operator",
17484            ),
17485            (
17486                ProviderHostDecisionAuthority::DeadlinePolicy,
17487                BlockAuthorityV1::HostDeadline,
17488                "denied by host policy after the operator deadline expired",
17489            ),
17490        ] {
17491            let (_, _, authority, _, reason, _) = blocked(&denied_host_request(by, None));
17492            assert_eq!(authority, expected_authority);
17493            assert_eq!(reason, expected_reason);
17494        }
17495    }
17496
17497    /// An over-long reason is cut to the wire bound BEFORE the chunk is built,
17498    /// at a safe UTF-8 boundary -- `validate` rejects an over-long value
17499    /// outright rather than truncating.
17500    #[test]
17501    fn overlong_blocked_reason_is_truncated_to_the_wire_bound() {
17502        let oversized = "x".repeat(MAX_ACP_BLOCKED_REASON_BYTES + 500);
17503        let (_, _, _, _, reason, _) = blocked(&denied_host_request(
17504            ProviderHostDecisionAuthority::Policy,
17505            Some(oversized),
17506        ));
17507        assert!(reason.len() <= MAX_ACP_BLOCKED_REASON_BYTES);
17508    }
17509}
17510
17511#[cfg(test)]
17512mod standalone_capability_platform_tests {
17513    use super::*;
17514
17515    #[test]
17516    fn standalone_workspace_capability_requires_windows_identity_guards() {
17517        let capabilities = baseline_capabilities().unwrap();
17518        let advertised = capabilities.iter().any(|capability| {
17519            capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
17520        });
17521        assert_eq!(advertised, cfg!(windows));
17522
17523        let request = NodeRequest::CreateStandaloneWorkspace {
17524            workspace_id: WorkspaceId::new("standalone-platform").unwrap(),
17525            root: OpaqueHostPath::utf8("standalone-platform-root".to_owned()).unwrap(),
17526            initial_branch: None,
17527        };
17528        assert_eq!(
17529            request_uses_unnegotiated_capability(&request, &capabilities),
17530            !cfg!(windows),
17531        );
17532    }
17533}
17534
17535
17536#[cfg(test)]
17537mod station_knobs_resolve_tests {
17538    use super::*;
17539    use gate4agent_catalog::EnvMutation;
17540    use gate4agent_runtime_native::{
17541        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
17542        NativeLaunchProfileId,
17543    };
17544    use std::ffi::OsString;
17545    use std::sync::Arc;
17546
17547    struct EmptyEnvironmentResolver;
17548
17549    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
17550        fn resolve_child_environment(
17551            &self,
17552        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
17553            Ok(vec![EnvMutation {
17554                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
17555                value: None,
17556            }])
17557        }
17558    }
17559
17560    fn agent(value: &str) -> AgentId {
17561        AgentId::new(value).unwrap()
17562    }
17563
17564    fn station_knobs_fixture() -> (NodeShared, crate::protocol::SpawnProfileDefaults) {
17565        let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
17566        let profile_revision =
17567            crate::protocol::SpawnEnvironmentProfileRevision::new("local-claude-r1").unwrap();
17568        let spawn_defaults = crate::protocol::SpawnProfileDefaults {
17569            profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
17570            revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
17571            provider: agent("claude"),
17572            mode: SessionMode::Pty,
17573            terminal_size: gate4agent_types::TerminalSize {
17574                rows: 24,
17575                columns: 80,
17576            },
17577            prompt: None,
17578            bundle_id: None,
17579            context_id: None,
17580            environment_profile_id: Some(profile_id.clone()),
17581        };
17582        let spawn_profiles = SpawnProfileRegistry::new([spawn_defaults.clone()]).unwrap();
17583        let catalog = active_registry().unwrap();
17584        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
17585        let control = runtime.native_launch_profile_control();
17586        let node_profile = NodeEnvironmentProfile::new(
17587            profile_id.clone(),
17588            profile_revision,
17589            agent("claude"),
17590            [NativeLaunchProfile::new(
17591                NativeLaunchProfileId::new("local-claude-pty").unwrap(),
17592                agent("claude"),
17593                TransportKind::Pty,
17594                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
17595                Arc::new(EmptyEnvironmentResolver),
17596            )
17597            .unwrap()],
17598        )
17599        .unwrap();
17600        let (binding, native_profiles, _materialization) = node_profile.into_parts();
17601        for native_profile in native_profiles {
17602            runtime.upsert_native_launch_profile(native_profile).unwrap();
17603        }
17604        let workspace = WorkspaceConfig::new(
17605            WorkspaceId::new("primary").unwrap(),
17606            std::env::current_dir().unwrap(),
17607        )
17608        .unwrap();
17609        let shared = NodeShared::new_with_incarnation(
17610            handle,
17611            "fixture-token".to_owned(),
17612            NodeId::new("node-station-knobs-test").unwrap(),
17613            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
17614            vec![workspace],
17615            vec![agent("claude")],
17616            ProviderRuntimeStatuses::default(),
17617            None,
17618            Vec::new(),
17619            Vec::new(),
17620            spawn_profiles,
17621            Some(control),
17622            None,
17623            None,
17624            None,
17625            Vec::new(),
17626            Vec::new(),
17627            Vec::new(),
17628            Vec::new(),
17629            None,
17630            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
17631        );
17632        shared
17633            .environment_profiles
17634            .write()
17635            .unwrap_or_else(|poisoned| poisoned.into_inner())
17636            .insert(binding.id.clone(), binding);
17637        (shared, spawn_defaults)
17638    }
17639
17640    /// Empty-default catalog refuses unknown network_allowlist ids.
17641    /// Feature-off dig2-station-probe keeps browser_profile stub echo.
17642    #[test]
17643    fn resolve_environment_profile_refuses_unknown_network_allowlist() {
17644        let (shared, defaults) = station_knobs_fixture();
17645        let unknown = crate::protocol::SpawnNetworkAllowlistId::new("not-registered").unwrap();
17646        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
17647        let spec = crate::protocol::SpawnSpec {
17648            target: crate::protocol::SpawnTarget {
17649                node_id: shared.node_id.clone(),
17650                workspace_id: WorkspaceId::new("primary").unwrap(),
17651                worktree_id: None,
17652            },
17653            profile_id: defaults.profile_id.clone(),
17654            expected_profile_revision: defaults.revision.clone(),
17655            overrides: crate::protocol::SpawnOverrides {
17656                network_allowlist: Some(unknown),
17657                browser_profile_id: Some(browser),
17658                ..crate::protocol::SpawnOverrides::default()
17659            },
17660            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
17661            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
17662                "station-knobs-unknown-allowlist",
17663            )
17664            .unwrap(),
17665            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
17666        };
17667        let resolved = spec.resolve(&defaults).unwrap();
17668        let failure = shared
17669            .resolve_environment_profile(&resolved)
17670            .expect_err("unknown allowlist must refuse");
17671        assert_eq!(failure.code, NodeFailureCode::UnknownNetworkAllowlist);
17672    }
17673
17674    /// Registered network_allowlist (+ browser_profile_id when feature off)
17675    /// echo onto the env-profile receipt. Empty catalog still allows omit
17676    /// (None). Empty/whitespace refuse is type-level. Feature-off
17677    /// dig2-station-probe keeps stub browser echo; feature-on refuse is
17678    /// covered by `…_refuses_browser_profile_when_station_probe_fails`.
17679    #[test]
17680    fn resolve_environment_profile_echoes_registered_station_network_and_browser_knobs() {
17681        let (shared, defaults) = station_knobs_fixture();
17682        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
17683        #[cfg(not(feature = "dig2-station-probe"))]
17684        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
17685        shared
17686            .network_allowlist_catalog
17687            .write()
17688            .unwrap_or_else(|poisoned| poisoned.into_inner())
17689            .insert_id(allowlist.clone())
17690            .unwrap();
17691        let spec = crate::protocol::SpawnSpec {
17692            target: crate::protocol::SpawnTarget {
17693                node_id: shared.node_id.clone(),
17694                workspace_id: WorkspaceId::new("primary").unwrap(),
17695                worktree_id: None,
17696            },
17697            profile_id: defaults.profile_id.clone(),
17698            expected_profile_revision: defaults.revision.clone(),
17699            overrides: crate::protocol::SpawnOverrides {
17700                network_allowlist: Some(allowlist.clone()),
17701                #[cfg(not(feature = "dig2-station-probe"))]
17702                browser_profile_id: Some(browser.clone()),
17703                ..crate::protocol::SpawnOverrides::default()
17704            },
17705            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
17706            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-echo")
17707                .unwrap(),
17708            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
17709        };
17710        let resolved = spec.resolve(&defaults).unwrap();
17711        assert_eq!(resolved.network_allowlist.as_ref(), Some(&allowlist));
17712        #[cfg(not(feature = "dig2-station-probe"))]
17713        assert_eq!(resolved.browser_profile_id.as_ref(), Some(&browser));
17714        let receipt = shared
17715            .resolve_environment_profile(&resolved)
17716            .expect("resolve ok")
17717            .expect("env profile present");
17718        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
17719        #[cfg(not(feature = "dig2-station-probe"))]
17720        assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
17721        #[cfg(feature = "dig2-station-probe")]
17722        assert_eq!(receipt.browser_profile_id, None);
17723        assert_eq!(receipt.profile_id.as_str(), "local-claude");
17724
17725        let omitted = crate::protocol::SpawnSpec {
17726            overrides: crate::protocol::SpawnOverrides::default(),
17727            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-omit")
17728                .unwrap(),
17729            ..spec
17730        };
17731        let resolved_omit = omitted.resolve(&defaults).unwrap();
17732        let bare = shared
17733            .resolve_environment_profile(&resolved_omit)
17734            .expect("resolve ok")
17735            .expect("env profile present");
17736        assert_eq!(bare.network_allowlist, None);
17737        assert_eq!(bare.browser_profile_id, None);
17738    }
17739
17740    /// Feature `dig2-station-probe`: browser_profile_id set refuses when the
17741    /// cheap station probe cannot succeed. On non-Windows (this CI host) that
17742    /// is `BrowserStationProbeUnavailable`. Never cookies on C2. Lease map is
17743    /// unit-tested separately (probe refuses before lease on Linux).
17744    #[cfg(feature = "dig2-station-probe")]
17745    #[test]
17746    fn resolve_environment_profile_refuses_browser_profile_when_station_probe_fails() {
17747        let (shared, defaults) = station_knobs_fixture();
17748        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
17749        let spec = crate::protocol::SpawnSpec {
17750            target: crate::protocol::SpawnTarget {
17751                node_id: shared.node_id.clone(),
17752                workspace_id: WorkspaceId::new("primary").unwrap(),
17753                worktree_id: None,
17754            },
17755            profile_id: defaults.profile_id.clone(),
17756            expected_profile_revision: defaults.revision.clone(),
17757            overrides: crate::protocol::SpawnOverrides {
17758                browser_profile_id: Some(browser),
17759                ..crate::protocol::SpawnOverrides::default()
17760            },
17761            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
17762            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
17763                "station-knobs-probe-refuse",
17764            )
17765            .unwrap(),
17766            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
17767        };
17768        let resolved = spec.resolve(&defaults).unwrap();
17769        let failure = shared
17770            .resolve_environment_profile(&resolved)
17771            .expect_err("browser_profile with failing probe must refuse");
17772        #[cfg(windows)]
17773        {
17774            assert!(
17775                matches!(
17776                    failure.code,
17777                    NodeFailureCode::BrowserStationUnreachable
17778                        | NodeFailureCode::BrowserStationProbeUnavailable
17779                ),
17780                "unexpected code {:?}",
17781                failure.code
17782            );
17783        }
17784        #[cfg(not(windows))]
17785        {
17786            assert_eq!(
17787                failure.code,
17788                NodeFailureCode::BrowserStationProbeUnavailable
17789            );
17790        }
17791    }
17792
17793    /// Feature on: omitting browser_profile_id still resolves (no probe).
17794    #[cfg(feature = "dig2-station-probe")]
17795    #[test]
17796    fn resolve_environment_profile_skips_station_probe_when_browser_profile_omitted() {
17797        let (shared, defaults) = station_knobs_fixture();
17798        let spec = crate::protocol::SpawnSpec {
17799            target: crate::protocol::SpawnTarget {
17800                node_id: shared.node_id.clone(),
17801                workspace_id: WorkspaceId::new("primary").unwrap(),
17802                worktree_id: None,
17803            },
17804            profile_id: defaults.profile_id.clone(),
17805            expected_profile_revision: defaults.revision.clone(),
17806            overrides: crate::protocol::SpawnOverrides::default(),
17807            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
17808            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
17809                "station-knobs-probe-omit",
17810            )
17811            .unwrap(),
17812            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
17813        };
17814        let resolved = spec.resolve(&defaults).unwrap();
17815        let receipt = shared
17816            .resolve_environment_profile(&resolved)
17817            .expect("omit browser_profile must not probe")
17818            .expect("env profile present");
17819        assert_eq!(receipt.browser_profile_id, None);
17820    }
17821
17822    /// Feature dig2-station-probe: exclusive BrowserStationLease on NodeShared
17823    /// refuses a second holder for the same opaque profile id; release on
17824    /// remove_binding allows reacquire. Never ImportSession/cookies on C2.
17825    #[cfg(feature = "dig2-station-probe")]
17826    #[test]
17827    fn browser_station_lease_exclusive_and_clears_on_remove_binding() {
17828        let (shared, _defaults) = station_knobs_fixture();
17829        let profile = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
17830        let workspace_id = WorkspaceId::new("primary").unwrap();
17831        let address_a = SessionAddress {
17832            workspace_id: workspace_id.clone(),
17833            session: SessionKey {
17834                instance_id: AgentInstanceId(101),
17835                generation: SessionGeneration(1),
17836            },
17837        };
17838        let address_b = SessionAddress {
17839            workspace_id: workspace_id.clone(),
17840            session: SessionKey {
17841                instance_id: AgentInstanceId(102),
17842                generation: SessionGeneration(1),
17843            },
17844        };
17845        let guard_a = crate::dig2_station_lease::acquire_lease(
17846            &shared.browser_station_leases,
17847            profile.clone(),
17848            address_a.session.instance_id,
17849            1,
17850        )
17851        .expect("first lease");
17852        let busy = crate::dig2_station_lease::acquire_lease(
17853            &shared.browser_station_leases,
17854            profile.clone(),
17855            address_b.session.instance_id,
17856            2,
17857        )
17858        .expect_err("second holder must refuse");
17859        assert!(matches!(
17860            busy,
17861            crate::dig2_station_lease::BrowserStationLeaseError::ProfileBusy { .. }
17862        ));
17863        // Simulate a bound session so remove_binding clears the lease.
17864        shared.bind_session_with_policy(
17865            &address_a,
17866            ProviderRuntimePolicy::raw_pty(),
17867            None,
17868        );
17869        guard_a.retain();
17870        assert!(shared.remove_binding(&address_a).is_some());
17871        crate::dig2_station_lease::acquire_lease(
17872            &shared.browser_station_leases,
17873            profile,
17874            address_b.session.instance_id,
17875            3,
17876        )
17877        .expect("reacquire after clear")
17878        .retain();
17879        assert!(
17880            crate::dig2_station_lease::release_lease_for_holder(
17881                &shared.browser_station_leases,
17882                address_b.session.instance_id,
17883            )
17884        );
17885    }
17886
17887    /// Feature-off: no browser_station_leases field; stub echo documented in
17888    /// resolve_environment_profile_echoes_registered_station_network_and_browser_knobs.
17889    #[cfg(not(feature = "dig2-station-probe"))]
17890    #[test]
17891    fn browser_station_lease_feature_off_keeps_stub_without_lease_table() {
17892        let (shared, defaults) = station_knobs_fixture();
17893        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
17894        let spec = crate::protocol::SpawnSpec {
17895            target: crate::protocol::SpawnTarget {
17896                node_id: shared.node_id.clone(),
17897                workspace_id: WorkspaceId::new("primary").unwrap(),
17898                worktree_id: None,
17899            },
17900            profile_id: defaults.profile_id.clone(),
17901            expected_profile_revision: defaults.revision.clone(),
17902            overrides: crate::protocol::SpawnOverrides {
17903                browser_profile_id: Some(browser.clone()),
17904                ..crate::protocol::SpawnOverrides::default()
17905            },
17906            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
17907            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("lease-stub-off")
17908                .unwrap(),
17909            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
17910        };
17911        let resolved = spec.resolve(&defaults).unwrap();
17912        let receipt = shared
17913            .resolve_environment_profile(&resolved)
17914            .expect("stub resolve")
17915            .expect("env profile");
17916        assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
17917    }
17918
17919
17920    /// Startup file loader registers ids into the empty-default catalog.
17921    /// Feature-off dig2 probe stays stub. Never secrets on C2.
17922    #[test]
17923    fn network_allowlist_catalog_loads_from_temp_file() {
17924        let unique = std::time::SystemTime::now()
17925            .duration_since(std::time::UNIX_EPOCH)
17926            .unwrap()
17927            .as_nanos();
17928        let path = std::env::temp_dir().join(format!(
17929            "gate4agent-node-allowlist-config-{}-{unique}.txt",
17930            std::process::id(),
17931        ));
17932        std::fs::write(
17933            &path,
17934            "# opaque station allowlist ids\negress-default\nlab-egress\n",
17935        )
17936        .unwrap();
17937        let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
17938            .expect("temp catalog file must load");
17939        let _ = std::fs::remove_file(&path);
17940        assert_eq!(loaded.len(), 2);
17941        let (shared, defaults) = station_knobs_fixture();
17942        {
17943            let mut catalog = shared
17944                .network_allowlist_catalog
17945                .write()
17946                .unwrap_or_else(|poisoned| poisoned.into_inner());
17947            *catalog = loaded;
17948        }
17949        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
17950        let spec = crate::protocol::SpawnSpec {
17951            target: crate::protocol::SpawnTarget {
17952                node_id: shared.node_id.clone(),
17953                workspace_id: WorkspaceId::new("primary").unwrap(),
17954                worktree_id: None,
17955            },
17956            profile_id: defaults.profile_id.clone(),
17957            expected_profile_revision: defaults.revision.clone(),
17958            overrides: crate::protocol::SpawnOverrides {
17959                network_allowlist: Some(allowlist.clone()),
17960                ..crate::protocol::SpawnOverrides::default()
17961            },
17962            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
17963            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
17964                "station-knobs-catalog-file",
17965            )
17966            .unwrap(),
17967            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
17968        };
17969        let resolved = spec.resolve(&defaults).unwrap();
17970        let receipt = shared
17971            .resolve_environment_profile(&resolved)
17972            .expect("registered via file load")
17973            .expect("env profile present");
17974        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
17975    }
17976
17977    /// Snapshot launch inventory echoes registered catalog ids (opaque).
17978    /// Dig2 station probe is orthogonal (feature `dig2-station-probe`). Never secrets on C2.
17979    #[test]
17980    fn snapshot_launch_inventory_lists_network_allowlist_catalog_ids() {
17981        let (shared, _defaults) = station_knobs_fixture();
17982        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
17983        shared
17984            .network_allowlist_catalog
17985            .write()
17986            .unwrap_or_else(|poisoned| poisoned.into_inner())
17987            .insert_id(allowlist.clone())
17988            .unwrap();
17989        let snapshot = shared.snapshot();
17990        let inventory = snapshot
17991            .launch_inventory
17992            .expect("launch inventory present");
17993        let ids = inventory
17994            .network_allowlists
17995            .expect("non-empty catalog surfaces ids");
17996        assert_eq!(ids, vec![allowlist]);
17997    }
17998
17999    /// Track B: registered id with provider_native codex_network_access refuses
18000    /// clearly for Claude (fixture provider). Unknown id still UnknownNetworkAllowlist.
18001    /// Never secrets on C2. Codex Moderate maps to `-c` overlay; RO/FullAuto refuse.
18002    #[test]
18003    fn resolve_environment_profile_refuses_unsupported_provider_native_mapping() {
18004        let (shared, defaults) = station_knobs_fixture();
18005        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-only").unwrap();
18006        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18007            id: allowlist.clone(),
18008            permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
18009                protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18010                peer: "127.0.0.1:443".to_owned(),
18011            }],
18012            provider_native: Some(
18013                crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
18014                    codex_network_access: Some(true),
18015                },
18016            ),
18017        };
18018        shared
18019            .network_allowlist_catalog
18020            .write()
18021            .unwrap_or_else(|poisoned| poisoned.into_inner())
18022            .insert(entry)
18023            .unwrap();
18024        let spec = crate::protocol::SpawnSpec {
18025            target: crate::protocol::SpawnTarget {
18026                node_id: shared.node_id.clone(),
18027                workspace_id: WorkspaceId::new("primary").unwrap(),
18028                worktree_id: None,
18029            },
18030            profile_id: defaults.profile_id.clone(),
18031            expected_profile_revision: defaults.revision.clone(),
18032            overrides: crate::protocol::SpawnOverrides {
18033                network_allowlist: Some(allowlist),
18034                ..crate::protocol::SpawnOverrides::default()
18035            },
18036            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18037            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18038                "station-knobs-unsupported-mapping",
18039            )
18040            .unwrap(),
18041            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18042        };
18043        let resolved = spec.resolve(&defaults).unwrap();
18044        assert_eq!(resolved.provider.as_str(), "claude");
18045        let failure = shared
18046            .resolve_environment_profile(&resolved)
18047            .expect_err("claude + codex_network_access must refuse");
18048        assert_eq!(
18049            failure.code,
18050            NodeFailureCode::UnsupportedNetworkAllowlistMapping
18051        );
18052    }
18053
18054    /// Codex Moderate + codex_network_access yields `-c` overlay argv.
18055    /// ReadOnly / FullAuto refuse UnsupportedNetworkAllowlistMapping.
18056    /// Never secrets on C2.
18057    #[test]
18058    fn resolve_codex_network_access_overlay_moderate_only() {
18059        let (shared, defaults) = station_knobs_fixture();
18060        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-net").unwrap();
18061        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18062            id: allowlist.clone(),
18063            permits: Vec::new(),
18064            provider_native: Some(
18065                crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
18066                    codex_network_access: Some(true),
18067                },
18068            ),
18069        };
18070        shared
18071            .network_allowlist_catalog
18072            .write()
18073            .unwrap_or_else(|poisoned| poisoned.into_inner())
18074            .insert(entry)
18075            .unwrap();
18076
18077        let overlay = shared
18078            .resolve_codex_network_access_overlay_args(
18079                &agent("codex"),
18080                SessionMode::Pty,
18081                ApprovalLevel::Moderate,
18082                Some(&allowlist),
18083            )
18084            .expect("Moderate Codex maps network_access");
18085        assert_eq!(
18086            overlay,
18087            vec![
18088                std::ffi::OsString::from("-c"),
18089                std::ffi::OsString::from("sandbox_workspace_write.network_access=true"),
18090            ]
18091        );
18092
18093        let acp = shared
18094            .resolve_codex_network_access_overlay_args(
18095                &agent("codex"),
18096                SessionMode::Acp,
18097                ApprovalLevel::Moderate,
18098                Some(&allowlist),
18099            )
18100            .unwrap();
18101        assert_eq!(acp, overlay);
18102
18103        let inline = shared
18104            .resolve_codex_network_access_overlay_args(
18105                &agent("codex"),
18106                SessionMode::Inline,
18107                ApprovalLevel::Moderate,
18108                Some(&allowlist),
18109            )
18110            .unwrap_err();
18111        assert_eq!(
18112            inline.code,
18113            NodeFailureCode::UnsupportedNetworkAllowlistMapping
18114        );
18115
18116        for level in [ApprovalLevel::ReadOnly, ApprovalLevel::FullAuto] {
18117            let spec = crate::protocol::SpawnSpec {
18118                target: crate::protocol::SpawnTarget {
18119                    node_id: shared.node_id.clone(),
18120                    workspace_id: WorkspaceId::new("primary").unwrap(),
18121                    worktree_id: None,
18122                },
18123                profile_id: defaults.profile_id.clone(),
18124                expected_profile_revision: defaults.revision.clone(),
18125                overrides: crate::protocol::SpawnOverrides {
18126                    provider: crate::protocol::SpawnOverride::Set {
18127                        value: agent("codex"),
18128                    },
18129                    approval_level: Some(level),
18130                    network_allowlist: Some(allowlist.clone()),
18131                    ..crate::protocol::SpawnOverrides::default()
18132                },
18133                deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18134                idempotency_key: crate::protocol::SpawnIdempotencyKey::new(format!(
18135                    "codex-net-refuse-{}",
18136                    match level {
18137                        ApprovalLevel::ReadOnly => "ro",
18138                        ApprovalLevel::FullAuto => "fa",
18139                        _ => "other",
18140                    }
18141                ))
18142                .unwrap(),
18143                required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18144            };
18145            let resolved = spec.resolve(&defaults).unwrap();
18146            let failure = shared
18147                .resolve_environment_profile(&resolved)
18148                .expect_err("RO/FullAuto must refuse");
18149            assert_eq!(
18150                failure.code,
18151                NodeFailureCode::UnsupportedNetworkAllowlistMapping
18152            );
18153        }
18154    }
18155
18156    /// Claude PTY + catalog permits → ProviderHome `--settings` sidecar with
18157    /// sandbox.network.allowedDomains. ACP / missing ProviderHome refuse.
18158    /// Never secrets on C2; does not invent provider_native Claude keys.
18159    #[test]
18160    fn resolve_claude_settings_network_overlay_pty_writes_sidecar() {
18161        let (shared, _defaults) = station_knobs_fixture();
18162        let allowlist =
18163            crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-domains").unwrap();
18164        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18165            id: allowlist.clone(),
18166            permits: vec![
18167                crate::network_allowlist_catalog::NetworkPermitSketch {
18168                    protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18169                    peer: "github.com:443".to_owned(),
18170                },
18171                crate::network_allowlist_catalog::NetworkPermitSketch {
18172                    protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18173                    peer: "*.npmjs.org".to_owned(),
18174                },
18175            ],
18176            provider_native: None,
18177        };
18178        shared
18179            .network_allowlist_catalog
18180            .write()
18181            .unwrap_or_else(|poisoned| poisoned.into_inner())
18182            .insert(entry)
18183            .unwrap();
18184
18185        let unique = std::time::SystemTime::now()
18186            .duration_since(std::time::UNIX_EPOCH)
18187            .unwrap()
18188            .as_nanos();
18189        let provider_home = std::env::temp_dir().join(format!(
18190            "gate4agent-claude-provider-home-{}-{unique}",
18191            std::process::id(),
18192        ));
18193        std::fs::create_dir_all(&provider_home).unwrap();
18194
18195        let overlay = shared
18196            .resolve_network_allowlist_overlay_args(
18197                &agent("claude"),
18198                SessionMode::Pty,
18199                ApprovalLevel::Moderate,
18200                Some(&allowlist),
18201                Some(provider_home.as_path()),
18202            )
18203            .expect("Claude PTY settings overlay on unix");
18204        let settings_path = provider_home.join(
18205            crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
18206        );
18207        assert_eq!(
18208            overlay,
18209            vec![
18210                std::ffi::OsString::from("--settings"),
18211                std::ffi::OsString::from(settings_path.as_os_str()),
18212            ]
18213        );
18214        let body = std::fs::read_to_string(&settings_path).unwrap();
18215        let value: serde_json::Value = serde_json::from_str(&body).unwrap();
18216        assert_eq!(value["sandbox"]["enabled"], true);
18217        assert_eq!(
18218            value["sandbox"]["network"]["allowedDomains"],
18219            serde_json::json!(["github.com", "*.npmjs.org"])
18220        );
18221        assert!(settings_path.starts_with(&provider_home));
18222
18223        let acp = shared
18224            .resolve_network_allowlist_overlay_args(
18225                &agent("claude"),
18226                SessionMode::Acp,
18227                ApprovalLevel::Moderate,
18228                Some(&allowlist),
18229                Some(provider_home.as_path()),
18230            )
18231            .expect_err("Claude ACP must refuse settings overlay");
18232        assert_eq!(
18233            acp.code,
18234            NodeFailureCode::UnsupportedNetworkAllowlistMapping
18235        );
18236
18237        let no_home = shared
18238            .resolve_network_allowlist_overlay_args(
18239                &agent("claude"),
18240                SessionMode::Pty,
18241                ApprovalLevel::Moderate,
18242                Some(&allowlist),
18243                None,
18244            )
18245            .expect_err("Claude overlay requires ProviderHome");
18246        assert_eq!(
18247            no_home.code,
18248            NodeFailureCode::UnsupportedNetworkAllowlistMapping
18249        );
18250
18251        let bare = crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-bare").unwrap();
18252        shared
18253            .network_allowlist_catalog
18254            .write()
18255            .unwrap_or_else(|poisoned| poisoned.into_inner())
18256            .insert_id(bare.clone())
18257            .unwrap();
18258        let empty = shared
18259            .resolve_network_allowlist_overlay_args(
18260                &agent("claude"),
18261                SessionMode::Pty,
18262                ApprovalLevel::Moderate,
18263                Some(&bare),
18264                Some(provider_home.as_path()),
18265            )
18266            .unwrap();
18267        assert!(empty.is_empty());
18268
18269        let _ = std::fs::remove_dir_all(&provider_home);
18270    }
18271
18272    /// Kimi / Grok with permits stay without invented argv overlays.
18273    #[test]
18274    fn resolve_kimi_grok_network_overlay_stays_empty_without_native() {
18275        let (shared, _defaults) = station_knobs_fixture();
18276        let allowlist =
18277            crate::protocol::SpawnNetworkAllowlistId::new("egress-kimi-permits").unwrap();
18278        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18279            id: allowlist.clone(),
18280            permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
18281                protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18282                peer: "example.com:443".to_owned(),
18283            }],
18284            provider_native: None,
18285        };
18286        shared
18287            .network_allowlist_catalog
18288            .write()
18289            .unwrap_or_else(|poisoned| poisoned.into_inner())
18290            .insert(entry)
18291            .unwrap();
18292        for provider in ["kimi", "grok"] {
18293            let overlay = shared
18294                .resolve_network_allowlist_overlay_args(
18295                    &agent(provider),
18296                    SessionMode::Pty,
18297                    ApprovalLevel::Moderate,
18298                    Some(&allowlist),
18299                    None,
18300                )
18301                .unwrap();
18302            assert!(
18303                overlay.is_empty(),
18304                "{provider} must not invent network argv from permits"
18305            );
18306        }
18307    }
18308
18309    /// Track B: JSON v2 catalog file loads permits; inventory still ids only.
18310    #[test]
18311    fn network_allowlist_catalog_loads_json_v2_and_lists_ids_only() {
18312        let unique = std::time::SystemTime::now()
18313            .duration_since(std::time::UNIX_EPOCH)
18314            .unwrap()
18315            .as_nanos();
18316        let path = std::env::temp_dir().join(format!(
18317            "gate4agent-node-allowlist-json-{}-{unique}.json",
18318            std::process::id(),
18319        ));
18320        std::fs::write(
18321            &path,
18322            r#"{
18323              "schema_version": 2,
18324              "entries": [
18325                {
18326                  "id": "egress-default",
18327                  "permits": [{ "protocol": "tcp", "peer": "127.0.0.1:443" }]
18328                },
18329                { "id": "lab-egress" }
18330              ]
18331            }"#,
18332        )
18333        .unwrap();
18334        let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
18335            .expect("json v2 catalog must load");
18336        let _ = std::fs::remove_file(&path);
18337        assert_eq!(loaded.len(), 2);
18338        let entry = loaded
18339            .get(&crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap())
18340            .unwrap();
18341        assert_eq!(entry.permits.len(), 1);
18342        let (shared, defaults) = station_knobs_fixture();
18343        {
18344            let mut catalog = shared
18345                .network_allowlist_catalog
18346                .write()
18347                .unwrap_or_else(|poisoned| poisoned.into_inner());
18348            *catalog = loaded;
18349        }
18350        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18351        let spec = crate::protocol::SpawnSpec {
18352            target: crate::protocol::SpawnTarget {
18353                node_id: shared.node_id.clone(),
18354                workspace_id: WorkspaceId::new("primary").unwrap(),
18355                worktree_id: None,
18356            },
18357            profile_id: defaults.profile_id.clone(),
18358            expected_profile_revision: defaults.revision.clone(),
18359            overrides: crate::protocol::SpawnOverrides {
18360                network_allowlist: Some(allowlist.clone()),
18361                ..crate::protocol::SpawnOverrides::default()
18362            },
18363            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18364            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18365                "station-knobs-catalog-json",
18366            )
18367            .unwrap(),
18368            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18369        };
18370        let resolved = spec.resolve(&defaults).unwrap();
18371        let receipt = shared
18372            .resolve_environment_profile(&resolved)
18373            .expect("permits-only entry ok for claude")
18374            .expect("env profile present");
18375        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18376        let inventory = shared
18377            .snapshot()
18378            .launch_inventory
18379            .expect("launch inventory present");
18380        let ids = inventory.network_allowlists.expect("ids surface");
18381        assert_eq!(
18382            ids,
18383            vec![
18384                crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap(),
18385                crate::protocol::SpawnNetworkAllowlistId::new("lab-egress").unwrap(),
18386            ]
18387        );
18388    }
18389
18390
18391}
18392
18393
18394#[cfg(test)]
18395mod workspace_environment_collision_tests {
18396    use super::*;
18397    use crate::session_environment::{
18398        NodeSessionMaterializationProfile, NodeSessionPathBinding, NodeSessionPathClass,
18399    };
18400    use gate4agent_catalog::EnvMutation;
18401    use gate4agent_runtime_native::{
18402        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
18403        NativeLaunchProfileId,
18404    };
18405    use std::ffi::OsString;
18406    use std::sync::Arc;
18407
18408    struct EmptyEnvironmentResolver;
18409
18410    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
18411        fn resolve_child_environment(
18412            &self,
18413        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
18414            Ok(vec![EnvMutation {
18415                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
18416                value: None,
18417            }])
18418        }
18419    }
18420
18421    fn agent(value: &str) -> AgentId {
18422        AgentId::new(value).unwrap()
18423    }
18424
18425    /// Two Claude ProviderHome profiles (`CLAUDE_CONFIG_DIR` A vs B) on one node.
18426    fn claude_config_dir_collision_fixture() -> (
18427        NodeShared,
18428        ResolvedEnvironmentProfileReceipt,
18429        ResolvedEnvironmentProfileReceipt,
18430    ) {
18431        let catalog = active_registry().unwrap();
18432        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18433        let control = runtime.native_launch_profile_control();
18434        let workspace = WorkspaceConfig::new(
18435            WorkspaceId::new("primary").unwrap(),
18436            std::env::current_dir().unwrap(),
18437        )
18438        .unwrap();
18439        let shared = NodeShared::new_with_incarnation(
18440            handle,
18441            "fixture-token".to_owned(),
18442            NodeId::new("node-env-collision-test").unwrap(),
18443            NodeIncarnationId::from_bytes([3; crate::protocol::NODE_INCARNATION_ID_BYTES]),
18444            vec![workspace],
18445            vec![agent("claude")],
18446            ProviderRuntimeStatuses::default(),
18447            None,
18448            Vec::new(),
18449            Vec::new(),
18450            SpawnProfileRegistry::default(),
18451            Some(control),
18452            None,
18453            None,
18454            None,
18455            Vec::new(),
18456            Vec::new(),
18457            Vec::new(),
18458            Vec::new(),
18459            None,
18460            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
18461        );
18462
18463        let mut receipts = Vec::new();
18464        for (suffix, native_suffix) in [("a", "a-pty"), ("b", "b-pty")] {
18465            let profile_id =
18466                SpawnEnvironmentProfileId::new(format!("claude-config-dir-{suffix}")).unwrap();
18467            let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(format!(
18468                "claude-config-dir-{suffix}-r1"
18469            ))
18470            .unwrap();
18471            let materialization = NodeSessionMaterializationProfile::new(
18472                Vec::new(),
18473                vec![NodeSessionPathBinding::new(
18474                    "CLAUDE_CONFIG_DIR",
18475                    NodeSessionPathClass::ProviderHome,
18476                )
18477                .unwrap()],
18478                Vec::new(),
18479            )
18480            .unwrap();
18481            let node_profile = NodeEnvironmentProfile::new_with_materialization(
18482                profile_id.clone(),
18483                profile_revision.clone(),
18484                agent("claude"),
18485                [NativeLaunchProfile::new(
18486                    NativeLaunchProfileId::new(format!("claude-config-dir-{native_suffix}"))
18487                        .unwrap(),
18488                    agent("claude"),
18489                    TransportKind::Pty,
18490                    vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
18491                    Arc::new(EmptyEnvironmentResolver),
18492                )
18493                .unwrap()],
18494                Some(materialization),
18495            )
18496            .unwrap();
18497            let (binding, native_profiles, materialization) = node_profile.into_parts();
18498            for native_profile in native_profiles {
18499                runtime.upsert_native_launch_profile(native_profile).unwrap();
18500            }
18501            shared
18502                .environment_profiles
18503                .write()
18504                .unwrap_or_else(|poisoned| poisoned.into_inner())
18505                .insert(binding.id.clone(), binding);
18506            shared
18507                .environment_materialization_profiles
18508                .write()
18509                .unwrap_or_else(|poisoned| poisoned.into_inner())
18510                .insert(profile_id.clone(), materialization.unwrap());
18511            receipts.push(ResolvedEnvironmentProfileReceipt {
18512                profile_id,
18513                profile_revision,
18514                network_allowlist: None,
18515                browser_profile_id: None,
18516            });
18517        }
18518        let profile_b = receipts.pop().unwrap();
18519        let profile_a = receipts.pop().unwrap();
18520        (shared, profile_a, profile_b)
18521    }
18522
18523    #[test]
18524    fn refuse_distinct_claude_config_dir_profiles_on_same_workspace_cwd() {
18525        let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
18526        let primary = WorkspaceId::new("primary").unwrap();
18527        let worktree = WorkspaceId::new("worktree-b").unwrap();
18528        let address = SessionAddress {
18529            workspace_id: primary.clone(),
18530            session: SessionKey {
18531                instance_id: AgentInstanceId(41),
18532                generation: SessionGeneration(1),
18533            },
18534        };
18535        shared.bind_session_with_policy(
18536            &address,
18537            ProviderRuntimePolicy::raw_pty(),
18538            Some(profile_a.clone()),
18539        );
18540
18541        let refused = shared
18542            .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_b))
18543            .unwrap_err();
18544        assert_eq!(refused.code, NodeFailureCode::WorkspaceBusy);
18545
18546        // Some vs None on the same cwd is also a collision.
18547        let refused_none = shared
18548            .ensure_workspace_environment_profile_compatible(&primary, None)
18549            .unwrap_err();
18550        assert_eq!(refused_none.code, NodeFailureCode::WorkspaceBusy);
18551
18552        // Same profile on the same workspace remains allowed.
18553        shared
18554            .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_a))
18555            .unwrap();
18556
18557        // Distinct workspace id (managed-worktree parallel path) allows profile B.
18558        shared
18559            .ensure_workspace_environment_profile_compatible(&worktree, Some(&profile_b))
18560            .unwrap();
18561    }
18562
18563    #[tokio::test]
18564    async fn spawn_session_refuses_conflicting_environment_profile_before_mutation() {
18565        let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
18566        let primary = WorkspaceId::new("primary").unwrap();
18567        let address = SessionAddress {
18568            workspace_id: primary.clone(),
18569            session: SessionKey {
18570                instance_id: AgentInstanceId(41),
18571                generation: SessionGeneration(1),
18572            },
18573        };
18574        shared.bind_session_with_policy(
18575            &address,
18576            ProviderRuntimePolicy::raw_pty(),
18577            Some(profile_a),
18578        );
18579
18580        let failure = shared
18581            .spawn_session_with_deadline(
18582                primary,
18583                agent("claude"),
18584                SessionMode::Pty,
18585                gate4agent_types::TerminalSize {
18586                    rows: 24,
18587                    columns: 80,
18588                },
18589                None,
18590                Some(profile_b),
18591                None,
18592                None,
18593                None,
18594                None,
18595                SpawnRecordPolicy::ProviderIdentityOnly,
18596                Some(Instant::now() + Duration::from_secs(30)),
18597                &[],
18598                None,
18599                ApprovalLevel::default(),
18600                None,
18601                None,
18602            )
18603            .await
18604            .unwrap_err();
18605        assert_eq!(failure.code, NodeFailureCode::WorkspaceBusy);
18606        // Guard runs before instance id mint / bind.
18607        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
18608        assert!(shared.handle.snapshot().sessions.is_empty());
18609    }
18610}
18611
18612#[cfg(all(test, windows))]
18613mod tests {
18614    use super::*;
18615    use gate4agent_types::ProviderActivity;
18616    use crate::session_environment::{
18617        NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
18618        NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
18619        NodeSessionPathClass,
18620    };
18621    use gate4agent_catalog::EnvMutation;
18622    use gate4agent_runtime_native::{
18623        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
18624        NativeLaunchProfile, NativeLaunchProfileId,
18625    };
18626    use std::ffi::OsString;
18627    use std::sync::Arc;
18628
18629    struct EmptyEnvironmentResolver;
18630
18631    struct FixtureSecretResolver {
18632        deny: Arc<AtomicBool>,
18633    }
18634
18635    impl NodeSecretResolver for FixtureSecretResolver {
18636        fn resolve(
18637            &self,
18638            _reference: &NodeSecretReference,
18639        ) -> Result<NodeSecretValue, NodeSecretResolveError> {
18640            if self.deny.load(Ordering::Acquire) {
18641                Err(NodeSecretResolveError::Denied)
18642            } else {
18643                NodeSecretValue::text("fixture-secret")
18644                    .map_err(|_| NodeSecretResolveError::Unavailable)
18645            }
18646        }
18647    }
18648
18649    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
18650        fn resolve_child_environment(
18651            &self,
18652        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
18653            Ok(vec![EnvMutation {
18654                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
18655                value: None,
18656            }])
18657        }
18658    }
18659
18660    fn agent(value: &str) -> AgentId {
18661        AgentId::new(value).unwrap()
18662    }
18663
18664    fn terminal_test_shared_with_mode(mode: WorktreeServiceMode) -> NodeShared {
18665        let catalog = active_registry().unwrap();
18666        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18667        let workspace = WorkspaceConfig::new(
18668            WorkspaceId::new("primary").unwrap(),
18669            std::env::current_dir().unwrap(),
18670        )
18671        .unwrap()
18672        .with_worktree_service_mode(mode);
18673        NodeShared::new(
18674            handle,
18675            "fixture-token".to_owned(),
18676            NodeId::new("node-terminal-test").unwrap(),
18677            vec![workspace],
18678            vec![agent("claude")],
18679        )
18680    }
18681
18682    fn terminal_test_shared() -> NodeShared {
18683        terminal_test_shared_with_mode(WorktreeServiceMode::Manual)
18684    }
18685
18686    #[cfg(feature = "fixture")]
18687    #[test]
18688    fn managed_worktree_v2_failure_probe_keeps_only_the_latest_code() {
18689        let probe = SpawnManagedWorktreeV2FailureProbe::default();
18690        assert_eq!(probe.latest_code(), None);
18691
18692        probe.record(Some(NodeFailureCode::ManagedWorktreeOwnershipConflict));
18693        assert_eq!(
18694            probe.latest_code(),
18695            Some(NodeFailureCode::ManagedWorktreeOwnershipConflict),
18696        );
18697
18698        probe.record(Some(NodeFailureCode::SpawnDeadlineExceeded));
18699        assert_eq!(
18700            probe.latest_code(),
18701            Some(NodeFailureCode::SpawnDeadlineExceeded),
18702        );
18703
18704        probe.record(None);
18705        assert_eq!(probe.latest_code(), None);
18706    }
18707
18708    #[test]
18709    fn launch_snapshot_is_authoritative_bounded_and_path_free() {
18710        let source = temporary_workspace_root("launch-inventory-source");
18711        let allocation = temporary_workspace_root("launch-inventory-allocation");
18712        std::fs::create_dir_all(&source).unwrap();
18713        std::fs::create_dir_all(&allocation).unwrap();
18714        let workspace_id = WorkspaceId::new("primary").unwrap();
18715        let mut workspace = WorkspaceConfig::new(workspace_id.clone(), &source)
18716            .unwrap()
18717            .with_worktree_service_mode(WorktreeServiceMode::Managed);
18718        for index in 0..MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE {
18719            workspace = workspace
18720                .with_managed_worktree_profile(
18721                    ManagedWorktreeProfile::new(
18722                        WorktreeProfileId::new(format!("profile-{index}")).unwrap(),
18723                        crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
18724                        &allocation,
18725                        "gate4agent",
18726                        "HEAD",
18727                        ManagedWorktreeRetention::RemoveWhenReleased,
18728                    )
18729                    .unwrap(),
18730                )
18731                .unwrap();
18732        }
18733        let overflow = workspace.clone().with_managed_worktree_profile(
18734            ManagedWorktreeProfile::new(
18735                WorktreeProfileId::new("overflow").unwrap(),
18736                crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
18737                &allocation,
18738                "gate4agent",
18739                "HEAD",
18740                ManagedWorktreeRetention::Retain,
18741            )
18742            .unwrap(),
18743        );
18744        assert!(matches!(
18745            overflow,
18746            Err(NodeServerError::ManagedWorktreeProfileCapacity {
18747                max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
18748                ..
18749            }),
18750        ));
18751
18752        let catalog = active_registry().unwrap();
18753        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18754        let shared = NodeShared::new(
18755            handle,
18756            "fixture-token".to_owned(),
18757            NodeId::new("node-launch-inventory").unwrap(),
18758            vec![workspace],
18759            vec![agent("claude")],
18760        );
18761        let snapshot = shared.snapshot();
18762        let launch = snapshot.launch_inventory.as_ref().unwrap();
18763        assert_eq!(launch.spawn_profiles.as_ref().unwrap().len(), 1);
18764        assert!(launch.bundles.as_ref().unwrap().is_empty());
18765        let profiles = snapshot.workspaces[0]
18766            .managed_worktree_profiles
18767            .as_ref()
18768            .unwrap();
18769        assert_eq!(
18770            snapshot.workspaces[0].worktree_service_mode,
18771            Some(WorktreeServiceMode::Managed),
18772        );
18773        assert_eq!(
18774            profiles.profiles.len(),
18775            MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
18776        );
18777        let public_inventory = serde_json::to_string(&(launch, profiles)).unwrap();
18778        for forbidden in ["allocation_root", "branch_prefix", "base", "prompt", "environment", "home"] {
18779            assert!(!public_inventory.contains(forbidden), "launch inventory leaked {forbidden}");
18780        }
18781        let legacy = snapshot_for_wire(
18782            &shared,
18783            true,
18784            true,
18785            false,
18786            false,
18787            true,
18788            false,
18789            true,
18790            false,
18791            true,
18792        );
18793        assert!(legacy.launch_inventory.is_none());
18794        assert!(legacy.workspaces[0].managed_worktree_profiles.is_none());
18795
18796        drop(shared);
18797        std::fs::remove_dir_all(source).unwrap();
18798        std::fs::remove_dir_all(allocation).unwrap();
18799    }
18800
18801    #[test]
18802    fn agent_progress_projection_omits_sensitive_provider_payloads_and_is_capability_gated() {
18803        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
18804            capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
18805        }));
18806        let source = gate4agent_types::ProviderSource {
18807            family: AdapterFamily::PtySemantic,
18808            binding: AdapterBinding::new(
18809                gate4agent_types::AdapterId::new("claude").unwrap(),
18810                "fixture/v1",
18811                gate4agent_types::AdapterVerification::SyntheticFixture,
18812            )
18813            .unwrap(),
18814        };
18815        let provider = ProviderSnapshot {
18816            sequence: 17,
18817            session: Some(ProviderSessionIdentity {
18818                key: ProviderSessionKey::SessionId,
18819                id: "PRIVATE_PROVIDER_SESSION".to_owned(),
18820                transcript_path: Some(r"C:\PRIVATE_PROVIDER_PATH".to_owned()),
18821            }),
18822            model: Some("PRIVATE_MODEL".to_owned()),
18823            tools: vec!["PRIVATE_TOOL_INVENTORY".to_owned()],
18824            completed_turns: 3,
18825            usage: gate4agent_types::TokenUsage {
18826                input_tokens: 10,
18827                output_tokens: 20,
18828                cache_read_tokens: 30,
18829                cache_write_tokens: 40,
18830                reasoning_tokens: 50,
18831                context_window: Some(200_000),
18832            },
18833            lead_activity: ProviderActivity::WaitingForInput,
18834            activity: ProviderActivity::WaitingForInput,
18835            current_prompt: Some("PRIVATE_CURRENT_PROMPT".to_owned()),
18836            active_tools: (0..9)
18837                .map(|index| gate4agent_types::ActiveProviderTool {
18838                    id: format!("private-tool-id-{index}"),
18839                    name: if index == 0 {
18840                        format!("  Read\u{0000}{}", "x".repeat(80))
18841                    } else {
18842                        format!("Tool-{index}")
18843                    },
18844                    input_json: "PRIVATE_TOOL_INPUT_JSON".to_owned(),
18845                })
18846                .collect(),
18847            interactions: vec![gate4agent_types::ProviderInteraction {
18848                id: gate4agent_types::ProviderInteractionId(17),
18849                source: source.clone(),
18850                provider_request_id: Some("PRIVATE_PROVIDER_REQUEST".to_owned()),
18851                interaction_kind: ProviderInteractionKind::Approval,
18852                tool_name: " Approve\u{0007}Tool ".to_owned(),
18853                prompt: "PRIVATE_INTERACTION_PROMPT".to_owned(),
18854                agent_id: Some("PRIVATE_INTERACTION_AGENT".to_owned()),
18855                resume_lead_activity: Some(ProviderActivity::Working),
18856                status: ProviderInteractionStatus::Pending,
18857            }],
18858            subagents: vec![gate4agent_types::ProviderSubagent {
18859                source: source.clone(),
18860                provider_agent_id: "PRIVATE_SUBAGENT_ID".to_owned(),
18861                agent_type: Some("PRIVATE_SUBAGENT_TYPE".to_owned()),
18862                description: Some("PRIVATE_SUBAGENT_DESCRIPTION".to_owned()),
18863            }],
18864            sources: vec![gate4agent_types::ProviderSourceCursor {
18865                source,
18866                sequence: 17,
18867                gap_count: 2,
18868                stale: true,
18869            }],
18870            last_event: Some(ProviderEvent::Error {
18871                message: "PRIVATE_PROVIDER_ERROR".to_owned(),
18872            }),
18873            gap_count: 2,
18874            stale: true,
18875        };
18876        let address = terminal_address(3);
18877        let entry = agent_progress_from_provider_snapshot(address.clone(), &provider).unwrap();
18878        assert_eq!(entry.address, address);
18879        assert_eq!(entry.progress.provider_sequence, 17);
18880        assert_eq!(entry.progress.active_tool_count, 9);
18881        assert_eq!(
18882            entry.progress.active_tool_labels.len(),
18883            MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS,
18884        );
18885        assert_eq!(entry.progress.subagent_count, 1);
18886        assert_eq!(entry.progress.last_event_kind, Some(AgentProgressEventKindV1::Error));
18887        assert!(entry.progress.truncated);
18888        let encoded = serde_json::to_string(&entry).unwrap();
18889        for forbidden in [
18890            "PRIVATE_PROVIDER_SESSION",
18891            "PRIVATE_PROVIDER_PATH",
18892            "PRIVATE_MODEL",
18893            "PRIVATE_TOOL_INVENTORY",
18894            "PRIVATE_CURRENT_PROMPT",
18895            "PRIVATE_TOOL_INPUT_JSON",
18896            "PRIVATE_PROVIDER_REQUEST",
18897            "PRIVATE_INTERACTION_PROMPT",
18898            "PRIVATE_INTERACTION_AGENT",
18899            "PRIVATE_SUBAGENT_ID",
18900            "PRIVATE_SUBAGENT_TYPE",
18901            "PRIVATE_SUBAGENT_DESCRIPTION",
18902            "PRIVATE_PROVIDER_ERROR",
18903            "input_json",
18904            "current_prompt",
18905            "session_id",
18906        ] {
18907            assert!(!encoded.contains(forbidden), "agent progress leaked {forbidden}");
18908        }
18909
18910        let snapshot = NodeSnapshot {
18911            node_id: NodeId::new("node-progress-test").unwrap(),
18912            enabled_providers: vec![agent("claude")],
18913            provider_runtime_statuses: ProviderRuntimeStatuses::default(),
18914            workspaces: Vec::new(),
18915            session_records: Vec::new(),
18916            managed_worktrees: Vec::new(),
18917            launch_inventory: None,
18918            agent_progress: vec![entry],
18919        };
18920        let mut legacy_reply = ResponseEnvelope {
18921            request_id: 1,
18922            result: Ok(NodeResponse::Snapshot {
18923                event_sequence: 0,
18924                controller: None,
18925                snapshot: snapshot.clone(),
18926            }),
18927        };
18928        project_response_without_agent_progress(&mut legacy_reply);
18929        let Ok(NodeResponse::Snapshot { snapshot: legacy, .. }) = legacy_reply.result else {
18930            panic!("agent progress projection changed response kind");
18931        };
18932        assert!(legacy.agent_progress.is_empty());
18933        assert_eq!(snapshot.agent_progress.len(), 1);
18934    }
18935
18936    fn terminal_address(generation: u64) -> SessionAddress {
18937        SessionAddress {
18938            workspace_id: WorkspaceId::new("primary").unwrap(),
18939            session: SessionKey {
18940                instance_id: AgentInstanceId(41),
18941                generation: SessionGeneration(generation),
18942            },
18943        }
18944    }
18945
18946    fn spawn_spec_fixture() -> SpawnSpec {
18947        SpawnSpec {
18948            target: crate::protocol::SpawnTarget {
18949                node_id: NodeId::new("node-terminal-test").unwrap(),
18950                workspace_id: WorkspaceId::new("primary").unwrap(),
18951                worktree_id: None,
18952            },
18953            profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
18954            expected_profile_revision: crate::protocol::SpawnProfileRevision::new("builtin-v1")
18955                .unwrap(),
18956            overrides: crate::protocol::SpawnOverrides::default(),
18957            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18958            idempotency_key: SpawnIdempotencyKey::new("spawn-spec-fixture").unwrap(),
18959            required_capabilities: SpawnRequiredCapabilities::default(),
18960        }
18961    }
18962
18963    fn environment_profile_test_shared(
18964    ) -> (NodeShared, NativeRuntime, ResolvedEnvironmentProfileReceipt) {
18965        let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
18966        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
18967            "local-claude-r1",
18968        )
18969        .unwrap();
18970        let spawn_profiles = SpawnProfileRegistry::new([
18971            crate::protocol::SpawnProfileDefaults {
18972                profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
18973                revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
18974                provider: agent("claude"),
18975                mode: SessionMode::Pty,
18976                terminal_size: gate4agent_types::TerminalSize {
18977                    rows: 24,
18978                    columns: 80,
18979                },
18980                prompt: None,
18981                bundle_id: None,
18982                context_id: None,
18983                environment_profile_id: Some(profile_id.clone()),
18984            },
18985        ])
18986        .unwrap();
18987        let catalog = active_registry().unwrap();
18988        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18989        let control = runtime.native_launch_profile_control();
18990        let node_profile = NodeEnvironmentProfile::new(
18991            profile_id.clone(),
18992            profile_revision.clone(),
18993            agent("claude"),
18994            [NativeLaunchProfile::new(
18995                NativeLaunchProfileId::new("local-claude-pty").unwrap(),
18996                agent("claude"),
18997                TransportKind::Pty,
18998                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
18999                Arc::new(EmptyEnvironmentResolver),
19000            )
19001            .unwrap()],
19002        )
19003        .unwrap();
19004        let (binding, native_profiles, _materialization) = node_profile.into_parts();
19005        for native_profile in native_profiles {
19006            runtime.upsert_native_launch_profile(native_profile).unwrap();
19007        }
19008        let workspace = WorkspaceConfig::new(
19009            WorkspaceId::new("primary").unwrap(),
19010            std::env::current_dir().unwrap(),
19011        )
19012        .unwrap();
19013        let shared = NodeShared::new_with_incarnation(
19014            handle,
19015            "fixture-token".to_owned(),
19016            NodeId::new("node-terminal-test").unwrap(),
19017            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19018            vec![workspace],
19019            vec![agent("claude")],
19020            ProviderRuntimeStatuses::default(),
19021            None,
19022            Vec::new(),
19023            Vec::new(),
19024            spawn_profiles,
19025            Some(control),
19026            None,
19027            None,
19028            None,
19029            Vec::new(),
19030            Vec::new(),
19031            Vec::new(),
19032            Vec::new(),
19033            None,
19034            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19035        );
19036        shared
19037            .environment_profiles
19038            .write()
19039            .unwrap_or_else(|poisoned| poisoned.into_inner())
19040            .insert(binding.id.clone(), binding);
19041        (
19042            shared,
19043            runtime,
19044            ResolvedEnvironmentProfileReceipt {
19045                profile_id,
19046                profile_revision,
19047                network_allowlist: None,
19048                browser_profile_id: None,
19049            },
19050        )
19051    }
19052
19053    fn materialization_test_shared(
19054        deny_secret: bool,
19055    ) -> (
19056        NodeShared,
19057        NativeRuntime,
19058        ResolvedEnvironmentProfileReceipt,
19059        PathBuf,
19060        Arc<AtomicBool>,
19061    ) {
19062        let workspace = WorkspaceConfig::new(
19063            WorkspaceId::new("primary").unwrap(),
19064            std::env::current_dir().unwrap(),
19065        )
19066        .unwrap();
19067        materialization_test_shared_with_workspace(deny_secret, workspace)
19068    }
19069
19070    fn materialization_test_shared_with_workspace(
19071        deny_secret: bool,
19072        workspace: WorkspaceConfig,
19073    ) -> (
19074        NodeShared,
19075        NativeRuntime,
19076        ResolvedEnvironmentProfileReceipt,
19077        PathBuf,
19078        Arc<AtomicBool>,
19079    ) {
19080        let root = temporary_workspace_root(if deny_secret {
19081            "materialization-denied"
19082        } else {
19083            "materialization-ready"
19084        });
19085        std::fs::create_dir_all(&root).unwrap();
19086        let materialization_root = root.join("session-environments");
19087        let deny = Arc::new(AtomicBool::new(deny_secret));
19088        let resolver = Arc::new(FixtureSecretResolver {
19089            deny: Arc::clone(&deny),
19090        });
19091        let materializer = SessionEnvironmentMaterializer::new(
19092            materialization_root,
19093            resolver,
19094        )
19095        .unwrap();
19096        let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
19097        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19098            "materialized-claude-r1",
19099        )
19100        .unwrap();
19101        let materialization = NodeSessionMaterializationProfile::new(
19102            vec![NodeSessionEnvironmentMutation::SetSecret {
19103                key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
19104                reference: NodeSecretReference::new("fixture-token").unwrap(),
19105            }],
19106            vec![NodeSessionPathBinding::new(
19107                "GATE4AGENT_PROVIDER_HOME",
19108                NodeSessionPathClass::ProviderHome,
19109            )
19110            .unwrap()],
19111            vec![NodeSessionFile::secret(
19112                NodeSessionPathClass::Config,
19113                "auth/token",
19114                NodeSecretReference::new("fixture-token").unwrap(),
19115            )
19116            .unwrap()],
19117        )
19118        .unwrap();
19119        let catalog = active_registry().unwrap();
19120        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19121        let control = runtime.native_launch_profile_control();
19122        let node_profile = NodeEnvironmentProfile::new_with_materialization(
19123            profile_id.clone(),
19124            profile_revision.clone(),
19125            agent("claude"),
19126            [NativeLaunchProfile::new(
19127                NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
19128                agent("claude"),
19129                TransportKind::Pty,
19130                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19131                Arc::new(EmptyEnvironmentResolver),
19132            )
19133            .unwrap()],
19134            Some(materialization),
19135        )
19136        .unwrap();
19137        let (binding, native_profiles, materialization) = node_profile.into_parts();
19138        for native_profile in native_profiles {
19139            runtime.upsert_native_launch_profile(native_profile).unwrap();
19140        }
19141        let shared = NodeShared::new_with_incarnation(
19142            handle,
19143            "fixture-token".to_owned(),
19144            NodeId::new("node-materialization-test").unwrap(),
19145            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19146            vec![workspace],
19147            vec![agent("claude")],
19148            ProviderRuntimeStatuses::default(),
19149            None,
19150            Vec::new(),
19151            Vec::new(),
19152            SpawnProfileRegistry::default(),
19153            Some(control),
19154            None,
19155            Some(materializer),
19156            None,
19157            Vec::new(),
19158            Vec::new(),
19159            Vec::new(),
19160            Vec::new(),
19161            None,
19162            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19163        );
19164        shared
19165            .environment_profiles
19166            .write()
19167            .unwrap_or_else(|poisoned| poisoned.into_inner())
19168            .insert(binding.id.clone(), binding);
19169        shared
19170            .environment_materialization_profiles
19171            .write()
19172            .unwrap_or_else(|poisoned| poisoned.into_inner())
19173            .insert(profile_id.clone(), materialization.unwrap());
19174        (
19175            shared,
19176            runtime,
19177            ResolvedEnvironmentProfileReceipt {
19178                profile_id,
19179                profile_revision,
19180                network_allowlist: None,
19181                browser_profile_id: None,
19182            },
19183            root,
19184            deny,
19185        )
19186    }
19187
19188    #[cfg(feature = "fixture")]
19189    #[derive(Clone, Copy)]
19190    enum CodexHomeProfile {
19191        Missing,
19192        WrongClass,
19193        Exact,
19194    }
19195
19196    #[cfg(feature = "fixture")]
19197    fn codex_bundle_test_shared(
19198        home_profile: CodexHomeProfile,
19199    ) -> (
19200        NodeShared,
19201        NativeRuntime,
19202        ResolvedEnvironmentProfileReceipt,
19203        NodeBundle,
19204        PathBuf,
19205    ) {
19206        const ROOT_MANIFEST: &[u8] = br#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"review-tools"}"#;
19207        const CLAUDE_MANIFEST: &[u8] =
19208            br#"{"name":"review-tools","version":"1.0.0","description":"Review helpers"}"#;
19209        const SKILL: &[u8] = b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n";
19210        const DIGEST: &str =
19211            "sha256:941f20868a6ef49b4329bf1bb1368515763f5b64d8279b05cd9700966083d707";
19212
19213        let (shared, mut runtime, _claude_receipt, root, _deny) =
19214            materialization_test_shared(false);
19215        let profile_id = SpawnEnvironmentProfileId::new(match home_profile {
19216            CodexHomeProfile::Missing => "codex-home-missing",
19217            CodexHomeProfile::WrongClass => "codex-home-wrong",
19218            CodexHomeProfile::Exact => "codex-home-exact",
19219        })
19220        .unwrap();
19221        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19222            "codex-home-r1",
19223        )
19224        .unwrap();
19225        let materialization = match home_profile {
19226            CodexHomeProfile::Missing => None,
19227            CodexHomeProfile::WrongClass | CodexHomeProfile::Exact => {
19228                Some(NodeSessionMaterializationProfile::new(
19229                    Vec::new(),
19230                    vec![NodeSessionPathBinding::new(
19231                        "CODEX_HOME",
19232                        if matches!(home_profile, CodexHomeProfile::Exact) {
19233                            NodeSessionPathClass::ProviderHome
19234                        } else {
19235                            NodeSessionPathClass::Config
19236                        },
19237                    )
19238                    .unwrap()],
19239                    Vec::new(),
19240                )
19241                .unwrap())
19242            }
19243        };
19244        let node_profile = NodeEnvironmentProfile::new_with_materialization(
19245            profile_id.clone(),
19246            profile_revision.clone(),
19247            agent("codex"),
19248            [NativeLaunchProfile::new(
19249                NativeLaunchProfileId::new(format!("{profile_id}-pty")).unwrap(),
19250                agent("codex"),
19251                TransportKind::Pty,
19252                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19253                Arc::new(EmptyEnvironmentResolver),
19254            )
19255            .unwrap()],
19256            materialization,
19257        )
19258        .unwrap();
19259        let (binding, native_profiles, materialization) = node_profile.into_parts();
19260        for native_profile in native_profiles {
19261            runtime.upsert_native_launch_profile(native_profile).unwrap();
19262        }
19263        shared
19264            .environment_profiles
19265            .write()
19266            .unwrap_or_else(|poisoned| poisoned.into_inner())
19267            .insert(binding.id.clone(), binding);
19268        if let Some(materialization) = materialization {
19269            shared
19270                .environment_materialization_profiles
19271                .write()
19272                .unwrap_or_else(|poisoned| poisoned.into_inner())
19273                .insert(profile_id.clone(), materialization);
19274        }
19275
19276        let bundle_source = root.join(format!("bundle-source-{profile_id}"));
19277        std::fs::create_dir_all(bundle_source.join(".claude-plugin")).unwrap();
19278        std::fs::create_dir_all(bundle_source.join("skills/review-code")).unwrap();
19279        std::fs::write(bundle_source.join("plugin.json"), ROOT_MANIFEST).unwrap();
19280        std::fs::write(
19281            bundle_source.join(".claude-plugin/plugin.json"),
19282            CLAUDE_MANIFEST,
19283        )
19284        .unwrap();
19285        std::fs::write(bundle_source.join("skills/review-code/SKILL.md"), SKILL).unwrap();
19286        crate::bundle_catalog::protect_bundle_source_tree_fixture(&bundle_source).unwrap();
19287        let bundle = NodeBundle::new(
19288            crate::protocol::SpawnBundleId::new("review-tools").unwrap(),
19289            crate::protocol::SpawnBundleRevision::new("review-tools-r1").unwrap(),
19290            crate::protocol::SpawnBundleDigest::new(DIGEST).unwrap(),
19291            &bundle_source,
19292        )
19293        .unwrap();
19294        *shared
19295            .bundle_catalog
19296            .write()
19297            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
19298            BundleCatalog::new([bundle.clone()]).unwrap();
19299        (
19300            shared,
19301            runtime,
19302            ResolvedEnvironmentProfileReceipt {
19303                profile_id,
19304                profile_revision,
19305                network_allowlist: None,
19306                browser_profile_id: None,
19307            },
19308            bundle,
19309            root,
19310        )
19311    }
19312
19313    #[cfg(feature = "fixture")]
19314    #[test]
19315    fn codex_bundle_preflight_rejects_missing_or_wrong_home_before_materialization() {
19316        for home_profile in [CodexHomeProfile::Missing, CodexHomeProfile::WrongClass] {
19317            let (shared, runtime, receipt, bundle, root) =
19318                codex_bundle_test_shared(home_profile);
19319            let error = shared
19320                .bundle_layout(
19321                    &agent("codex"),
19322                    SessionMode::Pty,
19323                    Some(&receipt),
19324                    &bundle,
19325                )
19326                .unwrap_err();
19327            assert_eq!(error.code, NodeFailureCode::BundleBindingMismatch);
19328            assert!(shared
19329                .materializations
19330                .lock()
19331                .unwrap_or_else(|poisoned| poisoned.into_inner())
19332                .is_empty());
19333            let materialization_root = root.join("session-environments");
19334            assert!(!std::fs::read_dir(&materialization_root)
19335                .unwrap()
19336                .filter_map(Result::ok)
19337                .any(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())));
19338            drop(shared);
19339            drop(runtime);
19340            let _ = std::fs::remove_dir_all(root);
19341        }
19342    }
19343
19344    #[cfg(feature = "fixture")]
19345    #[test]
19346    fn codex_bundle_exact_home_profile_materializes_only_the_skill_tree() {
19347        let (shared, runtime, receipt, bundle, root) =
19348            codex_bundle_test_shared(CodexHomeProfile::Exact);
19349        let address = SessionAddress {
19350            workspace_id: WorkspaceId::new("primary").unwrap(),
19351            session: SessionKey {
19352                instance_id: AgentInstanceId(310),
19353                generation: SessionGeneration(1),
19354            },
19355        };
19356        let (overlay, guard) = shared
19357            .prepare_session_materialization(
19358                &address,
19359                &agent("codex"),
19360                SessionMode::Pty,
19361                Some(&receipt),
19362                Some(&bundle.receipt()),
19363                None,
19364                None,
19365            )
19366            .unwrap()
19367            .unwrap();
19368        assert!(matches!(overlay, Some(PreparedNativeLaunchOverlay::Instance(_))));
19369        let ownership = shared
19370            .materializations
19371            .lock()
19372            .unwrap_or_else(|poisoned| poisoned.into_inner())
19373            .get(guard.id().unwrap())
19374            .unwrap()
19375            .clone();
19376        let (profile, _, _) = shared
19377            .materialization_profile(
19378                &agent("codex"),
19379                SessionMode::Pty,
19380                Some(&receipt),
19381                Some(&bundle.receipt()),
19382                None,
19383            )
19384            .unwrap()
19385            .unwrap();
19386        let environment = shared
19387            .session_environment_materializer
19388            .as_ref()
19389            .unwrap()
19390            .resolve_environment(&ownership, &profile)
19391            .unwrap();
19392        assert!(environment.iter().any(|mutation| {
19393            mutation.key == OsString::from("CODEX_HOME")
19394                && mutation.value.as_deref() == Some(ownership.provider_home().as_os_str())
19395        }));
19396        assert_eq!(
19397            std::fs::read(ownership.provider_home().join("skills/review-code/SKILL.md"))
19398                .unwrap(),
19399            b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n",
19400        );
19401        assert!(std::fs::read_dir(ownership.bundle_root())
19402            .unwrap()
19403            .next()
19404            .is_none());
19405        drop(guard);
19406        drop(shared);
19407        drop(runtime);
19408        let _ = std::fs::remove_dir_all(root);
19409    }
19410
19411    #[cfg(feature = "fixture")]
19412    #[test]
19413    fn codex_cold_record_resume_revalidates_the_isolated_skill_tree() {
19414        let (shared, runtime, receipt, bundle, root) =
19415            codex_bundle_test_shared(CodexHomeProfile::Exact);
19416        let bundle_receipt = bundle.receipt();
19417        let address = SessionAddress {
19418            workspace_id: WorkspaceId::new("primary").unwrap(),
19419            session: SessionKey {
19420                instance_id: AgentInstanceId(311),
19421                generation: SessionGeneration(1),
19422            },
19423        };
19424        let (_overlay, guard) = shared
19425            .prepare_session_materialization(
19426                &address,
19427                &agent("codex"),
19428                SessionMode::Pty,
19429                Some(&receipt),
19430                Some(&bundle_receipt),
19431                None,
19432                None,
19433            )
19434            .unwrap()
19435            .unwrap();
19436        let materialization_id = guard.id().unwrap().clone();
19437        let skill_path = shared
19438            .materializations
19439            .lock()
19440            .unwrap_or_else(|poisoned| poisoned.into_inner())
19441            .get(&materialization_id)
19442            .unwrap()
19443            .provider_home()
19444            .join("skills/review-code/SKILL.md");
19445        let record_id = shared
19446            .bind_spawn_session_with_materialization(
19447                &address,
19448                agent("codex"),
19449                SessionMode::Pty,
19450                ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap(),
19451                SpawnRecordPolicy::ProviderIdentityOnly,
19452                Some(receipt.clone()),
19453                Some(bundle_receipt.clone()),
19454                None,
19455                Some(materialization_id.clone()),
19456            )
19457            .unwrap()
19458            .unwrap();
19459        guard.retain();
19460        std::fs::write(skill_path, b"changed-after-record-bind").unwrap();
19461
19462        let error = match shared.resolve_record_materialization(
19463                &record_id,
19464                &agent("codex"),
19465                SessionMode::Pty,
19466                Some(&receipt),
19467                Some(&bundle_receipt),
19468                None,
19469            ) {
19470                Ok(_) => panic!("changed Codex skill tree unexpectedly revalidated"),
19471                Err(error) => error,
19472            };
19473        assert_eq!(error.code, NodeFailureCode::BundleMaterializationFailed);
19474        assert_eq!(
19475            shared
19476                .materializations
19477                .lock()
19478                .unwrap_or_else(|poisoned| poisoned.into_inner())
19479                .get(&materialization_id)
19480                .unwrap()
19481                .state(),
19482            MaterializationState::RecoveryRequired,
19483        );
19484        drop(shared);
19485        drop(runtime);
19486        let _ = std::fs::remove_dir_all(root);
19487    }
19488
19489    fn bind_record_owned_materialization_fixture(
19490        shared: &NodeShared,
19491        receipt: &ResolvedEnvironmentProfileReceipt,
19492        instance_id: u64,
19493    ) -> (SessionAddress, SessionRecordId, MaterializationId, PathBuf) {
19494        let address = SessionAddress {
19495            workspace_id: WorkspaceId::new("primary").unwrap(),
19496            session: SessionKey {
19497                instance_id: AgentInstanceId(instance_id),
19498                generation: SessionGeneration(1),
19499            },
19500        };
19501        let (overlay, materialization_guard) = shared
19502            .prepare_session_materialization(
19503                &address,
19504                &agent("claude"),
19505                SessionMode::Pty,
19506                Some(receipt),
19507                None,
19508                None,
19509                None,
19510            )
19511            .unwrap()
19512            .unwrap();
19513        let materialization_id = materialization_guard.id().unwrap().clone();
19514        let materialization_root = shared
19515            .materializations
19516            .lock()
19517            .unwrap_or_else(|poisoned| poisoned.into_inner())
19518            .get(&materialization_id)
19519            .unwrap()
19520            .root()
19521            .to_path_buf();
19522        let selection = shared
19523            .select_environment_profile(
19524                address.session.instance_id,
19525                &agent("claude"),
19526                SessionMode::Pty,
19527                Some(receipt),
19528            )
19529            .unwrap()
19530            .unwrap();
19531        assert!(shared
19532            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
19533            .unwrap()
19534            .is_none());
19535        let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
19536        let record_id = shared
19537            .bind_spawn_session_with_materialization(
19538                &address,
19539                agent("claude"),
19540                SessionMode::Pty,
19541                policy,
19542                SpawnRecordPolicy::ProviderIdentityOnly,
19543                Some(receipt.clone()),
19544                None,
19545                None,
19546                Some(materialization_id.clone()),
19547            )
19548            .unwrap()
19549            .unwrap();
19550        selection.retain();
19551        materialization_guard.retain();
19552        (address, record_id, materialization_id, materialization_root)
19553    }
19554
19555    #[test]
19556    fn child_environment_profile_capability_is_state_aware_and_legacy_safe() {
19557        let (shared, _runtime, environment_profile) = environment_profile_test_shared();
19558        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
19559            capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
19560        }));
19561        let support = node_compatibility_support_for_manifest(&[], &[]).unwrap();
19562        assert_eq!(support.state_schema.versions.maximum(), NODE_STATE_SCHEMA_V10);
19563        let mut spec = spawn_spec_fixture();
19564        spec.expected_profile_revision =
19565            crate::protocol::SpawnProfileRevision::new("test-r1").unwrap();
19566        assert!(matches!(
19567            &spec.overrides.environment_profile_id,
19568            crate::protocol::SpawnOverride::Inherit,
19569        ));
19570        assert!(!NodeRequest::SpawnSpec { spec: spec.clone() }
19571            .requires_child_environment_profile_capability());
19572        assert!(request_requires_child_environment_profile(
19573            &shared,
19574            &NodeRequest::SpawnSpec { spec: spec.clone() },
19575        ));
19576
19577        let address = terminal_address(1);
19578        shared.bind_session_with_policy(
19579            &address,
19580            ProviderRuntimePolicy::raw_pty(),
19581            Some(environment_profile.clone()),
19582        );
19583        assert!(request_requires_child_environment_profile(
19584            &shared,
19585            &NodeRequest::Input {
19586                session: address.clone(),
19587                text: "x".to_owned(),
19588            },
19589        ));
19590
19591        let mut record = record("claude", "profile-record");
19592        record.active_session = Some(address.clone());
19593        record.environment_profile = Some(environment_profile.clone());
19594        let record_id = record.record_id.clone();
19595        shared.insert_record(record.clone()).unwrap();
19596        assert!(request_requires_child_environment_profile(
19597            &shared,
19598            &NodeRequest::ResumeSessionRecord {
19599                record_id,
19600                terminal_size: gate4agent_types::TerminalSize {
19601                    rows: 24,
19602                    columns: 80,
19603                },
19604                initial_prompt: None,
19605            },
19606        ));
19607
19608        let snapshot = snapshot_for_wire(
19609            &shared, false, true, true, true, false, false, false, false, true,
19610        );
19611        assert_eq!(snapshot.session_records.len(), 1);
19612        assert!(snapshot.session_records[0].environment_profile.is_none());
19613        let event = project_event_without_child_environment_profile(NodeEventEnvelope {
19614            sequence: 1,
19615            event: NodeEvent::SessionRecordUpserted { record },
19616        });
19617        let NodeEvent::SessionRecordUpserted { record } = event.event else {
19618            panic!("record projection changed the event kind");
19619        };
19620        assert!(record.environment_profile.is_none());
19621
19622        let resolved = shared.resolve_spawn_spec(&spec).unwrap();
19623        let mut reply = ResponseEnvelope {
19624            request_id: 1,
19625            result: Ok(NodeResponse::SpawnSpecAccepted {
19626                receipt: resolved.receipt_with_environment(
19627                    shared.incarnation_id,
19628                    address,
19629                    Some(environment_profile),
19630                ),
19631            }),
19632        };
19633        project_response_without_child_environment_profile(&mut reply);
19634        assert_eq!(
19635            reply.result.unwrap_err().code,
19636            NodeFailureCode::UnsupportedCapability,
19637        );
19638    }
19639
19640    #[test]
19641    fn spawn_profile_environment_authority_is_exact_and_legacy_safe() {
19642        let (shared, _runtime, environment_profile) = environment_profile_test_shared();
19643        let snapshot = shared.snapshot();
19644        let profiles = snapshot
19645            .launch_inventory
19646            .as_ref()
19647            .and_then(|inventory| inventory.spawn_profiles.as_ref())
19648            .unwrap();
19649        assert_eq!(profiles.len(), 1);
19650        assert_eq!(profiles[0].environment_profile, Some(environment_profile));
19651        assert!(snapshot.requires_child_environment_profile_capability());
19652
19653        let mut legacy = snapshot.clone();
19654        clear_snapshot_child_environment_profiles(&mut legacy);
19655        assert!(legacy
19656            .launch_inventory
19657            .as_ref()
19658            .unwrap()
19659            .spawn_profiles
19660            .as_ref()
19661            .unwrap()[0]
19662            .environment_profile
19663            .is_none());
19664        assert!(!legacy.requires_child_environment_profile_capability());
19665
19666        let profile = shared.spawn_profiles.iter().next().unwrap().clone();
19667        let bindings = shared
19668            .environment_profiles
19669            .read()
19670            .unwrap_or_else(|poisoned| poisoned.into_inner())
19671            .clone();
19672        let mut provider_mismatch = bindings.clone();
19673        provider_mismatch
19674            .values_mut()
19675            .next()
19676            .unwrap()
19677            .provider = agent("codex");
19678        assert!(resolved_spawn_profile_summary(&profile, &provider_mismatch).is_none());
19679        let mut mode_mismatch = profile;
19680        mode_mismatch.mode = SessionMode::Inline;
19681        assert!(resolved_spawn_profile_summary(&mode_mismatch, &bindings).is_none());
19682
19683        shared
19684            .environment_profiles
19685            .write()
19686            .unwrap_or_else(|poisoned| poisoned.into_inner())
19687            .clear();
19688        assert!(shared
19689            .snapshot()
19690            .launch_inventory
19691            .unwrap()
19692            .spawn_profiles
19693            .unwrap()
19694            .is_empty());
19695    }
19696
19697    #[test]
19698    fn session_bundle_capability_is_state_aware_and_legacy_safe() {
19699        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
19700            capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
19701        }));
19702        let shared = terminal_test_shared();
19703        let bundle = ResolvedBundleReceipt {
19704            id: crate::protocol::SpawnBundleId::new("review-bundle").unwrap(),
19705            revision: crate::protocol::SpawnBundleRevision::new("r1").unwrap(),
19706            digest: crate::protocol::SpawnBundleDigest::new(format!(
19707                "sha256:{}",
19708                "0".repeat(64),
19709            ))
19710            .unwrap(),
19711        };
19712        let mut bundled = record("claude", "bundle-record");
19713        bundled.bundle = Some(bundle.clone());
19714        let record_id = bundled.record_id.clone();
19715        shared.insert_record(bundled.clone()).unwrap();
19716        assert!(request_requires_session_bundle(
19717            &shared,
19718            &NodeRequest::ForgetSessionRecord {
19719                record_id: record_id.clone(),
19720            },
19721        ));
19722
19723        let snapshot = snapshot_for_wire(
19724            &shared, false, true, true, true, true, false, false, false, true,
19725        );
19726        assert!(snapshot.session_records[0].bundle.is_none());
19727        let launch = snapshot.launch_inventory.as_ref().unwrap();
19728        assert!(launch.spawn_profiles.is_some());
19729        assert!(launch.bundles.is_none());
19730        let event = project_event_without_session_bundle(NodeEventEnvelope {
19731            sequence: 1,
19732            event: NodeEvent::SessionRecordUpserted { record: bundled },
19733        });
19734        let NodeEvent::SessionRecordUpserted { record } = event.event else {
19735            panic!("record projection changed the event kind");
19736        };
19737        assert!(record.bundle.is_none());
19738
19739        let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
19740        let mut reply = ResponseEnvelope {
19741            request_id: 1,
19742            result: Ok(NodeResponse::SpawnSpecAccepted {
19743                receipt: resolved.receipt_with_materialization(
19744                    shared.incarnation_id,
19745                    terminal_address(17),
19746                    None,
19747                    Some(bundle),
19748                    None,
19749                ),
19750            }),
19751        };
19752        project_response_without_session_bundle(&mut reply);
19753        assert_eq!(
19754            reply.result.unwrap_err().code,
19755            NodeFailureCode::UnsupportedCapability,
19756        );
19757    }
19758
19759    #[test]
19760    fn history_context_pack_capability_redacts_loaded_messages_and_legacy_metadata() {
19761        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
19762            capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
19763        }));
19764        let context_id = SpawnContextId::new("context-review").unwrap();
19765        let context = ResolvedContextPackReceipt {
19766            id: context_id.clone(),
19767            digest: crate::protocol::SpawnContextDigest::new(format!(
19768                "sha256:{}",
19769                "c".repeat(64),
19770            ))
19771            .unwrap(),
19772            lineage: ContextPackLineageReceipt {
19773                source_node_id: NodeId::new("node-source").unwrap(),
19774                source_session: terminal_address(1),
19775                source_provider: agent("kimi"),
19776            },
19777            source_message_count: 2,
19778            retained_message_count: 2,
19779            byte_len: 256,
19780            truncated: false,
19781        };
19782        assert!(context.is_valid());
19783        let mut bound_record = record("codex", "context-record");
19784        bound_record.context_id = Some(context_id);
19785        bound_record.context = Some(context.clone());
19786        let session = gate4agent_types::SessionSnapshot {
19787            instance_id: AgentInstanceId(91),
19788            agent_id: agent("kimi"),
19789            transport: TransportKind::Pty,
19790            generation: SessionGeneration(3),
19791            status: SessionStatus::Running,
19792            pending_operation: None,
19793            pending_input: None,
19794            process_id: Some(9001),
19795            terminal_size: None,
19796            terminal_frame: None,
19797            terminal_stale: None,
19798            session_options: None,
19799            capabilities: gate4agent_types::CapabilitySnapshot::default(),
19800            history: gate4agent_types::HistorySnapshot {
19801                pending: None,
19802                candidates: vec![HistoryCandidateSummary {
19803                    id: "candidate-opaque".to_owned(),
19804                    session_id_hint: "session-hint".to_owned(),
19805                    modified_at_unix_ms: Some(1),
19806                }],
19807                loaded_candidate_id: Some("candidate-opaque".to_owned()),
19808                loaded: Some(HistorySessionRecord {
19809                    session_id: "private-vendor-session".to_owned(),
19810                    title: Some("private title".to_owned()),
19811                    cwd: Some(r"C:\private\history-root".to_owned()),
19812                    model: Some("private-model".to_owned()),
19813                    message_count: 2,
19814                    completed_turn_count: None,
19815                    total_tokens: 19,
19816                    messages: vec![
19817                        gate4agent_types::HistoryMessageRecord {
19818                            role: gate4agent_types::HistoryMessageRole::User,
19819                            text: "F7_PRIVATE_HISTORY_MESSAGE".to_owned(),
19820                        },
19821                        gate4agent_types::HistoryMessageRecord {
19822                            role: gate4agent_types::HistoryMessageRole::Assistant,
19823                            text: "private answer".to_owned(),
19824                        },
19825                    ],
19826                }),
19827                last_error: None,
19828            },
19829            resume: gate4agent_types::ResumeSnapshot::default(),
19830            foreground: gate4agent_types::ForegroundSnapshot::default(),
19831            provider: gate4agent_types::ProviderSnapshot::default(),
19832            screen_state: gate4agent_types::PtyScreenState::default(),
19833        };
19834        let original = NodeSnapshot {
19835            node_id: NodeId::new("node-terminal-test").unwrap(),
19836            enabled_providers: vec![agent("kimi"), agent("codex")],
19837            provider_runtime_statuses: ProviderRuntimeStatuses::default(),
19838            workspaces: vec![WorkspaceSnapshot {
19839                workspace_id: WorkspaceId::new("primary").unwrap(),
19840                canonical_root: opaque_windows_path(
19841                    std::env::current_dir().unwrap().to_string_lossy().into_owned(),
19842                ),
19843                sessions: vec![session],
19844                worktree_service_mode: None,
19845                managed_worktree_profiles: None,
19846            }],
19847            session_records: vec![bound_record],
19848            managed_worktrees: Vec::new(),
19849            launch_inventory: None,
19850            agent_progress: Vec::new(),
19851        };
19852
19853        let mut negotiated = original.clone();
19854        project_snapshot_history_for_wire(&mut negotiated, true);
19855        assert_eq!(negotiated.workspaces[0].sessions[0].history.candidates.len(), 1);
19856        assert!(negotiated.workspaces[0].sessions[0].history.loaded.is_none());
19857        assert_eq!(negotiated.session_records[0].context.as_ref(), Some(&context));
19858        assert!(!serde_json::to_string(&negotiated)
19859            .unwrap()
19860            .contains("F7_PRIVATE_HISTORY_MESSAGE"));
19861
19862        let mut legacy = original;
19863        project_snapshot_history_for_wire(&mut legacy, false);
19864        clear_snapshot_context_packs(&mut legacy);
19865        assert_eq!(
19866            legacy.workspaces[0].sessions[0].history,
19867            gate4agent_types::HistorySnapshot::default(),
19868        );
19869        assert!(legacy.session_records[0].context_id.is_none());
19870        assert!(legacy.session_records[0].context.is_none());
19871        let encoded = serde_json::to_string(&legacy).unwrap();
19872        assert!(!encoded.contains("F7_PRIVATE_HISTORY_MESSAGE"));
19873        assert!(!encoded.contains("context-review"));
19874
19875        let mut reply = ResponseEnvelope {
19876            request_id: 7,
19877            result: Ok(NodeResponse::ContextPackExported { context }),
19878        };
19879        project_response_without_context_pack(&mut reply);
19880        assert_eq!(
19881            reply.result.unwrap_err().code,
19882            NodeFailureCode::UnsupportedCapability,
19883        );
19884    }
19885
19886    #[test]
19887    fn forgetting_unbound_context_pack_evicts_replay_but_retained_record_is_busy() {
19888        let shared = terminal_test_shared();
19889        let history = HistorySessionRecord {
19890            session_id: "fixture-history".to_owned(),
19891            title: None,
19892            cwd: None,
19893            model: None,
19894            message_count: 1,
19895            completed_turn_count: None,
19896            total_tokens: 0,
19897            messages: vec![gate4agent_types::HistoryMessageRecord {
19898                role: gate4agent_types::HistoryMessageRole::User,
19899                text: "bounded handoff".to_owned(),
19900            }],
19901        };
19902        let pack = NodeContextPack::export(
19903            ContextPackLineageReceipt {
19904                source_node_id: shared.node_id.clone(),
19905                source_session: terminal_address(1),
19906                source_provider: agent("claude"),
19907            },
19908            &history,
19909        )
19910        .unwrap();
19911        let context = shared
19912            .context_catalog
19913            .write()
19914            .unwrap_or_else(|poisoned| poisoned.into_inner())
19915            .insert(pack)
19916            .unwrap();
19917        let mut retained = record("codex", "context-retained-record");
19918        retained.context_id = Some(context.id.clone());
19919        retained.context = Some(context.clone());
19920        let record_id = retained.record_id.clone();
19921        shared.insert_record(retained).unwrap();
19922        assert_eq!(
19923            shared.forget_context_pack(&context.id).unwrap_err().code,
19924            NodeFailureCode::ContextPackBusy,
19925        );
19926        assert!(shared
19927            .context_catalog
19928            .read()
19929            .unwrap_or_else(|poisoned| poisoned.into_inner())
19930            .get(&context.id)
19931            .is_some());
19932        shared
19933            .session_records
19934            .lock()
19935            .unwrap_or_else(|poisoned| poisoned.into_inner())
19936            .records
19937            .remove(&record_id);
19938
19939        let mut spec = spawn_spec_fixture();
19940        spec.overrides.context_id = crate::protocol::SpawnOverride::Set {
19941            value: context.id.clone(),
19942        };
19943        let resolved = shared.resolve_spawn_spec(&spec).unwrap();
19944        let receipt = resolved.receipt_with_materialization(
19945            shared.incarnation_id,
19946            terminal_address(2),
19947            None,
19948            None,
19949            Some(context.clone()),
19950        );
19951        assert!(receipt.context_binding_is_valid());
19952        shared.remember_spawn_spec(spec.clone(), Ok(receipt), Instant::now());
19953
19954        shared.forget_context_pack(&context.id).unwrap();
19955        assert!(shared
19956            .context_catalog
19957            .read()
19958            .unwrap_or_else(|poisoned| poisoned.into_inner())
19959            .get(&context.id)
19960            .is_none());
19961        assert!(shared
19962            .replay_spawn_spec(&spec, Instant::now())
19963            .unwrap()
19964            .is_none());
19965    }
19966
19967    #[test]
19968    fn environment_profile_selection_is_owned_by_the_exact_session_binding() {
19969        let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
19970        let address = terminal_address(1);
19971        let selection = shared
19972            .select_environment_profile(
19973                address.session.instance_id,
19974                &agent("claude"),
19975                SessionMode::Pty,
19976                Some(&environment_profile),
19977            )
19978            .unwrap()
19979            .unwrap();
19980        shared.bind_session_with_policy(
19981            &address,
19982            ProviderRuntimePolicy::raw_pty(),
19983            Some(environment_profile.clone()),
19984        );
19985        selection.retain();
19986        let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
19987        assert!(matches!(
19988            runtime.remove_native_launch_profile(&native_id),
19989            Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
19990        ));
19991        assert!(shared.remove_binding(&address).is_some());
19992        assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
19993
19994        let mismatched = ResolvedEnvironmentProfileReceipt {
19995            profile_id: environment_profile.profile_id,
19996            profile_revision: crate::protocol::SpawnEnvironmentProfileRevision::new(
19997                "local-claude-r2",
19998            )
19999            .unwrap(),
20000            network_allowlist: None,
20001            browser_profile_id: None,
20002        };
20003        let error = match shared.select_environment_profile(
20004                AgentInstanceId(42),
20005                &agent("claude"),
20006                SessionMode::Pty,
20007                Some(&mismatched),
20008            ) {
20009            Ok(_) => panic!("mismatched environment profile revision was selected"),
20010            Err(error) => error,
20011        };
20012        assert_eq!(
20013            error.code,
20014            NodeFailureCode::EnvironmentProfileBindingMismatch,
20015        );
20016        assert!(!shared
20017            .session_bindings
20018            .lock()
20019            .unwrap_or_else(|poisoned| poisoned.into_inner())
20020            .contains_key(&AgentInstanceId(42)));
20021    }
20022
20023    #[test]
20024    fn combined_environment_and_bundle_overlay_clears_with_its_binding() {
20025        let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
20026        let address = terminal_address(1);
20027        let selection = shared
20028            .select_environment_profile(
20029                address.session.instance_id,
20030                &agent("claude"),
20031                SessionMode::Pty,
20032                Some(&environment_profile),
20033            )
20034            .unwrap()
20035            .unwrap();
20036        let overlay = NativeInstanceLaunchOverlay::new(
20037            agent("claude"),
20038            TransportKind::Pty,
20039            vec![EnvMutation {
20040                key: OsString::from("GATE4AGENT_SESSION_TOKEN"),
20041                value: Some(OsString::from("fixture-secret")),
20042            }],
20043            vec![
20044                OsString::from("--plugin-dir"),
20045                std::env::current_dir().unwrap().into_os_string(),
20046            ],
20047        )
20048        .unwrap();
20049        let instance_overlay = shared
20050            .install_prepared_launch_overlay(
20051                address.session.instance_id,
20052                PreparedNativeLaunchOverlay::Instance(overlay),
20053            )
20054            .unwrap()
20055            .unwrap();
20056        let bundle = ResolvedBundleReceipt {
20057            id: crate::protocol::SpawnBundleId::new("combined-bundle").unwrap(),
20058            revision: crate::protocol::SpawnBundleRevision::new("combined-r1").unwrap(),
20059            digest: crate::protocol::SpawnBundleDigest::new(format!(
20060                "sha256:{}",
20061                "0".repeat(64),
20062            ))
20063            .unwrap(),
20064        };
20065        shared.bind_session_with_materialization(
20066            &address,
20067            ProviderRuntimePolicy::raw_pty(),
20068            Some(environment_profile),
20069            Some(bundle),
20070            None,
20071            None,
20072        );
20073        selection.retain();
20074        instance_overlay.retain();
20075
20076        let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
20077        assert!(matches!(
20078            runtime.remove_native_launch_profile(&native_id),
20079            Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
20080        ));
20081        assert!(shared.remove_binding(&address).is_some());
20082        assert!(!shared
20083            .native_launch_profile_control
20084            .as_ref()
20085            .unwrap()
20086            .clear_native_instance_launch_overlay(address.session.instance_id));
20087        assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
20088    }
20089
20090    #[test]
20091    fn session_environment_materialization_failure_is_pre_child_and_durable() {
20092        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
20093        let address = SessionAddress {
20094            workspace_id: WorkspaceId::new("primary").unwrap(),
20095            session: SessionKey {
20096                instance_id: AgentInstanceId(201),
20097                generation: SessionGeneration(1),
20098            },
20099        };
20100        let error = match shared.prepare_session_materialization(
20101            &address,
20102            &agent("claude"),
20103            SessionMode::Pty,
20104            Some(&receipt),
20105            None,
20106            None,
20107            None,
20108        ) {
20109            Ok(_) => panic!("denied local reference unexpectedly materialized"),
20110            Err(error) => error,
20111        };
20112        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
20113        assert!(shared.handle.snapshot().sessions.is_empty());
20114        assert!(shared
20115            .session_bindings
20116            .lock()
20117            .unwrap_or_else(|poisoned| poisoned.into_inner())
20118            .is_empty());
20119        assert!(shared
20120            .materializations
20121            .lock()
20122            .unwrap_or_else(|poisoned| poisoned.into_inner())
20123            .is_empty());
20124        drop(shared);
20125        drop(runtime);
20126        std::fs::remove_dir_all(root).unwrap();
20127    }
20128
20129    #[test]
20130    fn session_environment_binding_retains_on_stop_and_cleans_after_remove_reap() {
20131        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20132        let address = SessionAddress {
20133            workspace_id: WorkspaceId::new("primary").unwrap(),
20134            session: SessionKey {
20135                instance_id: AgentInstanceId(202),
20136                generation: SessionGeneration(1),
20137            },
20138        };
20139        let (overlay, materialization_guard) = shared
20140            .prepare_session_materialization(
20141                &address,
20142                &agent("claude"),
20143                SessionMode::Pty,
20144                Some(&receipt),
20145                None,
20146                None,
20147                None,
20148            )
20149            .unwrap()
20150            .unwrap();
20151        let materialization_id = materialization_guard.id().unwrap().clone();
20152        let materialization_root = shared
20153            .materializations
20154            .lock()
20155            .unwrap_or_else(|poisoned| poisoned.into_inner())
20156            .get(&materialization_id)
20157            .unwrap()
20158            .root()
20159            .to_path_buf();
20160        let selection = shared
20161            .select_environment_profile(
20162                address.session.instance_id,
20163                &agent("claude"),
20164                SessionMode::Pty,
20165                Some(&receipt),
20166            )
20167            .unwrap()
20168            .unwrap();
20169        assert!(shared
20170            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
20171            .unwrap()
20172            .is_none());
20173        shared.bind_session_with_materialization(
20174            &address,
20175            ProviderRuntimePolicy::raw_pty(),
20176            Some(receipt),
20177            None,
20178            None,
20179            Some(materialization_id),
20180        );
20181        selection.retain();
20182        materialization_guard.retain();
20183
20184        assert!(materialization_root.is_dir());
20185        assert!(shared
20186            .session_bindings
20187            .lock()
20188            .unwrap_or_else(|poisoned| poisoned.into_inner())
20189            .contains_key(&address.session.instance_id));
20190
20191        let removed = shared.remove_binding(&address).unwrap();
20192        shared
20193            .cleanup_session_owned_materialization(&address, &removed)
20194            .unwrap();
20195        assert!(!materialization_root.exists());
20196        assert!(shared
20197            .materializations
20198            .lock()
20199            .unwrap_or_else(|poisoned| poisoned.into_inner())
20200            .is_empty());
20201        drop(shared);
20202        drop(runtime);
20203        std::fs::remove_dir_all(root).unwrap();
20204    }
20205
20206    #[tokio::test]
20207    async fn record_owned_session_environment_reuses_on_resume_and_forget_cleans() {
20208        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20209        let address = SessionAddress {
20210            workspace_id: WorkspaceId::new("primary").unwrap(),
20211            session: SessionKey {
20212                instance_id: AgentInstanceId(203),
20213                generation: SessionGeneration(1),
20214            },
20215        };
20216        let (overlay, materialization_guard) = shared
20217            .prepare_session_materialization(
20218                &address,
20219                &agent("claude"),
20220                SessionMode::Pty,
20221                Some(&receipt),
20222                None,
20223                None,
20224                None,
20225            )
20226            .unwrap()
20227            .unwrap();
20228        let materialization_id = materialization_guard.id().unwrap().clone();
20229        let materialization_root = shared
20230            .materializations
20231            .lock()
20232            .unwrap_or_else(|poisoned| poisoned.into_inner())
20233            .get(&materialization_id)
20234            .unwrap()
20235            .root()
20236            .to_path_buf();
20237        let selection = shared
20238            .select_environment_profile(
20239                address.session.instance_id,
20240                &agent("claude"),
20241                SessionMode::Pty,
20242                Some(&receipt),
20243            )
20244            .unwrap()
20245            .unwrap();
20246        assert!(shared
20247            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
20248            .unwrap()
20249            .is_none());
20250        let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
20251        let record_id = shared
20252            .bind_spawn_session_with_materialization(
20253                &address,
20254                agent("claude"),
20255                SessionMode::Pty,
20256                policy,
20257                SpawnRecordPolicy::ProviderIdentityOnly,
20258                Some(receipt.clone()),
20259                None,
20260                None,
20261                Some(materialization_id.clone()),
20262            )
20263            .unwrap()
20264            .unwrap();
20265        selection.retain();
20266        materialization_guard.retain();
20267        assert_eq!(
20268            shared
20269                .materializations
20270                .lock()
20271                .unwrap_or_else(|poisoned| poisoned.into_inner())
20272                .get(&materialization_id)
20273                .unwrap()
20274                .owner(),
20275            &MaterializationOwner::Record {
20276                record_id: record_id.clone(),
20277            },
20278        );
20279
20280        let removed = shared.remove_binding(&address).unwrap();
20281        shared
20282            .cleanup_session_owned_materialization(&address, &removed)
20283            .unwrap();
20284        assert!(materialization_root.is_dir());
20285        assert!(shared
20286            .resolve_record_materialization(
20287                &record_id,
20288                &agent("claude"),
20289                SessionMode::Pty,
20290                Some(&receipt),
20291                None,
20292                None,
20293            )
20294            .unwrap()
20295            .is_some());
20296        {
20297            let mut records = shared
20298                .session_records
20299                .lock()
20300                .unwrap_or_else(|poisoned| poisoned.into_inner());
20301            let record = records.records.get_mut(&record_id).unwrap();
20302            record.active_session = None;
20303            record.state = ManagedSessionState::Unavailable;
20304        }
20305        shared.forget_session_record(&record_id).await.unwrap();
20306        assert!(!materialization_root.exists());
20307        assert!(shared
20308            .materializations
20309            .lock()
20310            .unwrap_or_else(|poisoned| poisoned.into_inner())
20311            .is_empty());
20312        drop(shared);
20313        drop(runtime);
20314        std::fs::remove_dir_all(root).unwrap();
20315    }
20316
20317    #[test]
20318    fn record_materialization_revalidation_failure_persists_recovery_required() {
20319        let (mut shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20320        let state_path = root.join("node-state-v6.json");
20321        shared.state_path = Some(state_path.clone());
20322        let (_address, _record_id, materialization_id, materialization_root) =
20323            bind_record_owned_materialization_fixture(&shared, &receipt, 205);
20324        std::fs::write(
20325            materialization_root.join(".gate4agent-materialization-owner"),
20326            b"tampered",
20327        )
20328        .unwrap();
20329
20330        let error = match shared.resolve_record_materialization(
20331            &_record_id,
20332            &agent("claude"),
20333            SessionMode::Pty,
20334            Some(&receipt),
20335            None,
20336            None,
20337        ) {
20338            Ok(_) => panic!("tampered owner marker unexpectedly revalidated"),
20339            Err(error) => error,
20340        };
20341        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
20342        assert_eq!(
20343            shared
20344                .materializations
20345                .lock()
20346                .unwrap_or_else(|poisoned| poisoned.into_inner())
20347                .get(&materialization_id)
20348                .unwrap()
20349                .state(),
20350            MaterializationState::RecoveryRequired,
20351        );
20352        let loaded = session_registry::load(Some(&state_path), &shared.node_id).unwrap();
20353        assert_eq!(loaded.materializations.len(), 1);
20354        assert_eq!(
20355            loaded.materializations[0].state(),
20356            MaterializationState::RecoveryRequired,
20357        );
20358        drop(shared);
20359        drop(runtime);
20360        std::fs::remove_dir_all(root).unwrap();
20361    }
20362
20363    #[test]
20364    fn transient_record_materialization_resolution_failure_remains_ready() {
20365        let (shared, runtime, receipt, root, deny) = materialization_test_shared(false);
20366        let (_address, record_id, materialization_id, _materialization_root) =
20367            bind_record_owned_materialization_fixture(&shared, &receipt, 206);
20368        deny.store(true, Ordering::Release);
20369
20370        let error = match shared.resolve_record_materialization(
20371            &record_id,
20372            &agent("claude"),
20373            SessionMode::Pty,
20374            Some(&receipt),
20375            None,
20376            None,
20377        ) {
20378            Ok(_) => panic!("denied transient resolver unexpectedly succeeded"),
20379            Err(error) => error,
20380        };
20381        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
20382        assert_eq!(
20383            shared
20384                .materializations
20385                .lock()
20386                .unwrap_or_else(|poisoned| poisoned.into_inner())
20387                .get(&materialization_id)
20388                .unwrap()
20389                .state(),
20390            MaterializationState::Ready,
20391        );
20392        drop(shared);
20393        drop(runtime);
20394        std::fs::remove_dir_all(root).unwrap();
20395    }
20396
20397    #[test]
20398    fn startup_missing_record_materialization_is_not_resumable() {
20399        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20400        let record_id = SessionRecordId::new("sr-missing-materialization").unwrap();
20401        shared
20402            .insert_record(ManagedSessionRecord {
20403                record_id: record_id.clone(),
20404                display_name: "missing materialization".to_owned(),
20405                provider: agent("claude"),
20406                mode: SessionMode::Pty,
20407                state: ManagedSessionState::Dormant,
20408                workspace_id: WorkspaceId::new("primary").unwrap(),
20409                canonical_root: opaque_windows_path(
20410                    std::env::current_dir().unwrap().to_string_lossy().into_owned(),
20411                ),
20412                provider_session: Some(gate4agent_types::ProviderSessionIdentity {
20413                    key: gate4agent_types::ProviderSessionKey::SessionId,
20414                    id: "provider-session-missing-materialization".to_owned(),
20415                    transcript_path: None,
20416                }),
20417                active_session: None,
20418                environment_profile: Some(receipt),
20419                bundle: None,
20420                context_id: None,
20421                context: None,
20422                exported_context: None,
20423                task_binding: None,
20424                created_at_unix_ms: 1,
20425                updated_at_unix_ms: 1,
20426                last_error: None,
20427            })
20428            .unwrap();
20429
20430        shared.reconcile_materializations();
20431        let record = shared.record(&record_id).unwrap();
20432        assert_eq!(record.state, ManagedSessionState::Unavailable);
20433        assert!(record.provider_session.is_none());
20434        assert_eq!(
20435            record.last_error.as_deref(),
20436            Some("environment-profile-unavailable"),
20437        );
20438        drop(shared);
20439        drop(runtime);
20440        std::fs::remove_dir_all(root).unwrap();
20441    }
20442
20443    #[tokio::test]
20444    async fn managed_worktree_cleanup_waits_for_session_environment_cleanup() {
20445        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20446        let lease = install_managed_lease(&shared);
20447        let address = SessionAddress {
20448            workspace_id: WorkspaceId::new("primary").unwrap(),
20449            session: SessionKey {
20450                instance_id: AgentInstanceId(204),
20451                generation: SessionGeneration(1),
20452            },
20453        };
20454        let (overlay, materialization_guard) = shared
20455            .prepare_session_materialization(
20456                &address,
20457                &agent("claude"),
20458                SessionMode::Pty,
20459                Some(&receipt),
20460                None,
20461                None,
20462                Some(lease.lease_id.clone()),
20463            )
20464            .unwrap()
20465            .unwrap();
20466        let materialization_id = materialization_guard.id().unwrap().clone();
20467        let materialization_root = shared
20468            .materializations
20469            .lock()
20470            .unwrap_or_else(|poisoned| poisoned.into_inner())
20471            .get(&materialization_id)
20472            .unwrap()
20473            .root()
20474            .to_path_buf();
20475        let selection = shared
20476            .select_environment_profile(
20477                address.session.instance_id,
20478                &agent("claude"),
20479                SessionMode::Pty,
20480                Some(&receipt),
20481            )
20482            .unwrap()
20483            .unwrap();
20484        assert!(shared
20485            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
20486            .unwrap()
20487            .is_none());
20488        shared.bind_session_with_materialization(
20489            &address,
20490            ProviderRuntimePolicy::raw_pty(),
20491            Some(receipt),
20492            None,
20493            None,
20494            Some(materialization_id),
20495        );
20496        selection.retain();
20497        materialization_guard.retain();
20498
20499        let blocked = shared
20500            .cleanup_managed_worktree(&lease.lease_id, false)
20501            .await
20502            .unwrap_err();
20503        assert_eq!(blocked.code, NodeFailureCode::ManagedWorktreeRecoveryRequired);
20504        assert!(materialization_root.is_dir());
20505        let removed = shared.remove_binding(&address).unwrap();
20506        shared
20507            .cleanup_session_owned_materialization(&address, &removed)
20508            .unwrap();
20509        assert!(!materialization_root.exists());
20510        drop(shared);
20511        drop(runtime);
20512        std::fs::remove_dir_all(root).unwrap();
20513    }
20514
20515    #[tokio::test]
20516    async fn raw_managed_record_is_inventory_only_and_does_not_block_session_cleanup() {
20517        let git_root = temporary_workspace_root("raw-managed-inventory");
20518        let source = git_root.join("source");
20519        let allocation = git_root.join("allocation");
20520        std::fs::create_dir_all(&source).unwrap();
20521        std::fs::create_dir_all(&allocation).unwrap();
20522        run_test_git(&source, &["init"]);
20523        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
20524        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
20525        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
20526        run_test_git(&source, &["add", "seed.txt"]);
20527        run_test_git(&source, &["commit", "-m", "seed"]);
20528        let profile = ManagedWorktreeProfile::new(
20529            WorktreeProfileId::new("default").unwrap(),
20530            crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
20531            &allocation,
20532            "gate4agent",
20533            "HEAD",
20534            ManagedWorktreeRetention::RemoveWhenReleased,
20535        )
20536        .unwrap();
20537        let workspace = WorkspaceConfig::new(
20538            WorkspaceId::new("primary").unwrap(),
20539            &source,
20540        )
20541        .unwrap()
20542        .with_worktree_service_mode(WorktreeServiceMode::Managed)
20543        .with_managed_worktree_profile(profile.clone())
20544        .unwrap();
20545        let (shared, mut runtime, receipt, materialization_fixture_root, _deny) =
20546            materialization_test_shared_with_workspace(false, workspace);
20547        let target = PathBuf::from(profile.allocation_root()).join("mw-raw-inventory");
20548        let source_text = source.to_string_lossy().into_owned();
20549        let target_text = target.to_string_lossy().into_owned();
20550        let created = create_git_worktree(
20551            &source_text,
20552            &target_text,
20553            "gate4agent/mw-raw-inventory",
20554            Some("HEAD"),
20555        )
20556        .await
20557        .unwrap();
20558        let lease = ManagedWorktreeLeaseRecord {
20559            lease_id: ManagedWorktreeLeaseId::new("mw-raw-inventory").unwrap(),
20560            source_workspace_id: WorkspaceId::new("primary").unwrap(),
20561            workspace_id: WorkspaceId::new("managed-raw-inventory").unwrap(),
20562            profile_id: profile.profile_id().clone(),
20563            profile_revision: profile.revision().clone(),
20564            target_root: created.path.clone(),
20565            branch: "gate4agent/mw-raw-inventory".to_owned(),
20566            base_commit: created.head.clone(),
20567            expected_head: Some(created.head.clone()),
20568            retention: ManagedWorktreeRetention::RemoveWhenReleased,
20569            state: ManagedWorktreeLeaseState::Ready,
20570            session_holders: Vec::new(),
20571            record_holders: Vec::new(),
20572            cleanup_failure: None,
20573            created_at_unix_ms: 1,
20574            updated_at_unix_ms: 1,
20575        };
20576        shared
20577            .workspaces
20578            .write()
20579            .unwrap_or_else(|poisoned| poisoned.into_inner())
20580            .insert(lease.workspace_id.clone(), created.path.clone());
20581        *shared
20582            .managed_worktrees
20583            .lock()
20584            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
20585            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
20586        let address = SessionAddress {
20587            workspace_id: lease.workspace_id.clone(),
20588            session: SessionKey {
20589                instance_id: AgentInstanceId(205),
20590                generation: SessionGeneration(1),
20591            },
20592        };
20593        let (overlay, materialization_guard) = shared
20594            .prepare_session_materialization(
20595                &address,
20596                &agent("claude"),
20597                SessionMode::Pty,
20598                Some(&receipt),
20599                None,
20600                None,
20601                Some(lease.lease_id.clone()),
20602            )
20603            .unwrap()
20604            .unwrap();
20605        let materialization_id = materialization_guard.id().unwrap().clone();
20606        let materialization_root = shared
20607            .materializations
20608            .lock()
20609            .unwrap_or_else(|poisoned| poisoned.into_inner())
20610            .get(&materialization_id)
20611            .unwrap()
20612            .root()
20613            .to_path_buf();
20614        let selection = shared
20615            .select_environment_profile(
20616                address.session.instance_id,
20617                &agent("claude"),
20618                SessionMode::Pty,
20619                Some(&receipt),
20620            )
20621            .unwrap()
20622            .unwrap();
20623        assert!(shared
20624            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
20625            .unwrap()
20626            .is_none());
20627        let record_id = shared
20628            .bind_spawn_session_with_materialization(
20629                &address,
20630                agent("claude"),
20631                SessionMode::Pty,
20632                ProviderRuntimePolicy::raw_pty(),
20633                SpawnRecordPolicy::Always,
20634                Some(receipt.clone()),
20635                None,
20636                None,
20637                Some(materialization_id.clone()),
20638            )
20639            .unwrap()
20640            .unwrap();
20641        selection.retain();
20642        materialization_guard.retain();
20643        let record = shared.record(&record_id).unwrap();
20644        assert_eq!(record.state, ManagedSessionState::Live);
20645        assert!(record.provider_session.is_none());
20646        assert!(record.environment_profile.is_none());
20647        assert!(record.bundle.is_none());
20648        assert!(record.context_id.is_none());
20649        assert!(record.context.is_none());
20650        assert_eq!(record.active_session.as_ref(), Some(&address));
20651        assert_eq!(
20652            shared
20653                .materializations
20654                .lock()
20655                .unwrap_or_else(|poisoned| poisoned.into_inner())
20656                .get(&materialization_id)
20657                .unwrap()
20658                .owner(),
20659            &MaterializationOwner::Session {
20660                incarnation_id: shared.incarnation_id,
20661                instance_id: address.session.instance_id,
20662                generation: address.session.generation,
20663            },
20664        );
20665        {
20666            let mut bindings = shared
20667                .session_bindings
20668                .lock()
20669                .unwrap_or_else(|poisoned| poisoned.into_inner());
20670            bindings
20671                .get_mut(&address.session.instance_id)
20672                .unwrap()
20673                .managed_worktree_lease_id = Some(lease.lease_id.clone());
20674        }
20675        let lease_snapshot = shared
20676            .managed_worktrees
20677            .lock()
20678            .unwrap_or_else(|poisoned| poisoned.into_inner())
20679            .bind_session(
20680                &lease.lease_id,
20681                ManagedWorktreeSessionHolder {
20682                    incarnation_id: shared.incarnation_id,
20683                    instance_id: address.session.instance_id,
20684                    generation: address.session.generation,
20685                },
20686                None,
20687                unix_time_ms(),
20688            )
20689            .unwrap();
20690        assert_eq!(lease_snapshot.active_session_count, 1);
20691        assert_eq!(lease_snapshot.managed_record_count, 0);
20692        shared.persist_state().unwrap();
20693        assert!(shared
20694            .managed_worktrees
20695            .lock()
20696            .unwrap_or_else(|poisoned| poisoned.into_inner())
20697            .get(&lease.lease_id)
20698            .unwrap()
20699            .record_holders
20700            .is_empty());
20701        shared
20702            .handle
20703            .dispatch(shared.prepare_command(ControlCommand::Register {
20704                instance_id: address.session.instance_id,
20705                agent_id: agent("claude"),
20706                transport: TransportKind::Pty,
20707            }))
20708            .unwrap();
20709        runtime.tick().await;
20710        shared.reconcile_managed_record(
20711            &record_id,
20712            &address,
20713            &ControlEvent {
20714                sequence: 1,
20715                command_id: None,
20716                instance_id: address.session.instance_id,
20717                generation: address.session.generation,
20718                event: ControlEventKind::Removed,
20719            },
20720            false,
20721            false,
20722        );
20723        let remove = shared.remove_session(&address);
20724        let drive_runtime = async {
20725            runtime.tick().await;
20726        };
20727        let (removed, ()) = tokio::join!(remove, drive_runtime);
20728        removed.unwrap();
20729
20730        assert!(!materialization_root.exists());
20731        assert!(!Path::new(&created.path).exists());
20732        assert_eq!(
20733            shared
20734                .managed_worktrees
20735                .lock()
20736                .unwrap_or_else(|poisoned| poisoned.into_inner())
20737                .get(&lease.lease_id)
20738                .unwrap()
20739                .state,
20740            ManagedWorktreeLeaseState::Removed,
20741        );
20742        let renamed = shared
20743            .rename_session_record(&record_id, "raw managed inventory".to_owned())
20744            .unwrap();
20745        assert_eq!(renamed.record_id, record_id);
20746        assert_eq!(renamed.display_name, "raw managed inventory");
20747        assert_eq!(renamed.state, ManagedSessionState::Unavailable);
20748        assert!(renamed.active_session.is_none());
20749
20750        drop(shared);
20751        drop(runtime);
20752        std::fs::remove_dir_all(materialization_fixture_root).unwrap();
20753        std::fs::remove_dir_all(git_root).unwrap();
20754    }
20755
20756    fn install_managed_lease(shared: &NodeShared) -> ManagedWorktreeLeaseRecord {
20757        let root = std::env::current_dir().unwrap()
20758            .join("managed-test-target")
20759            .to_string_lossy()
20760            .into_owned();
20761        let lease = ManagedWorktreeLeaseRecord {
20762            lease_id: ManagedWorktreeLeaseId::new("mw-test").unwrap(),
20763            source_workspace_id: WorkspaceId::new("primary").unwrap(),
20764            workspace_id: WorkspaceId::new("managed-test").unwrap(),
20765            profile_id: WorktreeProfileId::new("default").unwrap(),
20766            profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
20767            target_root: root.clone(),
20768            branch: "gate4agent/mw-test".to_owned(),
20769            base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
20770            expected_head: Some("0123456789abcdef0123456789abcdef01234567".to_owned()),
20771            retention: ManagedWorktreeRetention::RemoveWhenReleased,
20772            state: ManagedWorktreeLeaseState::Ready,
20773            session_holders: Vec::new(),
20774            record_holders: Vec::new(),
20775            cleanup_failure: None,
20776            created_at_unix_ms: 1,
20777            updated_at_unix_ms: 1,
20778        };
20779        shared.workspaces.write()
20780            .unwrap_or_else(|poisoned| poisoned.into_inner())
20781            .insert(lease.workspace_id.clone(), root);
20782        *shared.managed_worktrees.lock()
20783            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
20784            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
20785        lease
20786    }
20787
20788    #[cfg(feature = "fixture")]
20789    #[test]
20790    fn monitoring_fixture_managed_spawn_retains_effective_record_holder_and_converges_live() {
20791        let mut shared = terminal_test_shared();
20792        shared.fixture_semantic_hook_policy = true;
20793        let lease = install_managed_lease(&shared);
20794        let address = SessionAddress {
20795            workspace_id: lease.workspace_id.clone(),
20796            session: SessionKey {
20797                instance_id: AgentInstanceId(206),
20798                generation: SessionGeneration(1),
20799            },
20800        };
20801        let pre_admission_policy = ProviderRuntimePolicy::raw_pty();
20802        let effective_runtime_policy =
20803            shared.effective_spawn_runtime_policy(pre_admission_policy);
20804        assert_ne!(effective_runtime_policy, pre_admission_policy);
20805        assert!(effective_runtime_policy.provider_session_identity);
20806
20807        let record_id = shared
20808            .bind_spawn_session(
20809                &address,
20810                agent("claude"),
20811                SessionMode::Pty,
20812                effective_runtime_policy,
20813                SpawnRecordPolicy::Always,
20814                None,
20815            )
20816            .unwrap()
20817            .unwrap();
20818        assert_eq!(
20819            shared.record(&record_id).unwrap().state,
20820            ManagedSessionState::IdentityPending,
20821        );
20822        let (record_holder, raw_inventory_record_id) = {
20823            let mut bindings = shared
20824                .session_bindings
20825                .lock()
20826                .unwrap_or_else(|poisoned| poisoned.into_inner());
20827            let binding = bindings.get_mut(&address.session.instance_id).unwrap();
20828            binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
20829            managed_spawn_record_ownership(binding, effective_runtime_policy)
20830        };
20831        assert_eq!(record_holder.as_ref(), Some(&record_id));
20832        assert!(raw_inventory_record_id.is_none());
20833        let snapshot = shared
20834            .managed_worktrees
20835            .lock()
20836            .unwrap_or_else(|poisoned| poisoned.into_inner())
20837            .bind_session(
20838                &lease.lease_id,
20839                ManagedWorktreeSessionHolder {
20840                    incarnation_id: shared.incarnation_id,
20841                    instance_id: address.session.instance_id,
20842                    generation: address.session.generation,
20843                },
20844                record_holder,
20845                unix_time_ms(),
20846            )
20847            .unwrap();
20848        assert_eq!(snapshot.managed_record_count, 1);
20849        assert_eq!(
20850            shared
20851                .managed_worktrees
20852                .lock()
20853                .unwrap_or_else(|poisoned| poisoned.into_inner())
20854                .get(&lease.lease_id)
20855                .unwrap()
20856                .record_holders,
20857            vec![record_id.clone()],
20858        );
20859
20860        let identity = ProviderSessionIdentity {
20861            key: ProviderSessionKey::SessionId,
20862            id: "monitoring-fixture-managed-session".to_owned(),
20863            transcript_path: None,
20864        };
20865        shared.publish_control(ControlEvent {
20866            sequence: 1,
20867            command_id: None,
20868            instance_id: address.session.instance_id,
20869            generation: address.session.generation,
20870            event: ControlEventKind::ProviderEvent {
20871                sequence: 1,
20872                source: gate4agent_types::ProviderSource {
20873                    family: AdapterFamily::Hook,
20874                    binding: AdapterBinding::new(
20875                        gate4agent_types::AdapterId::new("claude-code").unwrap(),
20876                        "fixture/v1",
20877                        gate4agent_types::AdapterVerification::SyntheticFixture,
20878                    )
20879                    .unwrap(),
20880                },
20881                source_sequence: 1,
20882                event: ProviderEvent::SessionIdentityObserved {
20883                    identity: identity.clone(),
20884                },
20885            },
20886        });
20887
20888        let live = shared.record(&record_id).unwrap();
20889        assert_eq!(live.state, ManagedSessionState::Live);
20890        assert_eq!(live.provider_session.as_ref(), Some(&identity));
20891        assert_eq!(live.active_session.as_ref(), Some(&address));
20892        assert_eq!(
20893            shared
20894                .managed_worktrees
20895                .lock()
20896                .unwrap_or_else(|poisoned| poisoned.into_inner())
20897                .get(&lease.lease_id)
20898                .unwrap()
20899                .record_holders,
20900            vec![record_id],
20901        );
20902    }
20903
20904    fn managed_v2_test_shared(
20905        allocation_root: &Path,
20906        state_path: Option<PathBuf>,
20907        profile_revision: &str,
20908    ) -> NodeShared {
20909        let profile = ManagedWorktreeProfile::new(
20910            WorktreeProfileId::new("default").unwrap(),
20911            WorktreeProfileRevision::new(profile_revision).unwrap(),
20912            allocation_root,
20913            "gate4agent",
20914            "HEAD",
20915            ManagedWorktreeRetention::RemoveWhenReleased,
20916        )
20917        .unwrap();
20918        let workspace = WorkspaceConfig::new(
20919            WorkspaceId::new("primary").unwrap(),
20920            std::env::current_dir().unwrap(),
20921        )
20922        .unwrap()
20923        .with_worktree_service_mode(WorktreeServiceMode::Managed)
20924        .with_managed_worktree_profile(profile)
20925        .unwrap();
20926        let catalog = active_registry().unwrap();
20927        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20928        let mut shared = NodeShared::new(
20929            handle,
20930            "fixture-token".to_owned(),
20931            NodeId::new("node-terminal-test").unwrap(),
20932            vec![workspace],
20933            vec![agent("claude")],
20934        );
20935        shared.state_path = state_path;
20936        shared
20937    }
20938
20939    fn managed_v2_request(idempotency_key: &str) -> ManagedWorktreeSpawnRequestV2 {
20940        let mut spawn_spec = spawn_spec_fixture();
20941        spawn_spec.idempotency_key = SpawnIdempotencyKey::new(idempotency_key).unwrap();
20942        ManagedWorktreeSpawnRequestV2 {
20943            spawn_spec,
20944            worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
20945            expected_profile_revision: WorktreeProfileRevision::new("v1").unwrap(),
20946        }
20947    }
20948
20949    #[test]
20950    fn managed_spawn_receipt_rebinds_allocated_worktree_and_roundtrips_wire() {
20951        let shared = terminal_test_shared();
20952        let mut lease = install_managed_lease(&shared);
20953        let session = SessionAddress {
20954            workspace_id: lease.workspace_id.clone(),
20955            session: SessionKey {
20956                instance_id: AgentInstanceId(73),
20957                generation: SessionGeneration(4),
20958            },
20959        };
20960        lease.state = ManagedWorktreeLeaseState::InUse;
20961        lease.session_holders.push(ManagedWorktreeSessionHolder {
20962            incarnation_id: shared.incarnation_id,
20963            instance_id: session.session.instance_id,
20964            generation: session.session.generation,
20965        });
20966        let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
20967        assert!(resolved.target.worktree_id.is_none());
20968
20969        let receipt = managed_spawn_receipt(
20970            &resolved,
20971            shared.incarnation_id,
20972            session.clone(),
20973            lease.snapshot(),
20974            None,
20975            None,
20976            None,
20977        );
20978        assert!(resolved.target.worktree_id.is_none());
20979        assert_eq!(receipt.spawn.target.workspace_id, lease.source_workspace_id);
20980        assert_eq!(receipt.spawn.target.worktree_id.as_ref(), Some(&lease.workspace_id));
20981        assert_eq!(receipt.spawn.session, session);
20982        assert_eq!(receipt.lease.workspace_id, lease.workspace_id);
20983        let encoded = serde_json::to_vec(&receipt).unwrap();
20984        let decoded: ManagedWorktreeSpawnReceipt = serde_json::from_slice(&encoded).unwrap();
20985        assert_eq!(decoded, receipt);
20986    }
20987
20988    #[tokio::test]
20989    async fn managed_spawn_v2_rejects_profile_revision_before_allocation() {
20990        let root = temporary_workspace_root("managed-v2-revision");
20991        let allocation = root.join("allocation");
20992        std::fs::create_dir_all(&allocation).unwrap();
20993        let shared = managed_v2_test_shared(
20994            &allocation,
20995            Some(root.join("node-state.json")),
20996            "v1",
20997        );
20998        let mut request = managed_v2_request("managed-v2-revision");
20999        request.expected_profile_revision = WorktreeProfileRevision::new("v2").unwrap();
21000
21001        let failure = shared.spawn_managed_worktree_v2(request).await.unwrap_err();
21002
21003        assert_eq!(
21004            failure.code,
21005            NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
21006        );
21007        assert!(shared
21008            .managed_worktrees
21009            .lock()
21010            .unwrap_or_else(|poisoned| poisoned.into_inner())
21011            .records()
21012            .is_empty());
21013        assert!(shared
21014            .managed_spawn_replays
21015            .lock()
21016            .unwrap_or_else(|poisoned| poisoned.into_inner())
21017            .is_empty());
21018        std::fs::remove_dir_all(root).unwrap();
21019    }
21020
21021    #[test]
21022    fn managed_spawn_v2_concurrent_same_key_has_one_durable_reservation() {
21023        let root = temporary_workspace_root("managed-v2-concurrent");
21024        let allocation = root.join("allocation");
21025        std::fs::create_dir_all(&allocation).unwrap();
21026        let shared = Arc::new(managed_v2_test_shared(
21027            &allocation,
21028            Some(root.join("node-state.json")),
21029            "v1",
21030        ));
21031        let request = managed_v2_request("managed-v2-concurrent");
21032        let barrier = Arc::new(std::sync::Barrier::new(3));
21033        let mut joins = Vec::new();
21034        for _ in 0..2 {
21035            let shared = Arc::clone(&shared);
21036            let request = request.clone();
21037            let barrier = Arc::clone(&barrier);
21038            joins.push(std::thread::spawn(move || {
21039                let digest = managed_spawn_request_digest_v2(&request).unwrap();
21040                barrier.wait();
21041                shared.acquire_durable_managed_spawn_v2(&request, digest)
21042            }));
21043        }
21044        barrier.wait();
21045        let results = joins
21046            .into_iter()
21047            .map(|join| join.join().unwrap())
21048            .collect::<Vec<_>>();
21049        assert_eq!(
21050            results
21051                .iter()
21052                .filter(|result| {
21053                    matches!(result, Ok(DurableManagedSpawnReplayDecision::Reserved))
21054                })
21055                .count(),
21056            1,
21057        );
21058        assert_eq!(
21059            results
21060                .iter()
21061                .filter(|result| {
21062                    matches!(result, Err(error) if error.code == NodeFailureCode::BackendBusy)
21063                })
21064                .count(),
21065            1,
21066        );
21067        assert_eq!(
21068            shared
21069                .managed_spawn_replays
21070                .lock()
21071                .unwrap_or_else(|poisoned| poisoned.into_inner())
21072                .len(),
21073            1,
21074        );
21075        assert!(shared
21076            .managed_worktrees
21077            .lock()
21078            .unwrap_or_else(|poisoned| poisoned.into_inner())
21079            .records()
21080            .is_empty());
21081        drop(shared);
21082        std::fs::remove_dir_all(root).unwrap();
21083    }
21084
21085    #[tokio::test]
21086    async fn managed_spawn_v2_linked_pending_reopen_fails_closed_without_reallocation() {
21087        let root = temporary_workspace_root("managed-v2-linked-pending");
21088        let allocation = root.join("allocation");
21089        std::fs::create_dir_all(&allocation).unwrap();
21090        let state_path = root.join("node-state.json");
21091        let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
21092        let lease = install_managed_lease(&shared);
21093        let request = managed_v2_request("managed-v2-linked-pending");
21094        assert!(matches!(
21095            shared
21096                .acquire_durable_managed_spawn_v2(
21097                    &request,
21098                    managed_spawn_request_digest_v2(&request).unwrap(),
21099                )
21100                .unwrap(),
21101            DurableManagedSpawnReplayDecision::Reserved,
21102        ));
21103        {
21104            let _transaction = shared
21105                .state_transaction
21106                .lock()
21107                .unwrap_or_else(|poisoned| poisoned.into_inner());
21108            shared
21109                .link_durable_managed_spawn_v2_lease_locked(
21110                    &request.spawn_spec.idempotency_key,
21111                    &lease.lease_id,
21112                )
21113                .unwrap();
21114            shared.persist_state_locked().unwrap();
21115        }
21116        drop(shared);
21117
21118        let mut loaded = session_registry::load(
21119            Some(&state_path),
21120            &NodeId::new("node-terminal-test").unwrap(),
21121        )
21122        .unwrap();
21123        let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
21124        *reopened
21125            .managed_worktrees
21126            .lock()
21127            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21128            ManagedWorktreeRegistry::from_records(
21129                std::mem::take(&mut loaded.managed_worktrees),
21130                std::mem::take(&mut loaded.managed_worktree_tombstones),
21131            )
21132            .unwrap();
21133        *reopened
21134            .managed_spawn_replays
21135            .lock()
21136            .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
21137            .managed_spawn_replays
21138            .into_iter()
21139            .map(|record| (record.idempotency_key.clone(), record))
21140            .collect();
21141        let lease_count = reopened
21142            .managed_worktrees
21143            .lock()
21144            .unwrap_or_else(|poisoned| poisoned.into_inner())
21145            .records()
21146            .len();
21147
21148        let failure = reopened
21149            .spawn_managed_worktree_v2(request)
21150            .await
21151            .unwrap_err();
21152
21153        assert_eq!(
21154            failure.code,
21155            NodeFailureCode::ManagedWorktreeRecoveryRequired,
21156        );
21157        assert_eq!(
21158            reopened
21159                .managed_worktrees
21160                .lock()
21161                .unwrap_or_else(|poisoned| poisoned.into_inner())
21162                .records()
21163                .len(),
21164            lease_count,
21165        );
21166        std::fs::remove_dir_all(root).unwrap();
21167    }
21168
21169    #[tokio::test]
21170    async fn managed_spawn_v2_replays_exact_receipt_after_durable_reopen_and_conflicts() {
21171        let root = temporary_workspace_root("managed-v2-reopen");
21172        let allocation = root.join("allocation");
21173        std::fs::create_dir_all(&allocation).unwrap();
21174        let state_path = root.join("node-state.json");
21175        let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
21176        let lease = install_managed_lease(&shared);
21177        let request = managed_v2_request("managed-v2-reopen");
21178        let session = SessionAddress {
21179            workspace_id: lease.workspace_id.clone(),
21180            session: SessionKey {
21181                instance_id: AgentInstanceId(91),
21182                generation: SessionGeneration(1),
21183            },
21184        };
21185        let snapshot = {
21186            let mut registry = shared
21187                .managed_worktrees
21188                .lock()
21189                .unwrap_or_else(|poisoned| poisoned.into_inner());
21190            let current = registry.get_mut(&lease.lease_id).unwrap();
21191            current.state = ManagedWorktreeLeaseState::InUse;
21192            current.session_holders.push(ManagedWorktreeSessionHolder {
21193                incarnation_id: shared.incarnation_id,
21194                instance_id: session.session.instance_id,
21195                generation: session.session.generation,
21196            });
21197            current.snapshot()
21198        };
21199        let resolved = shared.resolve_spawn_spec(&request.spawn_spec).unwrap();
21200        let receipt = managed_spawn_receipt(
21201            &resolved,
21202            shared.incarnation_id,
21203            session,
21204            snapshot,
21205            None,
21206            None,
21207            None,
21208        );
21209        assert!(matches!(
21210            shared
21211                .acquire_durable_managed_spawn_v2(
21212                    &request,
21213                    managed_spawn_request_digest_v2(&request).unwrap(),
21214                )
21215                .unwrap(),
21216            DurableManagedSpawnReplayDecision::Reserved,
21217        ));
21218        {
21219            let _transaction = shared
21220                .state_transaction
21221                .lock()
21222                .unwrap_or_else(|poisoned| poisoned.into_inner());
21223            shared
21224                .link_durable_managed_spawn_v2_lease_locked(
21225                    &request.spawn_spec.idempotency_key,
21226                    &lease.lease_id,
21227                )
21228                .unwrap();
21229            shared.persist_state_locked().unwrap();
21230        }
21231        shared
21232            .commit_durable_managed_spawn_v2(&request, receipt.clone())
21233            .unwrap();
21234        let legacy_same_key = ManagedWorktreeSpawnRequest {
21235            spawn_spec: request.spawn_spec.clone(),
21236            worktree_profile_id: request.worktree_profile_id.clone(),
21237        };
21238        shared.remember_managed_spawn_attempt(
21239            Some(&request.spawn_spec.idempotency_key),
21240            legacy_same_key.clone(),
21241            Ok(receipt.clone()),
21242            Instant::now(),
21243        );
21244        assert!(shared
21245            .replay_managed_spawn(&legacy_same_key, Instant::now())
21246            .unwrap()
21247            .is_none());
21248        drop(shared);
21249
21250        let mut loaded = session_registry::load(
21251            Some(&state_path),
21252            &NodeId::new("node-terminal-test").unwrap(),
21253        )
21254        .unwrap();
21255        assert_eq!(loaded.managed_spawn_replays.len(), 1);
21256        let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
21257        *reopened
21258            .managed_worktrees
21259            .lock()
21260            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21261            ManagedWorktreeRegistry::from_records(
21262                std::mem::take(&mut loaded.managed_worktrees),
21263                std::mem::take(&mut loaded.managed_worktree_tombstones),
21264            )
21265            .unwrap();
21266        *reopened
21267            .managed_spawn_replays
21268            .lock()
21269            .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
21270            .managed_spawn_replays
21271            .into_iter()
21272            .map(|record| (record.idempotency_key.clone(), record))
21273            .collect();
21274        let lease_count_before = reopened
21275            .managed_worktrees
21276            .lock()
21277            .unwrap_or_else(|poisoned| poisoned.into_inner())
21278            .records()
21279            .len();
21280
21281        assert_eq!(
21282            reopened
21283                .spawn_managed_worktree_v2(request.clone())
21284                .await
21285                .unwrap(),
21286            receipt,
21287        );
21288        assert_eq!(
21289            reopened
21290                .managed_worktrees
21291                .lock()
21292                .unwrap_or_else(|poisoned| poisoned.into_inner())
21293                .records()
21294                .len(),
21295            lease_count_before,
21296        );
21297        let mut changed = request;
21298        changed.spawn_spec.deadline_ms = crate::protocol::SpawnDeadlineMs::new(29_000).unwrap();
21299        let failure = reopened
21300            .spawn_managed_worktree_v2(changed)
21301            .await
21302            .unwrap_err();
21303        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
21304        assert_eq!(
21305            reopened
21306                .managed_worktrees
21307                .lock()
21308                .unwrap_or_else(|poisoned| poisoned.into_inner())
21309                .records()
21310                .len(),
21311            lease_count_before,
21312        );
21313        let mut new_key = managed_v2_request("managed-v2-new-after-profile-change");
21314        new_key.expected_profile_revision = WorktreeProfileRevision::new("v1").unwrap();
21315        let failure = reopened
21316            .spawn_managed_worktree_v2(new_key)
21317            .await
21318            .unwrap_err();
21319        assert_eq!(
21320            failure.code,
21321            NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
21322        );
21323        assert_eq!(
21324            reopened
21325                .managed_worktrees
21326                .lock()
21327                .unwrap_or_else(|poisoned| poisoned.into_inner())
21328                .records()
21329                .len(),
21330            lease_count_before,
21331        );
21332        assert_eq!(
21333            reopened
21334                .managed_spawn_replays
21335                .lock()
21336                .unwrap_or_else(|poisoned| poisoned.into_inner())
21337                .len(),
21338            1,
21339        );
21340        std::fs::remove_dir_all(root).unwrap();
21341    }
21342
21343    #[test]
21344    fn legacy_managed_spawn_idempotency_remains_transient_and_exact() {
21345        let shared = terminal_test_shared();
21346        let request = ManagedWorktreeSpawnRequest {
21347            spawn_spec: spawn_spec_fixture(),
21348            worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
21349        };
21350        let expected = failure(NodeFailureCode::BackendBusy, "legacy fixture failure");
21351        shared.remember_managed_spawn(
21352            request.clone(),
21353            Err(expected.clone()),
21354            Instant::now(),
21355        );
21356
21357        assert_eq!(
21358            shared
21359                .replay_managed_spawn(&request, Instant::now())
21360                .unwrap()
21361                .unwrap(),
21362            Err(expected),
21363        );
21364        assert!(shared
21365            .managed_spawn_replays
21366            .lock()
21367            .unwrap_or_else(|poisoned| poisoned.into_inner())
21368            .is_empty());
21369    }
21370
21371    #[tokio::test]
21372    async fn ordinary_spawn_and_spawn_spec_cannot_bypass_managed_lease_holder_fence() {
21373        let shared = terminal_test_shared();
21374        let lease = install_managed_lease(&shared);
21375        let raw = shared.spawn_session(
21376            lease.workspace_id.clone(),
21377            agent("claude"),
21378            SessionMode::Pty,
21379            gate4agent_types::TerminalSize { rows: 24, columns: 80 },
21380            None,
21381        ).await.unwrap_err();
21382        assert_eq!(raw.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
21383        let mut spec = spawn_spec_fixture();
21384        spec.target.workspace_id = lease.workspace_id.clone();
21385        let specified = shared.spawn_from_spec(spec).await.unwrap_err();
21386        assert_eq!(specified.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
21387        let mut selected = spawn_spec_fixture();
21388        selected.idempotency_key = SpawnIdempotencyKey::new("managed-selected-bypass").unwrap();
21389        selected.target.worktree_id = Some(lease.workspace_id);
21390        let selected = shared.spawn_from_spec(selected).await.unwrap_err();
21391        assert_eq!(selected.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
21392        assert!(shared.handle.snapshot().sessions.is_empty());
21393    }
21394
21395    #[test]
21396    fn legacy_projection_omits_managed_snapshot_events_and_path_bearing_diagnostics() {
21397        let shared = terminal_test_shared();
21398        let lease = install_managed_lease(&shared);
21399        let snapshot = snapshot_for_wire(
21400            &shared, true, true, true, false, true, true, false, false, true,
21401        );
21402        assert!(snapshot.managed_worktrees.is_empty());
21403        assert!(snapshot.workspaces.iter().all(|workspace| {
21404            workspace.managed_worktree_profiles.is_none()
21405        }));
21406        let mut reply = ResponseEnvelope {
21407            request_id: 1,
21408            result: Ok(NodeResponse::Resync {
21409                event_sequence: 1,
21410                oldest_available_sequence: 1,
21411                snapshot: shared.snapshot(),
21412                events: vec![NodeEventEnvelope {
21413                    sequence: 1,
21414                    event: NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() },
21415                }],
21416            }),
21417        };
21418        project_response_without_managed_worktrees(&mut reply);
21419        let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
21420            panic!("projection changed the response kind");
21421        };
21422        assert!(snapshot.managed_worktrees.is_empty());
21423        assert!(events.is_empty());
21424        let mut git = git_snapshot_for_parser();
21425        git.branch = Some(lease.branch.clone());
21426        git.managed_worktree = Some(crate::protocol::ManagedWorktreeGitScope {
21427            lease_id: lease.lease_id.clone(),
21428            source_workspace_id: lease.source_workspace_id.clone(),
21429            branch: lease.branch.clone(),
21430            base_commit: GitObjectId::new(lease.base_commit.clone()).unwrap(),
21431            active_session_count: 1,
21432            managed_record_count: 0,
21433        });
21434        let mut inspection_reply = ResponseEnvelope {
21435            request_id: 2,
21436            result: Ok(NodeResponse::WorkspaceInspected {
21437                inspection: WorkspaceInspection {
21438                    workspace_id: lease.workspace_id.clone(),
21439                    entries: Vec::new(),
21440                    tree_truncated: false,
21441                    git,
21442                    truncation: None,
21443                },
21444            }),
21445        };
21446        project_response_without_managed_worktrees(&mut inspection_reply);
21447        let Ok(NodeResponse::WorkspaceInspected { inspection }) = inspection_reply.result else {
21448            panic!("projection changed the inspection response kind");
21449        };
21450        assert!(inspection.git.managed_worktree.is_none());
21451        let failure = managed_git_worktree_failure(GitWorktreeError {
21452            kind: GitWorktreeErrorKind::Failed,
21453            message: r"git failed at C:\private\managed-target".to_owned(),
21454        });
21455        assert!(!failure.message.contains("private"));
21456        assert!(!failure.message.contains("managed-target"));
21457    }
21458
21459    #[tokio::test]
21460    async fn restart_reconciliation_retains_exact_owned_worktree_and_explicit_cleanup_removes_it() {
21461        let root = temporary_workspace_root("managed-restart");
21462        let source = root.join("source");
21463        let allocation = root.join("allocation");
21464        std::fs::create_dir_all(&source).unwrap();
21465        std::fs::create_dir_all(&allocation).unwrap();
21466        run_test_git(&source, &["init"]);
21467        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
21468        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
21469        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
21470        run_test_git(&source, &["add", "seed.txt"]);
21471        run_test_git(&source, &["commit", "-m", "seed"]);
21472        let profile = ManagedWorktreeProfile::new(
21473            WorktreeProfileId::new("default").unwrap(),
21474            crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
21475            &allocation,
21476            "gate4agent",
21477            "HEAD",
21478            ManagedWorktreeRetention::Retain,
21479        ).unwrap();
21480        let workspace = WorkspaceConfig::new(
21481            WorkspaceId::new("primary").unwrap(),
21482            &source,
21483        ).unwrap()
21484            .with_worktree_service_mode(WorktreeServiceMode::Managed)
21485            .with_managed_worktree_profile(profile.clone()).unwrap();
21486        let target = PathBuf::from(profile.allocation_root()).join("mw-restart");
21487        let source_text = workspace.canonical_root().to_owned();
21488        let target_text = target.to_string_lossy().into_owned();
21489        let created = create_git_worktree(
21490            &source_text,
21491            &target_text,
21492            "gate4agent/mw-restart",
21493            Some("HEAD"),
21494        ).await.unwrap();
21495        let lease = ManagedWorktreeLeaseRecord {
21496            lease_id: ManagedWorktreeLeaseId::new("mw-restart").unwrap(),
21497            source_workspace_id: WorkspaceId::new("primary").unwrap(),
21498            workspace_id: WorkspaceId::new("managed-restart").unwrap(),
21499            profile_id: profile.profile_id().clone(),
21500            profile_revision: profile.revision().clone(),
21501            target_root: created.path.clone(),
21502            branch: "gate4agent/mw-restart".to_owned(),
21503            base_commit: created.head.clone(),
21504            expected_head: None,
21505            retention: ManagedWorktreeRetention::Retain,
21506            state: ManagedWorktreeLeaseState::Allocating,
21507            session_holders: Vec::new(),
21508            record_holders: Vec::new(),
21509            cleanup_failure: None,
21510            created_at_unix_ms: 1,
21511            updated_at_unix_ms: 1,
21512        };
21513        let catalog = active_registry().unwrap();
21514        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
21515        let shared = NodeShared::new_with_incarnation(
21516            handle,
21517            "fixture-token".to_owned(),
21518            NodeId::new("node-managed-restart").unwrap(),
21519            NodeIncarnationId::from_bytes([7; crate::protocol::NODE_INCARNATION_ID_BYTES]),
21520            vec![workspace],
21521            vec![agent("claude")],
21522            ProviderRuntimeStatuses::default(),
21523            None,
21524            Vec::new(),
21525            Vec::new(),
21526            SpawnProfileRegistry::default(),
21527            None,
21528            None,
21529            None,
21530            None,
21531            Vec::new(),
21532            vec![lease.clone()],
21533            Vec::new(),
21534            Vec::new(),
21535            None,
21536            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
21537        );
21538        shared.reconcile_managed_worktrees().await;
21539        let retained = shared.managed_worktrees.lock()
21540            .unwrap_or_else(|poisoned| poisoned.into_inner())
21541            .get(&lease.lease_id).unwrap().clone();
21542        assert_eq!(retained.state, ManagedWorktreeLeaseState::Retained);
21543        assert!(shared.workspaces.read()
21544            .unwrap_or_else(|poisoned| poisoned.into_inner())
21545            .contains_key(&lease.workspace_id));
21546        let removed = shared.cleanup_managed_worktree(&lease.lease_id, true).await.unwrap();
21547        assert_eq!(removed.state, ManagedWorktreeLeaseState::Removed);
21548        assert!(!Path::new(&created.path).exists());
21549        std::fs::remove_dir_all(root).unwrap();
21550    }
21551
21552    fn run_test_git(root: &Path, arguments: &[&str]) {
21553        let output = std::process::Command::new("git")
21554            .arg("-C").arg(root).args(arguments).output().unwrap();
21555        assert!(
21556            output.status.success(),
21557            "git {:?} failed: {}",
21558            arguments,
21559            String::from_utf8_lossy(&output.stderr),
21560        );
21561    }
21562
21563    #[tokio::test]
21564    async fn spawn_spec_validation_fails_before_session_mutation() {
21565        let shared = terminal_test_shared();
21566        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21567            capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
21568        }));
21569        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21570            capability.as_str() == NODE_SPAWN_PROFILE_REVISION_CAPABILITY
21571        }));
21572        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21573            capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
21574        }));
21575
21576        let mut target_mismatch = spawn_spec_fixture();
21577        target_mismatch.target.node_id = NodeId::new("other-node").unwrap();
21578        let failure = shared.spawn_from_spec(target_mismatch).await.unwrap_err();
21579        assert_eq!(failure.code, NodeFailureCode::SpawnTargetMismatch);
21580
21581        let mut unknown_profile = spawn_spec_fixture();
21582        unknown_profile.idempotency_key = SpawnIdempotencyKey::new("unknown-profile").unwrap();
21583        unknown_profile.profile_id = crate::protocol::SpawnProfileId::new("missing").unwrap();
21584        let failure = shared.spawn_from_spec(unknown_profile).await.unwrap_err();
21585        assert_eq!(failure.code, NodeFailureCode::UnknownSpawnProfile);
21586
21587        let mut revision_mismatch = spawn_spec_fixture();
21588        revision_mismatch.idempotency_key =
21589            SpawnIdempotencyKey::new("profile-revision-mismatch").unwrap();
21590        revision_mismatch.expected_profile_revision =
21591            crate::protocol::SpawnProfileRevision::new("test-r2").unwrap();
21592        let failure = shared.spawn_from_spec(revision_mismatch).await.unwrap_err();
21593        assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
21594
21595        let mut unsupported_materializer = spawn_spec_fixture();
21596        unsupported_materializer.idempotency_key =
21597            SpawnIdempotencyKey::new("unsupported-materializer").unwrap();
21598        unsupported_materializer.overrides.bundle_id = crate::protocol::SpawnOverride::Set {
21599            value: crate::protocol::SpawnBundleId::new("bundle-v1").unwrap(),
21600        };
21601        let failure = shared
21602            .spawn_from_spec(unsupported_materializer)
21603            .await
21604            .unwrap_err();
21605        assert_eq!(failure.code, NodeFailureCode::UnknownBundle);
21606
21607        let failure = shared
21608            .spawn_session_with_deadline(
21609                WorkspaceId::new("primary").unwrap(),
21610                agent("claude"),
21611                SessionMode::Pty,
21612                gate4agent_types::TerminalSize {
21613                    rows: 24,
21614                    columns: 80,
21615                },
21616                None,
21617                None,
21618                None,
21619                None,
21620                None,
21621                None,
21622                SpawnRecordPolicy::ProviderIdentityOnly,
21623                Some(Instant::now()),
21624                &[],
21625                None,
21626                ApprovalLevel::default(),
21627                None,
21628                None,
21629            )
21630            .await
21631            .unwrap_err();
21632        assert_eq!(failure.code, NodeFailureCode::SpawnDeadlineExceeded);
21633        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
21634        assert!(shared.handle.snapshot().sessions.is_empty());
21635    }
21636
21637    #[test]
21638    fn worktree_selection_requires_its_separate_negotiated_capability() {
21639        let mut spec = spawn_spec_fixture();
21640        spec.target.worktree_id = Some(WorkspaceId::new("selected-worktree").unwrap());
21641        let request = NodeRequest::SpawnSpec { spec };
21642        let spawn_spec_capability =
21643            CapabilityId::new(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY).unwrap();
21644        assert!(request_uses_unnegotiated_capability(
21645            &request,
21646            &[spawn_spec_capability.clone()],
21647        ));
21648        let worktree_capability =
21649            CapabilityId::new(NODE_WORKTREE_SELECTION_CAPABILITY).unwrap();
21650        assert!(request_uses_unnegotiated_capability(
21651            &request,
21652            &[spawn_spec_capability.clone(), worktree_capability.clone()],
21653        ));
21654        let profile_revision_capability =
21655            CapabilityId::new(NODE_SPAWN_PROFILE_REVISION_CAPABILITY).unwrap();
21656        assert!(request_uses_unnegotiated_capability(
21657            &request,
21658            &[
21659                spawn_spec_capability.clone(),
21660                worktree_capability.clone(),
21661                profile_revision_capability.clone(),
21662            ],
21663        ));
21664        // Default `SpawnOverrides` leaves `bundle_id`/`context_id` at
21665        // `Inherit`, not `Clear` — the profile's own defaults might still
21666        // materialize a bundle or a context pack, so both capabilities stay
21667        // conservatively required until negotiated (see
21668        // `requires_session_bundle_materialization_capability` /
21669        // `requires_history_context_pack_capability`).
21670        let bundle_materialization_capability =
21671            CapabilityId::new(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY).unwrap();
21672        assert!(request_uses_unnegotiated_capability(
21673            &request,
21674            &[
21675                spawn_spec_capability.clone(),
21676                worktree_capability.clone(),
21677                profile_revision_capability.clone(),
21678                bundle_materialization_capability.clone(),
21679            ],
21680        ));
21681        let history_context_pack_capability =
21682            CapabilityId::new(NODE_HISTORY_CONTEXT_PACK_CAPABILITY).unwrap();
21683        assert!(!request_uses_unnegotiated_capability(
21684            &request,
21685            &[
21686                spawn_spec_capability,
21687                worktree_capability,
21688                profile_revision_capability,
21689                bundle_materialization_capability,
21690                history_context_pack_capability,
21691            ],
21692        ));
21693    }
21694
21695    #[tokio::test]
21696    async fn spawn_spec_idempotency_replays_receipt_and_conflicts_before_session_mutation() {
21697        let mut shared = terminal_test_shared();
21698        let spec = spawn_spec_fixture();
21699        let receipt = shared
21700            .resolve_spawn_spec(&spec)
21701            .unwrap()
21702            .receipt(shared.incarnation_id, terminal_address(1));
21703        shared.remember_spawn_spec(spec.clone(), Ok(receipt.clone()), Instant::now());
21704
21705        let mut revised = shared.spawn_profiles.get(&spec.profile_id).unwrap().clone();
21706        revised.revision = crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
21707        shared.spawn_profiles = SpawnProfileRegistry::new([revised]).unwrap();
21708
21709        assert_eq!(shared.spawn_from_spec(spec.clone()).await.unwrap(), receipt);
21710
21711        let mut revision_conflicting = spec.clone();
21712        revision_conflicting.expected_profile_revision =
21713            crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
21714        let failure = shared.spawn_from_spec(revision_conflicting).await.unwrap_err();
21715        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
21716
21717        let mut stale_cache_miss = spec.clone();
21718        stale_cache_miss.idempotency_key =
21719            SpawnIdempotencyKey::new("stale-profile-cache-miss").unwrap();
21720        let failure = shared.spawn_from_spec(stale_cache_miss).await.unwrap_err();
21721        assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
21722
21723        let mut conflicting = spec;
21724        conflicting.deadline_ms = crate::protocol::SpawnDeadlineMs::new(31_000).unwrap();
21725        let failure = shared.spawn_from_spec(conflicting).await.unwrap_err();
21726        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
21727        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
21728        assert!(shared.handle.snapshot().sessions.is_empty());
21729    }
21730
21731    #[tokio::test]
21732    async fn off_worktree_service_rejects_selection_and_mutation_before_git() {
21733        let shared = terminal_test_shared_with_mode(WorktreeServiceMode::Off);
21734        let mut spec = spawn_spec_fixture();
21735        spec.target.worktree_id = Some(WorkspaceId::new("unregistered-worktree").unwrap());
21736        let failure = shared.spawn_from_spec(spec).await.unwrap_err();
21737        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
21738
21739        let failure = shared
21740            .create_worktree(
21741                WorkspaceId::new("primary").unwrap(),
21742                WorkspaceId::new("new-worktree").unwrap(),
21743                "not-an-absolute-path".to_owned(),
21744                "invalid branch".to_owned(),
21745                None,
21746            )
21747            .await
21748            .unwrap_err();
21749        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
21750
21751        let failure = shared
21752            .remove_worktree(
21753                WorkspaceId::new("primary").unwrap(),
21754                "not-an-absolute-path".to_owned(),
21755            )
21756            .await
21757            .unwrap_err();
21758        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
21759        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
21760        assert!(shared.handle.snapshot().sessions.is_empty());
21761    }
21762
21763    #[tokio::test]
21764    async fn manual_worktree_registration_failure_removes_the_created_worktree() {
21765        let root = temporary_workspace_root("manual-worktree-compensation");
21766        let source = root.join("source");
21767        let target = root.join("target");
21768        let invalid_state_parent = root.join("state-parent-is-a-file");
21769        let invalid_state_path = invalid_state_parent.join("state.json");
21770        std::fs::create_dir_all(&source).unwrap();
21771        std::fs::write(&invalid_state_parent, "not a directory").unwrap();
21772        run_test_git(&source, &["init"]);
21773        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
21774        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
21775        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
21776        run_test_git(&source, &["add", "seed.txt"]);
21777        run_test_git(&source, &["commit", "-m", "seed"]);
21778
21779        let catalog = active_registry().unwrap();
21780        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
21781        let workspace = WorkspaceConfig::new(
21782            WorkspaceId::new("primary").unwrap(),
21783            &source,
21784        ).unwrap();
21785        let mut shared = NodeShared::new(
21786            handle,
21787            "fixture-token".to_owned(),
21788            NodeId::new("node-worktree-compensation").unwrap(),
21789            vec![workspace],
21790            vec![agent("claude")],
21791        );
21792        shared.state_path = Some(invalid_state_path);
21793
21794        let error = shared.create_worktree(
21795            WorkspaceId::new("primary").unwrap(),
21796            WorkspaceId::new("compensated").unwrap(),
21797            target.to_string_lossy().into_owned(),
21798            "gate4agent/compensated".to_owned(),
21799            Some("HEAD".to_owned()),
21800        ).await.unwrap_err();
21801
21802        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21803        assert!(!target.exists());
21804        assert!(list_git_worktrees(source.to_str().unwrap()).await.unwrap()
21805            .iter()
21806            .all(|worktree| !worktree_paths_equal(&worktree.path, &target.to_string_lossy())));
21807        std::fs::remove_dir_all(root).unwrap();
21808    }
21809
21810    #[test]
21811    fn selected_worktree_requires_authoritative_non_main_non_bare_entry() {
21812        fn worktree(path: &str, is_main: bool, is_bare: bool) -> NativeGitWorktreeSnapshot {
21813            NativeGitWorktreeSnapshot {
21814                path: path.to_owned(),
21815                head: "0123456789abcdef".to_owned(),
21816                branch: Some("refs/heads/test".to_owned()),
21817                is_bare,
21818                is_main,
21819                locked: false,
21820                lock_reason: None,
21821                prunable: false,
21822                prunable_reason: None,
21823                workspace_id: None,
21824            }
21825        }
21826
21827        let source = r"C:\repo";
21828        let selected = r"C:\repo-worktree";
21829        let listed = vec![
21830            worktree(source, true, false),
21831            worktree(selected, false, false),
21832        ];
21833        assert!(validate_selected_worktree(source, selected, &listed).is_ok());
21834
21835        let main_failure = validate_selected_worktree(source, source, &listed).unwrap_err();
21836        assert_eq!(main_failure.code, NodeFailureCode::WorktreeProtected);
21837
21838        let bare = vec![
21839            worktree(source, true, false),
21840            worktree(selected, false, true),
21841        ];
21842        let bare_failure = validate_selected_worktree(source, selected, &bare).unwrap_err();
21843        assert_eq!(bare_failure.code, NodeFailureCode::WorktreeProtected);
21844
21845        let foreign_failure =
21846            validate_selected_worktree(source, r"C:\foreign", &listed).unwrap_err();
21847        assert_eq!(foreign_failure.code, NodeFailureCode::WorktreeProtected);
21848    }
21849
21850    fn terminal_frame(sequence: u64) -> TerminalFrame {
21851        TerminalFrame {
21852            sequence,
21853            size: gate4agent_types::TerminalSize {
21854                rows: 24,
21855                columns: 80,
21856            },
21857            cursor_row: 2,
21858            cursor_column: 3,
21859            contents: format!("frame-{sequence}"),
21860            formatted: vec![1, 2, 3],
21861            scrollback_formatted: Vec::new(),
21862            alternate_screen: false,
21863            mouse_protocol_enabled: false,
21864            mouse_protocol_encoding: Default::default(),
21865            produced_at_unix_ms: 0,
21866            screen_state: gate4agent_types::PtyScreenState::default(),
21867            bracketed_paste: None,
21868        }
21869    }
21870
21871    #[test]
21872    fn terminal_frame_watermark_coalesces_and_resets_for_exact_session_address() {
21873        let shared = terminal_test_shared();
21874        let mut receiver = shared.terminal_event_tx.subscribe();
21875        let original = terminal_address(1);
21876
21877        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
21878        let first = receiver.try_recv().unwrap();
21879        assert_eq!(first.len(), 1);
21880        assert!(matches!(
21881            &first[0].event,
21882            NodeEvent::TerminalFrame { address, frame }
21883                if address == &original && frame.sequence == 7
21884        ));
21885
21886        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
21887        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(6))]);
21888        assert!(matches!(
21889            receiver.try_recv(),
21890            Err(broadcast::error::TryRecvError::Empty)
21891        ));
21892
21893        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(8))]);
21894        let advanced = receiver.try_recv().unwrap();
21895        assert_eq!(advanced[0].sequence, 2);
21896
21897        let resumed = terminal_address(2);
21898        shared.publish_terminal_frame_candidates(vec![(resumed.clone(), terminal_frame(1))]);
21899        let reset = receiver.try_recv().unwrap();
21900        assert_eq!(reset[0].sequence, 3);
21901        assert!(matches!(
21902            &reset[0].event,
21903            NodeEvent::TerminalFrame { address, frame }
21904                if address == &resumed && frame.sequence == 1
21905        ));
21906        shared.clear_terminal_frame_watermark(&resumed);
21907        assert!(!shared
21908            .terminal_frame_watermarks
21909            .lock()
21910            .unwrap_or_else(|poisoned| poisoned.into_inner())
21911            .contains_key(&resumed.session.instance_id));
21912    }
21913
21914    #[test]
21915    fn terminal_frame_batches_replace_without_entering_node_event_history() {
21916        let shared = terminal_test_shared();
21917        let mut receiver = shared.terminal_event_tx.subscribe();
21918        let address = terminal_address(1);
21919        let count = NODE_EVENT_HISTORY_MAX as u64 + 17;
21920
21921        for sequence in 1..=count {
21922            shared.publish_terminal_frame_candidates(vec![(
21923                address.clone(),
21924                terminal_frame(sequence),
21925            )]);
21926        }
21927
21928        let history = shared
21929            .history
21930            .lock()
21931            .unwrap_or_else(|poisoned| poisoned.into_inner());
21932        assert!(history.events.is_empty());
21933        assert_eq!(history.last_sequence, count);
21934        assert_eq!(history.replay_floor_sequence, 1);
21935        drop(history);
21936        let NodeResponse::Resync {
21937            event_sequence,
21938            oldest_available_sequence,
21939            events,
21940            ..
21941        } = shared.resync(0) else {
21942            unreachable!("resync helper returned another response")
21943        };
21944        assert_eq!(event_sequence, count);
21945        assert_eq!(oldest_available_sequence, 1);
21946        assert!(events.is_empty());
21947        assert!(matches!(
21948            resync_required_event(&shared).event,
21949            NodeEvent::ResyncRequired {
21950                oldest_available_sequence: 1,
21951            }
21952        ));
21953        assert!(matches!(
21954            receiver.try_recv(),
21955            Err(broadcast::error::TryRecvError::Lagged(skipped)) if skipped == count - 1
21956        ));
21957        let latest = receiver.try_recv().unwrap();
21958        assert_eq!(latest.len(), 1);
21959        assert_eq!(latest[0].sequence, count);
21960    }
21961
21962    #[test]
21963    fn real_durable_eviction_advances_authoritative_replay_floor() {
21964        let shared = terminal_test_shared();
21965        for _ in 0..=NODE_EVENT_HISTORY_MAX {
21966            shared.publish(NodeEvent::ControllerChanged { controller: None });
21967        }
21968
21969        let NodeResponse::Resync {
21970            event_sequence,
21971            oldest_available_sequence,
21972            events,
21973            ..
21974        } = shared.resync(0) else {
21975            unreachable!("resync helper returned another response")
21976        };
21977        assert_eq!(event_sequence, NODE_EVENT_HISTORY_MAX as u64 + 1);
21978        assert_eq!(oldest_available_sequence, 2);
21979        assert_eq!(events.first().map(|event| event.sequence), Some(2));
21980        assert_eq!(events.len(), NODE_EVENT_HISTORY_MAX);
21981        assert!(matches!(
21982            resync_required_event(&shared).event,
21983            NodeEvent::ResyncRequired {
21984                oldest_available_sequence: 2,
21985            }
21986        ));
21987    }
21988
21989    #[test]
21990    fn legacy_terminal_frame_connection_has_no_live_subscription() {
21991        let shared = terminal_test_shared();
21992        let without_capability = terminal_event_subscription(&shared, false);
21993        let with_capability = terminal_event_subscription(&shared, true);
21994        assert!(without_capability.is_none());
21995        assert!(with_capability.is_some());
21996        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21997            capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
21998        }));
21999    }
22000
22001    #[test]
22002    fn continuous_terminal_output_leaves_request_service_after_bounded_event_burst() {
22003        let mut pending = Vec::new();
22004        let mut event_sequence = 0_u64;
22005        for frame_sequence in 1..=4 {
22006            for instance in 1..=(NODE_CONNECTION_EVENT_BURST_MAX as u64 * 2) {
22007                event_sequence += 1;
22008                let mut address = terminal_address(1);
22009                address.session.instance_id = AgentInstanceId(instance);
22010                queue_connection_event(
22011                    &mut pending,
22012                    NodeEventEnvelope {
22013                        sequence: event_sequence,
22014                        event: NodeEvent::TerminalFrame {
22015                            address,
22016                            frame: terminal_frame(frame_sequence),
22017                        },
22018                    },
22019                    0,
22020                );
22021            }
22022        }
22023        assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX * 2);
22024        pending.sort_unstable_by_key(|event| event.sequence);
22025
22026        let mut uninterrupted = pending.clone();
22027        let first_burst = connection_event_burst_len(uninterrupted.len());
22028        uninterrupted.drain(..first_burst);
22029        let second_burst = connection_event_burst_len(uninterrupted.len());
22030        uninterrupted.drain(..second_burst);
22031        assert!(uninterrupted.is_empty());
22032
22033        let (frame_tx, mut frame_rx) =
22034            mpsc::channel::<Result<ClientFrame, FrameError>>(1);
22035        frame_tx
22036            .try_send(Ok(ClientFrame::Request(RequestEnvelope {
22037                request_id: 77,
22038                request: NodeRequest::Snapshot,
22039            })))
22040            .unwrap();
22041        let burst_len = connection_event_burst_len(pending.len());
22042        assert_eq!(burst_len, NODE_CONNECTION_EVENT_BURST_MAX);
22043        pending.drain(..burst_len);
22044        assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX);
22045        assert!(matches!(
22046            frame_rx.try_recv(),
22047            Ok(Ok(ClientFrame::Request(RequestEnvelope {
22048                request_id: 77,
22049                request: NodeRequest::Snapshot,
22050            })))
22051        ));
22052    }
22053
22054    #[test]
22055    fn terminal_lag_watermark_discards_retained_and_replayed_batches() {
22056        let mut pending = Vec::new();
22057        for sequence in [7_u64, 8] {
22058            let mut address = terminal_address(1);
22059            address.session.instance_id = AgentInstanceId(sequence);
22060            queue_connection_event(
22061                &mut pending,
22062                NodeEventEnvelope {
22063                    sequence,
22064                    event: NodeEvent::TerminalFrame {
22065                        address,
22066                        frame: terminal_frame(sequence),
22067                    },
22068                },
22069                0,
22070            );
22071        }
22072        let mut discard_through = 0;
22073        apply_connection_resync_watermark(&mut pending, &mut discard_through, 8);
22074        assert!(pending.is_empty());
22075
22076        let replayed = NodeEventEnvelope {
22077            sequence: 8,
22078            event: NodeEvent::TerminalFrame {
22079                address: terminal_address(1),
22080                frame: terminal_frame(8),
22081            },
22082        };
22083        let advanced = NodeEventEnvelope {
22084            sequence: 9,
22085            event: NodeEvent::TerminalFrame {
22086                address: terminal_address(2),
22087                frame: terminal_frame(1),
22088            },
22089        };
22090        queue_connection_event_batch(
22091            &mut pending,
22092            &[replayed, advanced.clone()],
22093            discard_through,
22094        );
22095        assert_eq!(pending, vec![advanced]);
22096    }
22097
22098    fn record(provider: &str, record_id: &str) -> ManagedSessionRecord {
22099        ManagedSessionRecord {
22100            record_id: SessionRecordId::new(record_id).unwrap(),
22101            display_name: format!("{provider} record"),
22102            provider: agent(provider),
22103            mode: SessionMode::Pty,
22104            state: ManagedSessionState::Unavailable,
22105            workspace_id: WorkspaceId::new("primary").unwrap(),
22106            canonical_root: opaque_windows_path(
22107                std::env::current_dir().unwrap().to_string_lossy().into_owned(),
22108            ),
22109            provider_session: None,
22110            active_session: None,
22111            environment_profile: None,
22112            bundle: None,
22113            context_id: None,
22114            context: None,
22115            exported_context: None,
22116            task_binding: None,
22117            created_at_unix_ms: 1,
22118            updated_at_unix_ms: 1,
22119            last_error: None,
22120        }
22121    }
22122
22123    fn retention_test_record(
22124        workspace_id: &str,
22125        record_id: &str,
22126        state: ManagedSessionState,
22127        updated_at_unix_ms: u64,
22128    ) -> ManagedSessionRecord {
22129        ManagedSessionRecord {
22130            record_id: SessionRecordId::new(record_id).unwrap(),
22131            display_name: format!("{record_id} display"),
22132            provider: agent("claude"),
22133            mode: SessionMode::Pty,
22134            state,
22135            workspace_id: WorkspaceId::new(workspace_id).unwrap(),
22136            canonical_root: opaque_windows_path(
22137                std::env::current_dir().unwrap().to_string_lossy().into_owned(),
22138            ),
22139            provider_session: None,
22140            active_session: None,
22141            environment_profile: None,
22142            bundle: None,
22143            context_id: None,
22144            context: None,
22145            exported_context: None,
22146            task_binding: None,
22147            created_at_unix_ms: updated_at_unix_ms,
22148            updated_at_unix_ms,
22149            last_error: None,
22150        }
22151    }
22152
22153    /// Two workspaces of three `Unavailable` records each, staggered by
22154    /// age, plus one never-touchable decoy per non-`Unavailable` state in
22155    /// `workspace-a` (all as old as that workspace's oldest real record --
22156    /// if the sweep's state gate ever leaked, these would be the first
22157    /// thing it wrongly retired). Ages, oldest to newest:
22158    /// `workspace-a`: a1 (500s), a2 (300s), a3 (100s).
22159    /// `workspace-b`: b1 (400s), b2 (200s), b3 (50s).
22160    fn retention_fixture_records(now: u64) -> Vec<ManagedSessionRecord> {
22161        vec![
22162            retention_test_record("workspace-a", "a1", ManagedSessionState::Unavailable, now - 500_000),
22163            retention_test_record("workspace-a", "a2", ManagedSessionState::Unavailable, now - 300_000),
22164            retention_test_record("workspace-a", "a3", ManagedSessionState::Unavailable, now - 100_000),
22165            retention_test_record("workspace-b", "b1", ManagedSessionState::Unavailable, now - 400_000),
22166            retention_test_record("workspace-b", "b2", ManagedSessionState::Unavailable, now - 200_000),
22167            retention_test_record("workspace-b", "b3", ManagedSessionState::Unavailable, now - 50_000),
22168            retention_test_record("workspace-a", "decoy-live", ManagedSessionState::Live, now - 500_000),
22169            retention_test_record(
22170                "workspace-a",
22171                "decoy-pending",
22172                ManagedSessionState::IdentityPending,
22173                now - 500_000,
22174            ),
22175            retention_test_record(
22176                "workspace-a",
22177                "decoy-dormant",
22178                ManagedSessionState::Dormant,
22179                now - 500_000,
22180            ),
22181        ]
22182    }
22183
22184    fn retention_fixture_shared() -> (NodeShared, NativeRuntime, PathBuf, u64) {
22185        let root = temporary_workspace_root("session-record-retention");
22186        std::fs::create_dir_all(&root).unwrap();
22187        let catalog = active_registry().unwrap();
22188        let (handle, runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22189        let workspace_a =
22190            WorkspaceConfig::new(WorkspaceId::new("workspace-a").unwrap(), &root).unwrap();
22191        let workspace_b =
22192            WorkspaceConfig::new(WorkspaceId::new("workspace-b").unwrap(), &root).unwrap();
22193        let mut shared = NodeShared::new(
22194            handle,
22195            "fixture-token".to_owned(),
22196            NodeId::new("node-retention-test").unwrap(),
22197            vec![workspace_a, workspace_b],
22198            vec![agent("claude")],
22199        );
22200        shared.state_path = Some(root.join("node-state-v10.json"));
22201        let now = unix_time_ms();
22202        for record in retention_fixture_records(now) {
22203            shared.insert_record(record).unwrap();
22204        }
22205        shared
22206            .persist_state_locked()
22207            .expect("fixture-only, empty-history persist against a fresh temp state path");
22208        (shared, runtime, root, now)
22209    }
22210
22211    fn retention_removed_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
22212        shared
22213            .history
22214            .lock()
22215            .unwrap_or_else(|poisoned| poisoned.into_inner())
22216            .events
22217            .iter()
22218            .filter_map(|envelope| match &envelope.event {
22219                NodeEvent::SessionRecordRemoved { record_id } => {
22220                    Some(record_id.as_str().to_owned())
22221                }
22222                _ => None,
22223            })
22224            .collect()
22225    }
22226
22227    fn retention_remaining_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
22228        shared
22229            .session_records
22230            .lock()
22231            .unwrap_or_else(|poisoned| poisoned.into_inner())
22232            .records
22233            .keys()
22234            .map(|record_id| record_id.as_str().to_owned())
22235            .collect()
22236    }
22237
22238    fn owned(values: &[&str]) -> std::collections::BTreeSet<String> {
22239        values.iter().map(|value| (*value).to_owned()).collect()
22240    }
22241
22242    #[tokio::test]
22243    async fn session_record_retention_keep_n_only_keeps_the_newest_per_workspace() {
22244        let (mut shared, runtime, root, _now) = retention_fixture_shared();
22245        shared.session_record_retention = SessionRecordRetentionConfig {
22246            age_ms: 0,
22247            keep_per_workspace: 1,
22248        };
22249        shared.retire_unavailable_session_records().await;
22250
22251        assert_eq!(
22252            retention_remaining_record_ids(&shared),
22253            owned(&["a3", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
22254        );
22255        assert_eq!(
22256            retention_removed_record_ids(&shared),
22257            owned(&["a1", "a2", "b1", "b2"]),
22258        );
22259        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 4);
22260
22261        drop(shared);
22262        drop(runtime);
22263        std::fs::remove_dir_all(root).unwrap();
22264    }
22265
22266    /// Age-only would retire {a1, a2, b1}; keep-N-only would retire
22267    /// {a1, a2, b1, b2}. With both flags set the sweep must retire exactly
22268    /// the intersection {a1, a2, b1} -- `b2` fails the age test (it is only
22269    /// 200s old against a 250s threshold) even though it fails the keep-N
22270    /// test too, proving retirement needs BOTH, not either.
22271    #[tokio::test]
22272    async fn session_record_retention_both_set_retires_only_the_intersection() {
22273        let (mut shared, runtime, root, _now) = retention_fixture_shared();
22274        shared.session_record_retention = SessionRecordRetentionConfig {
22275            age_ms: 250_000,
22276            keep_per_workspace: 1,
22277        };
22278        shared.retire_unavailable_session_records().await;
22279
22280        assert_eq!(
22281            retention_remaining_record_ids(&shared),
22282            owned(&["a3", "b2", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
22283        );
22284        assert_eq!(retention_removed_record_ids(&shared), owned(&["a1", "a2", "b1"]));
22285        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 3);
22286
22287        drop(shared);
22288        drop(runtime);
22289        std::fs::remove_dir_all(root).unwrap();
22290    }
22291
22292    #[tokio::test]
22293    async fn session_record_retention_both_disabled_is_a_no_op() {
22294        let (mut shared, runtime, root, _now) = retention_fixture_shared();
22295        shared.session_record_retention = SessionRecordRetentionConfig::default();
22296        shared.retire_unavailable_session_records().await;
22297
22298        assert_eq!(
22299            retention_remaining_record_ids(&shared),
22300            owned(&[
22301                "a1", "a2", "a3", "b1", "b2", "b3",
22302                "decoy-live", "decoy-pending", "decoy-dormant",
22303            ]),
22304        );
22305        assert!(retention_removed_record_ids(&shared).is_empty());
22306        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 0);
22307
22308        drop(shared);
22309        drop(runtime);
22310        std::fs::remove_dir_all(root).unwrap();
22311    }
22312
22313    #[test]
22314    fn session_record_context_export_selects_one_exact_history_identity() {
22315        let candidates = vec![
22316            HistoryCandidateSummary {
22317                id: "candidate-other".to_owned(),
22318                session_id_hint: "provider-other".to_owned(),
22319                modified_at_unix_ms: Some(1),
22320            },
22321            HistoryCandidateSummary {
22322                id: "candidate-exact".to_owned(),
22323                session_id_hint: "provider-exact".to_owned(),
22324                modified_at_unix_ms: Some(2),
22325            },
22326        ];
22327        assert_eq!(
22328            select_history_candidate(&candidates, Some("provider-exact")).unwrap(),
22329            "candidate-exact",
22330        );
22331    }
22332
22333    #[test]
22334    fn session_record_context_export_rejects_missing_and_ambiguous_history_identity() {
22335        let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
22336            id: id.to_owned(),
22337            session_id_hint: hint.to_owned(),
22338            modified_at_unix_ms: None,
22339        };
22340        let missing = select_history_candidate(
22341            &[candidate("candidate-other", "provider-other")],
22342            Some("provider-exact"),
22343        )
22344        .unwrap_err();
22345        assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
22346        let ambiguous = select_history_candidate(
22347            &[
22348                candidate("candidate-a", "provider-exact"),
22349                candidate("candidate-b", "provider-exact"),
22350            ],
22351            Some("provider-exact"),
22352        )
22353        .unwrap_err();
22354        assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
22355    }
22356
22357    /// The PTY-sidecar-observation shape (no fleet provider today, but
22358    /// capability-keyed rather than vendor-locked) never carries a verified
22359    /// `provider_session`; its record-bound export instead falls back to
22360    /// "exactly one discovered candidate" (see
22361    /// `export_context_pack_for_session_record_inner`). This is the same
22362    /// ambiguity refusal as the identity-filtered path, just without an
22363    /// identity to filter by.
22364    #[test]
22365    fn session_record_context_export_selects_sole_history_candidate_without_identity() {
22366        let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
22367            id: id.to_owned(),
22368            session_id_hint: hint.to_owned(),
22369            modified_at_unix_ms: None,
22370        };
22371        assert_eq!(
22372            select_history_candidate(&[candidate("candidate-sole", "sidecar-session")], None)
22373                .unwrap(),
22374            "candidate-sole",
22375        );
22376        let missing = select_history_candidate(&[], None).unwrap_err();
22377        assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
22378        let ambiguous = select_history_candidate(
22379            &[
22380                candidate("candidate-a", "sidecar-session-a"),
22381                candidate("candidate-b", "sidecar-session-b"),
22382            ],
22383            None,
22384        )
22385        .unwrap_err();
22386        assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
22387    }
22388
22389    #[test]
22390    fn session_record_context_export_revalidation_rejects_record_change() {
22391        let session = terminal_address(1);
22392        let identity = ProviderSessionIdentity {
22393            key: ProviderSessionKey::SessionId,
22394            id: "provider-exact".to_owned(),
22395            transcript_path: Some(r"C:\private\provider.jsonl".to_owned()),
22396        };
22397        let mut expected = record("claude", "record-context-export");
22398        expected.state = ManagedSessionState::Live;
22399        expected.provider_session = Some(identity.clone());
22400        expected.active_session = Some(session.clone());
22401        let binding = SessionBinding {
22402            workspace_id: session.workspace_id.clone(),
22403            generation: session.session.generation,
22404            runtime_policy: ProviderRuntimePolicy::raw_pty(),
22405            pending_resume: None,
22406            record_id: Some(expected.record_id.clone()),
22407            managed_worktree_lease_id: None,
22408            environment_profile: None,
22409            bundle: None,
22410            context: None,
22411            materialization_id: None,
22412        };
22413        let current_root = windows_path_text(&expected.canonical_root).to_owned();
22414        assert!(session_record_context_export_binding_is_exact(
22415            &expected,
22416            &expected,
22417            Some(&identity),
22418            &session,
22419            &binding,
22420            &expected.provider,
22421            &current_root,
22422            false,
22423        ));
22424        let mut changed = expected.clone();
22425        changed.updated_at_unix_ms += 1;
22426        assert!(!session_record_context_export_binding_is_exact(
22427            &expected,
22428            &changed,
22429            Some(&identity),
22430            &session,
22431            &binding,
22432            &expected.provider,
22433            &current_root,
22434            false,
22435        ));
22436        // A same-instant timestamp bump alone (state and active_session
22437        // otherwise unchanged) is not the clean-detachment transition
22438        // either, even with the reactive flag on: only the exact Live ->
22439        // Dormant + active_session cleared shape qualifies.
22440        assert!(!session_record_context_export_binding_is_exact(
22441            &expected,
22442            &changed,
22443            Some(&identity),
22444            &session,
22445            &binding,
22446            &expected.provider,
22447            &current_root,
22448            true,
22449        ));
22450        let mut detached = expected.clone();
22451        detached.state = ManagedSessionState::Dormant;
22452        detached.active_session = None;
22453        detached.updated_at_unix_ms += 1;
22454        assert!(!session_record_context_export_binding_is_exact(
22455            &expected,
22456            &detached,
22457            Some(&identity),
22458            &session,
22459            &binding,
22460            &expected.provider,
22461            &current_root,
22462            false,
22463        ));
22464        assert!(session_record_context_export_binding_is_exact(
22465            &expected,
22466            &detached,
22467            Some(&identity),
22468            &session,
22469            &binding,
22470            &expected.provider,
22471            &current_root,
22472            true,
22473        ));
22474    }
22475
22476    /// Mirrors `session_record_context_export_revalidation_rejects_record_change`
22477    /// for a record whose provider never carries a verified identity (the
22478    /// PTY-sidecar-observation shape -- capability-keyed, no fleet provider
22479    /// declares one today): `identity` is `None` throughout, and the
22480    /// exactness check falls back to comparing `provider_session` itself,
22481    /// which must still be `None` on both sides.
22482    #[test]
22483    fn session_record_context_export_binding_is_exact_without_identity() {
22484        let session = terminal_address(1);
22485        let mut expected = record("pty-sidecar-fixture", "record-context-export-no-identity");
22486        expected.state = ManagedSessionState::Live;
22487        expected.active_session = Some(session.clone());
22488        let binding = SessionBinding {
22489            workspace_id: session.workspace_id.clone(),
22490            generation: session.session.generation,
22491            runtime_policy: ProviderRuntimePolicy::raw_pty(),
22492            pending_resume: None,
22493            record_id: Some(expected.record_id.clone()),
22494            managed_worktree_lease_id: None,
22495            environment_profile: None,
22496            bundle: None,
22497            context: None,
22498            materialization_id: None,
22499        };
22500        let current_root = windows_path_text(&expected.canonical_root).to_owned();
22501        assert!(session_record_context_export_binding_is_exact(
22502            &expected,
22503            &expected,
22504            None,
22505            &session,
22506            &binding,
22507            &expected.provider,
22508            &current_root,
22509            false,
22510        ));
22511        // A record that gained an identity between the initial lookup and
22512        // this recheck no longer matches the `None` this shape expects to
22513        // keep seeing -- treated the same as any other mid-export drift.
22514        let mut drifted = expected.clone();
22515        drifted.provider_session = Some(ProviderSessionIdentity {
22516            key: ProviderSessionKey::SessionId,
22517            id: "unexpected-identity".to_owned(),
22518            transcript_path: None,
22519        });
22520        assert!(!session_record_context_export_binding_is_exact(
22521            &expected,
22522            &drifted,
22523            None,
22524            &session,
22525            &binding,
22526            &expected.provider,
22527            &current_root,
22528            false,
22529        ));
22530    }
22531
22532    #[test]
22533    fn session_record_context_export_preflight_requires_live_usable_source() {
22534        let mut source = record("claude", "record-context-export-preflight");
22535        source.state = ManagedSessionState::Live;
22536        assert!(session_record_context_export_source_is_usable(
22537            &source,
22538            &SessionStatus::Running,
22539            false,
22540        ));
22541        assert!(session_record_context_export_source_is_usable(
22542            &source,
22543            &SessionStatus::Exited { exit_code: Some(0) },
22544            false,
22545        ));
22546
22547        source.state = ManagedSessionState::Dormant;
22548        assert!(!session_record_context_export_source_is_usable(
22549            &source,
22550            &SessionStatus::Running,
22551            false,
22552        ));
22553        // The reactive auto-export-at-exit path (allow_clean_detachment) is
22554        // the only caller that may treat a Dormant, cleanly-exited record as
22555        // still usable — the on-demand path above never does.
22556        assert!(session_record_context_export_source_is_usable(
22557            &source,
22558            &SessionStatus::Exited { exit_code: Some(0) },
22559            true,
22560        ));
22561        assert!(!session_record_context_export_source_is_usable(
22562            &source,
22563            &SessionStatus::Exited { exit_code: Some(0) },
22564            false,
22565        ));
22566        source.state = ManagedSessionState::Live;
22567        assert!(!session_record_context_export_source_is_usable(
22568            &source,
22569            &SessionStatus::Starting,
22570            false,
22571        ));
22572    }
22573
22574    #[test]
22575    fn session_record_context_export_final_conflict_leaves_catalog_empty() {
22576        let catalog = active_registry().unwrap();
22577        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22578        let workspace = WorkspaceConfig::new(
22579            WorkspaceId::new("primary").unwrap(),
22580            std::env::current_dir().unwrap(),
22581        )
22582        .unwrap();
22583        let shared = NodeShared::new(
22584            handle,
22585            "fixture-token".to_owned(),
22586            NodeId::new("node-context-export-conflict").unwrap(),
22587            vec![workspace],
22588            vec![agent("claude")],
22589        );
22590        let history = HistorySessionRecord {
22591            session_id: "provider-exact".to_owned(),
22592            title: None,
22593            cwd: None,
22594            model: None,
22595            message_count: 1,
22596            completed_turn_count: None,
22597            total_tokens: 0,
22598            messages: vec![gate4agent_types::HistoryMessageRecord {
22599                role: gate4agent_types::HistoryMessageRole::User,
22600                text: "bounded context".to_owned(),
22601            }],
22602        };
22603        let pack = NodeContextPack::export(
22604            ContextPackLineageReceipt {
22605                source_node_id: shared.node_id.clone(),
22606                source_session: terminal_address(1),
22607                source_provider: agent("claude"),
22608            },
22609            &history,
22610        )
22611        .unwrap();
22612        let context_id = pack.receipt().id.clone();
22613        let session = terminal_address(1);
22614        let identity = ProviderSessionIdentity {
22615            key: ProviderSessionKey::SessionId,
22616            id: history.session_id.clone(),
22617            transcript_path: None,
22618        };
22619        let mut expected = record("claude", "record-context-export-final-conflict");
22620        expected.state = ManagedSessionState::Live;
22621        expected.provider_session = Some(identity.clone());
22622        expected.active_session = Some(session.clone());
22623        let mut changed = expected.clone();
22624        changed.updated_at_unix_ms += 1;
22625        shared.insert_record(changed).unwrap();
22626
22627        let error = shared
22628            .commit_context_pack_for_session_record(
22629                &expected,
22630                Some(&identity),
22631                &session,
22632                "candidate-exact",
22633                &history,
22634                pack,
22635                false,
22636            )
22637            .unwrap_err();
22638
22639        assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
22640        assert!(shared
22641            .context_catalog
22642            .read()
22643            .unwrap_or_else(|poisoned| poisoned.into_inner())
22644            .get(&context_id)
22645            .is_none());
22646    }
22647
22648    #[tokio::test]
22649    async fn session_task_binding_mints_assigns_clears_with_cas_and_persists_v9() {
22650        let root = temporary_workspace_root("session-task-binding");
22651        std::fs::create_dir_all(&root).unwrap();
22652        let state_path = root.join("node-state.json");
22653        let catalog = active_registry().unwrap();
22654        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22655        let workspace = WorkspaceConfig::new(WorkspaceId::new("primary").unwrap(), &root).unwrap();
22656        let node_id = NodeId::new("node-1").unwrap();
22657        let mut shared = NodeShared::new(
22658            handle,
22659            "fixture-token".to_owned(),
22660            node_id.clone(),
22661            vec![workspace],
22662            vec![agent("claude")],
22663        );
22664        shared.state_path = Some(state_path.clone());
22665        let record = record("claude", "sr-task");
22666        let record_id = record.record_id.clone();
22667        shared.insert_record(record).unwrap();
22668        shared.persist_state().unwrap();
22669        shared
22670            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
22671            .unwrap();
22672
22673        let minted = process_request_inner(
22674            &shared,
22675            77,
22676            ClientRole::Operator,
22677            NodeRequest::SetSessionTask {
22678                record_id: record_id.clone(),
22679                expected_revision: 0,
22680                target: SessionTaskTargetV1::New,
22681            },
22682        )
22683        .await
22684        .unwrap();
22685        let NodeResponse::SessionRecordUpdated { record: minted } = minted else {
22686            panic!("session task mutation returned another response");
22687        };
22688        let minted_binding = minted.task_binding.clone().unwrap();
22689        let minted_task_id = minted_binding.task_id.clone().unwrap();
22690        assert_eq!(minted_binding.revision, 1);
22691        assert!(minted_task_id.as_str().starts_with("task-"));
22692        assert_eq!(minted_task_id.as_str().len(), 29);
22693
22694        let event_count = shared.current_sequence();
22695        let repeated = process_request_inner(
22696            &shared,
22697            77,
22698            ClientRole::Operator,
22699            NodeRequest::SetSessionTask {
22700                record_id: record_id.clone(),
22701                expected_revision: 1,
22702                target: SessionTaskTargetV1::Existing {
22703                    task_id: minted_task_id.clone(),
22704                },
22705            },
22706        )
22707        .await
22708        .unwrap();
22709        assert_eq!(repeated, NodeResponse::SessionRecordUpdated { record: minted.clone() });
22710        assert_eq!(shared.current_sequence(), event_count);
22711
22712        let stale = process_request_inner(
22713            &shared,
22714            77,
22715            ClientRole::Operator,
22716            NodeRequest::SetSessionTask {
22717                record_id: record_id.clone(),
22718                expected_revision: 0,
22719                target: SessionTaskTargetV1::Existing {
22720                    task_id: minted_task_id,
22721                },
22722            },
22723        )
22724        .await
22725        .unwrap_err();
22726        assert_eq!(stale.code, NodeFailureCode::SessionRecordConflict);
22727
22728        let assigned_id: TaskId = "task-ffeeddccbbaa998877665544".parse().unwrap();
22729        let assigned = process_request_inner(
22730            &shared,
22731            77,
22732            ClientRole::Operator,
22733            NodeRequest::SetSessionTask {
22734                record_id: record_id.clone(),
22735                expected_revision: 1,
22736                target: SessionTaskTargetV1::Existing {
22737                    task_id: assigned_id.clone(),
22738                },
22739            },
22740        )
22741        .await
22742        .unwrap();
22743        let NodeResponse::SessionRecordUpdated { record: assigned } = assigned else {
22744            panic!("session task assignment returned another response");
22745        };
22746        assert_eq!(assigned.task_binding.as_ref().unwrap().revision, 2);
22747        assert_eq!(assigned.task_binding.as_ref().unwrap().task_id.as_ref(), Some(&assigned_id));
22748
22749        let cleared = process_request_inner(
22750            &shared,
22751            77,
22752            ClientRole::Operator,
22753            NodeRequest::SetSessionTask {
22754                record_id: record_id.clone(),
22755                expected_revision: 2,
22756                target: SessionTaskTargetV1::Clear,
22757            },
22758        )
22759        .await
22760        .unwrap();
22761        let NodeResponse::SessionRecordUpdated { record: cleared } = cleared else {
22762            panic!("session task clear returned another response");
22763        };
22764        assert_eq!(cleared.task_binding.as_ref().unwrap().revision, 3);
22765        assert!(cleared.task_binding.as_ref().unwrap().task_id.is_none());
22766        assert!(cleared.task_binding.as_ref().unwrap().changed_at_unix_ms > 0);
22767        assert_eq!(
22768            cleared.task_binding.as_ref().unwrap().changed_at_unix_ms,
22769            cleared.updated_at_unix_ms,
22770        );
22771
22772        let persisted = std::fs::read(&state_path).unwrap();
22773        assert_eq!(serde_json::from_slice::<serde_json::Value>(&persisted).unwrap()["version"], NODE_STATE_SCHEMA_V10);
22774        let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
22775        assert_eq!(loaded.records[0].task_binding, cleared.task_binding);
22776        std::fs::remove_dir_all(root).unwrap();
22777    }
22778
22779    #[test]
22780    fn session_task_capability_strips_snapshots_events_and_replies() {
22781        let mut bound = record("claude", "sr-task-projection");
22782        bound.updated_at_unix_ms = 2;
22783        bound.task_binding = Some(SessionTaskBindingV1 {
22784            revision: 1,
22785            task_id: Some("task-00112233445566778899aaff".parse().unwrap()),
22786            changed_at_unix_ms: 2,
22787        });
22788        let request = NodeRequest::SetSessionTask {
22789            record_id: bound.record_id.clone(),
22790            expected_revision: 1,
22791            target: SessionTaskTargetV1::Clear,
22792        };
22793        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22794            capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
22795        }));
22796        assert!(!request_uses_unnegotiated_capability(
22797            &request,
22798            &baseline_capabilities().unwrap(),
22799        ));
22800        assert!(request_uses_unnegotiated_capability(&request, &[]));
22801
22802        let mut snapshot = terminal_test_shared().snapshot();
22803        snapshot.session_records.push(bound.clone());
22804        let mut reply = ResponseEnvelope {
22805            request_id: 1,
22806            result: Ok(NodeResponse::Resync {
22807                event_sequence: 1,
22808                oldest_available_sequence: 1,
22809                snapshot,
22810                events: vec![NodeEventEnvelope {
22811                    sequence: 1,
22812                    event: NodeEvent::SessionRecordUpserted {
22813                        record: bound.clone(),
22814                    },
22815                }],
22816            }),
22817        };
22818        project_response_without_session_task_binding(&mut reply);
22819        let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
22820            panic!("task projection changed the response kind");
22821        };
22822        assert!(snapshot.session_records[0].task_binding.is_none());
22823        let NodeEvent::SessionRecordUpserted { record } = &events[0].event else {
22824            panic!("task projection changed the event kind");
22825        };
22826        assert!(record.task_binding.is_none());
22827
22828        let projected = project_event_without_session_task_binding(NodeEventEnvelope {
22829            sequence: 2,
22830            event: NodeEvent::SessionRecordUpserted { record: bound.clone() },
22831        });
22832        let NodeEvent::SessionRecordUpserted { record } = projected.event else {
22833            panic!("task event projection changed the event kind");
22834        };
22835        assert!(record.task_binding.is_none());
22836
22837        let mut direct = ResponseEnvelope {
22838            request_id: 2,
22839            result: Ok(NodeResponse::SessionRecordUpdated { record: bound }),
22840        };
22841        project_response_without_session_task_binding(&mut direct);
22842        let Ok(NodeResponse::SessionRecordUpdated { record }) = direct.result else {
22843            panic!("task reply projection changed the response kind");
22844        };
22845        assert!(record.task_binding.is_none());
22846    }
22847
22848    #[test]
22849    fn windows_runtime_default_node_endpoint_is_exact_and_valid() {
22850        assert_eq!(DEFAULT_NODE_ENDPOINT, r"\\.\pipe\gate4agent-node");
22851        let workspace = WorkspaceConfig::new(
22852            WorkspaceId::new("primary").unwrap(),
22853            std::env::current_dir().unwrap(),
22854        )
22855        .unwrap();
22856        let config = NodeServerConfig::new(
22857            DEFAULT_NODE_ENDPOINT,
22858            "fixture-token",
22859            NodeId::new("node-1").unwrap(),
22860            [workspace],
22861        )
22862        .unwrap();
22863        assert_eq!(config.endpoint, DEFAULT_NODE_ENDPOINT);
22864    }
22865
22866    #[cfg(all(feature = "fixture", windows))]
22867    #[test]
22868    fn provider_bundle_argv_hold_fixture_is_parent_bounded_and_waits_after_proof() {
22869        let root = temporary_workspace_root("provider-bundle-argv-hold");
22870        let outside = temporary_workspace_root("provider-bundle-argv-hold-outside");
22871        std::fs::create_dir_all(&root).unwrap();
22872        std::fs::create_dir_all(&outside).unwrap();
22873        let proof_path = root.join("provider.proof");
22874        let release_signal = root.join("release.signal");
22875        let config = NodeServerConfig::new(
22876            format!(
22877                r"\\.\pipe\gate4agent-provider-bundle-hold-{}",
22878                std::process::id(),
22879            ),
22880            "fixture-token",
22881            NodeId::new("provider-bundle-hold-node").unwrap(),
22882            [WorkspaceConfig::new(
22883                WorkspaceId::new("primary").unwrap(),
22884                std::env::current_dir().unwrap(),
22885            )
22886            .unwrap()],
22887        )
22888        .unwrap()
22889        .with_spawn_profiles(SpawnProfileRegistry::new([
22890            crate::protocol::SpawnProfileDefaults {
22891                profile_id: crate::protocol::SpawnProfileId::new("kimi-bundle-hold").unwrap(),
22892                revision: crate::protocol::SpawnProfileRevision::new("kimi-bundle-hold-r1")
22893                    .unwrap(),
22894                provider: agent("kimi"),
22895                mode: SessionMode::Pty,
22896                terminal_size: gate4agent_types::TerminalSize {
22897                    rows: 24,
22898                    columns: 80,
22899                },
22900                prompt: None,
22901                bundle_id: None,
22902                context_id: None,
22903                environment_profile_id: None,
22904            },
22905        ])
22906        .unwrap());
22907        let server = NodeServer::new_provider_bundle_argv_hold_fixture(
22908            config,
22909            agent("kimi"),
22910            proof_path.clone(),
22911            release_signal.clone(),
22912        )
22913        .unwrap();
22914        assert_eq!(server.shared.enabled_providers, [agent("kimi")]);
22915
22916        let spec = NodeServer::provider_bundle_argv_fixture_spec(
22917            agent("kimi"),
22918            proof_path.clone(),
22919            Some(release_signal.clone()),
22920        )
22921        .unwrap();
22922        let wrapper = &spec.launch.fixed_args[spec.launch.fixed_args.len() - 3];
22923        let proof_write = wrapper.find("WriteAllLines($proofPath").unwrap();
22924        let release_wait = wrapper
22925            .find("while (-not (Test-Path -LiteralPath $releaseSignal -PathType Leaf))")
22926            .unwrap();
22927        let fixture_ready = wrapper.find("fixture-ready>").unwrap();
22928        assert!(proof_write < release_wait);
22929        assert!(release_wait < fixture_ready);
22930        assert!(wrapper.contains("[DateTime]::UtcNow.AddSeconds(45)"));
22931        assert!(wrapper.contains("Start-Sleep -Milliseconds 20"));
22932        assert_eq!(
22933            &spec.launch.fixed_args[spec.launch.fixed_args.len() - 2..],
22934            &[
22935                proof_path.to_string_lossy().into_owned(),
22936                release_signal.to_string_lossy().into_owned(),
22937            ],
22938        );
22939
22940        let error = NodeServer::provider_bundle_argv_fixture_spec(
22941            agent("kimi"),
22942            proof_path,
22943            Some(outside.join("release.signal")),
22944        )
22945        .unwrap_err();
22946        assert!(matches!(
22947            error,
22948            NodeServerError::Registry(message)
22949                if message == "provider bundle argv release path must share the proof parent"
22950        ));
22951        drop(server);
22952        std::fs::remove_dir_all(root).unwrap();
22953        std::fs::remove_dir_all(outside).unwrap();
22954    }
22955
22956    #[cfg(feature = "fixture")]
22957    #[test]
22958    fn four_provider_context_fixture_registry_is_unambiguous_and_history_bound() {
22959        let root = temporary_workspace_root("context-fixture-registry");
22960        std::fs::create_dir_all(&root).unwrap();
22961        let proof_path = root.join("context.proof");
22962        let config = NodeServerConfig::new(
22963            format!(
22964                r"\\.\pipe\gate4agent-context-fixture-registry-{}",
22965                std::process::id(),
22966            ),
22967            "fixture-token",
22968            NodeId::new("context-fixture-node").unwrap(),
22969            [WorkspaceConfig::new(
22970                WorkspaceId::new("primary").unwrap(),
22971                std::env::current_dir().unwrap(),
22972            )
22973            .unwrap()],
22974        )
22975        .unwrap();
22976        let server = NodeServer::new_context_pack_fixture(config, proof_path).unwrap();
22977        let mut enabled = server
22978            .shared
22979            .enabled_providers
22980            .iter()
22981            .map(AgentId::as_str)
22982            .collect::<Vec<_>>();
22983        enabled.sort_unstable();
22984        assert_eq!(enabled, ["claude", "codex", "grok", "kimi"]);
22985        for provider in &server.shared.enabled_providers {
22986            assert!(server
22987                .shared
22988                .provider_adapter_contracts
22989                .iter()
22990                .any(|contract| {
22991                    &contract.provider == provider
22992                        && contract.family == AdapterFamily::History
22993                }));
22994        }
22995        drop(server);
22996        std::fs::remove_dir_all(root).unwrap();
22997    }
22998
22999    #[cfg(feature = "fixture")]
23000    #[test]
23001    fn monitoring_context_fixture_combines_claude_and_codex_hooks_with_history() {
23002        let root = temporary_workspace_root("monitoring-context-fixture");
23003        std::fs::create_dir_all(&root).unwrap();
23004        let proof_path = root.join("context.proof");
23005        let standard = NodeServer::context_pack_fixture_catalog(
23006            proof_path.clone(),
23007            None,
23008            false,
23009        ).unwrap();
23010        let monitoring = NodeServer::context_pack_fixture_catalog(
23011            proof_path.clone(),
23012            None,
23013            true,
23014        ).unwrap();
23015        let claude = AgentId::new("claude").unwrap();
23016        let standard_claude = standard.get(&claude).unwrap();
23017        let monitoring_claude = monitoring.get(&claude).unwrap();
23018        // Lifecycle hooks are retired (owner ruling 2026-09-25): neither the
23019        // standard nor the monitoring fixture declares a hook adapter
23020        // anymore.
23021        assert!(monitoring_claude.capabilities.adapters.hook.is_none());
23022        assert_eq!(
23023            monitoring_claude.capabilities.adapters.history,
23024            standard_claude.capabilities.adapters.history,
23025        );
23026        assert_ne!(monitoring_claude.launch, standard_claude.launch);
23027        let codex = AgentId::new("codex").unwrap();
23028        let standard_codex = standard.get(&codex).unwrap();
23029        let monitoring_codex = monitoring.get(&codex).unwrap();
23030        assert!(monitoring_codex.capabilities.adapters.hook.is_none());
23031        assert_eq!(
23032            monitoring_codex.capabilities.adapters.history,
23033            standard_codex.capabilities.adapters.history,
23034        );
23035        assert_ne!(monitoring_codex.launch, standard_codex.launch);
23036        let codex_validation = monitoring_codex.launch.fixed_args.iter()
23037            .find(|argument| argument.contains("F7_CODEX_BUNDLE_CONTEXT_VALIDATED"))
23038            .unwrap();
23039        for required in [
23040            "$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')",
23041            "$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')",
23042            "@('role', 'text')",
23043            "g4a-private-provider-session-canary",
23044            "private-provider-login-identity",
23045            "$rawProof",
23046            "$env:GATE4AGENT_HOOK_URL",
23047            "g4a-private-provider-session-canary",
23048        ] {
23049            assert!(codex_validation.contains(required), "missing privacy validation: {required}");
23050        }
23051        for provider in ["grok", "kimi"] {
23052            let provider = AgentId::new(provider).unwrap();
23053            assert_eq!(monitoring.get(&provider), standard.get(&provider));
23054        }
23055
23056        let config = NodeServerConfig::new(
23057            format!(
23058                r"\\.\pipe\gate4agent-monitoring-context-fixture-{}",
23059                std::process::id(),
23060            ),
23061            "fixture-token",
23062            NodeId::new("monitoring-context-fixture-node").unwrap(),
23063            [WorkspaceConfig::new(
23064                WorkspaceId::new("primary").unwrap(),
23065                std::env::current_dir().unwrap(),
23066            )
23067            .unwrap()],
23068        )
23069        .unwrap();
23070        let server = NodeServer::new_monitoring_context_pack_fixture(config, proof_path).unwrap();
23071        assert!(server.shared.fixture_semantic_hook_policy);
23072        drop(server);
23073        std::fs::remove_dir_all(root).unwrap();
23074    }
23075
23076    #[cfg(feature = "fixture")]
23077    #[test]
23078    fn kimi_context_only_fixture_is_bounded_and_preserves_codex_validation() {
23079        let root = temporary_workspace_root("kimi-context-only-fixture");
23080        std::fs::create_dir_all(&root).unwrap();
23081        let proof_path = root.join("context.proof");
23082        let standard = NodeServer::context_pack_fixture_catalog(
23083            proof_path.clone(),
23084            None,
23085            false,
23086        ).unwrap();
23087        let kimi = AgentId::new("kimi").unwrap();
23088        let context_only = NodeServer::context_pack_fixture_catalog(
23089            proof_path,
23090            Some(&kimi),
23091            false,
23092        )
23093        .unwrap();
23094
23095        let codex = AgentId::new("codex").unwrap();
23096        assert_eq!(
23097            standard.get(&codex).unwrap().launch.fixed_args,
23098            context_only.get(&codex).unwrap().launch.fixed_args,
23099        );
23100        let script = context_only
23101            .get(&kimi)
23102            .unwrap()
23103            .launch
23104            .fixed_args
23105            .iter()
23106            .find(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED"))
23107            .expect("Kimi context-only validation script is missing");
23108        for required in [
23109            "$bundleArgs.Count -ne 0",
23110            "GATE4AGENT_CONTEXT_ROOT",
23111            "context-pack.json",
23112            "$entries.Count -ne 1",
23113            "g4a-context-pack-v1",
23114            "$document.PSObject.Properties['cwd']",
23115            "'claude', 'codex', 'grok', 'kimi'",
23116            "$hasUser",
23117            "$hasAssistant",
23118            "SHA256",
23119            "context-only",
23120        ] {
23121            assert!(script.contains(required), "missing validation: {required}");
23122        }
23123        for forbidden in ["CODEX_HOME", "USERPROFILE", "auth", "token", "global config"] {
23124            assert!(!script.contains(forbidden), "forbidden home/auth access: {forbidden}");
23125        }
23126        assert!(!standard
23127            .get(&kimi)
23128            .unwrap()
23129            .launch
23130            .fixed_args
23131            .iter()
23132            .any(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED")));
23133
23134        std::fs::remove_dir_all(root).unwrap();
23135    }
23136
23137    #[cfg(all(feature = "fixture", windows))]
23138    #[tokio::test]
23139    async fn kimi_context_only_fixture_spawns_catalogued_context_pack_through_spawn_spec() {
23140        let root = temporary_workspace_root("kimi-context-only-spawn");
23141        let workspace_root = root.join("workspace");
23142        std::fs::create_dir_all(&workspace_root).unwrap();
23143        let materialization_root = root.join("private-materializations");
23144        let proof_path = root.join("context.proof");
23145        let node_id = NodeId::new("node-kimi-context-only-spawn").unwrap();
23146        let workspace_id = WorkspaceId::new("primary").unwrap();
23147        let profile_id = crate::protocol::SpawnProfileId::new("target-kimi").unwrap();
23148        let endpoint = format!(
23149            r"\\.\pipe\gate4agent-kimi-context-only-spawn-{}-{}",
23150            std::process::id(),
23151            unix_time_ms(),
23152        );
23153        let spawn_profiles = SpawnProfileRegistry::new([
23154            crate::protocol::SpawnProfileDefaults {
23155                profile_id: profile_id.clone(),
23156                revision: crate::protocol::SpawnProfileRevision::new("target-kimi-r1").unwrap(),
23157                provider: agent("kimi"),
23158                mode: SessionMode::Pty,
23159                terminal_size: gate4agent_types::TerminalSize {
23160                    rows: 30,
23161                    columns: 120,
23162                },
23163                prompt: None,
23164                bundle_id: None,
23165                context_id: None,
23166                environment_profile_id: None,
23167            },
23168        ])
23169        .unwrap();
23170        let mut config = NodeServerConfig::new(
23171            &endpoint,
23172            "fixture-token",
23173            node_id.clone(),
23174            [WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap()],
23175        )
23176        .unwrap()
23177        .with_spawn_profiles(spawn_profiles)
23178        .with_session_environment_materialization(
23179            materialization_root,
23180            Arc::new(FixtureSecretResolver {
23181                deny: Arc::new(AtomicBool::new(false)),
23182            }),
23183        )
23184        .unwrap();
23185        config.fixture_raw_pty_runtime = true;
23186        let server = NodeServer::new_context_only_proof_fixture(
23187            config,
23188            agent("kimi"),
23189            proof_path.clone(),
23190        )
23191        .unwrap();
23192        let shared = Arc::clone(&server.shared);
23193        let shutdown = server.shutdown_handle();
23194
23195        let history = HistorySessionRecord {
23196            session_id: "context-only-source-history".to_owned(),
23197            title: None,
23198            cwd: None,
23199            model: None,
23200            message_count: 2,
23201            completed_turn_count: None,
23202            total_tokens: 0,
23203            messages: vec![
23204                gate4agent_types::HistoryMessageRecord {
23205                    role: gate4agent_types::HistoryMessageRole::User,
23206                    text: "continue the bounded parent run through native C2".to_owned(),
23207                },
23208                gate4agent_types::HistoryMessageRecord {
23209                    role: gate4agent_types::HistoryMessageRole::Assistant,
23210                    text: "the exact parent context is ready for the child run".to_owned(),
23211                },
23212            ],
23213        };
23214        let pack = NodeContextPack::export(
23215            ContextPackLineageReceipt {
23216                source_node_id: node_id.clone(),
23217                source_session: SessionAddress {
23218                    workspace_id: workspace_id.clone(),
23219                    session: SessionKey {
23220                        instance_id: AgentInstanceId(71),
23221                        generation: SessionGeneration(1),
23222                    },
23223                },
23224                source_provider: agent("codex"),
23225            },
23226            &history,
23227        )
23228        .unwrap();
23229        let expected_context_hash = digest(&SHA256, pack.bytes())
23230            .as_ref()
23231            .iter()
23232            .map(|byte| format!("{byte:02x}"))
23233            .collect::<String>();
23234        let context = shared
23235            .context_catalog
23236            .write()
23237            .unwrap_or_else(|poisoned| poisoned.into_inner())
23238            .insert(pack)
23239            .unwrap();
23240        let spec = SpawnSpec {
23241            target: crate::protocol::SpawnTarget {
23242                node_id,
23243                workspace_id: workspace_id.clone(),
23244                worktree_id: None,
23245            },
23246            profile_id,
23247            expected_profile_revision: crate::protocol::SpawnProfileRevision::new(
23248                "target-kimi-r1",
23249            ).unwrap(),
23250            overrides: crate::protocol::SpawnOverrides {
23251                context_id: crate::protocol::SpawnOverride::Set {
23252                    value: context.id.clone(),
23253                },
23254                ..crate::protocol::SpawnOverrides::default()
23255            },
23256            deadline_ms: crate::protocol::SpawnDeadlineMs::new(10_000).unwrap(),
23257            idempotency_key: SpawnIdempotencyKey::new("kimi-context-only-spawn").unwrap(),
23258            required_capabilities: SpawnRequiredCapabilities::default(),
23259        };
23260        let resolved = match shared.resolve_spawn_spec(&spec) {
23261            Ok(resolved) => resolved,
23262            Err(error) => panic!(
23263                "Kimi context-only resolve rejected: exact_code={:?} message={}",
23264                error.code,
23265                error.message,
23266            ),
23267        };
23268        let resolved_context = match shared.resolve_context(&resolved) {
23269            Ok(Some(resolved_context)) => resolved_context,
23270            Ok(None) => panic!("Kimi context-only resolve omitted the exact ContextPack"),
23271            Err(error) => panic!(
23272                "Kimi context catalog resolve rejected: exact_code={:?} message={}",
23273                error.code,
23274                error.message,
23275            ),
23276        };
23277        assert_eq!(resolved_context, context);
23278        let runtime_policy = match shared
23279            .admit_provider_runtime(&agent("kimi"), ProviderRuntimeRequirement::RawPty)
23280            .await
23281        {
23282            Ok(runtime_policy) => runtime_policy,
23283            Err(error) => panic!(
23284                "Kimi RawPty admission rejected: exact_code={:?} message={}",
23285                error.code,
23286                error.message,
23287            ),
23288        };
23289        assert!(runtime_policy.raw_pty_lifecycle);
23290
23291        let launch_control = shared.native_launch_profile_control.as_ref().unwrap();
23292        let generic_overlay = NativeLaunchEnvironmentOverlay::new(
23293            agent("kimi"),
23294            TransportKind::Pty,
23295            vec![EnvMutation {
23296                key: OsString::from("GATE4AGENT_UNPROFILED_GENERIC"),
23297                value: Some(OsString::from("must-remain-rejected")),
23298            }],
23299        )
23300        .unwrap();
23301        assert_eq!(
23302            launch_control
23303                .install_native_launch_environment_overlay(
23304                    AgentInstanceId(72),
23305                    generic_overlay,
23306                )
23307                .unwrap_err(),
23308            gate4agent_runtime_native::NativeLaunchProfileError::EnvironmentOverlaySelectionMissing,
23309        );
23310
23311        let diagnostic_address = SessionAddress {
23312            workspace_id: workspace_id.clone(),
23313            session: SessionKey {
23314                instance_id: AgentInstanceId(73),
23315                generation: SessionGeneration(1),
23316            },
23317        };
23318        let (diagnostic_overlay, diagnostic_materialization) = match shared
23319            .prepare_session_materialization(
23320                &diagnostic_address,
23321                &agent("kimi"),
23322                SessionMode::Pty,
23323                None,
23324                None,
23325                Some(&resolved_context),
23326                None,
23327            )
23328        {
23329            Ok(Some(prepared)) => prepared,
23330            Ok(None) => panic!("Kimi context-only materialization produced no launch overlay"),
23331            Err(error) => panic!(
23332                "Kimi context-only materialization rejected: exact_code={:?} message={}",
23333                error.code,
23334                error.message,
23335            ),
23336        };
23337        let diagnostic_overlay = diagnostic_overlay
23338            .expect("Kimi context-only materialization omitted its environment overlay");
23339        assert!(matches!(
23340            &diagnostic_overlay,
23341            PreparedNativeLaunchOverlay::Environment(_),
23342        ));
23343        match shared.install_prepared_launch_overlay(
23344            diagnostic_address.session.instance_id,
23345            diagnostic_overlay,
23346        ) {
23347            Ok(None) => {}
23348            Ok(Some(_)) => panic!("Kimi context-only overlay unexpectedly retained argv authority"),
23349            Err(error) => panic!(
23350                "Kimi context-only overlay installation rejected: exact_code={:?} message={}",
23351                error.code,
23352                error.message,
23353            ),
23354        }
23355        assert!(launch_control.clear_native_instance_launch_overlay(
23356            diagnostic_address.session.instance_id,
23357        ));
23358        drop(diagnostic_materialization);
23359        assert!(shared
23360            .materializations
23361            .lock()
23362            .unwrap_or_else(|poisoned| poisoned.into_inner())
23363            .is_empty());
23364
23365        let node_task = tokio::spawn(server.run());
23366        let ready_client = timeout(Duration::from_secs(5), async {
23367            loop {
23368                match gate4agent_node_wire::LocalNodeClient::connect(
23369                    &endpoint,
23370                    &shared.node_id,
23371                    ClientRole::Observer,
23372                    "fixture-token",
23373                )
23374                .await
23375                {
23376                    Ok(client) => break client,
23377                    Err(_) if !node_task.is_finished() => sleep(Duration::from_millis(10)).await,
23378                    Err(error) => panic!("Kimi context-only Node stopped before readiness: {error}"),
23379                }
23380            }
23381        })
23382        .await
23383        .expect("Kimi context-only Node did not become ready");
23384        assert_eq!(
23385            ready_client
23386                .hello()
23387                .snapshot
23388                .provider_runtime_statuses
23389                .iter()
23390                .find(|status| status.provider().as_str() == "kimi")
23391                .expect("Kimi runtime status is missing")
23392                .mode(),
23393            crate::protocol::ProviderRuntimeMode::RawPassthrough,
23394        );
23395        drop(ready_client);
23396
23397        let receipt = match shared.spawn_from_spec(spec).await {
23398            Ok(receipt) => receipt,
23399            Err(error) => panic!(
23400                "Kimi context-only SpawnSpec rejected: exact_code={:?} message={}",
23401                error.code,
23402                error.message,
23403            ),
23404        };
23405        assert_eq!(receipt.provider, agent("kimi"));
23406        assert_eq!(receipt.context.as_ref(), Some(&context));
23407        assert!(receipt.context_binding_is_valid());
23408        timeout(Duration::from_secs(5), async {
23409            while !proof_path.is_file() {
23410                sleep(Duration::from_millis(10)).await;
23411            }
23412        })
23413        .await
23414        .expect("Kimi context-only fixture did not write its proof");
23415
23416        let proof = std::fs::read_to_string(&proof_path).unwrap();
23417        let lines = proof.lines().collect::<Vec<_>>();
23418        assert_eq!(lines.len(), 7);
23419        assert_eq!(lines[0], "context-only");
23420        assert!(Path::new(lines[1]).is_absolute());
23421        assert_ne!(
23422            std::fs::canonicalize(Path::new(lines[1])).unwrap(),
23423            std::fs::canonicalize(&workspace_root).unwrap(),
23424        );
23425        assert_eq!(
23426            std::fs::canonicalize(Path::new(lines[2])).unwrap(),
23427            std::fs::canonicalize(&workspace_root).unwrap(),
23428        );
23429        assert_eq!(lines[3], expected_context_hash);
23430        assert_eq!(lines[4], "g4a-context-pack-v1");
23431        assert_eq!(lines[5], "codex");
23432        assert_eq!(lines[6], "2");
23433        assert!(shared.handle.snapshot().sessions.iter().any(|session| {
23434            session.instance_id == receipt.session.session.instance_id
23435                && session.agent_id.as_str() == "kimi"
23436        }));
23437
23438        shutdown.request_shutdown().await.unwrap();
23439        timeout(Duration::from_secs(5), node_task)
23440            .await
23441            .unwrap()
23442            .unwrap()
23443            .unwrap();
23444        drop(shutdown);
23445        drop(shared);
23446        std::fs::remove_dir_all(root).unwrap();
23447    }
23448
23449    #[cfg(feature = "fixture")]
23450    #[test]
23451    fn clean_exit_fixture_constructor_is_fixture_scoped_and_path_bounded() {
23452        let root = temporary_workspace_root("clean-exit-constructor");
23453        std::fs::create_dir_all(&root).unwrap();
23454        let config = construction_test_config("clean-exit-constructor")
23455            .with_state_path(root.join("node-state.json"))
23456            .unwrap();
23457        let server = NodeServer::new_clean_exit_fixture(
23458            config,
23459            root.clone(),
23460            root.join("started.marker"),
23461            root.join("release.signal"),
23462        )
23463        .unwrap();
23464        assert_eq!(
23465            server
23466                .shared
23467                .enabled_providers
23468                .iter()
23469                .map(AgentId::as_str)
23470                .collect::<Vec<_>>(),
23471            ["claude"],
23472        );
23473        drop(server);
23474        std::fs::remove_dir_all(root).unwrap();
23475    }
23476
23477    #[test]
23478    fn production_registry_provider_contract_manifest_is_exact() {
23479        let registry = active_registry().unwrap();
23480        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
23481        assert_eq!(
23482            providers
23483                .iter()
23484                .map(|contract| (contract.provider.as_str(), contract.revision.as_str()))
23485                .collect::<Vec<_>>(),
23486            vec![
23487                ("claude", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
23488                ("codex", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
23489                ("grok", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
23490                ("kimi", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
23491            ]
23492        );
23493        assert_eq!(
23494            adapters
23495                .iter()
23496                .map(|contract| (
23497                    contract.provider.as_str(),
23498                    contract.family,
23499                    contract.adapter_id.as_str(),
23500                    contract.revision.as_str(),
23501                ))
23502                .collect::<Vec<_>>(),
23503            vec![
23504                ("claude", AdapterFamily::PtySemantic, "claude-code", "gate4agent-adapter/v1"),
23505                ("claude", AdapterFamily::Pipe, "claude-code", "gate4agent-adapter/v1"),
23506                ("claude", AdapterFamily::Acp, "claude-code", "gate4agent-adapter/v1"),
23507                ("claude", AdapterFamily::OneShot, "claude", "gate4agent-inline/claude-code-2.1/v1"),
23508                ("claude", AdapterFamily::History, "claude-code", "gate4agent-adapter/v1"),
23509                ("claude", AdapterFamily::Resume, "claude-code", "gate4agent-adapter/v1"),
23510                ("claude", AdapterFamily::SessionOptions, "claude-code", "gate4agent-session-options/orca-d8629c4/v1"),
23511                ("codex", AdapterFamily::PtySemantic, "codex", "gate4agent-adapter/v1"),
23512                ("codex", AdapterFamily::Pipe, "codex", "gate4agent-adapter/v1"),
23513                ("codex", AdapterFamily::Acp, "codex", "gate4agent-adapter/v1"),
23514                ("codex", AdapterFamily::OneShot, "codex", "gate4agent-inline/codex-cli-0.144/v1"),
23515                ("codex", AdapterFamily::History, "codex", "gate4agent-adapter/v1"),
23516                ("codex", AdapterFamily::Resume, "codex", "gate4agent-adapter/v1"),
23517                ("codex", AdapterFamily::SessionOptions, "codex", "gate4agent-session-options/orca-d8629c4/v1"),
23518                ("grok", AdapterFamily::Acp, "grok", "gate4agent-adapter/v1"),
23519                ("grok", AdapterFamily::History, "grok", "gate4agent-adapter/v1"),
23520                ("grok", AdapterFamily::Resume, "grok", "gate4agent-adapter/v1"),
23521                ("kimi", AdapterFamily::PtySemantic, "kimi", "gate4agent-adapter/v1"),
23522                ("kimi", AdapterFamily::Pipe, "kimi", "gate4agent-adapter/v1"),
23523                ("kimi", AdapterFamily::Acp, "kimi", "gate4agent-adapter/v1"),
23524                ("kimi", AdapterFamily::OneShot, "kimi", "gate4agent-inline/kimi-code-0.31/v1"),
23525                ("kimi", AdapterFamily::History, "kimi", "gate4agent-adapter/v1"),
23526                ("kimi", AdapterFamily::Resume, "kimi", "gate4agent-adapter/v1"),
23527            ]
23528        );
23529    }
23530
23531    fn construction_test_config(name: &str) -> NodeServerConfig {
23532        let workspace = WorkspaceConfig::new(
23533            WorkspaceId::new("primary").unwrap(),
23534            std::env::current_dir().unwrap(),
23535        )
23536        .unwrap();
23537        NodeServerConfig::new(
23538            format!(r"\\.\pipe\gate4agent-{name}"),
23539            "fixture-token",
23540            NodeId::new(name).unwrap(),
23541            [workspace],
23542        )
23543        .unwrap()
23544    }
23545
23546    fn maximum_size_provider_manifest_registry() -> AgentRegistry {
23547        let adapter = AdapterBinding::new(
23548            gate4agent_types::AdapterId::new("a".repeat(96)).unwrap(),
23549            "r".repeat(crate::protocol::MAX_ADAPTER_CONTRACT_REVISION_BYTES),
23550            gate4agent_types::AdapterVerification::SyntheticFixture,
23551        )
23552        .unwrap();
23553        let agent_ids = ["claude", "codex", "kimi"]
23554            .into_iter()
23555            .map(|agent_id| AgentId::new(agent_id).unwrap())
23556            .collect::<Vec<_>>();
23557        let adapters = gate4agent_catalog::AdapterRegistry::new(
23558            [
23559                AdapterFamily::PtySemantic,
23560                AdapterFamily::Pipe,
23561                AdapterFamily::OneShot,
23562                AdapterFamily::Acp,
23563                AdapterFamily::Hook,
23564                AdapterFamily::ManagedHook,
23565                AdapterFamily::History,
23566                AdapterFamily::Resume,
23567                AdapterFamily::SessionOptions,
23568                AdapterFamily::CapabilityProbe,
23569            ]
23570            .into_iter()
23571            .map(|family| gate4agent_catalog::AdapterDescriptor {
23572                family,
23573                binding: adapter.clone(),
23574                agents: agent_ids.clone(),
23575            }),
23576        )
23577        .unwrap();
23578        let specs = agent_ids
23579            .iter()
23580            .map(|agent_id| {
23581                let agent_id = agent_id.as_str();
23582                let mut spec = builtin_registry().get_by_id(agent_id).unwrap().clone();
23583                spec.revision =
23584                    "r".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES);
23585                spec.capabilities.transports.pty_adapter = Some(adapter.clone());
23586                spec.capabilities.transports.pipe = Some(gate4agent_types::PipeTransportSpec {
23587                    adapter: adapter.clone(),
23588                    protocol: gate4agent_types::PipeProtocol::SemanticNdjson,
23589                    launch_override: None,
23590                    prompt_delivery: gate4agent_types::PipePromptDelivery::None,
23591                });
23592                spec.capabilities.transports.acp = Some(gate4agent_types::AcpTransportSpec {
23593                    adapter: adapter.clone(),
23594                    launch_override: None,
23595                });
23596                spec.capabilities.adapters.hook = Some(adapter.clone());
23597                spec.capabilities.adapters.managed_hook = Some(adapter.clone());
23598                spec.capabilities.adapters.one_shot = Some(adapter.clone());
23599                spec.capabilities.adapters.history = Some(adapter.clone());
23600                spec.capabilities.adapters.resume = Some(adapter.clone());
23601                spec.capabilities.adapters.session_options = Some(adapter.clone());
23602                spec.capabilities.adapters.capability_probe = Some(adapter.clone());
23603                spec
23604            })
23605            .collect::<Vec<_>>();
23606        AgentRegistry::new_with_adapters(specs, &adapters).unwrap()
23607    }
23608
23609    /// The real fleet's manifest is accepted by a real server construction.
23610    ///
23611    /// The count is asserted against `active_registry` itself rather than a
23612    /// literal, so this stays a test that construction succeeds for whatever
23613    /// the fleet is -- which is the property worth guarding -- instead of a
23614    /// second place the fleet size is written down and has to be kept in
23615    /// step with the first.
23616    #[test]
23617    fn node_server_construction_accepts_the_production_manifest() {
23618        let registry = active_registry().unwrap();
23619        let (providers, _) = provider_contract_manifest(&registry).unwrap();
23620        assert_eq!(providers.len(), registry.iter().count());
23621        assert!(!providers.is_empty());
23622        let server = NodeServer::new_with_registry(
23623            construction_test_config("manifest-production-bound"),
23624            registry,
23625        )
23626        .unwrap();
23627        drop(server);
23628    }
23629
23630    #[test]
23631    fn node_server_construction_rejects_count_valid_manifest_over_handshake_capacity() {
23632        let registry = maximum_size_provider_manifest_registry();
23633        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
23634        assert_eq!(providers.len(), 3);
23635        assert_eq!(adapters.len(), 30);
23636        assert!(adapters.len() <= crate::protocol::MAX_PROVIDER_ADAPTER_CONTRACTS);
23637        let error = match NodeServer::new_with_registry(
23638            construction_test_config("manifest-handshake-overflow"),
23639            registry,
23640        ) {
23641            Ok(_) => panic!("oversized negotiated manifest was accepted"),
23642            Err(error) => error,
23643        };
23644        assert!(matches!(
23645            error,
23646            NodeServerError::ProviderContractManifest(message)
23647                if message.contains("authentication binding length")
23648                    && message.contains("8192-byte limit")
23649        ));
23650    }
23651
23652    #[test]
23653    fn provider_contract_manifest_rejects_wire_revision_overflow_before_startup() {
23654        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
23655        spec.revision = "x".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES + 1);
23656        let registry = AgentRegistry::new([spec]).unwrap();
23657        assert!(matches!(
23658            provider_contract_manifest(&registry),
23659            Err(NodeServerError::ProviderContractManifest(message))
23660                if message.contains("exceeds the 128-byte limit")
23661        ));
23662    }
23663
23664    #[test]
23665    fn provider_contract_manifest_rejects_invalid_wire_revision_before_startup() {
23666        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
23667        spec.revision = "invalid revision".to_owned();
23668        let registry = AgentRegistry::new([spec]).unwrap();
23669        assert!(matches!(
23670            provider_contract_manifest(&registry),
23671            Err(NodeServerError::ProviderContractManifest(message))
23672                if message.contains("bounded lowercase ASCII")
23673        ));
23674    }
23675
23676    #[test]
23677    fn provider_contract_manifest_rejects_conflicting_duplicate_family_before_startup() {
23678        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
23679        let transport_binding = spec.capabilities.adapters.one_shot.clone().unwrap();
23680        let conflicting_binding = builtin_registry()
23681            .get_by_id("codex")
23682            .unwrap()
23683            .capabilities
23684            .adapters
23685            .one_shot
23686            .clone()
23687            .unwrap();
23688        let pipe = spec.capabilities.transports.pipe.as_mut().unwrap();
23689        pipe.protocol = gate4agent_types::PipeProtocol::OneShotText;
23690        pipe.adapter = transport_binding;
23691        spec.capabilities.adapters.one_shot = Some(conflicting_binding);
23692        let registry = AgentRegistry::new([spec]).unwrap();
23693        assert!(matches!(
23694            provider_contract_manifest(&registry),
23695            Err(NodeServerError::ProviderContractManifest(message))
23696                if message.contains("conflicting OneShot adapter bindings")
23697        ));
23698    }
23699
23700    #[test]
23701    fn public_node_failures_expose_only_fixed_categories() {
23702        let provider = failure(
23703            NodeFailureCode::BackendOperationFailed,
23704            r"provider bearer-secret failed at C:\private\session.jsonl",
23705        );
23706        assert_eq!(provider.message, "backend-operation-failed");
23707        assert!(!provider.message.contains("bearer-secret"));
23708        assert!(!provider.message.contains(r"C:\private"));
23709        let persistence = persistence_failure(io::Error::new(
23710            io::ErrorKind::Other,
23711            r"state write failed at C:\private\state-v1.json",
23712        ));
23713        assert_eq!(persistence.message, DURABLE_STATE_COMMIT_FAILED_ERROR);
23714        assert!(!persistence.message.contains(r"C:\private"));
23715
23716        for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::Unsupported] {
23717            let generic = durable_state_load_error(io::Error::new(kind, "host I/O failure"));
23718            assert_eq!(
23719                generic.to_string(),
23720                "node durable state failed: durable-state-load-failed",
23721            );
23722        }
23723    }
23724
23725    #[test]
23726    fn terminal_byte_sequences_are_bounded_before_dispatch() {
23727        let sequence = b"\x1b[1;5D".to_vec();
23728        assert_eq!(
23729            terminal_bytes_action(sequence.clone()).unwrap(),
23730            InputAction::TerminalBytes(sequence),
23731        );
23732        assert_eq!(
23733            terminal_bytes_action(Vec::new()).unwrap_err().code,
23734            NodeFailureCode::InvalidRequest,
23735        );
23736        assert_eq!(
23737            terminal_bytes_action(vec![0; MAX_NODE_TERMINAL_BYTES + 1])
23738                .unwrap_err()
23739                .code,
23740            NodeFailureCode::InvalidRequest,
23741        );
23742    }
23743
23744    #[test]
23745    fn node_server_rejects_a_second_owner_of_the_same_durable_state_path() {
23746        let root = temporary_workspace_root("exclusive-state-owner");
23747        std::fs::create_dir_all(&root).unwrap();
23748        let workspace = WorkspaceConfig::new(
23749            WorkspaceId::new("primary").unwrap(),
23750            &root,
23751        )
23752        .unwrap();
23753        let state_path = root.join("state-v1.json");
23754        let endpoint = format!(
23755            r"\\.\pipe\gate4agent-node-state-lock-{}-{}",
23756            std::process::id(),
23757            std::time::SystemTime::now()
23758                .duration_since(std::time::UNIX_EPOCH)
23759                .unwrap()
23760                .as_nanos(),
23761        );
23762        let config = NodeServerConfig::new(
23763            endpoint,
23764            "fixture-token",
23765            NodeId::new("node-1").unwrap(),
23766            [workspace],
23767        )
23768        .unwrap()
23769        .with_state_path(&state_path)
23770        .unwrap();
23771
23772        let first = NodeServer::new(config.clone()).unwrap();
23773        let error = match NodeServer::new(config.clone()) {
23774            Ok(_) => panic!("a second node server acquired the same durable state path"),
23775            Err(error) => error,
23776        };
23777        assert!(matches!(
23778            &error,
23779            NodeServerError::DurableState(error)
23780                if error.kind() == io::ErrorKind::WouldBlock
23781        ));
23782        assert_eq!(
23783            error.to_string(),
23784            "node durable state failed: durable-state-lock-failed",
23785        );
23786        assert!(!error.to_string().contains(&state_path.to_string_lossy().into_owned()));
23787        drop(first);
23788        let second = NodeServer::new(config).unwrap();
23789        drop(second);
23790        std::fs::remove_dir_all(root).unwrap();
23791    }
23792
23793    #[test]
23794    fn session_binding_is_workspace_scoped_and_removed_fail_closed() {
23795        let catalog = active_registry().unwrap();
23796        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23797        let workspace_id = WorkspaceId::new("primary").unwrap();
23798        let workspace = WorkspaceConfig::new(
23799            workspace_id.clone(),
23800            std::env::current_dir().unwrap(),
23801        )
23802        .unwrap();
23803        let shared = NodeShared::new(
23804            handle,
23805            "fixture-token".to_owned(),
23806            NodeId::new("node-1").unwrap(),
23807            vec![workspace.clone()],
23808            vec![agent("claude"), agent("codex"), agent("kimi")],
23809        );
23810        let instance_id = AgentInstanceId(17);
23811        let generation = SessionGeneration(3);
23812        assert!(shared.address_for(instance_id, generation).is_none());
23813        shared.bind_session(instance_id, workspace_id.clone(), generation);
23814        let address = SessionAddress {
23815            workspace_id,
23816            session: SessionKey { instance_id, generation },
23817        };
23818        assert_eq!(
23819            shared.workspace_root(&address.workspace_id).unwrap(),
23820            workspace.canonical_root,
23821        );
23822        assert_eq!(shared.address_for(instance_id, generation), Some(address.clone()));
23823        shared.remove_binding(&address);
23824        assert!(shared.address_for(instance_id, generation).is_none());
23825    }
23826
23827    #[test]
23828    fn open_provider_request_gate_is_state_aware_and_fail_closed() {
23829        let address = SessionAddress {
23830            workspace_id: WorkspaceId::new("primary").unwrap(),
23831            session: SessionKey {
23832                instance_id: AgentInstanceId(7),
23833                generation: SessionGeneration(2),
23834            },
23835        };
23836        let record_id = SessionRecordId::new("record-a").unwrap();
23837        let size = gate4agent_types::TerminalSize {
23838            rows: 24,
23839            columns: 80,
23840        };
23841        let address_requests = [
23842            NodeRequest::Resume {
23843                session: address.clone(),
23844                terminal_size: size,
23845                initial_prompt: None,
23846            },
23847            NodeRequest::Prompt { session: address.clone(), text: "p".to_owned() },
23848            NodeRequest::Paste { session: address.clone(), text: "p".to_owned() },
23849            NodeRequest::Input { session: address.clone(), text: "p".to_owned() },
23850            NodeRequest::TerminalBytes { session: address.clone(), bytes: vec![1] },
23851            NodeRequest::TerminalControl {
23852                session: address.clone(),
23853                control: TerminalControl::Enter,
23854            },
23855            NodeRequest::Resize { session: address.clone(), size },
23856            NodeRequest::Interrupt { session: address.clone() },
23857            NodeRequest::Stop { session: address.clone(), force: false },
23858            NodeRequest::Remove { session: address },
23859        ];
23860        for request in &address_requests {
23861            assert!(request_requires_open_provider_ids_with(
23862                request,
23863                |_| Some(agent("third-party-agent")),
23864                |_| Some(agent("claude")),
23865            ));
23866            assert!(!request_requires_open_provider_ids_with(
23867                request,
23868                |_| Some(agent("claude")),
23869                |_| Some(agent("claude")),
23870            ));
23871            assert!(request_requires_open_provider_ids_with(
23872                request,
23873                |_| None,
23874                |_| Some(agent("claude")),
23875            ));
23876        }
23877
23878        let record_requests = [
23879            NodeRequest::RenameSessionRecord {
23880                record_id: record_id.clone(),
23881                display_name: "renamed".to_owned(),
23882            },
23883            NodeRequest::ResumeSessionRecord {
23884                record_id: record_id.clone(),
23885                terminal_size: size,
23886                initial_prompt: None,
23887            },
23888            NodeRequest::ForgetSessionRecord { record_id },
23889        ];
23890        for request in &record_requests {
23891            assert!(request_requires_open_provider_ids_with(
23892                request,
23893                |_| Some(agent("claude")),
23894                |_| Some(agent("grok")),
23895            ));
23896            assert!(!request_requires_open_provider_ids_with(
23897                request,
23898                |_| Some(agent("claude")),
23899                |_| Some(agent("kimi")),
23900            ));
23901            assert!(request_requires_open_provider_ids_with(
23902                request,
23903                |_| Some(agent("claude")),
23904                |_| None,
23905            ));
23906        }
23907
23908        assert!(request_requires_open_provider_ids_with(
23909            &NodeRequest::Spawn {
23910                workspace_id: WorkspaceId::new("primary").unwrap(),
23911                provider: agent("third-party-agent"),
23912                mode: SessionMode::Pty,
23913                terminal_size: size,
23914                initial_prompt: None,
23915            },
23916            |_| None,
23917            |_| None,
23918        ));
23919        assert!(!request_requires_open_provider_ids_with(
23920            &NodeRequest::Snapshot,
23921            |_| None,
23922            |_| None,
23923        ));
23924        assert!(request_requires_open_provider_ids_with(
23925            &NodeRequest::ForgetContextPack {
23926                context_id: SpawnContextId::new("ctx-forget").unwrap(),
23927            },
23928            |_| None,
23929            |_| None,
23930        ));
23931    }
23932
23933    #[test]
23934    fn legacy_projection_filters_open_record_replies_and_event_history() {
23935        let catalog = active_registry().unwrap();
23936        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23937        let workspace = WorkspaceConfig::new(
23938            WorkspaceId::new("primary").unwrap(),
23939            std::env::current_dir().unwrap(),
23940        )
23941        .unwrap();
23942        let shared = NodeShared::new(
23943            handle,
23944            "fixture-token".to_owned(),
23945            NodeId::new("node-1").unwrap(),
23946            vec![workspace],
23947            vec![agent("claude"), agent("grok")],
23948        );
23949        let legacy = record("claude", "legacy-record");
23950        let open = record("grok", "open-record");
23951        let legacy_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
23952            record: legacy.clone(),
23953        });
23954        let open_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
23955            record: open.clone(),
23956        });
23957        let legacy_removed = shared.publish(NodeEvent::SessionRecordRemoved {
23958            record_id: legacy.record_id.clone(),
23959        });
23960        let open_removed = shared.publish(NodeEvent::SessionRecordRemoved {
23961            record_id: open.record_id.clone(),
23962        });
23963        assert!(project_event_legacy_provider_ids(&shared, legacy_upsert.clone()).is_some());
23964        assert!(project_event_legacy_provider_ids(&shared, open_upsert.clone()).is_none());
23965        assert!(project_event_legacy_provider_ids(&shared, legacy_removed.clone()).is_some());
23966        assert!(project_event_legacy_provider_ids(&shared, open_removed.clone()).is_none());
23967
23968        let mut record_reply = ResponseEnvelope {
23969            request_id: 1,
23970            result: Ok(NodeResponse::SessionRecordUpdated { record: open }),
23971        };
23972        project_response_legacy_provider_ids(&shared, &mut record_reply);
23973        assert!(matches!(
23974            record_reply.result,
23975            Err(NodeFailure { code: NodeFailureCode::UnsupportedCapability, .. })
23976        ));
23977
23978        let mut resync = ResponseEnvelope {
23979            request_id: 2,
23980            result: Ok(NodeResponse::Resync {
23981                event_sequence: open_removed.sequence,
23982                oldest_available_sequence: 1,
23983                snapshot: shared.snapshot(),
23984                events: vec![legacy_upsert, open_upsert, legacy_removed, open_removed],
23985            }),
23986        };
23987        project_response_legacy_provider_ids(&shared, &mut resync);
23988        let Ok(NodeResponse::Resync { snapshot, events, .. }) = resync.result else {
23989            panic!("legacy projection changed the resync response kind");
23990        };
23991        assert!(snapshot.session_records.iter().all(|item| {
23992            provider_id_is_legacy(&item.provider)
23993        }));
23994        assert_eq!(events.len(), 2);
23995        assert!(events.iter().all(|event| matches!(
23996            &event.event,
23997            NodeEvent::SessionRecordUpserted { record }
23998                if provider_id_is_legacy(&record.provider)
23999        ) || matches!(&event.event, NodeEvent::SessionRecordRemoved { .. })));
24000    }
24001
24002    #[test]
24003    fn session_binding_capacity_is_bounded_by_the_control_session_limit() {
24004        let catalog = active_registry().unwrap();
24005        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24006        let workspace_id = WorkspaceId::new("primary").unwrap();
24007        let workspace = WorkspaceConfig::new(
24008            workspace_id.clone(),
24009            std::env::current_dir().unwrap(),
24010        )
24011        .unwrap();
24012        let shared = NodeShared::new(
24013            handle,
24014            "fixture-token".to_owned(),
24015            NodeId::new("node-1").unwrap(),
24016            vec![workspace],
24017            vec![agent("claude")],
24018        );
24019        for index in 0..CONTROL_SESSIONS_MAX {
24020            shared.bind_session(
24021                AgentInstanceId((index + 1) as u64),
24022                workspace_id.clone(),
24023                SessionGeneration(1),
24024            );
24025        }
24026        let error = shared.ensure_binding_capacity().unwrap_err();
24027        assert_eq!(error.code, NodeFailureCode::BackendBusy);
24028    }
24029
24030    #[tokio::test]
24031    async fn provider_spawn_admission_rejects_before_session_mutation() {
24032        let catalog = active_registry().unwrap();
24033        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24034        let workspace = WorkspaceConfig::new(
24035            WorkspaceId::new("primary").unwrap(),
24036            std::env::current_dir().unwrap(),
24037        )
24038        .unwrap();
24039        let mut shared = NodeShared::new(
24040            handle,
24041            "fixture-token".to_owned(),
24042            NodeId::new("node-provider-admission").unwrap(),
24043            vec![workspace],
24044            vec![agent("claude")],
24045        );
24046        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
24047            crate::protocol::ProviderRuntimeStatus::unavailable(agent("claude")),
24048        ])
24049        .unwrap();
24050        let terminal_size = gate4agent_types::TerminalSize {
24051            rows: 24,
24052            columns: 80,
24053        };
24054
24055        let unavailable = shared
24056            .spawn_session(
24057                WorkspaceId::new("primary").unwrap(),
24058                agent("claude"),
24059                SessionMode::Pty,
24060                terminal_size,
24061                None,
24062            )
24063            .await
24064            .unwrap_err();
24065        assert_eq!(unavailable.code, NodeFailureCode::BackendOperationFailed);
24066        assert_eq!(unavailable.message, "backend-operation-failed");
24067
24068        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
24069            crate::protocol::ProviderRuntimeStatus::raw_passthrough(
24070                agent("claude"),
24071                Some(crate::protocol::ProviderRuntimeVersion::new("999.0.0").unwrap()),
24072            ),
24073        ])
24074        .unwrap();
24075        let semantic = shared
24076            .spawn_session(
24077                WorkspaceId::new("primary").unwrap(),
24078                agent("claude"),
24079                SessionMode::Inline,
24080                terminal_size,
24081                None,
24082            )
24083            .await
24084            .unwrap_err();
24085        assert_eq!(semantic.code, NodeFailureCode::UnsupportedCapability);
24086        assert_eq!(semantic.message, "unsupported-capability");
24087
24088        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
24089        assert!(shared
24090            .session_bindings
24091            .lock()
24092            .unwrap_or_else(|poisoned| poisoned.into_inner())
24093            .is_empty());
24094        assert!(shared
24095            .session_records
24096            .lock()
24097            .unwrap_or_else(|poisoned| poisoned.into_inner())
24098            .records
24099            .is_empty());
24100        assert!(shared.handle.snapshot().sessions.is_empty());
24101    }
24102
24103    #[tokio::test]
24104    async fn raw_spawn_binding_is_visible_with_a_durable_live_record() {
24105        let catalog = active_registry().unwrap();
24106        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24107        let workspace_id = WorkspaceId::new("primary").unwrap();
24108        let workspace = WorkspaceConfig::new(
24109            workspace_id.clone(),
24110            std::env::current_dir().unwrap(),
24111        )
24112        .unwrap();
24113        let shared = NodeShared::new(
24114            handle,
24115            "fixture-token".to_owned(),
24116            NodeId::new("node-raw-spawn-record").unwrap(),
24117            vec![workspace],
24118            vec![agent("claude")],
24119        );
24120        let address = SessionAddress {
24121            workspace_id,
24122            session: SessionKey {
24123                instance_id: AgentInstanceId(71),
24124                generation: SessionGeneration::default(),
24125            },
24126        };
24127
24128        let record_id = shared
24129            .bind_spawn_session(
24130                &address,
24131                agent("claude"),
24132                SessionMode::Pty,
24133                ProviderRuntimePolicy::raw_pty(),
24134                SpawnRecordPolicy::Always,
24135                None,
24136            )
24137            .unwrap()
24138            .unwrap();
24139        shared
24140            .handle
24141            .dispatch(shared.prepare_command(ControlCommand::Register {
24142                instance_id: address.session.instance_id,
24143                agent_id: agent("claude"),
24144                transport: TransportKind::Pty,
24145            }))
24146            .unwrap();
24147        runtime.tick().await;
24148
24149        assert!(shared.handle.snapshot().sessions.iter().any(|session| {
24150            session.instance_id == address.session.instance_id
24151                && session.generation == address.session.generation
24152        }));
24153        let record = shared.record(&record_id).unwrap();
24154        assert_eq!(record.display_name, "claude #1");
24155        assert_eq!(record.state, ManagedSessionState::Live);
24156        assert_eq!(record.active_session.as_ref(), Some(&address));
24157        assert!(record.provider_session.is_none());
24158        let binding_record_id = shared
24159            .session_bindings
24160            .lock()
24161            .unwrap_or_else(|poisoned| poisoned.into_inner())
24162            .get(&address.session.instance_id)
24163            .and_then(|binding| binding.record_id.clone());
24164        assert_eq!(binding_record_id.as_ref(), Some(&record_id));
24165
24166        let renamed = shared
24167            .rename_session_record(&record_id, "raw review".to_owned())
24168            .unwrap();
24169        assert_eq!(renamed.record_id, record_id);
24170        assert_eq!(renamed.display_name, "raw review");
24171        shared.reconcile_managed_record(
24172            &record_id,
24173            &address,
24174            &ControlEvent {
24175                sequence: 1,
24176                command_id: None,
24177                instance_id: address.session.instance_id,
24178                generation: address.session.generation,
24179                event: ControlEventKind::Running { process_id: None },
24180            },
24181            false,
24182            false,
24183        );
24184        let running = shared.record(&record_id).unwrap();
24185        assert_eq!(running.state, ManagedSessionState::Live);
24186        assert_eq!(running.active_session.as_ref(), Some(&address));
24187        assert_eq!(running.display_name, "raw review");
24188
24189        shared.reconcile_managed_record(
24190            &record_id,
24191            &address,
24192            &ControlEvent {
24193                sequence: 2,
24194                command_id: None,
24195                instance_id: address.session.instance_id,
24196                generation: address.session.generation,
24197                event: ControlEventKind::Exited {
24198                    exit_code: Some(0),
24199                    forced: false,
24200                },
24201            },
24202            false,
24203            false,
24204        );
24205        let detached = shared.record(&record_id).unwrap();
24206        assert_eq!(detached.state, ManagedSessionState::Unavailable);
24207        assert!(detached.active_session.is_none());
24208        assert_eq!(detached.display_name, "raw review");
24209        let renamed_detached = shared
24210            .rename_session_record(&record_id, "raw review detached".to_owned())
24211            .unwrap();
24212        assert_eq!(renamed_detached.record_id, record_id);
24213        assert_eq!(renamed_detached.state, ManagedSessionState::Unavailable);
24214        assert!(renamed_detached.active_session.is_none());
24215        assert_eq!(renamed_detached.display_name, "raw review detached");
24216        assert_eq!(shared.snapshot().session_records, vec![renamed_detached]);
24217    }
24218
24219    #[test]
24220    fn legacy_raw_spawn_binding_remains_untracked() {
24221        let catalog = active_registry().unwrap();
24222        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24223        let workspace_id = WorkspaceId::new("primary").unwrap();
24224        let workspace = WorkspaceConfig::new(
24225            workspace_id.clone(),
24226            std::env::current_dir().unwrap(),
24227        )
24228        .unwrap();
24229        let shared = NodeShared::new(
24230            handle,
24231            "fixture-token".to_owned(),
24232            NodeId::new("node-legacy-raw-spawn").unwrap(),
24233            vec![workspace],
24234            vec![agent("claude")],
24235        );
24236        let address = SessionAddress {
24237            workspace_id,
24238            session: SessionKey {
24239                instance_id: AgentInstanceId(72),
24240                generation: SessionGeneration::default(),
24241            },
24242        };
24243
24244        let record_id = shared
24245            .bind_spawn_session(
24246                &address,
24247                agent("claude"),
24248                SessionMode::Pty,
24249                ProviderRuntimePolicy::raw_pty(),
24250                SpawnRecordPolicy::ProviderIdentityOnly,
24251                None,
24252            )
24253            .unwrap();
24254
24255        assert!(record_id.is_none());
24256        let binding_record_id = shared
24257            .session_bindings
24258            .lock()
24259            .unwrap_or_else(|poisoned| poisoned.into_inner())
24260            .get(&address.session.instance_id)
24261            .and_then(|binding| binding.record_id.clone());
24262        assert!(binding_record_id.is_none());
24263        assert!(shared.snapshot().session_records.is_empty());
24264    }
24265
24266    #[tokio::test]
24267    async fn raw_runtime_policy_rejects_prompt_paste_and_resume_before_mutation() {
24268        let catalog = active_registry().unwrap();
24269        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24270        let workspace_id = WorkspaceId::new("primary").unwrap();
24271        let workspace = WorkspaceConfig::new(
24272            workspace_id.clone(),
24273            std::env::current_dir().unwrap(),
24274        )
24275        .unwrap();
24276        let mut shared = NodeShared::new(
24277            handle,
24278            "fixture-token".to_owned(),
24279            NodeId::new("node-raw-semantic-gate").unwrap(),
24280            vec![workspace],
24281            vec![agent("claude")],
24282        );
24283        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
24284            crate::protocol::ProviderRuntimeStatus::raw_passthrough(agent("claude"), None),
24285        ])
24286        .unwrap();
24287        shared
24288            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
24289            .unwrap();
24290        let address = SessionAddress {
24291            workspace_id,
24292            session: SessionKey {
24293                instance_id: AgentInstanceId(72),
24294                generation: SessionGeneration::default(),
24295            },
24296        };
24297        shared.bind_session_with_policy(
24298            &address,
24299            ProviderRuntimePolicy::raw_pty(),
24300            None,
24301        );
24302        shared
24303            .handle
24304            .dispatch(shared.prepare_command(ControlCommand::Register {
24305                instance_id: address.session.instance_id,
24306                agent_id: agent("claude"),
24307                transport: TransportKind::Pty,
24308            }))
24309            .unwrap();
24310        runtime.tick().await;
24311        let command_id_before = shared.next_command_id.load(Ordering::Acquire);
24312        let revision_before = shared.handle.snapshot().revision;
24313        let history_sequence_before = shared.current_sequence();
24314        let terminal_size = gate4agent_types::TerminalSize {
24315            rows: 24,
24316            columns: 80,
24317        };
24318
24319        for request in [
24320            NodeRequest::Prompt {
24321                session: address.clone(),
24322                text: "prompt".to_owned(),
24323            },
24324            NodeRequest::Paste {
24325                session: address.clone(),
24326                text: "paste".to_owned(),
24327            },
24328            // `initial_prompt: Some(_)` selects `ResumeWithPrompt`, which needs
24329            // the same semantic capabilities as `Prompt`/`Paste`. A bare
24330            // (promptless) resume is a raw PTY relaunch and IS admitted under
24331            // `raw_pty()` — see `raw_pty_admits_provider_native_resume_without_prompt_only`
24332            // in `provider_runtime.rs`; it is deliberately not exercised here.
24333            NodeRequest::Resume {
24334                session: address.clone(),
24335                terminal_size,
24336                initial_prompt: Some("resume".to_owned()),
24337            },
24338        ] {
24339            let error = process_request_inner(
24340                &shared,
24341                77,
24342                ClientRole::Operator,
24343                request,
24344            )
24345            .await
24346            .unwrap_err();
24347            assert_eq!(error.code, NodeFailureCode::UnsupportedCapability);
24348        }
24349
24350        assert_eq!(shared.next_command_id.load(Ordering::Acquire), command_id_before);
24351        assert_eq!(shared.handle.snapshot().revision, revision_before);
24352        assert_eq!(shared.current_sequence(), history_sequence_before);
24353        let bindings = shared
24354            .session_bindings
24355            .lock()
24356            .unwrap_or_else(|poisoned| poisoned.into_inner());
24357        let binding = bindings.get(&address.session.instance_id).unwrap();
24358        assert_eq!(binding.generation, address.session.generation);
24359        assert!(binding.pending_resume.is_none());
24360        assert!(binding.record_id.is_none());
24361    }
24362
24363    #[tokio::test]
24364    async fn windows_rejects_unix_bytes_workspace_path_before_filesystem_access() {
24365        let catalog = active_registry().unwrap();
24366        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24367        let primary = WorkspaceConfig::new(
24368            WorkspaceId::new("primary").unwrap(),
24369            std::env::current_dir().unwrap(),
24370        )
24371        .unwrap();
24372        let shared = NodeShared::new(
24373            handle,
24374            "fixture-token".to_owned(),
24375            NodeId::new("node-1").unwrap(),
24376            vec![primary],
24377            vec![agent("claude")],
24378        );
24379        shared
24380            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
24381            .unwrap();
24382        let before = shared.snapshot();
24383        let error = process_request_inner(
24384            &shared,
24385            77,
24386            ClientRole::Operator,
24387            NodeRequest::RegisterWorkspace {
24388                workspace_id: WorkspaceId::new("foreign").unwrap(),
24389                root: OpaqueHostPath::unix_bytes(b"/srv/repo".to_vec()).unwrap(),
24390            },
24391        )
24392        .await
24393        .unwrap_err();
24394        assert_eq!(error.code, NodeFailureCode::InvalidRequest);
24395        assert_eq!(error.message, "invalid-request");
24396        assert_eq!(shared.snapshot().workspaces, before.workspaces);
24397    }
24398
24399    #[tokio::test]
24400    async fn dynamic_workspaces_reject_duplicates_busy_and_last_then_publish_ordered_events() {
24401        let catalog = active_registry().unwrap();
24402        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24403        let primary_id = WorkspaceId::new("primary").unwrap();
24404        let primary = WorkspaceConfig::new(
24405            primary_id.clone(),
24406            std::env::current_dir().unwrap(),
24407        )
24408        .unwrap();
24409        let shared = NodeShared::new(
24410            handle,
24411            "fixture-token".to_owned(),
24412            NodeId::new("node-1").unwrap(),
24413            vec![primary.clone()],
24414            vec![agent("claude")],
24415        );
24416        let secondary_id = WorkspaceId::new("secondary").unwrap();
24417        let secondary = shared
24418            .register_workspace(
24419                secondary_id.clone(),
24420                std::env::temp_dir().to_string_lossy().into_owned(),
24421            )
24422            .await
24423            .unwrap();
24424        assert_eq!(secondary.workspace_id, secondary_id);
24425        assert!(secondary.sessions.is_empty());
24426        assert_eq!(shared.snapshot().workspaces.len(), 2);
24427
24428        let duplicate_id = shared
24429            .register_workspace(
24430                secondary_id.clone(),
24431                primary.canonical_root().to_owned(),
24432            )
24433            .await
24434            .unwrap_err();
24435        assert_eq!(duplicate_id.code, NodeFailureCode::DuplicateWorkspaceId);
24436        let duplicate_root = shared
24437            .register_workspace(
24438                WorkspaceId::new("duplicate-root").unwrap(),
24439                windows_path_text(&secondary.canonical_root).to_owned(),
24440            )
24441            .await
24442            .unwrap_err();
24443        assert_eq!(duplicate_root.code, NodeFailureCode::DuplicateWorkspaceRoot);
24444
24445        let address = SessionAddress {
24446            workspace_id: secondary_id.clone(),
24447            session: SessionKey {
24448                instance_id: AgentInstanceId(41),
24449                generation: SessionGeneration(1),
24450            },
24451        };
24452        shared.bind_session(
24453            address.session.instance_id,
24454            secondary_id.clone(),
24455            address.session.generation,
24456        );
24457        let busy = shared.unregister_workspace(&secondary_id).unwrap_err();
24458        assert_eq!(busy.code, NodeFailureCode::WorkspaceBusy);
24459        shared.remove_binding(&address);
24460        shared.unregister_workspace(&secondary_id).unwrap();
24461        assert_eq!(shared.snapshot().workspaces.len(), 1);
24462
24463        let last = shared.unregister_workspace(&primary_id).unwrap_err();
24464        assert_eq!(last.code, NodeFailureCode::LastWorkspace);
24465        let history = shared
24466            .history
24467            .lock()
24468            .unwrap_or_else(|poisoned| poisoned.into_inner());
24469        assert!(matches!(
24470            &history.events[0],
24471            NodeEventEnvelope {
24472                sequence: 1,
24473                event: NodeEvent::WorkspaceAdded { workspace },
24474            } if workspace.workspace_id == secondary_id
24475        ));
24476        assert!(matches!(
24477            &history.events[1],
24478            NodeEventEnvelope {
24479                sequence: 2,
24480                event: NodeEvent::WorkspaceRemoved { workspace_id },
24481            } if workspace_id == &secondary_id
24482        ));
24483    }
24484
24485    #[tokio::test]
24486    async fn provider_session_reference_index_is_controller_gated_and_idempotent() {
24487        let secondary_root = temporary_workspace_root("provider-session-reference-index");
24488        std::fs::create_dir_all(&secondary_root).unwrap();
24489        let catalog = active_registry().unwrap();
24490        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24491        let primary_id = WorkspaceId::new("primary").unwrap();
24492        let secondary_id = WorkspaceId::new("secondary").unwrap();
24493        let shared = NodeShared::new(
24494            handle,
24495            "fixture-token".to_owned(),
24496            NodeId::new("node-session-reference-index").unwrap(),
24497            vec![
24498                WorkspaceConfig::new(primary_id.clone(), std::env::current_dir().unwrap())
24499                    .unwrap(),
24500                WorkspaceConfig::new(secondary_id.clone(), &secondary_root).unwrap(),
24501            ],
24502            vec![agent("claude"), agent("codex")],
24503        );
24504        let identity = ProviderSessionIdentity {
24505            key: gate4agent_types::ProviderSessionKey::SessionId,
24506            id: "provider-session-42".to_owned(),
24507            transcript_path: None,
24508        };
24509        let index_request = |workspace_id: WorkspaceId,
24510                             provider: AgentId,
24511                             identity: ProviderSessionIdentity,
24512                             display_name: &str| {
24513            NodeRequest::IndexProviderSession {
24514                workspace_id,
24515                provider,
24516                identity,
24517                display_name: display_name.to_owned(),
24518            }
24519        };
24520        let request = index_request(
24521            primary_id.clone(),
24522            agent("claude"),
24523            identity.clone(),
24524            "release shepherd",
24525        );
24526        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
24527            capability.as_str() == NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY
24528        }));
24529        let observer = process_request_inner(
24530            &shared,
24531            77,
24532            ClientRole::Observer,
24533            request.clone(),
24534        )
24535        .await
24536        .unwrap_err();
24537        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
24538        let uncontrolled = process_request_inner(
24539            &shared,
24540            77,
24541            ClientRole::Operator,
24542            request.clone(),
24543        )
24544        .await
24545        .unwrap_err();
24546        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
24547        shared
24548            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
24549            .unwrap();
24550
24551        let indexed = process_request_inner(
24552            &shared,
24553            77,
24554            ClientRole::Operator,
24555            request.clone(),
24556        )
24557        .await
24558        .unwrap();
24559        let NodeResponse::ProviderSessionIndexed { record } = indexed else {
24560            panic!("provider session reference index returned another response");
24561        };
24562        assert_eq!(record.state, ManagedSessionState::Dormant);
24563        assert_eq!(record.mode, SessionMode::Pty);
24564        assert_eq!(record.workspace_id, primary_id);
24565        assert_eq!(record.provider_session.as_ref(), Some(&identity));
24566        assert!(record.active_session.is_none());
24567        assert!(record.environment_profile.is_none());
24568        assert!(record.bundle.is_none());
24569        assert!(record.context.is_none());
24570
24571        let repeated = process_request_inner(
24572            &shared,
24573            77,
24574            ClientRole::Operator,
24575            index_request(
24576                primary_id.clone(),
24577                agent("claude"),
24578                identity.clone(),
24579                "ignored replacement",
24580            ),
24581        )
24582        .await
24583        .unwrap();
24584        assert!(matches!(
24585            repeated,
24586            NodeResponse::ProviderSessionIndexed { record: ref existing }
24587                if existing.record_id == record.record_id
24588                    && existing.display_name == "release shepherd"
24589        ));
24590        let conflicting_workspace = process_request_inner(
24591            &shared,
24592            77,
24593            ClientRole::Operator,
24594            index_request(
24595                secondary_id,
24596                agent("claude"),
24597                identity.clone(),
24598                "release shepherd",
24599            ),
24600        )
24601        .await
24602        .unwrap_err();
24603        assert_eq!(conflicting_workspace.code, NodeFailureCode::SessionRecordConflict);
24604        let conflicting_provider = process_request_inner(
24605            &shared,
24606            77,
24607            ClientRole::Operator,
24608            index_request(
24609                primary_id.clone(),
24610                agent("codex"),
24611                identity.clone(),
24612                "release shepherd",
24613            ),
24614        )
24615        .await
24616        .unwrap_err();
24617        assert_eq!(conflicting_provider.code, NodeFailureCode::SessionRecordConflict);
24618        let transcript_rejected = process_request_inner(
24619            &shared,
24620            77,
24621            ClientRole::Operator,
24622            index_request(
24623                primary_id,
24624                agent("claude"),
24625                ProviderSessionIdentity {
24626                    transcript_path: Some("C:/provider/transcript.jsonl".to_owned()),
24627                    ..identity
24628                },
24629                "release shepherd",
24630            ),
24631        )
24632        .await
24633        .unwrap_err();
24634        assert_eq!(transcript_rejected.code, NodeFailureCode::InvalidRequest);
24635        let upserts = shared
24636            .history
24637            .lock()
24638            .unwrap_or_else(|poisoned| poisoned.into_inner())
24639            .events
24640            .iter()
24641            .filter(|event| matches!(
24642                &event.event,
24643                NodeEvent::SessionRecordUpserted { record: published }
24644                    if published.record_id == record.record_id
24645            ))
24646            .count();
24647        assert_eq!(upserts, 1);
24648        std::fs::remove_dir_all(secondary_root).unwrap();
24649    }
24650
24651    #[tokio::test]
24652    async fn standalone_workspace_is_independent_registered_and_controller_gated() {
24653        let fixture_root = temporary_workspace_root("standalone-independent");
24654        let target = fixture_root.join("independent");
24655        std::fs::create_dir_all(&fixture_root).unwrap();
24656        let catalog = active_registry().unwrap();
24657        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24658        let selected = WorkspaceConfig::new(
24659            WorkspaceId::new("primary").unwrap(),
24660            std::env::current_dir().unwrap(),
24661        )
24662        .unwrap();
24663        let selected_root = selected.canonical_root().to_owned();
24664        let shared = NodeShared::new(
24665            handle,
24666            "fixture-token".to_owned(),
24667            NodeId::new("node-standalone").unwrap(),
24668            vec![selected],
24669            vec![agent("claude")],
24670        );
24671        let workspace_id = WorkspaceId::new("independent").unwrap();
24672        let request = NodeRequest::CreateStandaloneWorkspace {
24673            workspace_id: workspace_id.clone(),
24674            root: opaque_windows_path(target.to_string_lossy().into_owned()),
24675            initial_branch: Some("main".to_owned()),
24676        };
24677        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
24678            capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
24679        }));
24680        assert!(!request_uses_unnegotiated_capability(
24681            &request,
24682            &baseline_capabilities().unwrap(),
24683        ));
24684        assert!(request_uses_unnegotiated_capability(&request, &[]));
24685
24686        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
24687            .await
24688            .unwrap_err();
24689        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
24690        assert!(!target.exists());
24691        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
24692            .await
24693            .unwrap_err();
24694        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
24695        assert!(!target.exists());
24696        shared
24697            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
24698            .unwrap();
24699
24700        let response = process_request_inner(&shared, 77, ClientRole::Operator, request)
24701            .await
24702            .unwrap();
24703        let NodeResponse::StandaloneWorkspaceCreated { workspace } = response else {
24704            panic!("standalone workspace creation returned another response");
24705        };
24706        assert_eq!(workspace.workspace_id, workspace_id);
24707        assert!(target.join(".git").is_dir());
24708        let branch = run_git_bounded(
24709            target.to_str().unwrap(),
24710            &["branch", "--show-current"],
24711            GIT_OUTPUT_MAX_BYTES,
24712        )
24713        .await
24714        .unwrap();
24715        assert!(branch.success);
24716        assert_eq!(String::from_utf8(branch.stdout).unwrap().trim(), "main");
24717        let worktrees = list_git_worktrees(target.to_str().unwrap()).await.unwrap();
24718        assert_eq!(worktrees.len(), 1);
24719        let listed_root = WorkspaceConfig::new(
24720            WorkspaceId::new("listed-independent").unwrap(),
24721            &worktrees[0].path,
24722        )
24723        .unwrap();
24724        assert!(platform::roots_equal(
24725            listed_root.canonical_root(),
24726            windows_path_text(&workspace.canonical_root),
24727        ));
24728        assert!(!platform::roots_equal(listed_root.canonical_root(), &selected_root));
24729        assert!(shared.snapshot().workspaces.iter().any(|snapshot| {
24730            snapshot.workspace_id == workspace_id
24731                && snapshot.canonical_root == workspace.canonical_root
24732        }));
24733        assert!(shared
24734            .history
24735            .lock()
24736            .unwrap_or_else(|poisoned| poisoned.into_inner())
24737            .events
24738            .iter()
24739            .any(|event| matches!(
24740                &event.event,
24741                NodeEvent::WorkspaceAdded { workspace }
24742                    if workspace.workspace_id == workspace_id
24743            )));
24744        std::fs::remove_dir_all(fixture_root).unwrap();
24745    }
24746
24747    #[tokio::test]
24748    async fn standalone_workspace_registration_failure_preserves_guarded_repository_for_recovery() {
24749        let fixture_root = temporary_workspace_root("standalone-compensation");
24750        let created_target = fixture_root.join("created-target");
24751        let existing_target = fixture_root.join("existing-target");
24752        let invalid_state_parent = fixture_root.join("state-parent-is-a-file");
24753        let invalid_state_path = invalid_state_parent.join("state.json");
24754        std::fs::create_dir_all(&existing_target).unwrap();
24755        std::fs::write(&invalid_state_parent, "not a directory").unwrap();
24756        let catalog = active_registry().unwrap();
24757        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24758        let primary = WorkspaceConfig::new(
24759            WorkspaceId::new("primary").unwrap(),
24760            std::env::current_dir().unwrap(),
24761        )
24762        .unwrap();
24763        let mut shared = NodeShared::new(
24764            handle,
24765            "fixture-token".to_owned(),
24766            NodeId::new("node-standalone-compensation").unwrap(),
24767            vec![primary],
24768            vec![agent("claude")],
24769        );
24770        shared.state_path = Some(invalid_state_path);
24771
24772        let created_recovery = shared
24773            .create_standalone_workspace(
24774                WorkspaceId::new("compensated").unwrap(),
24775                created_target.to_string_lossy().into_owned(),
24776                Some("main".to_owned()),
24777            )
24778            .await
24779            .unwrap_err();
24780        assert_eq!(
24781            created_recovery.code,
24782            NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
24783        );
24784        assert!(created_target.join(".git").is_dir());
24785        assert!(shared.snapshot().workspaces.iter().all(|workspace| {
24786            workspace.workspace_id.as_str() != "compensated"
24787        }));
24788
24789        let existing_recovery = shared
24790            .create_standalone_workspace(
24791                WorkspaceId::new("restored").unwrap(),
24792                existing_target.to_string_lossy().into_owned(),
24793                None,
24794            )
24795            .await
24796            .unwrap_err();
24797        assert_eq!(
24798            existing_recovery.code,
24799            NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
24800        );
24801        assert!(existing_target.is_dir());
24802        assert!(existing_target.join(".git").is_dir());
24803        assert!(shared.snapshot().workspaces.iter().all(|workspace| {
24804            workspace.workspace_id.as_str() != "restored"
24805        }));
24806
24807        let registered_empty = fixture_root.join("registered-empty");
24808        std::fs::create_dir(&registered_empty).unwrap();
24809        let catalog = active_registry().unwrap();
24810        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24811        let registered = WorkspaceConfig::new(
24812            WorkspaceId::new("registered-empty").unwrap(),
24813            &registered_empty,
24814        )
24815        .unwrap();
24816        let duplicate_shared = NodeShared::new(
24817            handle,
24818            "fixture-token".to_owned(),
24819            NodeId::new("node-standalone-duplicate-root").unwrap(),
24820            vec![registered],
24821            vec![agent("claude")],
24822        );
24823        let duplicate = duplicate_shared
24824            .create_standalone_workspace(
24825                WorkspaceId::new("duplicate-root").unwrap(),
24826                registered_empty.to_string_lossy().into_owned(),
24827                None,
24828            )
24829            .await
24830            .unwrap_err();
24831        assert_eq!(duplicate.code, NodeFailureCode::DuplicateWorkspaceRoot);
24832        assert!(std::fs::read_dir(&registered_empty).unwrap().next().is_none());
24833        std::fs::remove_dir_all(fixture_root).unwrap();
24834    }
24835
24836    #[test]
24837    fn managed_record_id_collision_preserves_the_existing_record() {
24838        let catalog = active_registry().unwrap();
24839        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24840        let workspace = WorkspaceConfig::new(
24841            WorkspaceId::new("primary").unwrap(),
24842            std::env::current_dir().unwrap(),
24843        )
24844        .unwrap();
24845        let shared = NodeShared::new(
24846            handle,
24847            "fixture-token".to_owned(),
24848            NodeId::new("node-1").unwrap(),
24849            vec![workspace.clone()],
24850            vec![agent("claude")],
24851        );
24852        let record_id = SessionRecordId::new("sr-collision").unwrap();
24853        let original = ManagedSessionRecord {
24854            record_id: record_id.clone(),
24855            display_name: "original".to_owned(),
24856            provider: agent("claude"),
24857            mode: SessionMode::Pty,
24858            state: ManagedSessionState::Dormant,
24859            workspace_id: workspace.workspace_id,
24860            canonical_root: opaque_windows_path(workspace.canonical_root),
24861            provider_session: None,
24862            active_session: None,
24863            environment_profile: None,
24864            bundle: None,
24865            context_id: None,
24866            context: None,
24867            exported_context: None,
24868            task_binding: None,
24869            created_at_unix_ms: 1,
24870            updated_at_unix_ms: 1,
24871            last_error: None,
24872        };
24873        shared.insert_record(original.clone()).unwrap();
24874        let mut collision = original.clone();
24875        collision.display_name = "replacement".to_owned();
24876        let error = shared.insert_record(collision).unwrap_err();
24877        assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
24878        assert_eq!(shared.record(&record_id).unwrap(), original);
24879    }
24880
24881    #[test]
24882    fn managed_record_runtime_errors_are_exposed_only_as_allowlisted_categories() {
24883        let catalog = active_registry().unwrap();
24884        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24885        let workspace = WorkspaceConfig::new(
24886            WorkspaceId::new("primary").unwrap(),
24887            std::env::current_dir().unwrap(),
24888        )
24889        .unwrap();
24890        let shared = NodeShared::new(
24891            handle,
24892            "fixture-token".to_owned(),
24893            NodeId::new("node-1").unwrap(),
24894            vec![workspace.clone()],
24895            vec![agent("claude")],
24896        );
24897        let record_id = SessionRecordId::new("sr-runtime-error").unwrap();
24898        shared
24899            .insert_record(ManagedSessionRecord {
24900                record_id: record_id.clone(),
24901                display_name: "runtime error".to_owned(),
24902                provider: agent("claude"),
24903                mode: SessionMode::Pty,
24904                state: ManagedSessionState::IdentityPending,
24905                workspace_id: workspace.workspace_id,
24906                canonical_root: opaque_windows_path(workspace.canonical_root),
24907                provider_session: None,
24908                active_session: None,
24909                environment_profile: None,
24910                bundle: None,
24911                context_id: None,
24912                context: None,
24913                exported_context: None,
24914                task_binding: None,
24915                created_at_unix_ms: 1,
24916                updated_at_unix_ms: 1,
24917                last_error: None,
24918            })
24919            .unwrap();
24920
24921        shared
24922            .mark_record_error(
24923                &record_id,
24924                r"provider failed with bearer-secret-123 at C:\private\session.jsonl",
24925            )
24926            .unwrap();
24927        let record = shared.record(&record_id).unwrap();
24928        assert_eq!(record.state, ManagedSessionState::Unavailable);
24929        assert_eq!(
24930            record.last_error.as_deref(),
24931            Some("provider-runtime-failed"),
24932        );
24933        let snapshot = shared.snapshot();
24934        assert_eq!(snapshot.session_records, vec![record]);
24935    }
24936
24937    /// Regression coverage for the "operator sees `deadline` instead of the
24938    /// kernel's own named rejection" defect: a spawn-dispatch waiter must be
24939    /// able to learn a command's asynchronous kernel rejection (here,
24940    /// specifically classified as `UnsupportedTransport`) exactly once, and
24941    /// must not still find it after that -- proving the fast path this
24942    /// mechanism exists for (`spawn_session_with_deadline`'s commit-wait
24943    /// loop) drains the reason instead of leaving it to be rediscovered (or
24944    /// missed) on a later poll.
24945    #[test]
24946    fn rejected_command_reason_is_classified_and_consumed_exactly_once() {
24947        let catalog = active_registry().unwrap();
24948        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24949        let workspace = WorkspaceConfig::new(
24950            WorkspaceId::new("primary").unwrap(),
24951            std::env::current_dir().unwrap(),
24952        )
24953        .unwrap();
24954        let shared = NodeShared::new(
24955            handle,
24956            "fixture-token".to_owned(),
24957            NodeId::new("node-1").unwrap(),
24958            vec![workspace],
24959            vec![agent("claude")],
24960        );
24961
24962        assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
24963
24964        shared.record_rejected_command(
24965            CommandId(1),
24966            "agent 'claude' does not support transport Acp".to_owned(),
24967            true,
24968        );
24969        // An unrelated command_id must not see another command's reason.
24970        assert!(shared.take_rejected_command_reason(CommandId(2)).is_none());
24971
24972        let reason = shared.take_rejected_command_reason(CommandId(1)).unwrap();
24973        assert_eq!(reason.message, "agent 'claude' does not support transport Acp");
24974        assert!(reason.unsupported_transport);
24975
24976        // Consumed: a second read for the same command_id finds nothing.
24977        assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
24978
24979        // A rejection unrelated to transport support keeps its own
24980        // classification distinct, so the reader can choose the generic
24981        // `BackendOperationFailed` code instead of misreporting
24982        // `UnsupportedTransport`.
24983        shared.record_rejected_command(
24984            CommandId(3),
24985            "backend busy".to_owned(),
24986            false,
24987        );
24988        let unclassified = shared.take_rejected_command_reason(CommandId(3)).unwrap();
24989        assert!(!unclassified.unsupported_transport);
24990    }
24991
24992    #[test]
24993    fn provider_identity_update_is_ignored_without_exact_session_policy() {
24994        let catalog = active_registry().unwrap();
24995        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24996        let workspace = WorkspaceConfig::new(
24997            WorkspaceId::new("primary").unwrap(),
24998            std::env::current_dir().unwrap(),
24999        )
25000        .unwrap();
25001        let shared = NodeShared::new(
25002            handle,
25003            "fixture-token".to_owned(),
25004            NodeId::new("node-identity-policy").unwrap(),
25005            vec![workspace.clone()],
25006            vec![agent("claude")],
25007        );
25008        let address = SessionAddress {
25009            workspace_id: workspace.workspace_id.clone(),
25010            session: SessionKey {
25011                instance_id: AgentInstanceId(90),
25012                generation: SessionGeneration(1),
25013            },
25014        };
25015        let record_id = SessionRecordId::new("sr-policy-denied").unwrap();
25016        shared
25017            .insert_record(ManagedSessionRecord {
25018                record_id: record_id.clone(),
25019                display_name: "policy denied".to_owned(),
25020                provider: agent("claude"),
25021                mode: SessionMode::Pty,
25022                state: ManagedSessionState::IdentityPending,
25023                workspace_id: workspace.workspace_id,
25024                canonical_root: opaque_windows_path(workspace.canonical_root),
25025                provider_session: None,
25026                active_session: Some(address.clone()),
25027                environment_profile: None,
25028                bundle: None,
25029                context_id: None,
25030                context: None,
25031                exported_context: None,
25032                task_binding: None,
25033                created_at_unix_ms: 1,
25034                updated_at_unix_ms: 1,
25035                last_error: None,
25036            })
25037            .unwrap();
25038        let event = ControlEvent {
25039            sequence: 1,
25040            command_id: None,
25041            instance_id: address.session.instance_id,
25042            generation: address.session.generation,
25043            event: ControlEventKind::ProviderEvent {
25044                sequence: 1,
25045                source: gate4agent_types::ProviderSource {
25046                    family: AdapterFamily::PtySemantic,
25047                    binding: AdapterBinding::new(
25048                        gate4agent_types::AdapterId::new("claude").unwrap(),
25049                        "fixture/v1",
25050                        gate4agent_types::AdapterVerification::SyntheticFixture,
25051                    )
25052                    .unwrap(),
25053                },
25054                source_sequence: 1,
25055                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
25056                    identity: gate4agent_types::ProviderSessionIdentity {
25057                        key: gate4agent_types::ProviderSessionKey::SessionId,
25058                        id: "provider-session".to_owned(),
25059                        transcript_path: None,
25060                    },
25061                },
25062            },
25063        };
25064
25065        shared.reconcile_managed_record(&record_id, &address, &event, false, false);
25066
25067        let record = shared.record(&record_id).unwrap();
25068        assert!(record.provider_session.is_none());
25069        assert_eq!(record.state, ManagedSessionState::IdentityPending);
25070        assert_eq!(record.active_session.as_ref(), Some(&address));
25071    }
25072
25073    #[test]
25074    fn provider_identity_cannot_rebind_a_record_across_workspaces() {
25075        let secondary_root = temporary_workspace_root("identity-scope");
25076        std::fs::create_dir_all(&secondary_root).unwrap();
25077        let catalog = active_registry().unwrap();
25078        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25079        let primary = WorkspaceConfig::new(
25080            WorkspaceId::new("primary").unwrap(),
25081            std::env::current_dir().unwrap(),
25082        )
25083        .unwrap();
25084        let secondary = WorkspaceConfig::new(
25085            WorkspaceId::new("secondary").unwrap(),
25086            &secondary_root,
25087        )
25088        .unwrap();
25089        let shared = NodeShared::new(
25090            handle,
25091            "fixture-token".to_owned(),
25092            NodeId::new("node-1").unwrap(),
25093            vec![primary.clone(), secondary.clone()],
25094            vec![agent("claude")],
25095        );
25096        let identity = gate4agent_types::ProviderSessionIdentity {
25097            key: gate4agent_types::ProviderSessionKey::SessionId,
25098            id: "provider-session".to_owned(),
25099            transcript_path: None,
25100        };
25101        let dormant_id = SessionRecordId::new("sr-dormant").unwrap();
25102        shared
25103            .insert_record(ManagedSessionRecord {
25104                record_id: dormant_id.clone(),
25105                display_name: "dormant".to_owned(),
25106                provider: agent("claude"),
25107                mode: SessionMode::Pty,
25108                state: ManagedSessionState::Dormant,
25109                workspace_id: primary.workspace_id,
25110                canonical_root: opaque_windows_path(primary.canonical_root),
25111                provider_session: Some(identity.clone()),
25112                active_session: None,
25113                environment_profile: None,
25114                bundle: None,
25115                context_id: None,
25116                context: None,
25117                exported_context: None,
25118                task_binding: None,
25119                created_at_unix_ms: 1,
25120                updated_at_unix_ms: 1,
25121                last_error: None,
25122            })
25123            .unwrap();
25124        let address = SessionAddress {
25125            workspace_id: secondary.workspace_id.clone(),
25126            session: SessionKey {
25127                instance_id: AgentInstanceId(91),
25128                generation: SessionGeneration(1),
25129            },
25130        };
25131        let current_id = SessionRecordId::new("sr-current").unwrap();
25132        shared
25133            .insert_record(ManagedSessionRecord {
25134                record_id: current_id.clone(),
25135                display_name: "current".to_owned(),
25136                provider: agent("claude"),
25137                mode: SessionMode::Pty,
25138                state: ManagedSessionState::IdentityPending,
25139                workspace_id: secondary.workspace_id,
25140                canonical_root: opaque_windows_path(secondary.canonical_root),
25141                provider_session: None,
25142                active_session: Some(address.clone()),
25143                environment_profile: None,
25144                bundle: None,
25145                context_id: None,
25146                context: None,
25147                exported_context: None,
25148                task_binding: None,
25149                created_at_unix_ms: 2,
25150                updated_at_unix_ms: 2,
25151                last_error: None,
25152            })
25153            .unwrap();
25154        shared.bind_managed_session(
25155            &address,
25156            current_id.clone(),
25157            ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
25158            None,
25159        );
25160        let event = ControlEvent {
25161            sequence: 1,
25162            command_id: None,
25163            instance_id: address.session.instance_id,
25164            generation: address.session.generation,
25165            event: ControlEventKind::ProviderEvent {
25166                sequence: 1,
25167                source: gate4agent_types::ProviderSource {
25168                    family: gate4agent_types::AdapterFamily::PtySemantic,
25169                    binding: gate4agent_types::AdapterBinding::new(
25170                        gate4agent_types::AdapterId::new("claude").unwrap(),
25171                        "fixture/v1",
25172                        gate4agent_types::AdapterVerification::SyntheticFixture,
25173                    )
25174                    .unwrap(),
25175                },
25176                source_sequence: 1,
25177                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
25178                    identity,
25179                },
25180            },
25181        };
25182        shared.reconcile_managed_record(&current_id, &address, &event, true, false);
25183
25184        let binding = shared
25185            .session_bindings
25186            .lock()
25187            .unwrap_or_else(|poisoned| poisoned.into_inner())
25188            .get(&address.session.instance_id)
25189            .cloned()
25190            .unwrap();
25191        assert_eq!(binding.record_id.as_ref(), Some(&current_id));
25192        assert_eq!(shared.record(&dormant_id).unwrap().state, ManagedSessionState::Dormant);
25193        let current = shared.record(&current_id).unwrap();
25194        assert_eq!(current.state, ManagedSessionState::Unavailable);
25195        assert_eq!(current.active_session.as_ref(), Some(&address));
25196        assert_eq!(
25197            current.last_error.as_deref(),
25198            Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR),
25199        );
25200        std::fs::remove_dir_all(secondary_root).unwrap();
25201    }
25202
25203    #[tokio::test]
25204    async fn semantic_identity_with_transcript_path_reuses_record_and_survives_reload() {
25205        let root = temporary_workspace_root("semantic-provider-session");
25206        std::fs::create_dir_all(&root).unwrap();
25207        let state_path = root.join("state-v1.json");
25208        let catalog = active_registry().unwrap();
25209        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25210        let workspace = WorkspaceConfig::new(
25211            WorkspaceId::new("primary").unwrap(),
25212            &root,
25213        )
25214        .unwrap();
25215        let node_id = NodeId::new("node-semantic-identity").unwrap();
25216        let shared = NodeShared::new_with_incarnation(
25217            handle,
25218            "fixture-token".to_owned(),
25219            node_id.clone(),
25220            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
25221            vec![workspace.clone()],
25222            vec![agent("claude")],
25223            ProviderRuntimeStatuses::default(),
25224            None,
25225            Vec::new(),
25226            Vec::new(),
25227            SpawnProfileRegistry::default(),
25228            None,
25229            None,
25230            None,
25231            Some(state_path.clone()),
25232            Vec::new(),
25233            Vec::new(),
25234            Vec::new(),
25235            Vec::new(),
25236            None,
25237            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
25238        );
25239        let persisted_identity = gate4agent_types::ProviderSessionIdentity {
25240            key: gate4agent_types::ProviderSessionKey::SessionId,
25241            id: "provider-session".to_owned(),
25242            transcript_path: None,
25243        };
25244        let durable_id = SessionRecordId::new("sr-durable").unwrap();
25245        shared
25246            .insert_record(ManagedSessionRecord {
25247                record_id: durable_id.clone(),
25248                display_name: "durable".to_owned(),
25249                provider: agent("claude"),
25250                mode: SessionMode::Pty,
25251                state: ManagedSessionState::Dormant,
25252                workspace_id: workspace.workspace_id.clone(),
25253                canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
25254                provider_session: Some(persisted_identity.clone()),
25255                active_session: None,
25256                environment_profile: None,
25257                bundle: None,
25258                context_id: None,
25259                context: None,
25260                exported_context: None,
25261                task_binding: None,
25262                created_at_unix_ms: 1,
25263                updated_at_unix_ms: 1,
25264                last_error: None,
25265            })
25266            .unwrap();
25267        let address = SessionAddress {
25268            workspace_id: workspace.workspace_id.clone(),
25269            session: SessionKey {
25270                instance_id: AgentInstanceId(92),
25271                generation: SessionGeneration(2),
25272            },
25273        };
25274        let pending_id = SessionRecordId::new("sr-pending").unwrap();
25275        shared
25276            .insert_record(ManagedSessionRecord {
25277                record_id: pending_id.clone(),
25278                display_name: "pending".to_owned(),
25279                provider: agent("claude"),
25280                mode: SessionMode::Pty,
25281                state: ManagedSessionState::IdentityPending,
25282                workspace_id: workspace.workspace_id.clone(),
25283                canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
25284                provider_session: None,
25285                active_session: Some(address.clone()),
25286                environment_profile: None,
25287                bundle: None,
25288                context_id: None,
25289                context: None,
25290                exported_context: None,
25291                task_binding: None,
25292                created_at_unix_ms: 2,
25293                updated_at_unix_ms: 2,
25294                last_error: None,
25295            })
25296            .unwrap();
25297        shared.bind_managed_session(
25298            &address,
25299            pending_id.clone(),
25300            ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
25301            None,
25302        );
25303        let observed_identity = gate4agent_types::ProviderSessionIdentity {
25304            transcript_path: Some(r"C:\private\provider-transcript.jsonl".to_owned()),
25305            ..persisted_identity
25306        };
25307        let event = ControlEvent {
25308            sequence: 1,
25309            command_id: None,
25310            instance_id: address.session.instance_id,
25311            generation: address.session.generation,
25312            event: ControlEventKind::ProviderEvent {
25313                sequence: 1,
25314                source: gate4agent_types::ProviderSource {
25315                    family: gate4agent_types::AdapterFamily::PtySemantic,
25316                    binding: gate4agent_types::AdapterBinding::new(
25317                        gate4agent_types::AdapterId::new("claude").unwrap(),
25318                        "fixture/v1",
25319                        gate4agent_types::AdapterVerification::SyntheticFixture,
25320                    )
25321                    .unwrap(),
25322                },
25323                source_sequence: 1,
25324                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
25325                    identity: observed_identity.clone(),
25326                },
25327            },
25328        };
25329        shared.reconcile_managed_record(&pending_id, &address, &event, true, false);
25330
25331        let snapshot = shared.snapshot();
25332        assert_eq!(snapshot.session_records.len(), 1);
25333        let live = &snapshot.session_records[0];
25334        assert_eq!(live.record_id, durable_id);
25335        assert_eq!(live.state, ManagedSessionState::Live);
25336        assert_eq!(live.active_session.as_ref(), Some(&address));
25337        assert_eq!(live.provider_session.as_ref(), Some(&observed_identity));
25338        let binding = shared
25339            .session_bindings
25340            .lock()
25341            .unwrap_or_else(|poisoned| poisoned.into_inner())
25342            .get(&address.session.instance_id)
25343            .cloned()
25344            .unwrap();
25345        assert_eq!(binding.record_id.as_ref(), Some(&durable_id));
25346
25347        let mut duplicate = live.clone();
25348        duplicate.record_id = SessionRecordId::new("sr-duplicate").unwrap();
25349        duplicate.display_name = "duplicate".to_owned();
25350        duplicate.state = ManagedSessionState::Dormant;
25351        duplicate.active_session = None;
25352        duplicate.provider_session.as_mut().unwrap().transcript_path = None;
25353        let duplicate_error = shared.insert_record(duplicate).unwrap_err();
25354        assert_eq!(duplicate_error.code, NodeFailureCode::SessionRecordConflict);
25355        assert_eq!(shared.snapshot().session_records.len(), 1);
25356
25357        let terminal_size = gate4agent_types::TerminalSize {
25358            rows: 24,
25359            columns: 80,
25360        };
25361        let (first_busy, second_busy) = tokio::join!(
25362            shared.resume_session_record(&durable_id, terminal_size, None),
25363            shared.resume_session_record(&durable_id, terminal_size, None),
25364        );
25365        for busy in [first_busy.unwrap_err(), second_busy.unwrap_err()] {
25366            assert_eq!(busy.code, NodeFailureCode::SessionRecordBusy);
25367            assert_eq!(busy.message, "session-record-busy");
25368        }
25369
25370        let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
25371        assert_eq!(loaded.records.len(), 1);
25372        assert_eq!(loaded.records[0].record_id, durable_id);
25373        assert_eq!(loaded.records[0].state, ManagedSessionState::Dormant);
25374        assert_eq!(
25375            loaded.records[0]
25376                .provider_session
25377                .as_ref()
25378                .unwrap()
25379                .transcript_path,
25380            None,
25381        );
25382        let catalog = active_registry().unwrap();
25383        let (reloaded_handle, _reloaded_runtime) =
25384            NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25385        let reloaded = NodeShared::new_with_incarnation(
25386            reloaded_handle,
25387            "fixture-token".to_owned(),
25388            node_id,
25389            NodeIncarnationId::from_bytes([1; crate::protocol::NODE_INCARNATION_ID_BYTES]),
25390            vec![workspace],
25391            vec![agent("claude")],
25392            ProviderRuntimeStatuses::default(),
25393            None,
25394            Vec::new(),
25395            Vec::new(),
25396            SpawnProfileRegistry::default(),
25397            None,
25398            None,
25399            None,
25400            None,
25401            loaded.records,
25402            loaded.managed_worktrees,
25403            loaded.managed_worktree_tombstones,
25404            loaded.materializations,
25405            loaded.warning,
25406            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
25407        );
25408        assert_eq!(reloaded.snapshot().session_records.len(), 1);
25409        assert_eq!(reloaded.snapshot().session_records[0].record_id, durable_id);
25410        std::fs::remove_dir_all(root).unwrap();
25411    }
25412
25413    #[tokio::test]
25414    async fn workspace_reregistration_requires_the_recorded_root_and_restores_records() {
25415        let secondary_root = temporary_workspace_root("workspace-restore");
25416        let other_root = temporary_workspace_root("workspace-conflict");
25417        std::fs::create_dir_all(&secondary_root).unwrap();
25418        std::fs::create_dir_all(&other_root).unwrap();
25419        let catalog = active_registry().unwrap();
25420        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25421        let primary = WorkspaceConfig::new(
25422            WorkspaceId::new("primary").unwrap(),
25423            std::env::current_dir().unwrap(),
25424        )
25425        .unwrap();
25426        let secondary = WorkspaceConfig::new(
25427            WorkspaceId::new("secondary").unwrap(),
25428            &secondary_root,
25429        )
25430        .unwrap();
25431        let shared = NodeShared::new(
25432            handle,
25433            "fixture-token".to_owned(),
25434            NodeId::new("node-1").unwrap(),
25435            vec![primary, secondary.clone()],
25436            vec![agent("claude")],
25437        );
25438        let record_id = SessionRecordId::new("sr-workspace").unwrap();
25439        shared
25440            .insert_record(ManagedSessionRecord {
25441                record_id: record_id.clone(),
25442                display_name: "workspace record".to_owned(),
25443                provider: agent("claude"),
25444                mode: SessionMode::Pty,
25445                state: ManagedSessionState::Dormant,
25446                workspace_id: secondary.workspace_id.clone(),
25447                canonical_root: opaque_windows_path(secondary.canonical_root.clone()),
25448                provider_session: Some(gate4agent_types::ProviderSessionIdentity {
25449                    key: gate4agent_types::ProviderSessionKey::SessionId,
25450                    id: "provider-session".to_owned(),
25451                    transcript_path: None,
25452                }),
25453                active_session: None,
25454                environment_profile: None,
25455                bundle: None,
25456                context_id: None,
25457                context: None,
25458                exported_context: None,
25459                task_binding: None,
25460                created_at_unix_ms: 1,
25461                updated_at_unix_ms: 1,
25462                last_error: None,
25463            })
25464            .unwrap();
25465        shared.unregister_workspace(&secondary.workspace_id).unwrap();
25466        let unavailable = shared.record(&record_id).unwrap();
25467        assert_eq!(unavailable.state, ManagedSessionState::Unavailable);
25468        assert_eq!(windows_path_text(&unavailable.canonical_root), secondary.canonical_root);
25469
25470        let conflict = shared
25471            .register_workspace(
25472                secondary.workspace_id.clone(),
25473                other_root.to_string_lossy().into_owned(),
25474            )
25475            .await
25476            .unwrap_err();
25477        assert_eq!(conflict.code, NodeFailureCode::SessionRecordConflict);
25478        shared
25479            .register_workspace(
25480                secondary.workspace_id,
25481                secondary_root.to_string_lossy().into_owned(),
25482            )
25483            .await
25484            .unwrap();
25485        let restored = shared.record(&record_id).unwrap();
25486        assert_eq!(restored.state, ManagedSessionState::Dormant);
25487        assert!(restored.last_error.is_none());
25488        std::fs::remove_dir_all(secondary_root).unwrap();
25489        std::fs::remove_dir_all(other_root).unwrap();
25490    }
25491
25492    #[test]
25493    fn dynamic_workspace_root_is_rejected_before_filesystem_canonicalization() {
25494        let error = validate_workspace_request_root(
25495            &"x".repeat(MAX_WORKSPACE_ROOT_BYTES + 1),
25496        )
25497        .unwrap_err();
25498        assert_eq!(error.code, NodeFailureCode::InvalidWorkspaceRoot);
25499        let control = validate_workspace_request_root("C:\\repo\nchild").unwrap_err();
25500        assert_eq!(control.code, NodeFailureCode::InvalidWorkspaceRoot);
25501    }
25502
25503    #[test]
25504    fn concurrent_publish_and_resync_never_acknowledge_an_unappended_event() {
25505        let catalog = active_registry().unwrap();
25506        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25507        let workspace = WorkspaceConfig::new(
25508            WorkspaceId::new("primary").unwrap(),
25509            std::env::current_dir().unwrap(),
25510        )
25511        .unwrap();
25512        let shared = NodeShared::new(
25513            handle,
25514            "fixture-token".to_owned(),
25515            NodeId::new("node-1").unwrap(),
25516            vec![workspace],
25517            vec![agent("claude")],
25518        );
25519        let publishers = 4_u64;
25520        let events_per_publisher = 256_u64;
25521        let start = std::sync::Barrier::new(publishers as usize + 1);
25522        let finished = AtomicU64::new(0);
25523
25524        std::thread::scope(|scope| {
25525            for _ in 0..publishers {
25526                scope.spawn(|| {
25527                    start.wait();
25528                    for _ in 0..events_per_publisher {
25529                        shared.publish(NodeEvent::ControllerChanged { controller: None });
25530                    }
25531                    finished.fetch_add(1, Ordering::Release);
25532                });
25533            }
25534            start.wait();
25535            while finished.load(Ordering::Acquire) != publishers {
25536                assert_resync_history_is_linearized(shared.resync(0));
25537                std::thread::yield_now();
25538            }
25539        });
25540        let response = shared.resync(0);
25541        assert_resync_history_is_linearized(response.clone());
25542        let NodeResponse::Resync { event_sequence, events, .. } = response else {
25543            unreachable!("resync helper returned another response");
25544        };
25545        assert_eq!(event_sequence, publishers * events_per_publisher);
25546        assert_eq!(events.len() as u64, event_sequence);
25547    }
25548
25549    fn assert_resync_history_is_linearized(response: NodeResponse) {
25550        let NodeResponse::Resync { event_sequence, events, .. } = response else {
25551            unreachable!("resync helper returned another response");
25552        };
25553        assert!(events.windows(2).all(|pair| {
25554            pair[0].sequence.checked_add(1) == Some(pair[1].sequence)
25555        }));
25556        assert_eq!(events.last().map_or(0, |event| event.sequence), event_sequence);
25557    }
25558
25559    #[test]
25560    fn canonical_workspace_roots_are_safe_for_cmd_exe() {
25561        assert_eq!(
25562            platform::normalize_canonical_root(r"\\?\C:\repo\worktree".to_owned()),
25563            r"C:\repo\worktree",
25564        );
25565        assert_eq!(
25566            platform::normalize_canonical_root(r"\\?\UNC\server\share\repo".to_owned()),
25567            r"\\server\share\repo",
25568        );
25569        assert!(validate_workspace_root(
25570            &WorkspaceId::new("network").unwrap(),
25571            r"\\server\share\repo",
25572        )
25573        .is_err());
25574        let workspace = WorkspaceConfig::new(
25575            WorkspaceId::new("primary").unwrap(),
25576            std::env::current_dir().unwrap(),
25577        )
25578        .unwrap();
25579        assert!(!workspace.canonical_root.starts_with(r"\\?\"));
25580    }
25581
25582    #[test]
25583    fn resume_rebinds_only_on_the_expected_authorized_generation() {
25584        let catalog = active_registry().unwrap();
25585        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25586        let workspace_id = WorkspaceId::new("primary").unwrap();
25587        let workspace = WorkspaceConfig::new(
25588            workspace_id.clone(),
25589            std::env::current_dir().unwrap(),
25590        )
25591        .unwrap();
25592        let shared = NodeShared::new(
25593            handle,
25594            "fixture-token".to_owned(),
25595            NodeId::new("node-1").unwrap(),
25596            vec![workspace],
25597            vec![agent("claude")],
25598        );
25599        let instance_id = AgentInstanceId(17);
25600        let original = SessionAddress {
25601            workspace_id,
25602            session: SessionKey {
25603                instance_id,
25604                generation: SessionGeneration(3),
25605            },
25606        };
25607        shared.bind_session(
25608            instance_id,
25609            original.workspace_id.clone(),
25610            original.session.generation,
25611        );
25612        shared
25613            .arm_resume(
25614                &original,
25615                CommandId(41),
25616                ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
25617            )
25618            .unwrap();
25619
25620        shared.publish_control(ControlEvent {
25621            sequence: 1,
25622            command_id: None,
25623            instance_id,
25624            generation: SessionGeneration(4),
25625            event: ControlEventKind::Running { process_id: None },
25626        });
25627        assert_eq!(
25628            shared.address_for(instance_id, SessionGeneration(3)),
25629            Some(original.clone()),
25630        );
25631        assert!(shared.address_for(instance_id, SessionGeneration(4)).is_none());
25632
25633        shared.publish_control(ControlEvent {
25634            sequence: 2,
25635            command_id: Some(CommandId(42)),
25636            instance_id,
25637            generation: SessionGeneration(3),
25638            event: ControlEventKind::CommandRejected {
25639                message: "unrelated command".to_owned(),
25640            },
25641        });
25642
25643        shared.publish_control(ControlEvent {
25644            sequence: 3,
25645            command_id: None,
25646            instance_id,
25647            generation: SessionGeneration(4),
25648            event: ControlEventKind::ResumeAuthorized {
25649                session: gate4agent_types::ResumeSessionSummary {
25650                    key: gate4agent_types::ProviderSessionKey::SessionId,
25651                    id: "fixture-session".to_owned(),
25652                },
25653            },
25654        });
25655        assert!(shared.address_for(instance_id, SessionGeneration(3)).is_none());
25656        let rebound = shared.address_for(instance_id, SessionGeneration(4)).unwrap();
25657        assert_eq!(rebound.workspace_id, original.workspace_id);
25658        let history = shared
25659            .history
25660            .lock()
25661            .unwrap_or_else(|poisoned| poisoned.into_inner());
25662        assert!(history.events.iter().any(|envelope| matches!(
25663            &envelope.event,
25664            NodeEvent::Control { address, event }
25665                if address == &rebound
25666                    && matches!(&event.event, ControlEventKind::ResumeAuthorized { .. })
25667        )));
25668    }
25669
25670    #[test]
25671    fn all_node_pty_prompts_use_bracketed_paste_framing() {
25672        for agent in ["claude", "codex", "kimi"] {
25673            assert_eq!(
25674                prompt_framing(&AgentId::new(agent).unwrap()),
25675                PromptFraming::BracketedPaste
25676            );
25677        }
25678    }
25679
25680    fn generous_walk_deadline() -> Instant {
25681        Instant::now() + Duration::from_secs(30)
25682    }
25683
25684    #[test]
25685    fn workspace_tree_is_relative_and_skips_heavy_directories() {
25686        let root = temporary_workspace_root("tree");
25687        std::fs::create_dir_all(root.join("src")).unwrap();
25688        std::fs::create_dir_all(root.join(".git/objects")).unwrap();
25689        std::fs::create_dir_all(root.join("target/debug")).unwrap();
25690        std::fs::create_dir_all(root.join("node_modules/package")).unwrap();
25691        std::fs::write(root.join("src/lib.rs"), b"pub fn fixture() {}\n").unwrap();
25692        std::fs::write(root.join("README.md"), b"fixture\n").unwrap();
25693        // A renamed/agent-scoped build directory (`target-a2`-style, per the
25694        // real repository's own build layout) that carries the Cache
25695        // Directory Tagging Specification marker cargo writes into every
25696        // `--target-dir` it creates: must be skipped even though its name
25697        // does not exactly match `target`.
25698        std::fs::create_dir_all(root.join("target-a2/debug")).unwrap();
25699        std::fs::write(root.join("target-a2/CACHEDIR.TAG"), b"Signature: 8a477f597d28d172789f06886806bc55\n").unwrap();
25700        // A directory that merely *starts with* `target` but carries no
25701        // CACHEDIR.TAG: the new prefix-adjacent check must not false-match
25702        // on the name alone, so this stays in the response.
25703        std::fs::create_dir_all(root.join("target-notes")).unwrap();
25704        std::fs::write(root.join("target-notes/plan.md"), b"not a build directory\n").unwrap();
25705
25706        let (entries, truncated, _budget) =
25707            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
25708        assert!(!truncated);
25709        assert!(entries.iter().any(|entry| {
25710            entry.relative_path.as_utf8() == Some("src")
25711                && entry.kind == WorkspaceEntryKind::Directory
25712        }));
25713        assert!(entries.iter().any(|entry| {
25714            entry.relative_path.as_utf8() == Some("src/lib.rs")
25715                && entry.kind == WorkspaceEntryKind::File
25716        }));
25717        assert!(entries.iter().any(|entry| {
25718            entry.relative_path.as_utf8() == Some("target-notes")
25719                && entry.kind == WorkspaceEntryKind::Directory
25720        }));
25721        assert!(entries.iter().any(|entry| {
25722            entry.relative_path.as_utf8() == Some("target-notes/plan.md")
25723                && entry.kind == WorkspaceEntryKind::File
25724        }));
25725        assert!(entries.iter().all(|entry| {
25726            let path = entry.relative_path.as_utf8().unwrap();
25727            !path.starts_with(".git")
25728                && !(path == "target" || path.starts_with("target/"))
25729                && !(path == "target-a2" || path.starts_with("target-a2/"))
25730                && !path.starts_with("node_modules")
25731        }));
25732
25733        std::fs::remove_dir_all(&root).unwrap();
25734    }
25735
25736    #[test]
25737    fn workspace_walk_stops_at_its_entry_cap_and_marks_a_partial_result() {
25738        let root = temporary_workspace_root("tree-entry-cap");
25739        std::fs::create_dir_all(&root).unwrap();
25740        for index in 0..20 {
25741            std::fs::write(root.join(format!("file-{index:02}.txt")), b"fixture\n").unwrap();
25742        }
25743
25744        let (entries, truncated, budget) =
25745            collect_workspace_entries(&root, generous_walk_deadline(), 5);
25746        assert!(truncated);
25747        assert!(budget.entry_cap_exceeded);
25748        assert!(!budget.time_budget_exceeded);
25749        assert!(entries.len() <= 5);
25750
25751        let inspection_truncation = workspace_inspection_truncation(&budget, false, Duration::from_millis(1))
25752            .expect("an entry-cap-truncated walk must report a truncation marker");
25753        assert!(inspection_truncation.walk_entry_cap_exceeded);
25754        assert!(!inspection_truncation.walk_time_budget_exceeded);
25755        assert!(!inspection_truncation.git_time_budget_exceeded);
25756        assert!(inspection_truncation.entries_visited >= 5);
25757
25758        std::fs::remove_dir_all(&root).unwrap();
25759    }
25760
25761    #[test]
25762    fn workspace_walk_within_budget_reports_no_truncation() {
25763        let root = temporary_workspace_root("tree-no-truncation");
25764        std::fs::create_dir_all(&root).unwrap();
25765        std::fs::write(root.join("only-file.txt"), b"fixture\n").unwrap();
25766
25767        let (_entries, truncated, budget) =
25768            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
25769        assert!(!truncated);
25770        assert!(workspace_inspection_truncation(&budget, false, Duration::from_millis(1)).is_none());
25771
25772        std::fs::remove_dir_all(&root).unwrap();
25773    }
25774
25775    #[test]
25776    fn workspace_walk_emits_strict_global_byte_order_for_prefix_named_siblings() {
25777        let root = temporary_workspace_root("tree-prefix-siblings");
25778        std::fs::create_dir_all(root.join("gate4agent-c2/src")).unwrap();
25779        std::fs::write(root.join("gate4agent-c2/src/lib.rs"), b"fixture\n").unwrap();
25780        std::fs::create_dir_all(root.join("gate4agent-c2-client")).unwrap();
25781        std::fs::write(root.join("gate4agent-c2-client/lib.rs"), b"fixture\n").unwrap();
25782
25783        let (entries, truncated, _budget) =
25784            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
25785        assert!(!truncated);
25786        let paths = entries
25787            .iter()
25788            .map(|entry| entry.relative_path.as_utf8().unwrap().to_owned())
25789            .collect::<Vec<_>>();
25790        assert!(paths.windows(2).all(|pair| pair[0] < pair[1]));
25791        assert_eq!(
25792            paths,
25793            vec![
25794                "gate4agent-c2".to_owned(),
25795                "gate4agent-c2-client".to_owned(),
25796                "gate4agent-c2-client/lib.rs".to_owned(),
25797                "gate4agent-c2/src".to_owned(),
25798                "gate4agent-c2/src/lib.rs".to_owned(),
25799            ],
25800        );
25801
25802        std::fs::remove_dir_all(&root).unwrap();
25803    }
25804
25805    #[test]
25806    fn git_parsers_cap_status_and_commit_results() {
25807        let mut status_output = Vec::new();
25808        for index in 0..=GIT_STATUS_MAX_ENTRIES {
25809            status_output.extend_from_slice(format!(" M src/file-{index}.rs\0").as_bytes());
25810        }
25811        let mut snapshot = git_snapshot_for_parser();
25812        parse_git_status(&status_output, &mut snapshot);
25813        assert_eq!(snapshot.status.len(), GIT_STATUS_MAX_ENTRIES);
25814        assert!(snapshot.truncated);
25815        assert_eq!(snapshot.status[0].index_status, " ");
25816        assert_eq!(snapshot.status[0].worktree_status, "M");
25817
25818        snapshot.truncated = false;
25819        let commit_output = (0..=GIT_COMMIT_MAX_ENTRIES)
25820            .map(|index| format!("abc{index}\u{1f}commit {index}\n"))
25821            .collect::<String>();
25822        parse_git_commits(commit_output.as_bytes(), &mut snapshot);
25823        assert_eq!(snapshot.recent_commits.len(), GIT_COMMIT_MAX_ENTRIES);
25824        assert!(snapshot.truncated);
25825    }
25826
25827    #[test]
25828    fn porcelain_v1_z_parser_preserves_current_then_previous_rename_and_copy_order() {
25829        let mut snapshot = git_snapshot_for_parser();
25830        parse_git_status(
25831            b"R  src/current name.rs\0src/previous name.rs\0 C src/copy.rs\0src/original.rs\0?? src/line\nname.rs\0",
25832            &mut snapshot,
25833        );
25834
25835        assert!(!snapshot.truncated);
25836        assert_eq!(snapshot.status.len(), 3);
25837        assert_eq!(snapshot.status[0].index_status, "R");
25838        assert_eq!(snapshot.status[0].worktree_status, " ");
25839        assert_eq!(snapshot.status[0].path.as_utf8(), Some("src/current name.rs"));
25840        assert_eq!(
25841            snapshot.status[0]
25842                .previous_path
25843                .as_ref()
25844                .and_then(RepositoryPath::as_utf8),
25845            Some("src/previous name.rs"),
25846        );
25847        assert_eq!(snapshot.status[1].index_status, " ");
25848        assert_eq!(snapshot.status[1].worktree_status, "C");
25849        assert_eq!(snapshot.status[1].path.as_utf8(), Some("src/copy.rs"));
25850        assert_eq!(
25851            snapshot.status[1]
25852                .previous_path
25853                .as_ref()
25854                .and_then(RepositoryPath::as_utf8),
25855            Some("src/original.rs"),
25856        );
25857        assert_eq!(snapshot.status[2].path.as_utf8(), Some("src/line\nname.rs"));
25858        assert!(snapshot.status[2].previous_path.is_none());
25859    }
25860
25861    #[test]
25862    fn porcelain_v1_z_parser_never_invents_invalid_or_incomplete_path_identity() {
25863        let mut snapshot = git_snapshot_for_parser();
25864        parse_git_status(
25865            b"?? valid.rs\0?? invalid-\xff.rs\0?? invalid\\windows.rs\0R  current.rs\0",
25866            &mut snapshot,
25867        );
25868
25869        assert!(snapshot.truncated);
25870        assert_eq!(snapshot.status.len(), 1);
25871        assert_eq!(snapshot.status[0].path.as_utf8(), Some("valid.rs"));
25872        assert!(snapshot.status[0].previous_path.is_none());
25873    }
25874
25875    #[tokio::test]
25876    async fn observer_can_browse_host_directories_without_controller() {
25877        let root = temporary_workspace_root("host-directory-observer");
25878        std::fs::create_dir_all(root.join("directory-b")).unwrap();
25879        std::fs::create_dir_all(root.join("directory-a")).unwrap();
25880        std::fs::write(root.join("ordinary-file"), b"not a directory").unwrap();
25881        let workspace_id = WorkspaceId::new("primary").unwrap();
25882        let workspace = WorkspaceConfig::new(workspace_id, &root).unwrap();
25883        let catalog = active_registry().unwrap();
25884        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25885        let shared = NodeShared::new(
25886            handle,
25887            "fixture-token".to_owned(),
25888            NodeId::new("node-1").unwrap(),
25889            vec![workspace],
25890            vec![agent("claude")],
25891        );
25892        let request = NodeRequest::BrowseHostDirectories {
25893            directory: Some(OpaqueHostPath::utf8(root.to_string_lossy().into_owned()).unwrap()),
25894            after: None,
25895        };
25896        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
25897            capability.as_str() == CAPABILITY_HOST_DIRECTORY_BROWSE_V1
25898        }));
25899        assert!(!request_uses_unnegotiated_capability(
25900            &request,
25901            &baseline_capabilities().unwrap(),
25902        ));
25903        assert!(request_uses_unnegotiated_capability(&request, &[]));
25904
25905        let response = process_request_inner(&shared, 77, ClientRole::Observer, request)
25906            .await
25907            .unwrap();
25908        let NodeResponse::HostDirectoriesBrowsed { listing } = response else {
25909            panic!("host directory browse returned another response");
25910        };
25911        assert_eq!(
25912            listing
25913                .entries
25914                .iter()
25915                .map(|entry| entry.display_name.as_str())
25916                .collect::<Vec<_>>(),
25917            vec!["directory-a", "directory-b"],
25918        );
25919        assert!(listing.entries.iter().all(|entry| !entry.is_link));
25920        assert!(!listing.incomplete);
25921        std::fs::remove_dir_all(root).unwrap();
25922    }
25923
25924    #[tokio::test]
25925    async fn observer_can_inspect_only_a_registered_workspace() {
25926        let root = temporary_workspace_root("observer");
25927        std::fs::create_dir_all(root.join("src")).unwrap();
25928        std::fs::write(root.join("src/main.rs"), b"fn main() {}\n").unwrap();
25929        let workspace_id = WorkspaceId::new("primary").unwrap();
25930        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
25931        let catalog = active_registry().unwrap();
25932        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25933        let shared = NodeShared::new(
25934            handle,
25935            "fixture-token".to_owned(),
25936            NodeId::new("node-1").unwrap(),
25937            vec![workspace],
25938            vec![agent("claude")],
25939        );
25940
25941        let response = process_request_inner(
25942            &shared,
25943            77,
25944            ClientRole::Observer,
25945            NodeRequest::InspectWorkspace {
25946                workspace_id: workspace_id.clone(),
25947            },
25948        )
25949        .await
25950        .unwrap();
25951        let NodeResponse::WorkspaceInspected { inspection } = response else {
25952            panic!("workspace inspection returned another response");
25953        };
25954        assert_eq!(inspection.workspace_id, workspace_id);
25955        assert!(inspection.entries.iter().any(|entry| {
25956            entry.relative_path.as_utf8() == Some("src/main.rs")
25957        }));
25958
25959        let unknown = process_request_inner(
25960            &shared,
25961            77,
25962            ClientRole::Observer,
25963            NodeRequest::InspectWorkspace {
25964                workspace_id: WorkspaceId::new("unknown").unwrap(),
25965            },
25966        )
25967        .await
25968        .unwrap_err();
25969        assert_eq!(unknown.code, NodeFailureCode::UnknownWorkspace);
25970        std::fs::remove_dir_all(&root).unwrap();
25971    }
25972
25973    #[test]
25974    fn context_pack_source_accepts_only_running_or_successful_exit() {
25975        assert!(context_pack_source_status_is_usable(&SessionStatus::Running));
25976        assert!(context_pack_source_status_is_usable(&SessionStatus::Exited {
25977            exit_code: Some(0),
25978        }));
25979        assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
25980            exit_code: Some(1),
25981        }));
25982        assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
25983            exit_code: None,
25984        }));
25985        assert!(!context_pack_source_status_is_usable(&SessionStatus::Failed {
25986            message: "provider failed".to_owned(),
25987        }));
25988        assert!(!context_pack_source_status_is_usable(&SessionStatus::Registered));
25989        assert!(!context_pack_source_status_is_usable(&SessionStatus::Starting));
25990        assert!(!context_pack_source_status_is_usable(&SessionStatus::Stopping));
25991    }
25992
25993    #[test]
25994    fn context_pack_repository_rejects_changed_head_with_same_git_snapshot() {
25995        let git = git_snapshot_for_parser();
25996        let first = ContextPackRepositoryObservation {
25997            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
25998            git: git.clone(),
25999            files: Vec::new(),
26000        };
26001        let second = ContextPackRepositoryObservation {
26002            head: ContextPackRepositoryHead::Commit("b".repeat(40)),
26003            git,
26004            files: Vec::new(),
26005        };
26006
26007        let error = stable_context_pack_repository(first, second).unwrap_err();
26008
26009        assert_eq!(
26010            error.code,
26011            NodeFailureCode::ContextPackMaterializationFailed,
26012        );
26013    }
26014
26015    #[test]
26016    fn context_pack_repository_rejects_changed_file_with_same_git_snapshot() {
26017        let git = git_snapshot_for_parser();
26018        let first = ContextPackRepositoryObservation {
26019            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
26020            git: git.clone(),
26021            files: vec![ContextPackRepositoryFileSource::utf8(
26022                "README.md",
26023                "first\n".to_owned(),
26024                6,
26025            )],
26026        };
26027        let second = ContextPackRepositoryObservation {
26028            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
26029            git,
26030            files: vec![ContextPackRepositoryFileSource::utf8(
26031                "README.md",
26032                "other\n".to_owned(),
26033                6,
26034            )],
26035        };
26036
26037        let error = stable_context_pack_repository(first, second).unwrap_err();
26038
26039        assert_eq!(
26040            error.code,
26041            NodeFailureCode::ContextPackMaterializationFailed,
26042        );
26043    }
26044
26045    #[tokio::test]
26046    async fn native_session_catalog_zero_active_session_fixture() {
26047        let workspace_root = temporary_workspace_root("native-catalog-workspace");
26048        let history_root = temporary_workspace_root("native-catalog-history");
26049        let outside_root = temporary_workspace_root("native-catalog-outside");
26050        std::fs::create_dir_all(&workspace_root).unwrap();
26051        std::fs::create_dir_all(&history_root).unwrap();
26052        std::fs::create_dir_all(&outside_root).unwrap();
26053        let transcript = |session_id: &str, cwd: &Path, title: &str| {
26054            [
26055                serde_json::json!({
26056                    "type": "user",
26057                    "sessionId": session_id,
26058                    "cwd": cwd.to_str().unwrap(),
26059                    "message": { "content": "raw user text must not cross the catalog wire" }
26060                }),
26061                serde_json::json!({
26062                    "type": "assistant",
26063                    "sessionId": session_id,
26064                    "cwd": cwd.to_str().unwrap(),
26065                    "message": { "model": "claude-sonnet", "content": "raw answer" }
26066                }),
26067                serde_json::json!({
26068                    "type": "ai-title",
26069                    "sessionId": session_id,
26070                    "aiTitle": title
26071                }),
26072            ]
26073            .into_iter()
26074            .map(|record| record.to_string())
26075            .collect::<Vec<_>>()
26076            .join("\n")
26077        };
26078        std::fs::write(
26079            history_root.join("stable-session.jsonl"),
26080            transcript("stable-session", &workspace_root, "Workspace review"),
26081        )
26082        .unwrap();
26083        std::fs::write(
26084            history_root.join("outside-session.jsonl"),
26085            transcript("outside-session", &outside_root, "Outside review"),
26086        )
26087        .unwrap();
26088        let history = NativeHistoryConfig::new(vec![
26089            NativeHistoryRoot::new(
26090                gate4agent_types::AdapterId::new("claude-code").unwrap(),
26091                HistorySourceLayout::SingleNdjson,
26092                history_root.clone(),
26093            )
26094            .unwrap(),
26095        ])
26096        .unwrap();
26097        let catalog = active_registry().unwrap();
26098        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26099        assert!(handle.snapshot().sessions.is_empty());
26100        let workspace_id = WorkspaceId::new("primary").unwrap();
26101        let workspace = WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap();
26102        let mut shared = NodeShared::new(
26103            handle,
26104            "fixture-token".to_owned(),
26105            NodeId::new("node-native-catalog").unwrap(),
26106            vec![workspace],
26107            vec![agent("claude")],
26108        );
26109        shared.native_session_catalog = Some(Arc::new(Mutex::new(
26110            NativeSessionCatalogAuthority::new(history),
26111        )));
26112        let route = NativeSessionCatalogRoute::workspace(
26113            workspace_id.clone(),
26114            agent("claude"),
26115        );
26116        let request = NodeRequest::CatalogNativeSessions {
26117            route: route.clone(),
26118            limit: 2,
26119        };
26120        let observer = process_request_inner(&shared, 7, ClientRole::Observer, request.clone())
26121            .await
26122            .unwrap_err();
26123        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26124        let response = process_request_inner(&shared, 7, ClientRole::Operator, request)
26125            .await
26126            .unwrap();
26127        let NodeResponse::NativeSessionsCataloged { entries, summary, .. } = response else {
26128            panic!("native catalog returned a different response");
26129        };
26130        let summary = summary.expect("node must emit native session catalog summary metadata");
26131        assert_eq!(entries.len(), 1);
26132        assert!(gate4agent_types::validate_candidate_id(&entries[0].selection_id).is_ok());
26133        assert_eq!(entries[0].external_group, None);
26134        assert_eq!(entries[0].record_id, None);
26135        assert_eq!(entries[0].title, None);
26136        assert_eq!(entries[0].model, None);
26137        assert_eq!(entries[0].message_count, 0);
26138        let encoded_entries = serde_json::to_string(&entries).unwrap();
26139        assert!(!encoded_entries.contains("stable-session"));
26140        assert!(!encoded_entries.contains(history_root.to_string_lossy().as_ref()));
26141        let page_request = NodeRequest::PageNativeSessions {
26142            route: route.clone(),
26143            window: crate::protocol::NativeSessionCatalogWindow::Recent,
26144            catalog_revision: summary.catalog_revision,
26145            recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
26146            after_selection_id: None,
26147            limit: 2,
26148        };
26149        let observer = process_request_inner(
26150            &shared,
26151            7,
26152            ClientRole::Observer,
26153            page_request.clone(),
26154        )
26155        .await
26156        .unwrap_err();
26157        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26158        let page_response = process_request_inner(
26159            &shared,
26160            7,
26161            ClientRole::Operator,
26162            page_request.clone(),
26163        )
26164        .await
26165        .unwrap();
26166        assert!(matches!(
26167            page_response,
26168            NodeResponse::NativeSessionsPaged { page, .. }
26169                if page.entries.len() == 1 && !page.has_more && page.remaining_count == 0
26170        ));
26171        let stale = process_request_inner(
26172            &shared,
26173            7,
26174            ClientRole::Operator,
26175            NodeRequest::PageNativeSessions {
26176                route: route.clone(),
26177                window: crate::protocol::NativeSessionCatalogWindow::Recent,
26178                catalog_revision: summary.catalog_revision.wrapping_add(1),
26179                recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
26180                after_selection_id: None,
26181                limit: 2,
26182            },
26183        )
26184        .await
26185        .unwrap_err();
26186        assert_eq!(stale.code, NodeFailureCode::StaleNativeSessionCatalog);
26187        let selection = NativeSessionSelection {
26188            route: route.clone(),
26189            catalog_revision: summary.catalog_revision,
26190            recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
26191            selection_id: entries[0].selection_id.clone(),
26192        };
26193        let preview_response = process_request_inner(
26194            &shared,
26195            7,
26196            ClientRole::Operator,
26197            NodeRequest::PreviewNativeSession {
26198                selection: selection.clone(),
26199                message_limit: 1,
26200            },
26201        )
26202        .await
26203        .unwrap();
26204        let NodeResponse::NativeSessionPreviewed { preview, .. } = preview_response else {
26205            panic!("native preview returned a different response");
26206        };
26207        assert_eq!(preview.messages.len(), 1);
26208        assert!(preview.message_count_exact);
26209        assert_eq!(preview.messages[0].text, "raw answer");
26210        assert!(preview.truncated);
26211
26212        let index_request = NodeRequest::IndexNativeSession {
26213            selection: selection.clone(),
26214            display_name: "Saved Claude".to_owned(),
26215        };
26216        let observer = process_request_inner(
26217            &shared,
26218            7,
26219            ClientRole::Observer,
26220            index_request.clone(),
26221        )
26222        .await
26223        .unwrap_err();
26224        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26225        let without_controller = process_request_inner(
26226            &shared,
26227            7,
26228            ClientRole::Operator,
26229            index_request.clone(),
26230        )
26231        .await
26232        .unwrap_err();
26233        assert_eq!(without_controller.code, NodeFailureCode::ControllerRequired);
26234        let acquired = process_request_inner(
26235            &shared,
26236            7,
26237            ClientRole::Operator,
26238            NodeRequest::AcquireController { lease_ms: 5_000 },
26239        )
26240        .await
26241        .unwrap();
26242        assert!(matches!(acquired, NodeResponse::Controller { controller: Some(_) }));
26243        let indexed = process_request_inner(
26244            &shared,
26245            7,
26246            ClientRole::Operator,
26247            index_request,
26248        )
26249        .await
26250        .unwrap();
26251        assert!(indexed.native_session_index_contract_is_valid());
26252        let NodeResponse::NativeSessionIndexed {
26253            selection: echoed_selection,
26254            record,
26255        } = indexed else {
26256            panic!("native session index returned a different response");
26257        };
26258        assert_eq!(echoed_selection, selection);
26259        let recataloged = process_request_inner(
26260            &shared,
26261            7,
26262            ClientRole::Operator,
26263            NodeRequest::CatalogNativeSessions {
26264                route,
26265                limit: 2,
26266            },
26267        )
26268        .await
26269        .unwrap();
26270        let NodeResponse::NativeSessionsCataloged { entries, .. } = recataloged else {
26271            panic!("native catalog returned a different response after indexing");
26272        };
26273        assert_eq!(entries[0].record_id.as_ref(), Some(&record.record_id));
26274
26275        let unregistered_route = NativeSessionCatalogRoute::unregistered(agent("claude"));
26276        let external_catalog = process_request_inner(
26277            &shared,
26278            7,
26279            ClientRole::Operator,
26280            NodeRequest::CatalogNativeSessions {
26281                route: unregistered_route.clone(),
26282                limit: 2,
26283            },
26284        )
26285        .await
26286        .unwrap();
26287        let external_json = serde_json::to_string(&external_catalog).unwrap();
26288        assert!(!external_json.contains("outside-session"));
26289        assert!(!external_json.contains(outside_root.to_string_lossy().as_ref()));
26290        assert!(!external_json.contains(history_root.to_string_lossy().as_ref()));
26291        assert!(!external_json.contains("\"session_id\""));
26292        assert!(!external_json.contains("\"cwd\""));
26293        assert!(!external_json.contains("\"path\""));
26294        let NodeResponse::NativeSessionsCataloged {
26295            route: echoed_route,
26296            entries: external_entries,
26297            summary: Some(external_summary),
26298        } = external_catalog
26299        else {
26300            panic!("unregistered native catalog returned a different response");
26301        };
26302        assert_eq!(echoed_route, unregistered_route);
26303        assert_eq!(external_entries.len(), 1);
26304        assert_eq!(external_entries[0].record_id, None);
26305        let external_group = external_entries[0]
26306            .external_group
26307            .as_ref()
26308            .expect("unregistered session must have an external group");
26309        assert_eq!(
26310            external_group.kind,
26311            gate4agent_types::NativeSessionExternalGroupKind::Project,
26312        );
26313        assert!(external_group.group_id.starts_with("external-"));
26314        assert_eq!(
26315            external_group.display_name,
26316            outside_root.file_name().unwrap().to_string_lossy(),
26317        );
26318        let external_selection = NativeSessionSelection {
26319            route: unregistered_route,
26320            catalog_revision: external_summary.catalog_revision,
26321            recent_cutoff_unix_ms: external_summary.recent_cutoff_unix_ms,
26322            selection_id: external_entries[0].selection_id.clone(),
26323        };
26324        let external_preview = process_request_inner(
26325            &shared,
26326            7,
26327            ClientRole::Operator,
26328            NodeRequest::PreviewNativeSession {
26329                selection: external_selection.clone(),
26330                message_limit: 1,
26331            },
26332        )
26333        .await
26334        .unwrap();
26335        let external_preview_json = serde_json::to_string(&external_preview).unwrap();
26336        assert!(!external_preview_json.contains("outside-session"));
26337        assert!(!external_preview_json.contains(outside_root.to_string_lossy().as_ref()));
26338        assert!(!external_preview_json.contains("\"session_id\""));
26339        let NodeResponse::NativeSessionPreviewed { preview, .. } = external_preview else {
26340            panic!("unregistered native preview returned a different response");
26341        };
26342        assert_eq!(preview.messages.len(), 1);
26343        let external_index = process_request_inner(
26344            &shared,
26345            7,
26346            ClientRole::Operator,
26347            NodeRequest::IndexNativeSession {
26348                selection: external_selection,
26349                display_name: "External Claude".to_owned(),
26350            },
26351        )
26352        .await
26353        .unwrap_err();
26354        assert_eq!(
26355            external_index.code,
26356            NodeFailureCode::WorkspaceRegistrationRequired,
26357        );
26358        let record_response = process_request_inner(
26359            &shared,
26360            7,
26361            ClientRole::Operator,
26362            NodeRequest::PreviewSessionRecord {
26363                record_id: record.record_id.clone(),
26364                message_limit: 2,
26365            },
26366        )
26367        .await
26368        .unwrap();
26369        let NodeResponse::SessionRecordPreviewed { record_id, preview } = record_response else {
26370            panic!("record preview returned a different response");
26371        };
26372        assert_eq!(record_id, record.record_id);
26373        assert_eq!(preview.messages.len(), 2);
26374        let encoded = serde_json::to_string(&preview).unwrap();
26375        assert!(!encoded.contains("stable-session"));
26376        assert!(!encoded.contains(history_root.to_string_lossy().as_ref()));
26377        let NodeResponse::Resync { events, .. } = shared.resync(0) else {
26378            panic!("record preview history summary returned a different response");
26379        };
26380        let summaries = events
26381            .iter()
26382            .filter_map(|envelope| match &envelope.event {
26383                NodeEvent::SessionRecordHistorySummarized { record_id, summary }
26384                    if record_id == &record.record_id =>
26385                {
26386                    Some(*summary)
26387                }
26388                _ => None,
26389            })
26390            .collect::<Vec<_>>();
26391        assert_eq!(summaries, vec![SessionHistorySummaryV1::from(&preview)]);
26392        let summary_json = serde_json::to_string(&summaries).unwrap();
26393        for private in [
26394            "stable-session",
26395            "raw user text must not cross the catalog wire",
26396            "raw answer",
26397            "Workspace review",
26398            "claude-sonnet",
26399        ] {
26400            assert!(!summary_json.contains(private), "{private}");
26401        }
26402        let identity = record.provider_session.clone().unwrap();
26403        assert_eq!(
26404            shared.revalidate_session_record_preview(&record, &identity),
26405            Ok(())
26406        );
26407        shared.remove_record_memory(&record.record_id);
26408        assert_eq!(
26409            shared
26410                .revalidate_session_record_preview(&record, &identity)
26411                .unwrap_err()
26412                .code,
26413            NodeFailureCode::UnknownSessionRecord
26414        );
26415        let released = process_request_inner(
26416            &shared,
26417            7,
26418            ClientRole::Operator,
26419            NodeRequest::ReleaseController,
26420        )
26421        .await
26422        .unwrap();
26423        assert!(matches!(released, NodeResponse::Controller { controller: None }));
26424        assert!(shared.controller_state().is_none());
26425        std::fs::remove_dir_all(workspace_root).unwrap();
26426        std::fs::remove_dir_all(history_root).unwrap();
26427        std::fs::remove_dir_all(outside_root).unwrap();
26428    }
26429
26430    #[tokio::test]
26431    async fn native_session_catalog_concurrent_operation_fails_fast_without_waiting_on_mutex() {
26432        let history_root = temporary_workspace_root("native-catalog-concurrent-operation");
26433        std::fs::create_dir_all(&history_root).unwrap();
26434        let history = NativeHistoryConfig::new(vec![
26435            NativeHistoryRoot::new(
26436                gate4agent_types::AdapterId::new("claude-code").unwrap(),
26437                HistorySourceLayout::SingleNdjson,
26438                history_root.clone(),
26439            )
26440            .unwrap(),
26441        ])
26442        .unwrap();
26443        let catalog = Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)));
26444        let held = catalog.lock().unwrap();
26445        let result = timeout(
26446            Duration::from_secs(1),
26447            run_native_session_catalog_operation(
26448                Arc::clone(&catalog),
26449                "native session catalog",
26450                |_| Ok::<(), std::convert::Infallible>(()),
26451            ),
26452        )
26453        .await
26454        .expect("concurrent native catalog request must not wait for the operation deadline")
26455        .unwrap_err();
26456        assert_eq!(result.code, NodeFailureCode::BackendBusy);
26457        drop(held);
26458        std::fs::remove_dir_all(history_root).unwrap();
26459    }
26460
26461    fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
26462        let unique = std::time::SystemTime::now()
26463            .duration_since(std::time::UNIX_EPOCH)
26464            .unwrap()
26465            .as_nanos();
26466        std::env::temp_dir().join(format!(
26467            "gate4agent-node-{label}-{}-{unique}",
26468            std::process::id(),
26469        ))
26470    }
26471
26472    fn git_snapshot_for_parser() -> GitSnapshot {
26473        GitSnapshot {
26474            is_repository: true,
26475            branch: Some("main".to_owned()),
26476            status: Vec::new(),
26477            recent_commits: Vec::new(),
26478            worktrees: Vec::new(),
26479            managed_worktree: None,
26480            truncated: false,
26481            diagnostic: None,
26482        }
26483    }
26484
26485    #[tokio::test]
26486    async fn workspace_inspection_scopes_only_exact_active_managed_branch() {
26487        let root = temporary_workspace_root("managed-git-scope");
26488        std::fs::create_dir_all(&root).unwrap();
26489        run_test_git(&root, &["init"]);
26490        run_test_git(&root, &["config", "user.email", "gate4agent@example.invalid"]);
26491        run_test_git(&root, &["config", "user.name", "Gate4Agent Test"]);
26492        std::fs::write(root.join("seed.txt"), "seed\n").unwrap();
26493        run_test_git(&root, &["add", "seed.txt"]);
26494        run_test_git(&root, &["commit", "-m", "seed"]);
26495        run_test_git(&root, &["checkout", "-b", "gate4agent/a"]);
26496
26497        let workspace_id = WorkspaceId::new("managed-a").unwrap();
26498        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
26499        let catalog = active_registry().unwrap();
26500        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26501        let shared = NodeShared::new(
26502            handle,
26503            "fixture-token".to_owned(),
26504            NodeId::new("node-1").unwrap(),
26505            vec![workspace],
26506            vec![agent("claude")],
26507        );
26508        let mut lease = ManagedWorktreeLeaseRecord {
26509            lease_id: ManagedWorktreeLeaseId::new("mw-a").unwrap(),
26510            source_workspace_id: WorkspaceId::new("primary").unwrap(),
26511            workspace_id: workspace_id.clone(),
26512            profile_id: WorktreeProfileId::new("default").unwrap(),
26513            profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
26514            target_root: root.to_string_lossy().into_owned(),
26515            branch: "gate4agent/a".to_owned(),
26516            base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
26517            expected_head: None,
26518            retention: ManagedWorktreeRetention::Retain,
26519            state: ManagedWorktreeLeaseState::InUse,
26520            session_holders: vec![ManagedWorktreeSessionHolder {
26521                incarnation_id: shared.incarnation_id,
26522                instance_id: AgentInstanceId(7),
26523                generation: SessionGeneration(1),
26524            }],
26525            record_holders: Vec::new(),
26526            cleanup_failure: None,
26527            created_at_unix_ms: 1,
26528            updated_at_unix_ms: 1,
26529        };
26530        *shared
26531            .managed_worktrees
26532            .lock()
26533            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
26534            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
26535
26536        let inspection = shared.inspect_workspace(workspace_id.clone()).await.unwrap();
26537        let scope = inspection.git.managed_worktree.unwrap();
26538        assert_eq!(scope.lease_id, lease.lease_id);
26539        assert_eq!(scope.branch, "gate4agent/a");
26540        assert_eq!(scope.active_session_count, 1);
26541
26542        lease.branch = "gate4agent/b".to_owned();
26543        *shared
26544            .managed_worktrees
26545            .lock()
26546            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
26547            ManagedWorktreeRegistry::from_records(vec![lease], Vec::new()).unwrap();
26548        let mismatch = shared.inspect_workspace(workspace_id).await.unwrap();
26549        assert!(mismatch.git.managed_worktree.is_none());
26550        std::fs::remove_dir_all(root).unwrap();
26551    }
26552
26553    #[tokio::test]
26554    async fn workspace_file_write_requires_controller_and_rejects_stale_revision() {
26555        let root = temporary_workspace_root("workspace-file-write-controller");
26556        std::fs::create_dir_all(root.join("src")).unwrap();
26557        std::fs::write(root.join("src/lib.rs"), b"old\n").unwrap();
26558        let workspace_id = WorkspaceId::new("primary").unwrap();
26559        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
26560        let catalog = active_registry().unwrap();
26561        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26562        let shared = NodeShared::new(
26563            handle,
26564            "fixture-token".to_owned(),
26565            NodeId::new("node-1").unwrap(),
26566            vec![workspace],
26567            vec![agent("claude")],
26568        );
26569        let path = RepositoryPath::utf8("src/lib.rs".to_owned()).unwrap();
26570        let expected_revision = workspace_file_revision(b"old\n");
26571        let request = NodeRequest::WriteWorkspaceFile {
26572            workspace_id: workspace_id.clone(),
26573            path: path.clone(),
26574            expected_revision: expected_revision.clone(),
26575            text: "new\n".to_owned(),
26576        };
26577        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
26578            .await
26579            .unwrap_err();
26580        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26581        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
26582            .await
26583            .unwrap_err();
26584        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
26585        shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
26586        let response = process_request_inner(&shared, 77, ClientRole::Operator, request).await.unwrap();
26587        let NodeResponse::WorkspaceFileWritten { file } = response else {
26588            panic!("workspace file write returned another response");
26589        };
26590        assert_eq!(file.revision, Some(workspace_file_revision(b"new\n")));
26591        assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
26592        let stale = process_request_inner(
26593            &shared,
26594            77,
26595            ClientRole::Operator,
26596            NodeRequest::WriteWorkspaceFile {
26597                workspace_id,
26598                path,
26599                expected_revision,
26600                text: "lost update\n".to_owned(),
26601            },
26602        )
26603        .await
26604        .unwrap_err();
26605        assert_eq!(stale.code, NodeFailureCode::RepositoryFileRevisionConflict);
26606        assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
26607        std::fs::remove_dir_all(root).unwrap();
26608    }
26609
26610    #[tokio::test]
26611    async fn workspace_entry_create_requires_controller_and_never_overwrites() {
26612        let root = temporary_workspace_root("workspace-entry-create-controller");
26613        std::fs::create_dir_all(root.join("src")).unwrap();
26614        let workspace_id = WorkspaceId::new("primary").unwrap();
26615        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
26616        let catalog = active_registry().unwrap();
26617        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26618        let shared = NodeShared::new(
26619            handle,
26620            "fixture-token".to_owned(),
26621            NodeId::new("node-1").unwrap(),
26622            vec![workspace],
26623            vec![agent("claude")],
26624        );
26625        let path = RepositoryPath::utf8("src/new.rs".to_owned()).unwrap();
26626        let request = NodeRequest::CreateWorkspaceFile {
26627            workspace_id: workspace_id.clone(),
26628            path: path.clone(),
26629        };
26630        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
26631            capability.as_str() == NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY
26632        }));
26633        assert!(!request_uses_unnegotiated_capability(
26634            &request,
26635            &baseline_capabilities().unwrap(),
26636        ));
26637        assert!(request_uses_unnegotiated_capability(&request, &[]));
26638        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
26639            .await
26640            .unwrap_err();
26641        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26642        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
26643            .await
26644            .unwrap_err();
26645        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
26646        shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
26647
26648        let response = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
26649            .await
26650            .unwrap();
26651        let NodeResponse::WorkspaceFileCreated { file } = response else {
26652            panic!("workspace file create returned another response");
26653        };
26654        assert_eq!(file.workspace_id, workspace_id);
26655        assert_eq!(file.path, path);
26656        assert_eq!(file.revision, Some(workspace_file_revision(&[])));
26657        assert_eq!(std::fs::read(root.join("src/new.rs")).unwrap(), Vec::<u8>::new());
26658
26659        let duplicate = process_request_inner(&shared, 77, ClientRole::Operator, request)
26660            .await
26661            .unwrap_err();
26662        assert_eq!(duplicate.code, NodeFailureCode::RepositoryEntryAlreadyExists);
26663
26664        let directory_path = RepositoryPath::utf8("src/nested".to_owned()).unwrap();
26665        let response = process_request_inner(
26666            &shared,
26667            77,
26668            ClientRole::Operator,
26669            NodeRequest::CreateWorkspaceDirectory {
26670                workspace_id: workspace_id.clone(),
26671                path: directory_path.clone(),
26672            },
26673        )
26674        .await
26675        .unwrap();
26676        assert_eq!(
26677            response,
26678            NodeResponse::WorkspaceDirectoryCreated {
26679                workspace_id: workspace_id.clone(),
26680                entry: WorkspaceEntry {
26681                    relative_path: directory_path,
26682                    kind: WorkspaceEntryKind::Directory,
26683                },
26684            },
26685        );
26686        assert!(root.join("src/nested").is_dir());
26687
26688        let missing_parent = process_request_inner(
26689            &shared,
26690            77,
26691            ClientRole::Operator,
26692            NodeRequest::CreateWorkspaceFile {
26693                workspace_id: workspace_id.clone(),
26694                path: RepositoryPath::utf8("missing/new.rs".to_owned()).unwrap(),
26695            },
26696        )
26697        .await
26698        .unwrap_err();
26699        assert_eq!(missing_parent.code, NodeFailureCode::RepositoryParentNotFound);
26700        let non_directory_parent = process_request_inner(
26701            &shared,
26702            77,
26703            ClientRole::Operator,
26704            NodeRequest::CreateWorkspaceFile {
26705                workspace_id,
26706                path: RepositoryPath::utf8("src/new.rs/child".to_owned()).unwrap(),
26707            },
26708        )
26709        .await
26710        .unwrap_err();
26711        assert_eq!(
26712            non_directory_parent.code,
26713            NodeFailureCode::RepositoryParentNotDirectory,
26714        );
26715        std::fs::remove_dir_all(root).unwrap();
26716    }
26717
26718    #[tokio::test]
26719    async fn workspace_entry_create_timeout_cancels_before_commit_without_late_mutation() {
26720        let root = temporary_workspace_root("workspace-entry-create-timeout-cancel");
26721        std::fs::create_dir_all(root.join("src")).unwrap();
26722        #[cfg(windows)]
26723        let root = std::fs::canonicalize(root).unwrap();
26724        let path = RepositoryPath::utf8("src/late.rs".to_owned()).unwrap();
26725        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
26726        let barrier = Arc::new(std::sync::Barrier::new(2));
26727        let done = Arc::new(AtomicBool::new(false));
26728        let task_commit_state = Arc::clone(&commit_state);
26729        let task_barrier = Arc::clone(&barrier);
26730        let task_done = Arc::clone(&done);
26731        let task_root = root.clone();
26732        let task = tokio::task::spawn_blocking(move || {
26733            task_barrier.wait();
26734            let result = create_workspace_file_on_disk(
26735                &task_root,
26736                &path,
26737                &task_commit_state,
26738            );
26739            task_done.store(true, Ordering::Release);
26740            result
26741        });
26742
26743        let error = settle_workspace_entry_create(
26744            task,
26745            Arc::clone(&commit_state),
26746            Duration::from_millis(10),
26747            "fixture deadline",
26748            "fixture task failed",
26749        )
26750        .await
26751        .unwrap_err();
26752        assert_eq!(error.code, NodeFailureCode::RepositoryEntryCreateTimedOut);
26753        assert_eq!(
26754            commit_state.load(Ordering::Acquire),
26755            WORKSPACE_ENTRY_CREATE_CANCELED,
26756        );
26757
26758        barrier.wait();
26759        timeout(Duration::from_secs(1), async {
26760            while !done.load(Ordering::Acquire) {
26761                tokio::task::yield_now().await;
26762            }
26763        })
26764        .await
26765        .expect("canceled blocking create did not settle");
26766        assert!(!root.join("src/late.rs").exists());
26767        std::fs::remove_dir_all(root).unwrap();
26768    }
26769
26770}