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    /// Linux/macOS use owner-only UDS; Windows uses owner-only named pipes.
1311    /// Both platforms review the helper the same way (absolute regular file +
1312    /// identity hash) — the old `cfg(not(windows))` hard-refuse was a leftover
1313    /// gate after Unix listener/peer-cred support landed.
1314    pub fn with_harness_mcp_helper(
1315        mut self,
1316        helper_program: impl Into<PathBuf>,
1317    ) -> Result<Self, NodeServerError> {
1318        self.harness_mcp_helper = Some(
1319            ReviewedHarnessMcpProgram::review(helper_program.into())
1320                .map_err(|_| NodeServerError::InvalidHarnessMcpHelper)?,
1321        );
1322        Ok(self)
1323    }
1324
1325    /// Install an in-memory station network allowlist catalog (opaque ids +
1326    /// optional node-local permits / provider-native mapping). Empty remains
1327    /// the default — unknown spawn ids refuse at resolve. Never cookies /
1328    /// OAuth / proxy credentials. C2 / inventory still list ids only.
1329    pub fn with_network_allowlist_catalog(
1330        mut self,
1331        catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog,
1332    ) -> Result<Self, NodeServerError> {
1333        if catalog.len() > crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES {
1334            return Err(NodeServerError::NetworkAllowlistCatalogCapacity {
1335                max: crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES,
1336            });
1337        }
1338        self.network_allowlist_catalog = catalog;
1339        Ok(self)
1340    }
1341
1342    /// Load station network allowlist catalog from an absolute regular file
1343    /// (v1 id-list or v2 JSON schema). Unset path is not used here — callers
1344    /// that want env fallback use `resolve_network_allowlist_catalog`.
1345    pub fn with_network_allowlist_catalog_file(
1346        self,
1347        path: impl AsRef<std::path::Path>,
1348    ) -> Result<Self, NodeServerError> {
1349        let catalog = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(path)
1350            .map_err(NodeServerError::from)?;
1351        self.with_network_allowlist_catalog(catalog)
1352    }
1353}
1354
1355pub fn default_state_path(node_id: &NodeId) -> Result<PathBuf, NodeServerError> {
1356    platform::default_state_path(node_id.as_str())
1357        .ok_or(NodeServerError::LocalStateDirectoryUnavailable)
1358}
1359
1360pub fn default_node_endpoint() -> Result<PathBuf, NodeServerError> {
1361    platform::default_node_endpoint().ok_or(NodeServerError::LocalRuntimeDirectoryUnavailable)
1362}
1363
1364fn delivery_store_root_for_state_path(state_path: &Path) -> Option<PathBuf> {
1365    let parent = state_path.parent()?;
1366    let mut name = state_path.file_name()?.to_os_string();
1367    name.push(".delivery-store");
1368    Some(parent.join(name))
1369}
1370
1371fn context_pack_store_root_for_state_path(state_path: &Path) -> Option<PathBuf> {
1372    let parent = state_path.parent()?;
1373    let mut name = state_path.file_name()?.to_os_string();
1374    name.push(".context-pack-store");
1375    Some(parent.join(name))
1376}
1377
1378pub struct NodeServer {
1379    config: NodeServerConfig,
1380    runtime: NativeRuntime,
1381    shared: Arc<NodeShared>,
1382    events: EventSubscription,
1383    state_path_lock: Option<session_registry::StatePathLock>,
1384}
1385
1386#[cfg(feature = "fixture")]
1387#[derive(Clone, Default)]
1388pub struct SpawnManagedWorktreeV2FailureProbe {
1389    latest_code: Arc<Mutex<Option<NodeFailureCode>>>,
1390}
1391
1392#[cfg(feature = "fixture")]
1393impl SpawnManagedWorktreeV2FailureProbe {
1394    pub fn latest_code(&self) -> Option<NodeFailureCode> {
1395        *self
1396            .latest_code
1397            .lock()
1398            .unwrap_or_else(|poisoned| poisoned.into_inner())
1399    }
1400
1401    fn record(&self, code: Option<NodeFailureCode>) {
1402        *self
1403            .latest_code
1404            .lock()
1405            .unwrap_or_else(|poisoned| poisoned.into_inner()) = code;
1406    }
1407}
1408
1409#[cfg(all(feature = "fixture", windows))]
1410struct IsolatedCodexHomeFixtureEnvironment;
1411
1412#[cfg(all(feature = "fixture", windows))]
1413impl gate4agent_runtime_native::NativeChildEnvironmentResolver
1414    for IsolatedCodexHomeFixtureEnvironment
1415{
1416    fn resolve_child_environment(
1417        &self,
1418    ) -> Result<
1419        Vec<gate4agent_catalog::EnvMutation>,
1420        gate4agent_runtime_native::NativeChildEnvironmentResolveError,
1421    > {
1422        Ok(vec![gate4agent_catalog::EnvMutation {
1423            key: OsString::from("USERPROFILE"),
1424            value: None,
1425        }])
1426    }
1427}
1428
1429impl NodeServer {
1430    pub fn new(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1431        let catalog = active_registry()?;
1432        Self::new_with_registry(config, catalog)
1433    }
1434
1435    #[cfg(feature = "fixture")]
1436    pub fn new_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1437        let mut spec = gate4agent_testkit::interactive_agent_spec();
1438        spec.id = AgentId::new("claude").map_err(|error| NodeServerError::Registry(error.to_string()))?;
1439        spec.display_name = "Controlled Claude PTY fixture".to_owned();
1440        Self::new_fixture_with_spec(config, spec)
1441    }
1442
1443    #[cfg(feature = "fixture")]
1444    pub fn new_clean_exit_fixture(
1445        config: NodeServerConfig,
1446        fixture_root: PathBuf,
1447        started_marker: PathBuf,
1448        release_signal: PathBuf,
1449    ) -> Result<Self, NodeServerError> {
1450        let mut spec = gate4agent_testkit::controlled_clean_exit_agent_spec(
1451            &fixture_root,
1452            &started_marker,
1453            &release_signal,
1454        )
1455        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1456        spec.id = AgentId::new("claude")
1457            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1458        spec.display_name = "Controlled Claude clean-exit fixture".to_owned();
1459        Self::new_fixture_with_spec(config, spec)
1460    }
1461
1462    /// A `claude` source whose session establishes a verified provider
1463    /// identity via an early `SessionStart` Hook post (so it is eligible for
1464    /// auto-export-at-exit and its native history is independently
1465    /// loadable), waits for `release_signal`, then exits cleanly — paired
1466    /// on the same Node with a plain `codex` target that never validates
1467    /// anything about what it receives. Built for A2 durable ContextPack
1468    /// E2E coverage: the test itself inspects materialized files and
1469    /// managed-session-record fields directly, rather than having the
1470    /// target script self-validate.
1471    #[cfg(all(feature = "fixture", windows))]
1472    pub fn new_durable_context_pack_clean_exit_fixture(
1473        config: NodeServerConfig,
1474        fixture_root: PathBuf,
1475        started_marker: PathBuf,
1476        release_signal: PathBuf,
1477        session_id: &str,
1478    ) -> Result<Self, NodeServerError> {
1479        let mut source_spec = gate4agent_testkit::identified_clean_exit_agent_spec(
1480            &fixture_root,
1481            &started_marker,
1482            &release_signal,
1483            session_id,
1484        )
1485        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1486        let source_id = AgentId::new("claude")
1487            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1488        let source_provider = builtin_registry().get(&source_id).ok_or_else(|| {
1489            NodeServerError::Registry(
1490                "durable context pack clean-exit fixture source adapter is unavailable"
1491                    .to_owned(),
1492            )
1493        })?;
1494        source_spec.capabilities.adapters.history =
1495            source_provider.capabilities.adapters.history.clone();
1496        source_spec.id = source_id;
1497        source_spec.display_name =
1498            "Controlled Claude durable context-pack clean-exit fixture".to_owned();
1499        // Both specs launch via `powershell.exe`; the registry's ambiguity
1500        // check keys on `detection.command` (+ aliases), not `launch.program`
1501        // -- give each a distinct detection identity, same precedent as
1502        // `context_pack_fixture_catalog`'s per-provider
1503        // `gate4agent-{provider}-context-fixture.cmd` commands.
1504        source_spec.detection.command =
1505            "gate4agent-claude-durable-context-pack-fixture.cmd".to_owned();
1506        source_spec.detection.aliases.clear();
1507
1508        let mut target_spec = gate4agent_testkit::interactive_agent_spec();
1509        target_spec.id = AgentId::new("codex")
1510            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1511        target_spec.display_name =
1512            "Controlled Codex durable context-pack target fixture".to_owned();
1513        target_spec.detection.command =
1514            "gate4agent-codex-durable-context-pack-fixture.cmd".to_owned();
1515        target_spec.detection.aliases.clear();
1516
1517        let catalog = AgentRegistry::new([source_spec, target_spec])
1518            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1519        let mut server = Self::new_with_registry(config, catalog)?;
1520        Arc::get_mut(&mut server.shared)
1521            .expect("fixture Node shared state must remain uniquely owned before run")
1522            .fixture_semantic_hook_policy = true;
1523        Ok(server)
1524    }
1525
1526    #[cfg(feature = "fixture")]
1527    pub fn new_monitoring_hook_fixture(
1528        config: NodeServerConfig,
1529    ) -> Result<Self, NodeServerError> {
1530        let mut spec = gate4agent_testkit::monitoring_hook_agent_spec();
1531        spec.id = AgentId::new("claude")
1532            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1533        let mut server = Self::new_fixture_with_spec(config, spec)?;
1534        Arc::get_mut(&mut server.shared)
1535            .expect("fixture Node shared state must remain uniquely owned before run")
1536            .fixture_semantic_hook_policy = true;
1537        Ok(server)
1538    }
1539
1540    #[cfg(all(feature = "fixture", windows))]
1541    pub fn new_provider_bundle_argv_fixture(
1542        config: NodeServerConfig,
1543        agent_id: AgentId,
1544        proof_path: PathBuf,
1545    ) -> Result<Self, NodeServerError> {
1546        let spec = Self::provider_bundle_argv_fixture_spec(agent_id, proof_path, None)?;
1547        Self::new_fixture_with_spec(config, spec)
1548    }
1549
1550    #[cfg(all(feature = "fixture", windows))]
1551    pub fn new_provider_bundle_argv_hold_fixture(
1552        config: NodeServerConfig,
1553        agent_id: AgentId,
1554        proof_path: PathBuf,
1555        release_signal: PathBuf,
1556    ) -> Result<Self, NodeServerError> {
1557        let spec = Self::provider_bundle_argv_fixture_spec(
1558            agent_id,
1559            proof_path,
1560            Some(release_signal),
1561        )?;
1562        Self::new_fixture_with_spec(config, spec)
1563    }
1564
1565    #[cfg(all(feature = "fixture", windows))]
1566    fn provider_bundle_argv_fixture_spec(
1567        agent_id: AgentId,
1568        proof_path: PathBuf,
1569        release_signal: Option<PathBuf>,
1570    ) -> Result<gate4agent_types::AgentSpec, NodeServerError> {
1571        if !matches!(agent_id.as_str(), "claude" | "codex" | "kimi") {
1572            return Err(NodeServerError::Registry(
1573                "provider bundle argv fixture requires Claude, Codex, or Kimi".to_owned(),
1574            ));
1575        }
1576        let mut spec = gate4agent_testkit::interactive_agent_spec();
1577        spec.id = agent_id.clone();
1578        spec.display_name = format!("Controlled {agent_id} bundle argv fixture");
1579        let provider = builtin_registry()
1580            .get(&agent_id)
1581            .ok_or_else(|| {
1582                NodeServerError::Registry(
1583                    "provider bundle argv fixture adapter is unavailable".to_owned(),
1584                )
1585            })?;
1586        spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1587        if !proof_path.is_absolute()
1588            || !proof_path
1589                .parent()
1590                .is_some_and(|parent| parent.is_dir())
1591        {
1592            return Err(NodeServerError::Registry(
1593                "provider bundle argv proof path requires an existing absolute parent"
1594                    .to_owned(),
1595            ));
1596        }
1597        if let Some(release_signal) = release_signal.as_ref() {
1598            if !release_signal.is_absolute()
1599                || !release_signal
1600                    .parent()
1601                    .is_some_and(|parent| parent.is_dir())
1602            {
1603                return Err(NodeServerError::Registry(
1604                    "provider bundle argv release path requires an existing absolute parent"
1605                        .to_owned(),
1606                ));
1607            }
1608            if release_signal.exists() {
1609                return Err(NodeServerError::Registry(
1610                    "provider bundle argv release path must not exist".to_owned(),
1611                ));
1612            }
1613            let proof_parent = std::fs::canonicalize(
1614                proof_path
1615                    .parent()
1616                    .expect("validated provider bundle proof parent"),
1617            )
1618            .map_err(|_| {
1619                NodeServerError::Registry(
1620                    "provider bundle argv proof parent is unavailable".to_owned(),
1621                )
1622            })?;
1623            let release_parent = std::fs::canonicalize(
1624                release_signal
1625                    .parent()
1626                    .expect("validated provider bundle release parent"),
1627            )
1628            .map_err(|_| {
1629                NodeServerError::Registry(
1630                    "provider bundle argv release parent is unavailable".to_owned(),
1631                )
1632            })?;
1633            if proof_parent != release_parent {
1634                return Err(NodeServerError::Registry(
1635                    "provider bundle argv release path must share the proof parent".to_owned(),
1636                ));
1637            }
1638        }
1639        let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1640            NodeServerError::Registry(
1641                "provider bundle argv proof path is not valid Unicode".to_owned(),
1642            )
1643        })?;
1644        let release_signal = release_signal.map(|release_signal| {
1645            release_signal.into_os_string().into_string().map_err(|_| {
1646                NodeServerError::Registry(
1647                    "provider bundle argv release path is not valid Unicode".to_owned(),
1648                )
1649            })
1650        }).transpose()?;
1651        let script = spec
1652            .launch
1653            .fixed_args
1654            .pop()
1655            .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
1656        let bundle_validation = if agent_id.as_str() == "codex" {
1657            "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') }"
1658        } else if agent_id.as_str() == "claude" {
1659            "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 }"
1660        } else {
1661            "if ($bundleArgs.Count -eq 0) { [IO.File]::WriteAllLines($proofPath, [string[]]@()); [Console]::Out.WriteLine('F62_NO_BUNDLE_ARGV') } elseif ($bundleArgs.Count -eq 2 -and $bundleArgs[0] -ceq '--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 }"
1662        };
1663        if let Some(release_signal) = release_signal {
1664            spec.launch.fixed_args.push(format!(
1665                "& {{ 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} }}",
1666            ));
1667            spec.launch.fixed_args.push(proof_path);
1668            spec.launch.fixed_args.push(release_signal);
1669        } else {
1670            spec.launch.fixed_args.push(format!(
1671                "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {bundle_validation}; {script} }}",
1672            ));
1673            spec.launch.fixed_args.push(proof_path);
1674        }
1675        Ok(spec)
1676    }
1677
1678    #[cfg(all(feature = "fixture", windows))]
1679    pub fn new_context_pack_fixture(
1680        config: NodeServerConfig,
1681        proof_path: PathBuf,
1682    ) -> Result<Self, NodeServerError> {
1683        let catalog = Self::context_pack_fixture_catalog(proof_path, None, false)?;
1684        Self::new_with_registry(config, catalog)
1685    }
1686
1687    #[cfg(all(feature = "fixture", windows))]
1688    pub fn new_monitoring_context_pack_fixture(
1689        config: NodeServerConfig,
1690        proof_path: PathBuf,
1691    ) -> Result<Self, NodeServerError> {
1692        let catalog = Self::context_pack_fixture_catalog(proof_path, None, true)?;
1693        let mut server = Self::new_with_registry(config, catalog)?;
1694        Arc::get_mut(&mut server.shared)
1695            .expect("fixture Node shared state must remain uniquely owned before run")
1696            .fixture_semantic_hook_policy = true;
1697        Ok(server)
1698    }
1699
1700    #[cfg(all(feature = "fixture", windows))]
1701    pub fn install_isolated_codex_home_environment_fixture(
1702        &mut self,
1703        profile_id: SpawnEnvironmentProfileId,
1704        profile_revision: crate::protocol::SpawnEnvironmentProfileRevision,
1705    ) -> Result<(), NodeServerError> {
1706        let provider = AgentId::new("codex")
1707            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1708        let native_profile = gate4agent_runtime_native::NativeLaunchProfile::new(
1709            gate4agent_runtime_native::NativeLaunchProfileId::new(format!(
1710                "{}-pty",
1711                profile_id.as_str(),
1712            ))
1713            .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1714            provider.clone(),
1715            TransportKind::Pty,
1716            vec![OsString::from("USERPROFILE")],
1717            Arc::new(IsolatedCodexHomeFixtureEnvironment),
1718        )
1719        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1720        let materialization = NodeSessionMaterializationProfile::new(
1721            Vec::new(),
1722            vec![crate::session_environment::NodeSessionPathBinding::new(
1723                "CODEX_HOME",
1724                crate::session_environment::NodeSessionPathClass::ProviderHome,
1725            )
1726            .map_err(|error| NodeServerError::Registry(error.to_string()))?],
1727            Vec::new(),
1728        )
1729        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1730        self.install_environment_profile(
1731            NodeEnvironmentProfile::new_with_materialization(
1732                profile_id,
1733                profile_revision,
1734                provider,
1735                [native_profile],
1736                Some(materialization),
1737            )
1738            .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1739        )
1740    }
1741
1742    #[cfg(all(feature = "fixture", windows))]
1743    pub fn new_context_only_proof_fixture(
1744        config: NodeServerConfig,
1745        target_provider: AgentId,
1746        proof_path: PathBuf,
1747    ) -> Result<Self, NodeServerError> {
1748        if target_provider.as_str() != "kimi" {
1749            return Err(NodeServerError::Registry(
1750                "context-only proof fixture requires Kimi".to_owned(),
1751            ));
1752        }
1753        let catalog = Self::context_pack_fixture_catalog(
1754            proof_path,
1755            Some(&target_provider),
1756            false,
1757        )?;
1758        Self::new_with_registry(config, catalog)
1759    }
1760
1761    #[cfg(all(feature = "fixture", windows))]
1762    fn context_pack_fixture_catalog(
1763        proof_path: PathBuf,
1764        context_only_target: Option<&AgentId>,
1765        monitoring_claude_source: bool,
1766    ) -> Result<AgentRegistry, NodeServerError> {
1767        if !proof_path.is_absolute()
1768            || !proof_path
1769                .parent()
1770                .is_some_and(|parent| parent.is_dir())
1771        {
1772            return Err(NodeServerError::Registry(
1773                "context pack proof path requires an existing absolute parent".to_owned(),
1774            ));
1775        }
1776        let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1777            NodeServerError::Registry(
1778                "context pack proof path is not valid Unicode".to_owned(),
1779            )
1780        })?;
1781        let providers = ["claude", "codex", "grok", "kimi"];
1782        let builtins = builtin_registry();
1783        let mut specs = Vec::with_capacity(providers.len());
1784        for provider_id in providers {
1785            let agent_id = AgentId::new(provider_id)
1786                .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1787            let provider = builtins.get(&agent_id).ok_or_else(|| {
1788                NodeServerError::Registry(
1789                    "context pack fixture provider adapter is unavailable".to_owned(),
1790                )
1791            })?;
1792            let monitored_provider =
1793                monitoring_claude_source && matches!(provider_id, "claude" | "codex");
1794            let mut spec = if monitored_provider {
1795                gate4agent_testkit::monitoring_hook_agent_spec()
1796            } else {
1797                gate4agent_testkit::interactive_agent_spec()
1798            };
1799            spec.id = agent_id.clone();
1800            spec.display_name = format!("Controlled {agent_id} context pack fixture");
1801            spec.detection.command = format!("gate4agent-{provider_id}-context-fixture.cmd");
1802            spec.detection.aliases.clear();
1803            spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1804            if provider_id == "codex" {
1805                let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1806                    NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1807                })?;
1808                let validation = r#"
1809$bundleArgs = @($bundleArgs)
1810if ($bundleArgs.Count -ne 0) { exit 91 }
1811$codexHome = [Environment]::GetEnvironmentVariable('CODEX_HOME', 'Process')
1812$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1813$cwd = (Get-Location).ProviderPath
1814if ([string]::IsNullOrEmpty($codexHome) -or -not [IO.Path]::IsPathRooted($codexHome) -or -not (Test-Path -LiteralPath $codexHome -PathType Container)) { exit 92 }
1815$skillPath = Join-Path $codexHome 'skills/review-code/SKILL.md'
1816if (-not (Test-Path -LiteralPath $skillPath -PathType Leaf)) { exit 93 }
1817$homeFiles = @(Get-ChildItem -LiteralPath $codexHome -Recurse -Force -File)
1818if ($homeFiles.Count -ne 1 -or [IO.Path]::GetFullPath($homeFiles[0].FullName) -ine [IO.Path]::GetFullPath($skillPath)) { exit 94 }
1819$sha = [Security.Cryptography.SHA256]::Create()
1820try { $skillHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($skillPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1821if ($skillHash -cne 'd78fe03be106b673fcf5415c8359e99f0298b5071cd11822c58ebcb27e52a68d') { exit 95 }
1822if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 96 }
1823$contextPath = Join-Path $contextRoot 'context-pack.json'
1824if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 97 }
1825$contextFiles = @(Get-ChildItem -LiteralPath $contextRoot -Recurse -Force -File)
1826if ($contextFiles.Count -ne 1 -or [IO.Path]::GetFullPath($contextFiles[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 98 }
1827$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1828$codexFull = [IO.Path]::GetFullPath($codexHome).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1829$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1830$separator = [IO.Path]::DirectorySeparatorChar
1831if ($contextFull.Equals($codexFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($codexFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 99 }
1832if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 100 }
1833$rawContext = [IO.File]::ReadAllText($contextPath)
1834if ($rawContext.Contains('g4a-private-provider-session-canary') -or $rawContext.Contains('private-provider-login-identity')) { exit 101 }
1835try { $document = $rawContext | ConvertFrom-Json -ErrorAction Stop } catch { exit 102 }
1836$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')
1837$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')
1838foreach ($property in @($document.PSObject.Properties.Name)) {
1839    if ($allowedTopLevel -cnotcontains [string]$property) { exit 103 }
1840}
1841foreach ($property in $requiredTopLevel) {
1842    if ($null -eq $document.PSObject.Properties[$property]) { exit 104 }
1843}
1844if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 105 }
1845if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 106 }
1846$messages = @($document.retained_messages)
1847if ($messages.Count -eq 0) { exit 107 }
1848foreach ($message in $messages) {
1849    $messageProperties = @($message.PSObject.Properties.Name)
1850    foreach ($property in $messageProperties) {
1851        if (@('role', 'text') -cnotcontains [string]$property) { exit 108 }
1852    }
1853    if ($messageProperties.Count -ne 2 -or @('user', 'assistant') -cnotcontains [string]$message.role -or [string]::IsNullOrEmpty([string]$message.text)) { exit 109 }
1854}
1855$sha = [Security.Cryptography.SHA256]::Create()
1856try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1857[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))
1858$rawProof = [IO.File]::ReadAllText($proofPath)
1859if ($rawProof.Contains('g4a-private-provider-session-canary') -or $rawProof.Contains('private-provider-login-identity')) { exit 110 }
1860[Console]::Out.WriteLine('F7_CODEX_BUNDLE_CONTEXT_VALIDATED')
1861"#;
1862                spec.launch.fixed_args.push(format!(
1863                    "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1864                ));
1865                spec.launch.fixed_args.push(proof_path.clone());
1866            } else if context_only_target.map(AgentId::as_str) == Some(provider_id) {
1867                let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1868                    NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1869                })?;
1870                let validation = r#"
1871$bundleArgs = @($bundleArgs)
1872if ($bundleArgs.Count -ne 0) { exit 91 }
1873$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1874$cwd = (Get-Location).ProviderPath
1875if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 92 }
1876$contextPath = Join-Path $contextRoot 'context-pack.json'
1877if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 93 }
1878$entries = @(Get-ChildItem -LiteralPath $contextRoot -Force)
1879if ($entries.Count -ne 1 -or $entries[0] -isnot [IO.FileInfo] -or [IO.Path]::GetFullPath($entries[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 94 }
1880$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1881$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1882$separator = [IO.Path]::DirectorySeparatorChar
1883if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase) -or $cwdFull.StartsWith($contextFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 95 }
1884try { $document = [IO.File]::ReadAllText($contextPath) | ConvertFrom-Json -ErrorAction Stop } catch { exit 96 }
1885if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 97 }
1886if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 98 }
1887$messages = @($document.retained_messages)
1888if ($messages.Count -eq 0) { exit 99 }
1889$hasUser = $false
1890$hasAssistant = $false
1891foreach ($message in $messages) {
1892    if ([string]::IsNullOrWhiteSpace([string]$message.text)) { exit 100 }
1893    if ([string]$message.role -ceq 'user') { $hasUser = $true }
1894    elseif ([string]$message.role -ceq 'assistant') { $hasAssistant = $true }
1895    else { exit 101 }
1896}
1897if (-not $hasUser -or -not $hasAssistant) { exit 102 }
1898$sha = [Security.Cryptography.SHA256]::Create()
1899try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1900[IO.File]::WriteAllLines($proofPath, [string[]]@('context-only', $contextRoot, $cwd, $contextHash, [string]$document.schema, [string]$document.source_provider, [string]$messages.Count))
1901[Console]::Out.WriteLine('F7_KIMI_CONTEXT_ONLY_VALIDATED')
1902"#;
1903                spec.launch.fixed_args.push(format!(
1904                    "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1905                ));
1906                spec.launch.fixed_args.push(proof_path.clone());
1907            }
1908            specs.push(spec);
1909        }
1910        let catalog = AgentRegistry::new(specs)
1911            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1912        Ok(catalog)
1913    }
1914
1915    #[cfg(feature = "fixture")]
1916    pub fn new_exact_launcher_fixture(
1917        config: NodeServerConfig,
1918        agent_id: AgentId,
1919        launcher: String,
1920    ) -> Result<Self, NodeServerError> {
1921        if !matches!(agent_id.as_str(), "claude" | "codex" | "grok" | "kimi") {
1922            return Err(NodeServerError::Registry(
1923                "exact launcher fixture requires a supported provider".to_owned(),
1924            ));
1925        }
1926        if launcher.contains('\0') {
1927            return Err(NodeServerError::Registry(
1928                "exact launcher fixture path contains NUL".to_owned(),
1929            ));
1930        }
1931        let launcher_path = Path::new(&launcher);
1932        if !launcher_path.is_absolute() {
1933            return Err(NodeServerError::Registry(
1934                "exact launcher fixture path must be absolute".to_owned(),
1935            ));
1936        }
1937        let metadata = std::fs::metadata(launcher_path).map_err(|_| {
1938            NodeServerError::Registry("exact launcher fixture path is unavailable".to_owned())
1939        })?;
1940        if !metadata.is_file() {
1941            return Err(NodeServerError::Registry(
1942                "exact launcher fixture path must be a regular file".to_owned(),
1943            ));
1944        }
1945        let mut spec = builtin_registry()
1946            .get(&agent_id)
1947            .ok_or_else(|| {
1948                NodeServerError::Registry(
1949                    "exact launcher fixture provider is unavailable".to_owned(),
1950                )
1951            })?
1952            .clone();
1953        spec.launch.program = launcher;
1954        Self::new_fixture_with_spec(config, spec)
1955    }
1956
1957    /// Shared H3B validation and spec construction for
1958    /// `new_harness_mcp_proxy_fixture` and
1959    /// `new_harness_mcp_acp_launcher_fixture`: an absolute, exact-regular-
1960    /// file provider launcher plus at most 32 Unicode, NUL-free fixed
1961    /// arguments (65,536 bytes total), replacing `spec.launch` on a clone of
1962    /// the named builtin catalog entry -- detection command and expected
1963    /// process matcher rebased on the launcher's own basename, exactly the
1964    /// same shape `new_harness_mcp_proxy_fixture` always built.
1965    #[cfg(feature = "fixture")]
1966    fn h3b_provider_spec_with_program_and_args(
1967        agent_id: AgentId,
1968        provider_program: PathBuf,
1969        fixed_args: Vec<OsString>,
1970    ) -> Result<AgentSpec, NodeServerError> {
1971        if !provider_program.is_absolute() || fixed_args.len() > 32 {
1972            return Err(NodeServerError::Registry(
1973                "H3B fixture requires an absolute provider and at most 32 arguments".to_owned(),
1974            ));
1975        }
1976        let metadata = std::fs::symlink_metadata(&provider_program).map_err(|_| {
1977            NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
1978        })?;
1979        if !metadata.file_type().is_file() || metadata.file_type().is_symlink() {
1980            return Err(NodeServerError::Registry(
1981                "H3B fixture provider must be an exact regular file".to_owned(),
1982            ));
1983        }
1984        let program = provider_program.into_os_string().into_string().map_err(|_| {
1985            NodeServerError::Registry("H3B fixture provider path is not Unicode".to_owned())
1986        })?;
1987        let fixed_args = fixed_args.into_iter().map(|argument| {
1988            argument.into_string().map_err(|_| NodeServerError::Registry(
1989                "H3B fixture argument is not Unicode".to_owned(),
1990            ))
1991        }).collect::<Result<Vec<_>, _>>()?;
1992        let argument_bytes = fixed_args.iter()
1993            .try_fold(0usize, |total, argument| total.checked_add(argument.len()));
1994        if program.contains('\0')
1995            || fixed_args.iter().any(|argument| argument.contains('\0'))
1996            || !matches!(argument_bytes, Some(total) if total <= 65_536)
1997        {
1998            return Err(NodeServerError::Registry(
1999                "H3B fixture provider or arguments are invalid".to_owned(),
2000            ));
2001        }
2002        let mut spec = builtin_registry().get(&agent_id).ok_or_else(|| {
2003            NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
2004        })?.clone();
2005        let process_name = Path::new(&program).file_name().and_then(|name| name.to_str())
2006            .ok_or_else(|| NodeServerError::Registry(
2007                "H3B fixture provider basename is unavailable".to_owned(),
2008            ))?.to_owned();
2009        spec.detection.command = process_name.clone();
2010        spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
2011            name: process_name,
2012        }];
2013        spec.launch.program = program;
2014        spec.launch.fixed_args = fixed_args;
2015        Ok(spec)
2016    }
2017
2018    #[cfg(feature = "fixture")]
2019    pub fn new_harness_mcp_proxy_fixture(
2020        config: NodeServerConfig,
2021        provider_program: PathBuf,
2022        fixed_args: Vec<OsString>,
2023        harness_mcp_program: PathBuf,
2024    ) -> Result<Self, NodeServerError> {
2025        let mut config = config.with_harness_mcp_helper(harness_mcp_program)?;
2026        let agent_id = AgentId::new("codex")
2027            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2028        let mut spec =
2029            Self::h3b_provider_spec_with_program_and_args(agent_id, provider_program, fixed_args)?;
2030        spec.display_name = "Controlled H3B MCP proxy fixture".to_owned();
2031        config.fixture_raw_pty_runtime = true;
2032        Self::new_fixture_with_spec(config, spec)
2033    }
2034
2035    /// The ACP-mode sibling of `new_harness_mcp_proxy_fixture`: the same
2036    /// `--harness-mcp-helper` wiring and the same absolute-program-plus-
2037    /// fixed-args launcher replacement as `new_exact_launcher_fixture`, but
2038    /// admitting `ProviderRuntimeRequirement::Acp` the honest way instead of
2039    /// hardcoding it. `new_harness_mcp_proxy_fixture` forces `config.
2040    /// fixture_raw_pty_runtime = true`, which tears out the live
2041    /// `ProviderRuntimeMonitor` entirely (`NodeServer::new_with_registry`)
2042    /// and makes `NodeShared::admit_provider_runtime`'s no-monitor branch
2043    /// hardcode `acp_transport = false` for every provider, refusing
2044    /// `ProviderRuntimeRequirement::Acp` unconditionally regardless of what
2045    /// the catalog declares. This constructor leaves `fixture_raw_pty_
2046    /// runtime` at its default `false`, so a real `ProviderRuntimeMonitor`
2047    /// is built from the (cloned, launcher-replaced) catalog spec, and
2048    /// `ProviderRuntimeMonitor::supports_acp_transport` reads the honest,
2049    /// unmodified catalog fact: every builtin provider this accepts
2050    /// (`claude`, `codex`, `grok`, `kimi`) already declares `capabilities.
2051    /// transports.acp` in `gate4agent-catalog::builtin`, so no capability is
2052    /// invented here -- this constructor only removes the fixture-only
2053    /// bypass standing in its way. `provider_runtime::policy_for_transport`'s
2054    /// `TransportKind::Acp` arm (applied unconditionally at spawn-prep time
2055    /// for every ACP session, fixture or not) is what actually grants the
2056    /// ACP-shaped `ProviderRuntimePolicy` downstream of that admission --
2057    /// this constructor never calls it directly, and does not need to.
2058    #[cfg(feature = "fixture")]
2059    pub fn new_harness_mcp_acp_launcher_fixture(
2060        config: NodeServerConfig,
2061        helper_program: PathBuf,
2062        provider: AgentId,
2063        program: PathBuf,
2064        args: Vec<OsString>,
2065    ) -> Result<Self, NodeServerError> {
2066        if !matches!(provider.as_str(), "claude" | "codex" | "grok" | "kimi") {
2067            return Err(NodeServerError::Registry(
2068                "ACP harness-MCP launcher fixture requires a supported provider".to_owned(),
2069            ));
2070        }
2071        let config = config.with_harness_mcp_helper(helper_program)?;
2072        let mut spec = Self::h3b_provider_spec_with_program_and_args(provider, program, args)?;
2073        let launch = spec.launch.clone();
2074        let acp = spec.capabilities.transports.acp.as_mut().ok_or_else(|| {
2075            NodeServerError::Registry(
2076                "ACP harness-MCP launcher fixture provider does not declare an ACP transport"
2077                    .to_owned(),
2078            )
2079        })?;
2080        // Routes the ACP spawn itself through the exact replaced launcher
2081        // (`AcpSession::spawn_with_launch` -> a bare `Command::new(&launch.
2082        // program)`, `gate4agent`'s `src/acp/spawn.rs`) instead of the
2083        // catalog's own vendor-npm resolution (`acp_command`, same file) --
2084        // without this the ACP transport ignores `spec.launch` entirely and
2085        // tries to spawn the real vendor CLI (e.g. `npx -y @agentclientprotocol/
2086        // codex-acp@1.10.0`), which this fixture must never reach.
2087        acp.launch_override = Some(launch);
2088        spec.display_name = "Controlled ACP harness-MCP launcher fixture".to_owned();
2089        Self::new_fixture_with_spec(config, spec)
2090    }
2091
2092    #[cfg(feature = "fixture")]
2093    pub fn new_resume_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
2094        let mut spec = gate4agent_testkit::interactive_agent_spec();
2095        let claude_id = AgentId::new("claude")
2096            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2097        let claude = builtin_registry()
2098            .get(&claude_id)
2099            .ok_or_else(|| NodeServerError::Registry("Claude fixture adapter is unavailable".to_owned()))?;
2100        spec.id = claude_id;
2101        spec.display_name = "Resumable Claude PTY fixture".to_owned();
2102        spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
2103            name: "claude".to_owned(),
2104        }];
2105        spec.capabilities.transports.pty_adapter = claude
2106            .capabilities
2107            .transports
2108            .pty_adapter
2109            .clone();
2110        spec.capabilities.adapters.resume = claude
2111            .capabilities
2112            .adapters
2113            .resume
2114            .clone();
2115        let script = spec
2116            .launch
2117            .fixed_args
2118            .pop()
2119            .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
2120        spec.launch.fixed_args.push(format!(
2121            "& {{ param([Parameter(ValueFromRemainingArguments=$true)][string[]]$ignored) {script} }}",
2122        ));
2123        Self::new_fixture_with_spec(config, spec)
2124    }
2125
2126    #[cfg(feature = "fixture")]
2127    pub fn new_cmd_cwd_fixture(
2128        config: NodeServerConfig,
2129        command: String,
2130    ) -> Result<Self, NodeServerError> {
2131        let mut spec = gate4agent_testkit::interactive_agent_spec();
2132        spec.id = AgentId::new("claude")
2133            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2134        spec.display_name = "Controlled cmd cwd fixture".to_owned();
2135        spec.detection.command = "gate4agent-cwd-fixture.cmd".to_owned();
2136        spec.launch.program = command;
2137        spec.launch.fixed_args.clear();
2138        Self::new_fixture_with_spec(config, spec)
2139    }
2140
2141    #[cfg(feature = "fixture")]
2142    fn new_fixture_with_spec(
2143        config: NodeServerConfig,
2144        spec: gate4agent_types::AgentSpec,
2145    ) -> Result<Self, NodeServerError> {
2146        let catalog = AgentRegistry::new([spec])
2147            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2148        Self::new_with_registry(config, catalog)
2149    }
2150
2151    fn new_with_registry(
2152        config: NodeServerConfig,
2153        catalog: AgentRegistry,
2154    ) -> Result<Self, NodeServerError> {
2155        for profile in config.spawn_profiles.iter() {
2156            if catalog.get(&profile.provider).is_none() {
2157                return Err(NodeServerError::Registry(
2158                    "spawn profile references an unavailable provider".to_owned(),
2159                ));
2160            }
2161        }
2162        let input_settle_timeout_ms = catalog
2163            .iter()
2164            .map(|spec| spec.readiness.timeout_ms)
2165            .max()
2166            .unwrap_or(MUTATION_SETTLE_TIMEOUT_MS)
2167            .saturating_add(READINESS_SETTLE_HEADROOM_MS)
2168            .max(MUTATION_SETTLE_TIMEOUT_MS);
2169        let (provider_contracts, provider_adapter_contracts) =
2170            provider_contract_manifest(&catalog)?;
2171        let compatibility_support = node_compatibility_support_for_manifest(
2172            &provider_contracts,
2173            &provider_adapter_contracts,
2174        )?;
2175        validate_node_negotiated_handshake_capacity(&compatibility_support)
2176            .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2177        let enabled_providers = provider_contracts
2178            .iter()
2179            .map(|contract| contract.provider.clone())
2180            .collect();
2181        #[cfg(feature = "fixture")]
2182        let fixture_raw_pty_runtime = config.fixture_raw_pty_runtime;
2183        #[cfg(not(feature = "fixture"))]
2184        let fixture_raw_pty_runtime = false;
2185        let (provider_runtime_monitor, provider_runtime_statuses) = if fixture_raw_pty_runtime {
2186            let statuses = ProviderRuntimeStatuses::new(catalog.iter().map(|spec| {
2187                crate::protocol::ProviderRuntimeStatus::raw_passthrough(spec.id.clone(), None)
2188            })).expect("fixture provider catalog remains bounded");
2189            (None, statuses)
2190        } else {
2191            let monitor = Arc::new(ProviderRuntimeMonitor::new(&catalog));
2192            let statuses = monitor.collect();
2193            (Some(monitor), statuses)
2194        };
2195        let state_path_lock =
2196            session_registry::StatePathLock::acquire(config.state_path.as_deref())
2197                .map_err(|error| {
2198                    durable_state_server_error(error, DURABLE_STATE_LOCK_FAILED_ERROR)
2199                })?;
2200        let (delivery_store, delivered_bundles) = match config.state_path.as_ref() {
2201            Some(state_path) => {
2202                let delivery_root = delivery_store_root_for_state_path(state_path)
2203                    .ok_or_else(|| {
2204                        NodeServerError::DeliveryStore(
2205                            "delivery store root could not be derived from state path".to_owned(),
2206                        )
2207                    })?;
2208                let (store, bundles) = DeliveryStore::open(delivery_root).map_err(|error| {
2209                    let detail = match &error {
2210                        DeliveryStoreError::Storage(io_error) => {
2211                            format!("{error}: {io_error}")
2212                        }
2213                        _ => error.to_string(),
2214                    };
2215                    NodeServerError::DeliveryStore(detail)
2216                })?;
2217                (Some(store), bundles)
2218            }
2219            None => (None, Vec::new()),
2220        };
2221        let delivered_catalog = BundleCatalog::new(delivered_bundles).map_err(|error| {
2222            NodeServerError::DeliveryStore(format!("bundle catalog: {error}"))
2223        })?;
2224        let (context_pack_store, durable_context_packs) = match config.state_path.as_ref() {
2225            Some(state_path) => {
2226                let context_pack_root = context_pack_store_root_for_state_path(state_path)
2227                    .ok_or(NodeServerError::ContextPackStore)?;
2228                let (store, packs) = ContextPackStore::open(context_pack_root)
2229                    .map_err(|_| NodeServerError::ContextPackStore)?;
2230                (Some(store), packs)
2231            }
2232            None => (None, Vec::new()),
2233        };
2234        let mut durable_context_catalog = ContextPackCatalog::default();
2235        for pack in durable_context_packs {
2236            durable_context_catalog
2237                .insert(pack)
2238                .map_err(|_| NodeServerError::ContextPackStore)?;
2239        }
2240        let session_environment_materializer = config
2241            .session_environment
2242            .as_ref()
2243            .map(|environment| {
2244                SessionEnvironmentMaterializer::new(
2245                    environment.root.clone(),
2246                    Arc::clone(&environment.resolver),
2247                )
2248                .map_err(|_| NodeServerError::SessionEnvironmentMaterializer)
2249            })
2250            .transpose()?;
2251        let native_session_catalog = config.history.clone().map(|history| {
2252            Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)))
2253        });
2254        let (handle, runtime) = match config.history.clone() {
2255            Some(history) => NativeRuntime::new_with_history(catalog, config.runtime, history),
2256            None => NativeRuntime::new(catalog, config.runtime),
2257        };
2258        let native_launch_profile_control = runtime.native_launch_profile_control();
2259        let harness_mcp_registry = config
2260            .harness_mcp_helper
2261            .clone()
2262            .map(HarnessMcpProxyRegistry::new);
2263        let events = handle.subscribe(CONTROL_EVENT_SUBSCRIPTION_CAPACITY);
2264        let incarnation_id = random_incarnation_id()
2265            .map_err(NodeServerError::IncarnationIdentity)?;
2266        let mut loaded = session_registry::load(config.state_path.as_deref(), &config.node_id)
2267            .map_err(durable_state_load_error)?;
2268        let managed_worktrees = std::mem::take(&mut loaded.managed_worktrees);
2269        let managed_worktree_tombstones =
2270            std::mem::take(&mut loaded.managed_worktree_tombstones);
2271        let materializations = std::mem::take(&mut loaded.materializations);
2272        let managed_spawn_replays = std::mem::take(&mut loaded.managed_spawn_replays);
2273        let (workspaces, records, persistence_warning) =
2274            merge_durable_state(&config.workspaces, loaded)?;
2275        let mut shared = NodeShared::new_with_incarnation(
2276            handle,
2277            config.access_token.clone(),
2278            config.node_id.clone(),
2279            incarnation_id,
2280            workspaces,
2281            enabled_providers,
2282            provider_runtime_statuses,
2283            provider_runtime_monitor,
2284            provider_contracts,
2285            provider_adapter_contracts,
2286            config.spawn_profiles.clone(),
2287            Some(native_launch_profile_control),
2288            native_session_catalog,
2289            session_environment_materializer,
2290            config.state_path.clone(),
2291            records,
2292            managed_worktrees,
2293            managed_worktree_tombstones,
2294            materializations,
2295            persistence_warning,
2296            input_settle_timeout_ms,
2297        );
2298        shared.harness_mcp_registry = harness_mcp_registry.clone();
2299        shared.session_record_retention = config.session_record_retention;
2300        shared.network_allowlist_catalog =
2301            RwLock::new(config.network_allowlist_catalog.clone());
2302        shared.bundle_catalog = RwLock::new(delivered_catalog);
2303        shared.delivery_store = Mutex::new(delivery_store);
2304        shared.context_catalog = RwLock::new(durable_context_catalog);
2305        shared.context_pack_store = Mutex::new(context_pack_store);
2306        shared.managed_spawn_replays = Mutex::new(
2307            managed_spawn_replays
2308                .into_iter()
2309                .map(|record| (record.idempotency_key.clone(), record))
2310                .collect(),
2311        );
2312        let shared = Arc::new(shared);
2313        if let Some(registry) = harness_mcp_registry {
2314            let weak = Arc::downgrade(&shared);
2315            registry.set_event_sink(Arc::new(move |event| match weak.upgrade() {
2316                Some(shared) => shared.publish_transient(event),
2317                None => false,
2318            }));
2319        }
2320        if shared.persistence_error().is_none() {
2321            shared.persist_state().map_err(|error| {
2322                durable_state_server_error(error, DURABLE_STATE_COMMIT_FAILED_ERROR)
2323            })?;
2324        }
2325        Ok(Self {
2326            config,
2327            runtime,
2328            shared,
2329            events,
2330            state_path_lock,
2331        })
2332    }
2333
2334    pub fn shutdown_handle(&self) -> NodeShutdownHandle {
2335        NodeShutdownHandle {
2336            shared: Arc::clone(&self.shared),
2337        }
2338    }
2339
2340    #[cfg(feature = "fixture")]
2341    pub fn spawn_managed_worktree_v2_failure_probe_fixture(
2342        &self,
2343    ) -> SpawnManagedWorktreeV2FailureProbe {
2344        self.shared
2345            .fixture_spawn_managed_worktree_v2_failure_probe
2346            .clone()
2347    }
2348
2349    /// Installs one immutable host-local environment profile before the server runs.
2350    ///
2351    /// Native resolvers remain inside the runtime; the shared node registry keeps
2352    /// only public IDs, revisions, provider bindings, and opaque native profile IDs.
2353    pub fn install_environment_profile(
2354        &mut self,
2355        profile: NodeEnvironmentProfile,
2356    ) -> Result<(), NodeServerError> {
2357        if !self.shared.enabled_providers.contains(profile.provider()) {
2358            return Err(NodeServerError::EnvironmentProfileProviderUnavailable(
2359                profile.provider().clone(),
2360            ));
2361        }
2362        let (binding, native_profiles, materialization) = profile.into_parts();
2363        if materialization.is_some() && self.shared.session_environment_materializer.is_none() {
2364            return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2365        }
2366        let mut profiles = self
2367            .shared
2368            .environment_profiles
2369            .write()
2370            .unwrap_or_else(|poisoned| poisoned.into_inner());
2371        if profiles.contains_key(&binding.id) {
2372            return Err(NodeServerError::DuplicateEnvironmentProfile(
2373                binding.id.clone(),
2374            ));
2375        }
2376        if profiles.len() >= MAX_NODE_ENVIRONMENT_PROFILES {
2377            return Err(NodeServerError::EnvironmentProfileCapacity {
2378                max: MAX_NODE_ENVIRONMENT_PROFILES,
2379            });
2380        }
2381        for native_id in binding.native_profile_ids() {
2382            if profiles.values().any(|current| {
2383                current.native_profile_ids().any(|current_id| current_id == native_id)
2384            }) {
2385                return Err(NodeServerError::DuplicateNativeEnvironmentProfile(
2386                    native_id.to_string(),
2387                ));
2388            }
2389        }
2390
2391        let mut installed = Vec::with_capacity(native_profiles.len());
2392        for native_profile in native_profiles {
2393            let native_id = native_profile.id().clone();
2394            if let Err(error) = self.runtime.upsert_native_launch_profile(native_profile) {
2395                for installed_id in installed.into_iter().rev() {
2396                    let _ = self.runtime.remove_native_launch_profile(&installed_id);
2397                }
2398                return Err(NodeServerError::NativeEnvironmentProfile(error.to_string()));
2399            }
2400            installed.push(native_id);
2401        }
2402        if let Some(materialization) = materialization {
2403            self.shared
2404                .environment_materialization_profiles
2405                .write()
2406                .unwrap_or_else(|poisoned| poisoned.into_inner())
2407                .insert(binding.id.clone(), materialization);
2408        }
2409        profiles.insert(binding.id.clone(), binding);
2410        Ok(())
2411    }
2412
2413    /// Installs one immutable host-local bundle before the server runs.
2414    pub fn install_bundle(&mut self, bundle: NodeBundle) -> Result<(), NodeServerError> {
2415        if self.shared.session_environment_materializer.is_none() {
2416            return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2417        }
2418        let mut catalog = self
2419            .shared
2420            .bundle_catalog
2421            .write()
2422            .unwrap_or_else(|poisoned| poisoned.into_inner());
2423        catalog
2424            .insert_idempotent(bundle)
2425            .map_err(|error| NodeServerError::BundleCatalog(error.to_string()))?;
2426        Ok(())
2427    }
2428
2429    pub async fn run_until_ctrl_signal(self) -> Result<(), NodeServerError> {
2430        let shutdown = self.shutdown_handle();
2431        let run = self.run();
2432        tokio::pin!(run);
2433        tokio::select! {
2434            result = &mut run => result,
2435            signal = wait_for_ctrl_signal() => {
2436                signal?;
2437                let shutdown_result = shutdown.request_shutdown().await;
2438                let run_result = run.await;
2439                shutdown_result.and(run_result)
2440            }
2441        }
2442    }
2443
2444    pub async fn run(self) -> Result<(), NodeServerError> {
2445        let NodeServer {
2446            config,
2447            mut runtime,
2448            shared,
2449            events,
2450            state_path_lock: _state_path_lock,
2451        } = self;
2452        shared.reconcile_materializations();
2453        shared.reconcile_managed_worktrees().await;
2454        shared.retire_unavailable_session_records().await;
2455        shared.reconcile_context_pack_exports().await;
2456        // Lifecycle hooks are retired (owner ruling 2026-09-25): the node
2457        // observes sessions through ACP where a provider has it; there is no
2458        // hook ingress.
2459        let endpoint = config.endpoint.clone();
2460        let call_home = config.call_home;
2461        let api_listen = config.api_listen;
2462        let bridge_listen = config.bridge_listen;
2463        let bridge_underlay = config.bridge_underlay;
2464        let bridge_auth = browser_bridge::BridgeAuth::from_optional(config.bridge_token)
2465            .map_err(|_| NodeServerError::InvalidBridgeToken)?;
2466        let accept_shared = Arc::clone(&shared);
2467        let api_shared = Arc::clone(&shared);
2468        let bridge_shared = Arc::clone(&shared);
2469        let shutdown_shared = Arc::clone(&shared);
2470        let shutdown_timeout = Duration::from_millis(
2471            config.runtime.provider_shutdown_timeout_ms.max(1),
2472        );
2473        let result = {
2474            let runtime_loop = drive_runtime_until_shutdown(
2475                &mut runtime,
2476                events,
2477                Arc::clone(&shared),
2478                shutdown_timeout,
2479            );
2480            let accept_loop = accept_connections(&endpoint, call_home, accept_shared);
2481            let api_loop = http_api::run(api_listen, api_shared);
2482            let bridge_loop = browser_bridge::run(bridge_listen, bridge_auth, bridge_shared, bridge_underlay);
2483            tokio::pin!(runtime_loop);
2484            tokio::pin!(accept_loop);
2485            tokio::pin!(api_loop);
2486            tokio::pin!(bridge_loop);
2487            tokio::select! {
2488                runtime_result = &mut runtime_loop => {
2489                    let shutdown_result = shutdown_shared
2490                        .begin_shutdown()
2491                        .await
2492                        .map_err(NodeServerError::ShutdownDispatch);
2493                    let (accept_result, api_result, bridge_result) =
2494                        tokio::join!(&mut accept_loop, &mut api_loop, &mut bridge_loop);
2495                    match runtime_result {
2496                        Err(error) => Err(error),
2497                        Ok(()) => shutdown_result
2498                            .and(accept_result)
2499                            .and(api_result.map_err(NodeServerError::HttpApi))
2500                            .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2501                    }
2502                }
2503                accept_result = &mut accept_loop => {
2504                    let shutdown_result = shutdown_shared
2505                        .begin_shutdown()
2506                        .await
2507                        .map_err(NodeServerError::ShutdownDispatch);
2508                    let (runtime_result, api_result, bridge_result) =
2509                        tokio::join!(&mut runtime_loop, &mut api_loop, &mut bridge_loop);
2510                    match accept_result {
2511                        Err(error) => Err(error),
2512                        Ok(()) => shutdown_result
2513                            .and(runtime_result)
2514                            .and(api_result.map_err(NodeServerError::HttpApi))
2515                            .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2516                    }
2517                }
2518                api_result = &mut api_loop => {
2519                    let shutdown_result = shutdown_shared
2520                        .begin_shutdown()
2521                        .await
2522                        .map_err(NodeServerError::ShutdownDispatch);
2523                    let (runtime_result, accept_result, bridge_result) =
2524                        tokio::join!(&mut runtime_loop, &mut accept_loop, &mut bridge_loop);
2525                    match api_result {
2526                        Err(error) => Err(NodeServerError::HttpApi(error)),
2527                        Ok(()) => shutdown_result
2528                            .and(runtime_result)
2529                            .and(accept_result)
2530                            .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2531                    }
2532                }
2533                bridge_result = &mut bridge_loop => {
2534                    let shutdown_result = shutdown_shared
2535                        .begin_shutdown()
2536                        .await
2537                        .map_err(NodeServerError::ShutdownDispatch);
2538                    let (runtime_result, accept_result, api_result) =
2539                        tokio::join!(&mut runtime_loop, &mut accept_loop, &mut api_loop);
2540                    match bridge_result {
2541                        Err(error) => Err(NodeServerError::BridgeApi(error)),
2542                        Ok(()) => shutdown_result
2543                            .and(runtime_result)
2544                            .and(accept_result)
2545                            .and(api_result.map_err(NodeServerError::HttpApi)),
2546                    }
2547                }
2548            }
2549        };
2550        // Persistence gap fix: durable DeliveryStore / ContextPackStore hold
2551        // exclusive flock (Unix) / share-mode locks (Windows) inside NodeShared.
2552        // NodeShutdownHandle also clones that Arc, so without an explicit release
2553        // here a supervisor that keeps the shutdown handle cannot reopen the same
2554        // state path after run() returns (EAGAIN / Resource temporarily unavailable).
2555        // StatePathLock already drops with this stack frame (`_state_path_lock`).
2556        if let Ok(mut guard) = shared.delivery_store.lock() {
2557            *guard = None;
2558        }
2559        if let Ok(mut guard) = shared.context_pack_store.lock() {
2560            *guard = None;
2561        }
2562        result
2563    }
2564}
2565
2566async fn drive_runtime_until_shutdown(
2567    runtime: &mut NativeRuntime,
2568    events: EventSubscription,
2569    shared: Arc<NodeShared>,
2570    shutdown_timeout: Duration,
2571) -> Result<(), NodeServerError> {
2572    // Published once, never per iteration. The worker loops inside
2573    // `NativeRuntime` fold into this profile continuously; turning it into
2574    // percentiles is what costs, and that is `GET /metrics`'s bill to pay.
2575    let _ = shared
2576        .shell_efficiency_profile
2577        .set(runtime.shell_efficiency_profile());
2578    let mut shutdown_started = None;
2579    let mut last_retention_sweep = Instant::now();
2580    loop {
2581        // Every phase below is timed unconditionally -- see
2582        // `drive_loop_profile`'s own doc comment for why an always-on,
2583        // allocation-free ring is cheap enough to never need a feature
2584        // flag. This is the only place these numbers can be taken: the
2585        // phases are calls made directly from this loop, not something a
2586        // callee could time on our behalf.
2587        let runtime_tick_start = Instant::now();
2588        let tick_result = runtime.tick().await;
2589        let runtime_tick_elapsed = runtime_tick_start.elapsed();
2590
2591        let event_drain_start = Instant::now();
2592        // Every command the kernel refused, named. `NativeRuntimeTick`
2593        // has carried these outcomes all along and nothing read them, so a
2594        // rejected command -- a hook event denied by runtime policy, a
2595        // stale generation, an unsupported capability -- vanished at the
2596        // last step of the loop that produced it. A refusal the operator
2597        // cannot see is indistinguishable from an event that never arrived,
2598        // and telling those two apart is most of debugging this stack.
2599        for outcome in &tick_result.command_outcomes {
2600            if let Err(error) = &outcome.result {
2601                shared.rejected_commands_total.fetch_add(1, Ordering::Relaxed);
2602                tracing::warn!(
2603                    command_id = outcome.command_id.0,
2604                    cause = %error,
2605                    "control command rejected"
2606                );
2607                shared.record_rejected_command(
2608                    outcome.command_id,
2609                    error.to_string(),
2610                    error.is_unsupported_transport(),
2611                );
2612            }
2613        }
2614        let mut events_drained: u64 = 0;
2615        while let Ok(event) = events.try_recv() {
2616            events_drained += 1;
2617            let clean_exit = matches!(
2618                event.event,
2619                ControlEventKind::Exited { exit_code: Some(0), forced: false },
2620            );
2621            let instance_id = event.instance_id;
2622            let generation = event.generation;
2623            // Resolve and spawn before publish_control: publish_control's own
2624            // managed-record bookkeeping (reconcile_managed_record) downgrades
2625            // this exact record from Live to Dormant and clears
2626            // active_session for every Exited event, inside the same
2627            // synchronous call. Capturing (record_id, session) here — before
2628            // that runs — is the only way to observe them still Live and
2629            // still populated; the export itself must still run on its own
2630            // detached task (never awaited inline on this loop) because it
2631            // needs NativeRuntime::tick() to keep running to ever settle, and
2632            // tick() is only ever driven by this same loop. See
2633            // reconcile_context_pack_export_for_record's doc comment.
2634            if clean_exit {
2635                if let Some((record_id, session)) =
2636                    shared.managed_record_export_target_for_instance(instance_id, generation)
2637                {
2638                    let export_shared = Arc::clone(&shared);
2639                    tokio::spawn(async move {
2640                        export_shared
2641                            .reconcile_context_pack_export_for_record(&record_id, session)
2642                            .await;
2643                    });
2644                }
2645            }
2646            shared.publish_control(event);
2647        }
2648        let event_drain_elapsed = event_drain_start.elapsed();
2649
2650        let publish_terminal_frames_start = Instant::now();
2651        shared.publish_terminal_frames();
2652        let publish_terminal_frames_elapsed = publish_terminal_frames_start.elapsed();
2653
2654        // Physical PTY count and the runtime's own per-phase tick timings
2655        // both live inside `NativeRuntime`, which `GET /metrics` (reading
2656        // only `NodeShared`) cannot reach directly -- this loop is the one
2657        // place holding `&mut NativeRuntime`, so it is the only place that
2658        // can copy either out.
2659        shared
2660            .native_session_gauge
2661            .store(runtime.active_native_sessions(), Ordering::Relaxed);
2662        *shared
2663            .runtime_tick_profile
2664            .lock()
2665            .unwrap_or_else(|poisoned| poisoned.into_inner()) = runtime.tick_profile_snapshot();
2666
2667        let remainder_start = Instant::now();
2668        if shared.shutdown.load(Ordering::Acquire) {
2669            let started = *shutdown_started.get_or_insert_with(Instant::now);
2670            let snapshot = shared.handle.snapshot();
2671            let sessions_settled = snapshot.sessions.iter().all(|session| {
2672                !matches!(
2673                    session.status,
2674                    gate4agent_types::SessionStatus::Starting
2675                        | gate4agent_types::SessionStatus::Running
2676                        | gate4agent_types::SessionStatus::Stopping
2677                )
2678            });
2679            if runtime.active_native_sessions() == 0 && sessions_settled {
2680                return Ok(());
2681            }
2682            if started.elapsed() >= shutdown_timeout {
2683                return Err(NodeServerError::ShutdownTimedOut {
2684                    active_native_sessions: runtime.active_native_sessions(),
2685                });
2686            }
2687        }
2688        let remainder_elapsed = remainder_start.elapsed();
2689
2690        {
2691            let mut profile = shared
2692                .drive_loop_profile
2693                .lock()
2694                .unwrap_or_else(|poisoned| poisoned.into_inner());
2695            profile.record_runtime_tick(runtime_tick_elapsed);
2696            profile.record_event_drain(event_drain_elapsed);
2697            profile.record_publish_terminal_frames(publish_terminal_frames_elapsed);
2698            profile.record_remainder(remainder_elapsed);
2699            profile.note_iteration(Instant::now());
2700        }
2701
2702        // "Produced nothing" means this iteration's `NativeRuntime::tick`
2703        // applied zero observations (`tick_result.observations_applied`,
2704        // which already folds in any terminal frames collected -- see
2705        // `NativeEffectDispatcher::drain_observations`) AND the
2706        // control-event subscription drained above forwarded zero events.
2707        // Command ingress and dispatched effects are deliberately NOT part
2708        // of this definition: an idle node still runs `step_control_plane`
2709        // and every provider supervisor's `tick` regardless, every 10ms --
2710        // so "nothing" here means nothing OBSERVABLE left this iteration,
2711        // matching what a reader would assume from `drive_loop_iterations`'s
2712        // own field name, not that the tick itself was free of work.
2713        shared.drive_loop_iterations_total.fetch_add(1, Ordering::Relaxed);
2714        if tick_result.observations_applied == 0 && events_drained == 0 {
2715            shared.drive_loop_iterations_idle.fetch_add(1, Ordering::Relaxed);
2716        }
2717
2718        // A sweep cadence, not a policy value -- see
2719        // `SESSION_RECORD_RETENTION_SWEEP_PERIOD`'s own doc comment. The
2720        // policy itself (whether this does anything at all) lives entirely
2721        // in `NodeShared::session_record_retention`.
2722        if last_retention_sweep.elapsed() >= SESSION_RECORD_RETENTION_SWEEP_PERIOD {
2723            shared.retire_unavailable_session_records().await;
2724            last_retention_sweep = Instant::now();
2725        }
2726
2727        sleep(Duration::from_millis(10)).await;
2728    }
2729}
2730
2731#[derive(Clone)]
2732pub struct NodeShutdownHandle {
2733    shared: Arc<NodeShared>,
2734}
2735
2736impl NodeShutdownHandle {
2737    pub async fn request_shutdown(&self) -> Result<(), NodeServerError> {
2738        self.shared
2739            .begin_shutdown()
2740            .await
2741            .map_err(NodeServerError::ShutdownDispatch)
2742    }
2743}
2744
2745async fn wait_for_ctrl_signal() -> Result<(), NodeServerError> {
2746    #[cfg(windows)]
2747    {
2748    let mut ctrl_c = tokio::signal::windows::ctrl_c()?;
2749    let mut ctrl_break = tokio::signal::windows::ctrl_break()?;
2750    return tokio::select! {
2751        signal = ctrl_c.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2752        signal = ctrl_break.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2753    };
2754    }
2755    #[cfg(unix)]
2756    {
2757        let mut interrupt = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())?;
2758        let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
2759        tokio::select! {
2760            signal = interrupt.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2761            signal = terminate.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2762        }
2763    }
2764}
2765
2766/// The providers this node actually runs.
2767///
2768/// `builtin_registry()` is a reference catalog of everything the project has
2769/// ever described; the fleet is this list, and it is deliberately short. A
2770/// provider only belongs here if it can be exercised for real -- there is a
2771/// subscription behind it and a failure in it can be reproduced and
2772/// debugged. Carrying one that cannot is carrying code nobody can verify,
2773/// which is worse than not supporting it: it looks supported.
2774///
2775/// A provider dropped from the fleet by this rule is removed outright, not
2776/// carried dark: no catalog entry, no adapter, no dead code claiming support
2777/// for something nobody can verify runs.
2778fn active_registry() -> Result<AgentRegistry, NodeServerError> {
2779    let specs = builtin_registry()
2780        .iter()
2781        .filter(|spec| matches!(spec.id.as_str(), "claude" | "codex" | "grok" | "kimi"))
2782        .cloned()
2783        .collect::<Vec<_>>();
2784    AgentRegistry::new(specs).map_err(|error| NodeServerError::Registry(error.to_string()))
2785}
2786
2787fn provider_contract_manifest(
2788    catalog: &AgentRegistry,
2789) -> Result<
2790    (
2791        Vec<ProviderContractSupport>,
2792        Vec<ProviderAdapterContractSupport>,
2793    ),
2794    NodeServerError,
2795> {
2796    let mut provider_contracts = Vec::new();
2797    let mut provider_adapter_contracts = Vec::new();
2798    for spec in catalog.iter() {
2799        let provider = spec.id.clone();
2800        let revision = ProviderContractRevision::new(spec.revision.clone())
2801            .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2802        provider_contracts.push(ProviderContractSupport {
2803            provider: provider.clone(),
2804            revision,
2805        });
2806        for (family, binding) in declared_adapter_bindings(spec)? {
2807            let revision = AdapterContractRevision::new(binding.revision.clone())
2808                .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2809            provider_adapter_contracts.push(ProviderAdapterContractSupport {
2810                provider: provider.clone(),
2811                family,
2812                adapter_id: binding.id.clone(),
2813                revision,
2814            });
2815        }
2816    }
2817    validate_provider_contract_manifest(&provider_contracts, &provider_adapter_contracts)
2818        .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2819    Ok((provider_contracts, provider_adapter_contracts))
2820}
2821
2822fn declared_adapter_bindings(
2823    spec: &AgentSpec,
2824) -> Result<Vec<(AdapterFamily, &AdapterBinding)>, NodeServerError> {
2825    let mut bindings = Vec::new();
2826    push_declared_binding(
2827        &mut bindings,
2828        AdapterFamily::PtySemantic,
2829        spec.capabilities.transports.pty_adapter.as_ref(),
2830    )?;
2831    push_declared_binding(
2832        &mut bindings,
2833        AdapterFamily::Pipe,
2834        spec.capabilities.adapters.pty_sidecar.as_ref(),
2835    )?;
2836    let pipe_transport = spec.capabilities.transports.pipe.as_ref();
2837    let pipe_family = pipe_transport.map(|transport| match transport.protocol {
2838        gate4agent_types::PipeProtocol::SemanticNdjson
2839        | gate4agent_types::PipeProtocol::StructuredJsonl => AdapterFamily::Pipe,
2840        gate4agent_types::PipeProtocol::OneShotText => AdapterFamily::OneShot,
2841    });
2842    push_declared_binding(
2843        &mut bindings,
2844        AdapterFamily::Pipe,
2845        pipe_transport
2846            .filter(|_| pipe_family == Some(AdapterFamily::Pipe))
2847            .map(|transport| &transport.adapter),
2848    )?;
2849    push_declared_binding(
2850        &mut bindings,
2851        AdapterFamily::Acp,
2852        spec.capabilities
2853            .transports
2854            .acp
2855            .as_ref()
2856            .map(|transport| &transport.adapter),
2857    )?;
2858    push_declared_binding(
2859        &mut bindings,
2860        AdapterFamily::Hook,
2861        spec.capabilities.adapters.hook.as_ref(),
2862    )?;
2863    push_declared_binding(
2864        &mut bindings,
2865        AdapterFamily::ManagedHook,
2866        spec.capabilities.adapters.managed_hook.as_ref(),
2867    )?;
2868    push_declared_binding(
2869        &mut bindings,
2870        AdapterFamily::OneShot,
2871        pipe_transport
2872            .filter(|_| pipe_family == Some(AdapterFamily::OneShot))
2873            .map(|transport| &transport.adapter),
2874    )?;
2875    push_declared_binding(
2876        &mut bindings,
2877        AdapterFamily::OneShot,
2878        spec.capabilities.adapters.one_shot.as_ref(),
2879    )?;
2880    push_declared_binding(
2881        &mut bindings,
2882        AdapterFamily::History,
2883        spec.capabilities.adapters.history.as_ref(),
2884    )?;
2885    push_declared_binding(
2886        &mut bindings,
2887        AdapterFamily::Resume,
2888        spec.capabilities.adapters.resume.as_ref(),
2889    )?;
2890    push_declared_binding(
2891        &mut bindings,
2892        AdapterFamily::SessionOptions,
2893        spec.capabilities.adapters.session_options.as_ref(),
2894    )?;
2895    push_declared_binding(
2896        &mut bindings,
2897        AdapterFamily::CapabilityProbe,
2898        spec.capabilities.adapters.capability_probe.as_ref(),
2899    )?;
2900    Ok(bindings)
2901}
2902
2903fn push_declared_binding<'a>(
2904    bindings: &mut Vec<(AdapterFamily, &'a AdapterBinding)>,
2905    family: AdapterFamily,
2906    binding: Option<&'a AdapterBinding>,
2907) -> Result<(), NodeServerError> {
2908    let Some(binding) = binding else {
2909        return Ok(());
2910    };
2911    if let Some((_, existing)) = bindings
2912        .iter()
2913        .find(|(existing_family, _)| *existing_family == family)
2914    {
2915        if *existing == binding {
2916            return Ok(());
2917        }
2918        return Err(NodeServerError::ProviderContractManifest(format!(
2919            "provider declares conflicting {family:?} adapter bindings"
2920        )));
2921    }
2922    bindings.push((family, binding));
2923    Ok(())
2924}
2925
2926fn merge_durable_state(
2927    configured: &[WorkspaceConfig],
2928    mut loaded: LoadedNodeState,
2929) -> Result<(Vec<WorkspaceConfig>, Vec<ManagedSessionRecord>, Option<String>), NodeServerError> {
2930    let mut workspaces = configured
2931        .iter()
2932        .cloned()
2933        .map(|workspace| (workspace.workspace_id.clone(), workspace))
2934        .collect::<BTreeMap<_, _>>();
2935    let mut roots = workspaces
2936        .values()
2937        .map(|workspace| {
2938            (
2939                platform::root_identity(&workspace.canonical_root),
2940                workspace.workspace_id.clone(),
2941            )
2942        })
2943        .collect::<BTreeMap<_, _>>();
2944    let mut warning = loaded
2945        .warning
2946        .take()
2947        .map(|message| session_registry::sanitized_persistence_summary(&message));
2948
2949    for (workspace_id, canonical_root) in loaded.workspaces {
2950        if let Some(existing) = workspaces.get(&workspace_id) {
2951            if !platform::roots_equal(&existing.canonical_root, &canonical_root) {
2952                return Err(NodeServerError::DurableState(io::Error::new(
2953                    io::ErrorKind::InvalidData,
2954                    DURABLE_STATE_CONFLICT_ERROR,
2955                )));
2956            }
2957            continue;
2958        }
2959        if roots.get(&platform::root_identity(&canonical_root)).is_some() {
2960            return Err(NodeServerError::DurableState(io::Error::new(
2961                io::ErrorKind::InvalidData,
2962                DURABLE_STATE_CONFLICT_ERROR,
2963            )));
2964        }
2965        if !Path::new(&canonical_root).is_dir() {
2966            warning.get_or_insert_with(|| {
2967                session_registry::DURABLE_STATE_WORKSPACE_WARNING.to_owned()
2968            });
2969            continue;
2970        }
2971        let workspace = WorkspaceConfig::new(workspace_id.clone(), &canonical_root).map_err(|_| {
2972            NodeServerError::DurableState(io::Error::new(
2973                io::ErrorKind::InvalidData,
2974                DURABLE_STATE_CONFLICT_ERROR,
2975            ))
2976        })?;
2977        roots.insert(
2978            platform::root_identity(&workspace.canonical_root),
2979            workspace.workspace_id.clone(),
2980        );
2981        workspaces.insert(workspace_id, workspace);
2982    }
2983
2984    for record in &mut loaded.records {
2985        match workspaces.get(&record.workspace_id) {
2986            Some(workspace)
2987                if platform::roots_equal(
2988                    &workspace.canonical_root,
2989                    windows_path_text(&record.canonical_root),
2990                ) => {}
2991            Some(_) => {
2992                return Err(NodeServerError::DurableState(io::Error::new(
2993                    io::ErrorKind::InvalidData,
2994                    DURABLE_STATE_CONFLICT_ERROR,
2995                )));
2996            }
2997            None => {
2998                record.state = ManagedSessionState::Unavailable;
2999                record.active_session = None;
3000                record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
3001            }
3002        }
3003    }
3004    Ok((workspaces.into_values().collect(), loaded.records, warning))
3005}
3006
3007/// One entry of `NodeShared::rejected_command_reasons` -- see that field's
3008/// own doc for the mechanism this exists to close.
3009struct RejectedCommandReason {
3010    message: String,
3011    /// True when the kernel refused this command specifically because the
3012    /// agent does not declare the requested transport (`KernelCommandError::
3013    /// UnsupportedTransport`) -- lets the reader choose the named
3014    /// `NodeFailureCode::UnsupportedTransport` over the generic
3015    /// `BackendOperationFailed` bucket, so this one well-known rejection
3016    /// keeps its identity all the way to the operator wire.
3017    unsupported_transport: bool,
3018    expires_at: Instant,
3019}
3020
3021struct NodeShared {
3022    handle: Gate4AgentHandle,
3023    access_token: String,
3024    node_id: NodeId,
3025    incarnation_id: NodeIncarnationId,
3026    started_at_unix_ms: u64,
3027    workspaces: RwLock<BTreeMap<WorkspaceId, String>>,
3028    worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode>,
3029    managed_worktree_profiles:
3030        BTreeMap<WorkspaceId, BTreeMap<WorktreeProfileId, ManagedWorktreeProfile>>,
3031    managed_worktrees: Mutex<ManagedWorktreeRegistry>,
3032    enabled_providers: Vec<AgentId>,
3033    provider_runtime_statuses: ProviderRuntimeStatuses,
3034    provider_runtime_status_updates:
3035        RwLock<BTreeMap<AgentId, crate::protocol::ProviderRuntimeStatus>>,
3036    provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
3037    provider_contracts: Vec<ProviderContractSupport>,
3038    provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
3039    spawn_profiles: SpawnProfileRegistry,
3040    environment_profiles:
3041        RwLock<BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>>,
3042    environment_materialization_profiles:
3043        RwLock<BTreeMap<SpawnEnvironmentProfileId, NodeSessionMaterializationProfile>>,
3044    /// Station **network** allowlist catalog (opaque policy ids only).
3045    /// Empty by default — any `network_allowlist` on spawn must be registered
3046    /// here or resolve refuses with `UnknownNetworkAllowlist`. Optional
3047    /// startup load via `NodeServerConfig::with_network_allowlist_catalog(_file)`
3048    /// / env `GATE4AGENT_NODE_NETWORK_ALLOWLIST_CATALOG`. Plan
3049    /// `station-network-and-browser-profile-knobs-2026-10-02.md` §2.1 / §4.
3050    /// Never holds cookies / OAuth / proxy credentials.
3051    network_allowlist_catalog: RwLock<crate::network_allowlist_catalog::NetworkAllowlistCatalog>,
3052    /// Node-local exclusive dig2browser station profile leases (feature
3053    /// `dig2-station-probe`). Opaque `browser_profile_id` ↔ session holder;
3054    /// cleared on session end. Never ImportSession / cookies on C2.
3055    #[cfg(feature = "dig2-station-probe")]
3056    browser_station_leases: crate::dig2_station_lease::BrowserStationLeaseTable,
3057    bundle_catalog: RwLock<BundleCatalog>,
3058    delivery_store: Mutex<Option<DeliveryStore>>,
3059    context_pack_store: Mutex<Option<ContextPackStore>>,
3060    context_catalog: RwLock<ContextPackCatalog>,
3061    native_launch_profile_control: Option<NativeLaunchProfileControl>,
3062    harness_mcp_registry: Option<HarnessMcpProxyRegistry>,
3063    native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
3064    session_environment_materializer: Option<SessionEnvironmentMaterializer>,
3065    materializations: Mutex<BTreeMap<MaterializationId, MaterializationOwnershipRecord>>,
3066    spawn_idempotency: Mutex<SpawnIdempotencyCache>,
3067    managed_spawn_replays:
3068        Mutex<BTreeMap<SpawnIdempotencyKey, ManagedWorktreeSpawnReplayRecordV10>>,
3069    controller: Mutex<Option<ControllerLease>>,
3070    history: Mutex<NodeEventHistory>,
3071    event_tx: broadcast::Sender<NodeEventEnvelope>,
3072    terminal_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3073    /// The outbound `NodeEvent::AgentStream` push channel -- its own
3074    /// dedicated, capability-gated broadcast, mirroring `terminal_event_tx`
3075    /// exactly rather than riding the durable `event_tx` history (see
3076    /// `publish`'s own assertion): a subscriber who never negotiated
3077    /// `NODE_AGENT_STREAM_EVENTS_CAPABILITY` must never receive one of
3078    /// these, the same guarantee `include_terminal_frame_events` already
3079    /// gives terminal frames.
3080    agent_stream_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3081    /// The outbound `NodeEvent::HarnessMcpReadCall` push channel -- mirrors
3082    /// `agent_stream_event_tx` exactly, its own dedicated, capability-gated
3083    /// broadcast rather than a ride on the durable `event_tx` history. A
3084    /// `HarnessMcpReadCall` is a request/response call a live proxy call is
3085    /// waiting on (`harness_mcp_proxy.rs`'s `begin_call`); the shared bus's
3086    /// finite capacity and its willingness to silently evict a lagging
3087    /// subscriber's backlog is exactly how the call vanished with neither
3088    /// side ever logging it (see `publish_transient`'s own doc).
3089    harness_mcp_proxy_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3090    terminal_frame_watermarks:
3091        Mutex<BTreeMap<AgentInstanceId, (SessionAddress, u64)>>,
3092    next_connection_id: AtomicU64,
3093    next_instance_id: AtomicU64,
3094    next_command_id: AtomicU64,
3095    shutdown: AtomicBool,
3096    shutdown_notify: Notify,
3097    preauth_slots: Arc<Semaphore>,
3098    authenticated_slots: Arc<Semaphore>,
3099    inspection_slots: Arc<Semaphore>,
3100    mutation_gate: AsyncMutex<()>,
3101    session_bindings: Mutex<BTreeMap<AgentInstanceId, SessionBinding>>,
3102    session_records: Mutex<SessionRecords>,
3103    state_path: Option<PathBuf>,
3104    persistence_error: RwLock<Option<String>>,
3105    state_transaction: Mutex<()>,
3106    input_settle_timeout_ms: u64,
3107    /// Drive-loop phase/rate timings -- see `drive_loop_profile`'s own doc
3108    /// comment. Written once per iteration by
3109    /// `drive_runtime_until_shutdown`, read by `GET /metrics`.
3110    drive_loop_profile: Mutex<DriveLoopProfiler>,
3111    /// Latest copy of `NativeRuntime::tick_profile_snapshot()` -- `Copy`,
3112    /// so an overwrite each iteration is cheap. The runtime itself is not
3113    /// reachable from here (it is owned locally by
3114    /// `drive_runtime_until_shutdown`), so this is how its own per-phase
3115    /// tick timings reach `GET /metrics`.
3116    runtime_tick_profile: Mutex<TickProfileSnapshot>,
3117    /// The live shell-efficiency profile, shared with the per-instance
3118    /// worker loops that fold into it.
3119    ///
3120    /// Deliberately NOT the per-iteration snapshot copy `runtime_tick_
3121    /// profile` above keeps: producing a snapshot sorts every ring behind
3122    /// it, and that sort belongs on the read. A `OnceLock` rather than a
3123    /// field set at construction because `NodeShared` is built before the
3124    /// `NativeRuntime` that owns the profile exists -- the drive loop
3125    /// publishes it once, before its first iteration. Unset means the
3126    /// runtime has not started driving yet, which `GET /metrics` reports as
3127    /// empty series rather than as zeroes it never measured.
3128    shell_efficiency_profile: OnceLock<Arc<Mutex<ShellEfficiencyProfile>>>,
3129    /// Physical PTY sessions the native runtime currently owns
3130    /// (`NativeRuntime::active_native_sessions`), refreshed once per drive
3131    /// loop iteration -- deliberately a separate count from
3132    /// `handle.snapshot().sessions.len()` (the control-plane kernel's own
3133    /// session bookkeeping), since the two can disagree and the gap is
3134    /// itself a diagnostic signal `GET /metrics` exposes.
3135    native_session_gauge: AtomicUsize,
3136    /// Lifetime totals for the per-connection serve loop, summed across
3137    /// every live connection.
3138    ///
3139    /// The drive loop's own phase timings account for about ten
3140    /// microseconds an iteration on an idle node, yet the process burns a
3141    /// quarter of a core — and burns it ONLY while a c2 is attached (0.5%
3142    /// with none, 23% with two, measured). That gap is somewhere in
3143    /// serving a connection, and there was no way to see it: iterations
3144    /// says whether the loop is spinning rather than sleeping, and events
3145    /// says whether it is spinning over real work or over nothing.
3146    connection_loop_iterations: AtomicU64,
3147    connection_events_sent: AtomicU64,
3148    /// Lifetime totals for `drive_runtime_until_shutdown`'s own iterations
3149    /// -- `drive_loop_iterations_idle` counts iterations that applied zero
3150    /// `NativeRuntime::tick` observations AND forwarded zero control
3151    /// events; see the exact "produced nothing" definition at the site in
3152    /// `drive_runtime_until_shutdown` that increments these. Read twice
3153    /// and divide by the interval, same rule as `connections` above --
3154    /// `idle / total` is backlog item 5's "wake on work" number.
3155    /// Lifetime count of control commands the kernel refused.
3156    rejected_commands_total: AtomicU64,
3157    /// The named reason behind a recent kernel rejection, by the
3158    /// `CommandId` `dispatch_bounded` handed back when it enqueued that
3159    /// command -- populated by `drive_runtime_until_shutdown` for every
3160    /// `Err` in a tick's `command_outcomes`, and drained by whichever
3161    /// spawn-dispatch waiter recognizes its own `Register`/`Start`
3162    /// `CommandId` among them.
3163    ///
3164    /// A `ControlCommand` only ever fails synchronously (a `NodeFailure`
3165    /// out of `dispatch`/`dispatch_bounded` itself) for a port-level ingress
3166    /// problem -- full or disconnected -- never for the kernel refusing the
3167    /// command's content (an unsupported transport, an unknown agent, ...);
3168    /// that verdict exists only once `NativeRuntime::tick` has processed the
3169    /// command, one drive-loop iteration later, in a different task than
3170    /// whichever caller is awaiting the spawn. Without this map, a spawn
3171    /// wait loop that polls `self.handle.snapshot()` for a session that
3172    /// will now never appear has no way to learn why -- it can only spin
3173    /// until its own commit deadline and report a generic "did not commit"
3174    /// timeout, discarding a reason the kernel already named precisely (see
3175    /// `KernelCommandError`'s variants, e.g. `UnsupportedTransport`).
3176    rejected_command_reasons: Mutex<BTreeMap<CommandId, RejectedCommandReason>>,
3177    drive_loop_iterations_total: AtomicU64,
3178    drive_loop_iterations_idle: AtomicU64,
3179    /// The operator-chosen age/keep-N policy the retention sweep applies to
3180    /// `ManagedSessionState::Unavailable` records. Both `0` by default --
3181    /// see `SessionRecordRetentionConfig`'s own doc comment.
3182    session_record_retention: SessionRecordRetentionConfig,
3183    /// Lifetime count of managed session records the retention sweep has
3184    /// retired. Same tier as `rejected_commands_total` above: a live,
3185    /// process-lifetime instrument, never persisted and never reloaded from
3186    /// `state-v1.json` -- a restart legitimately resets it, matching every
3187    /// other `*_total` counter here.
3188    retired_records_total: AtomicU64,
3189    #[cfg(feature = "fixture")]
3190    fixture_semantic_hook_policy: bool,
3191    #[cfg(feature = "fixture")]
3192    fixture_spawn_managed_worktree_v2_failure_probe: SpawnManagedWorktreeV2FailureProbe,
3193}
3194
3195impl Drop for NodeShared {
3196    fn drop(&mut self) {
3197        if let Some(registry) = self.harness_mcp_registry.as_ref() {
3198            let instances = registry.shutdown();
3199            if let Some(control) = self.native_launch_profile_control.as_ref() {
3200                for instance_id in instances {
3201                    control.clear_native_mcp_server_launch_overlay(instance_id);
3202                }
3203            }
3204        }
3205    }
3206}
3207
3208#[derive(Clone, Debug, Eq, PartialEq)]
3209struct SessionBinding {
3210    workspace_id: WorkspaceId,
3211    generation: SessionGeneration,
3212    runtime_policy: ProviderRuntimePolicy,
3213    pending_resume: Option<(SessionGeneration, CommandId, ProviderRuntimePolicy)>,
3214    record_id: Option<SessionRecordId>,
3215    managed_worktree_lease_id: Option<ManagedWorktreeLeaseId>,
3216    environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
3217    bundle: Option<ResolvedBundleReceipt>,
3218    context: Option<ResolvedContextPackReceipt>,
3219    materialization_id: Option<MaterializationId>,
3220}
3221
3222#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3223enum SpawnRecordPolicy {
3224    ProviderIdentityOnly,
3225    Always,
3226}
3227
3228struct NativeEnvironmentSelectionGuard {
3229    control: Option<NativeLaunchProfileControl>,
3230    instance_id: AgentInstanceId,
3231}
3232
3233struct NativeInstanceOverlayGuard {
3234    control: Option<NativeLaunchProfileControl>,
3235    instance_id: AgentInstanceId,
3236}
3237
3238struct NativeHarnessMcpOverlayGuard {
3239    control: NativeLaunchProfileControl,
3240    instance_id: AgentInstanceId,
3241}
3242
3243impl NativeHarnessMcpOverlayGuard {
3244    fn retain(self) { std::mem::forget(self); }
3245}
3246
3247impl Drop for NativeHarnessMcpOverlayGuard {
3248    fn drop(&mut self) {
3249        self.control
3250            .clear_native_mcp_server_launch_overlay(self.instance_id);
3251    }
3252}
3253
3254impl NativeInstanceOverlayGuard {
3255    fn retain(mut self) {
3256        self.control = None;
3257    }
3258}
3259
3260impl Drop for NativeInstanceOverlayGuard {
3261    fn drop(&mut self) {
3262        if let Some(control) = self.control.as_ref() {
3263            control.clear_native_instance_launch_overlay(self.instance_id);
3264        }
3265    }
3266}
3267
3268enum PreparedNativeLaunchOverlay {
3269    Environment(NativeLaunchEnvironmentOverlay),
3270    Instance(NativeInstanceLaunchOverlay),
3271}
3272
3273impl NativeEnvironmentSelectionGuard {
3274    fn retain(mut self) {
3275        self.control = None;
3276    }
3277}
3278
3279impl Drop for NativeEnvironmentSelectionGuard {
3280    fn drop(&mut self) {
3281        if let Some(control) = self.control.as_ref() {
3282            control.clear_native_launch_profile_selection(self.instance_id);
3283        }
3284    }
3285}
3286
3287struct SessionMaterializationGuard<'a> {
3288    shared: &'a NodeShared,
3289    id: Option<MaterializationId>,
3290    /// Absolute ProviderHome (`home/`) under the session materialization root.
3291    /// Used for Claude station network `--settings` sidecar writes.
3292    provider_home: Option<PathBuf>,
3293}
3294
3295impl SessionMaterializationGuard<'_> {
3296    fn id(&self) -> Option<&MaterializationId> {
3297        self.id.as_ref()
3298    }
3299
3300    fn provider_home(&self) -> Option<&Path> {
3301        self.provider_home.as_deref()
3302    }
3303
3304    fn retain(mut self) {
3305        self.id = None;
3306    }
3307}
3308
3309impl Drop for SessionMaterializationGuard<'_> {
3310    fn drop(&mut self) {
3311        if let Some(id) = self.id.take() {
3312            let _ = self.shared.cleanup_materialization(&id);
3313        }
3314    }
3315}
3316
3317struct SessionRecords {
3318    records: BTreeMap<SessionRecordId, ManagedSessionRecord>,
3319}
3320
3321struct SpawnIdempotencyCache {
3322    entries: BTreeMap<SpawnIdempotencyKey, SpawnIdempotencyEntry>,
3323}
3324
3325struct SpawnIdempotencyEntry {
3326    value: SpawnIdempotencyValue,
3327    expires_at: Instant,
3328}
3329
3330enum SpawnIdempotencyValue {
3331    Standard {
3332        spec: SpawnSpec,
3333        result: Result<ResolvedSpawnReceipt, NodeFailure>,
3334    },
3335    Managed {
3336        request: ManagedWorktreeSpawnRequest,
3337        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
3338    },
3339    HarnessMcp,
3340}
3341
3342enum DurableManagedSpawnReplayDecision {
3343    Reserved,
3344    PendingLinked,
3345    Committed(ManagedWorktreeSpawnReceipt),
3346}
3347
3348impl SpawnIdempotencyValue {
3349    fn references_context(&self, context_id: &SpawnContextId) -> bool {
3350        match self {
3351            Self::Standard { result, .. } => result
3352                .as_ref()
3353                .ok()
3354                .and_then(|receipt| receipt.context.as_ref()),
3355            Self::Managed { result, .. } => result
3356                .as_ref()
3357                .ok()
3358                .and_then(|receipt| receipt.spawn.context.as_ref()),
3359            Self::HarnessMcp => None,
3360        }
3361        .is_some_and(|receipt| &receipt.id == context_id)
3362    }
3363}
3364
3365#[derive(Clone, Copy)]
3366struct ControllerLease {
3367    connection_id: u64,
3368    expires_at: Instant,
3369}
3370
3371struct NodeEventHistory {
3372    last_sequence: u64,
3373    replay_floor_sequence: u64,
3374    events: VecDeque<NodeEventEnvelope>,
3375    record_providers: BTreeMap<SessionRecordId, AgentId>,
3376    removed_record_providers: BTreeMap<u64, AgentId>,
3377}
3378
3379impl NodeEventHistory {
3380    fn new(record_providers: BTreeMap<SessionRecordId, AgentId>) -> Self {
3381        Self {
3382            last_sequence: 0,
3383            replay_floor_sequence: 1,
3384            events: VecDeque::with_capacity(NODE_EVENT_HISTORY_MAX),
3385            record_providers,
3386            removed_record_providers: BTreeMap::new(),
3387        }
3388    }
3389}
3390
3391impl NodeShared {
3392    fn new_with_incarnation(
3393        handle: Gate4AgentHandle,
3394        access_token: String,
3395        node_id: NodeId,
3396        incarnation_id: NodeIncarnationId,
3397        workspaces: Vec<WorkspaceConfig>,
3398        enabled_providers: Vec<AgentId>,
3399        provider_runtime_statuses: ProviderRuntimeStatuses,
3400        provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
3401        provider_contracts: Vec<ProviderContractSupport>,
3402        provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
3403        spawn_profiles: SpawnProfileRegistry,
3404        native_launch_profile_control: Option<NativeLaunchProfileControl>,
3405        native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
3406        session_environment_materializer: Option<SessionEnvironmentMaterializer>,
3407        state_path: Option<PathBuf>,
3408        records: Vec<ManagedSessionRecord>,
3409        managed_worktrees: Vec<ManagedWorktreeLeaseRecord>,
3410        managed_worktree_tombstones: Vec<ManagedWorktreeLeaseRecord>,
3411        materializations: Vec<MaterializationOwnershipRecord>,
3412        persistence_warning: Option<String>,
3413        input_settle_timeout_ms: u64,
3414    ) -> Self {
3415        let (event_tx, _) = broadcast::channel(NODE_BROADCAST_CAPACITY);
3416        let (terminal_event_tx, _) = broadcast::channel(NODE_TERMINAL_BROADCAST_CAPACITY);
3417        let (agent_stream_event_tx, _) = broadcast::channel(NODE_AGENT_STREAM_BROADCAST_CAPACITY);
3418        let (harness_mcp_proxy_event_tx, _) =
3419            broadcast::channel(NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY);
3420        let record_providers = records
3421            .iter()
3422            .map(|record| (record.record_id.clone(), record.provider.clone()))
3423            .collect();
3424        let mut worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode> = workspaces
3425            .iter()
3426            .map(|workspace| {
3427                (workspace.workspace_id.clone(), workspace.worktree_service_mode)
3428            })
3429            .collect();
3430        for lease in &managed_worktrees {
3431            worktree_service_modes.insert(lease.workspace_id.clone(), WorktreeServiceMode::Off);
3432        }
3433        let managed_worktree_profiles = workspaces.iter()
3434            .map(|workspace| (
3435                workspace.workspace_id.clone(),
3436                workspace.managed_worktree_profiles.clone(),
3437            ))
3438            .collect();
3439        let mut managed_worktrees = ManagedWorktreeRegistry::from_records(
3440            managed_worktrees,
3441            managed_worktree_tombstones,
3442        ).expect("durable managed worktree registry was validated while loading");
3443        managed_worktrees.clear_stale_session_holders(incarnation_id, unix_time_ms());
3444        managed_worktrees.reattach_record_holders(
3445            &managed_worktree_record_holders(&records),
3446            unix_time_ms(),
3447        );
3448        Self {
3449            handle,
3450            access_token,
3451            node_id,
3452            incarnation_id,
3453            started_at_unix_ms: std::time::SystemTime::now()
3454                .duration_since(std::time::UNIX_EPOCH)
3455                .unwrap_or_default()
3456                .as_millis()
3457                .min(u64::MAX as u128) as u64,
3458            workspaces: RwLock::new(
3459                workspaces
3460                    .into_iter()
3461                    .map(|workspace| (workspace.workspace_id, workspace.canonical_root))
3462                    .collect(),
3463            ),
3464            worktree_service_modes,
3465            managed_worktree_profiles,
3466            managed_worktrees: Mutex::new(managed_worktrees),
3467            enabled_providers,
3468            provider_runtime_statuses,
3469            provider_runtime_status_updates: RwLock::new(BTreeMap::new()),
3470            provider_runtime_monitor,
3471            provider_contracts,
3472            provider_adapter_contracts,
3473            spawn_profiles,
3474            environment_profiles: RwLock::new(BTreeMap::new()),
3475            environment_materialization_profiles: RwLock::new(BTreeMap::new()),
3476            network_allowlist_catalog: RwLock::new(crate::network_allowlist_catalog::NetworkAllowlistCatalog::new()),
3477            #[cfg(feature = "dig2-station-probe")]
3478            browser_station_leases: crate::dig2_station_lease::new_lease_table(),
3479            bundle_catalog: RwLock::new(BundleCatalog::default()),
3480            delivery_store: Mutex::new(None),
3481            context_pack_store: Mutex::new(None),
3482            context_catalog: RwLock::new(ContextPackCatalog::default()),
3483            native_launch_profile_control,
3484            harness_mcp_registry: None,
3485            native_session_catalog,
3486            session_environment_materializer,
3487            materializations: Mutex::new(
3488                materializations
3489                    .into_iter()
3490                    .map(|record| (record.id().clone(), record))
3491                    .collect(),
3492            ),
3493            spawn_idempotency: Mutex::new(SpawnIdempotencyCache {
3494                entries: BTreeMap::new(),
3495            }),
3496            managed_spawn_replays: Mutex::new(BTreeMap::new()),
3497            controller: Mutex::new(None),
3498            history: Mutex::new(NodeEventHistory::new(record_providers)),
3499            event_tx,
3500            terminal_event_tx,
3501            agent_stream_event_tx,
3502            harness_mcp_proxy_event_tx,
3503            terminal_frame_watermarks: Mutex::new(BTreeMap::new()),
3504            next_connection_id: AtomicU64::new(1),
3505            next_instance_id: AtomicU64::new(1),
3506            next_command_id: AtomicU64::new(1),
3507            shutdown: AtomicBool::new(false),
3508            shutdown_notify: Notify::new(),
3509            preauth_slots: Arc::new(Semaphore::new(MAX_PREAUTH_CONNECTIONS)),
3510            authenticated_slots: Arc::new(Semaphore::new(MAX_AUTHENTICATED_CONNECTIONS)),
3511            inspection_slots: Arc::new(Semaphore::new(WORKSPACE_INSPECTION_MAX_CONCURRENCY)),
3512            mutation_gate: AsyncMutex::new(()),
3513            session_bindings: Mutex::new(BTreeMap::new()),
3514            session_records: Mutex::new(SessionRecords {
3515                records: records
3516                    .into_iter()
3517                    .map(|record| (record.record_id.clone(), record))
3518                    .collect(),
3519            }),
3520            state_path,
3521            persistence_error: RwLock::new(
3522                persistence_warning
3523                    .map(|message| session_registry::sanitized_persistence_summary(&message)),
3524            ),
3525            state_transaction: Mutex::new(()),
3526            input_settle_timeout_ms,
3527            drive_loop_profile: Mutex::new(DriveLoopProfiler::default()),
3528            runtime_tick_profile: Mutex::new(TickProfileSnapshot::default()),
3529            shell_efficiency_profile: OnceLock::new(),
3530            native_session_gauge: AtomicUsize::new(0),
3531            connection_loop_iterations: AtomicU64::new(0),
3532            connection_events_sent: AtomicU64::new(0),
3533            rejected_commands_total: AtomicU64::new(0),
3534            rejected_command_reasons: Mutex::new(BTreeMap::new()),
3535            drive_loop_iterations_total: AtomicU64::new(0),
3536            drive_loop_iterations_idle: AtomicU64::new(0),
3537            session_record_retention: SessionRecordRetentionConfig::default(),
3538            retired_records_total: AtomicU64::new(0),
3539            #[cfg(feature = "fixture")]
3540            fixture_semantic_hook_policy: false,
3541            #[cfg(feature = "fixture")]
3542            fixture_spawn_managed_worktree_v2_failure_probe:
3543                SpawnManagedWorktreeV2FailureProbe::default(),
3544        }
3545    }
3546
3547    #[cfg(test)]
3548    fn new(
3549        handle: Gate4AgentHandle,
3550        access_token: String,
3551        node_id: NodeId,
3552        workspaces: Vec<WorkspaceConfig>,
3553        enabled_providers: Vec<AgentId>,
3554    ) -> Self {
3555        Self::new_with_incarnation(
3556            handle,
3557            access_token,
3558            node_id,
3559            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
3560            workspaces,
3561            enabled_providers,
3562            ProviderRuntimeStatuses::default(),
3563            None,
3564            Vec::new(),
3565            Vec::new(),
3566            SpawnProfileRegistry::default(),
3567            None,
3568            None,
3569            None,
3570            None,
3571            Vec::new(),
3572            Vec::new(),
3573            Vec::new(),
3574            Vec::new(),
3575            None,
3576            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
3577        )
3578    }
3579
3580    fn persistence_error(&self) -> Option<String> {
3581        self.persistence_error
3582            .read()
3583            .unwrap_or_else(|poisoned| poisoned.into_inner())
3584            .clone()
3585    }
3586
3587    async fn admit_provider_runtime(
3588        &self,
3589        provider: &AgentId,
3590        requirement: ProviderRuntimeRequirement,
3591    ) -> Result<ProviderRuntimePolicy, NodeFailure> {
3592        let admission = if let Some(monitor) = self.provider_runtime_monitor.clone() {
3593            let pipe_transport = monitor.supports_pipe_transport(provider);
3594            let acp_transport = monitor.supports_acp_transport(provider);
3595            let provider = provider.clone();
3596            let refresh = tokio::task::spawn_blocking(move || monitor.evaluate(&provider));
3597            let (status, admission) = timeout(
3598                Duration::from_millis(PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS),
3599                refresh,
3600            )
3601            .await
3602            .map_err(|_| failure(
3603                NodeFailureCode::BackendBusy,
3604                "provider runtime probe exceeded its bounded deadline",
3605            ))?
3606            .map_err(|_| failure(
3607                NodeFailureCode::BackendOperationFailed,
3608                "provider runtime probe task failed",
3609            ))?;
3610            if let Some(status) = status {
3611                self
3612                    .provider_runtime_status_updates
3613                    .write()
3614                    .unwrap_or_else(|poisoned| poisoned.into_inner())
3615                    .insert(status.provider().clone(), status);
3616            }
3617            admission.and_then(|policy| {
3618                require_policy(policy, pipe_transport, acp_transport, requirement).map(|()| policy)
3619            })
3620        } else {
3621            // No live monitor means no catalog to consult -- there is
3622            // nothing here to confirm this provider declares a Pipe or ACP
3623            // transport, so Inline/Acp are rejected the same conservative
3624            // way an undeclared provider always was, rather than admitted
3625            // on trust.
3626            crate::provider_runtime::admit_status(
3627                &self.provider_runtime_statuses,
3628                provider,
3629                false,
3630                false,
3631                requirement,
3632            )
3633        };
3634        let admission = admission.map(|policy| self.admit_declared_hook_semantics(provider, policy));
3635        admission.map_err(|error| match error {
3636            ProviderRuntimeAdmissionError::LauncherUnavailable => failure(
3637                NodeFailureCode::BackendOperationFailed,
3638                "provider launcher is unavailable",
3639            ),
3640            ProviderRuntimeAdmissionError::SemanticCapabilityUnverified => failure(
3641                NodeFailureCode::UnsupportedCapability,
3642                "provider semantic capability is not verified",
3643            ),
3644            ProviderRuntimeAdmissionError::ProbeBusy => failure(
3645                NodeFailureCode::BackendBusy,
3646                "provider runtime probe is already in progress",
3647            ),
3648        })
3649    }
3650
3651    /// Whether the catalog declares a Pipe transport for `provider` --
3652    /// the fact `require_policy` needs to admit or reject
3653    /// `ProviderRuntimeRequirement::Inline`. With no live monitor there is no
3654    /// catalog to consult, so this stays conservative and rejects, matching
3655    /// `admit_provider_runtime`'s own no-monitor branch.
3656    fn provider_supports_pipe_transport(&self, provider: &AgentId) -> bool {
3657        match self.provider_runtime_monitor.as_deref() {
3658            Some(monitor) => monitor.supports_pipe_transport(provider),
3659            None => false,
3660        }
3661    }
3662
3663    /// Whether the catalog declares an ACP transport for `provider` -- the
3664    /// fact `require_policy` needs to admit or reject
3665    /// `ProviderRuntimeRequirement::Acp`. Mirrors
3666    /// `provider_supports_pipe_transport` exactly, including the
3667    /// conservative no-monitor default.
3668    fn provider_supports_acp_transport(&self, provider: &AgentId) -> bool {
3669        match self.provider_runtime_monitor.as_deref() {
3670            Some(monitor) => monitor.supports_acp_transport(provider),
3671            None => false,
3672        }
3673    }
3674
3675    /// Grants hook ingestion to a provider whose catalog entry declares a
3676    /// Hook adapter, whatever runtime mode its launcher probe landed in.
3677    ///
3678    /// `admit_status`'s `RawPassthrough` arm hands back
3679    /// `ProviderRuntimePolicy::raw_pty()`, which grants nothing semantic --
3680    /// correct for everything it was written to answer, and wrong for hooks
3681    /// specifically. A hook event is not inferred from terminal behaviour,
3682    /// so it has nothing to do with whether a launcher probe verified this
3683    /// CLI version's PTY contract; the authority is the catalog declaring
3684    /// the adapter plus the ingress authenticating the route. Without this
3685    /// the node installs a provider's hooks, the CLI faithfully calls them,
3686    /// and every event is refused -- which is exactly the state grok was in.
3687    ///
3688    /// Applied to both admission paths so the monitor's own derivation and
3689    /// this coarse fallback cannot disagree about the same provider.
3690    fn admit_declared_hook_semantics(
3691        &self,
3692        provider: &AgentId,
3693        policy: ProviderRuntimePolicy,
3694    ) -> ProviderRuntimePolicy {
3695        if policy.hook_semantics || !policy.raw_pty_lifecycle {
3696            return policy;
3697        }
3698        let declares_hook = self.provider_adapter_contracts.iter().any(|contract| {
3699            contract.provider == *provider && contract.family == AdapterFamily::Hook
3700        });
3701        if !declares_hook {
3702            return policy;
3703        }
3704        ProviderRuntimePolicy::new(
3705            policy.raw_pty_lifecycle,
3706            policy.semantic_readiness,
3707            policy.structured_prompt,
3708            policy.provider_session_identity,
3709            policy.semantic_resume,
3710            true,
3711        )
3712        .expect("granting hook semantics over a raw-PTY policy is internally valid")
3713    }
3714
3715    /// Station network allowlist → provider launch overlay argv.
3716    ///
3717    /// - **Codex**: first-slice `-c sandbox_workspace_write.network_access` from
3718    ///   `provider_native` under Moderate (PTY or ACP).
3719    /// - **Claude**: catalog `permits` → ProviderHome sidecar settings JSON +
3720    ///   documented `--settings <path>` on **PTY** only (Linux/macOS/WSL2).
3721    ///   ACP refuses (no measured ACP settings-path surface). Native Windows
3722    ///   refuses (no vendor Bash sandbox). Membership-only (empty permits) =
3723    ///   no overlay.
3724    /// - **Kimi / Grok**: no first-party settings/argv network surface here —
3725    ///   empty overlay when no `provider_native`; invented native keys already
3726    ///   refuse at load / resolve.
3727    ///
3728    /// Empty when no allowlist / nothing to map. Never cookies / secrets.
3729    fn resolve_network_allowlist_overlay_args(
3730        &self,
3731        provider: &AgentId,
3732        mode: SessionMode,
3733        approval_level: ApprovalLevel,
3734        network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
3735        provider_home: Option<&Path>,
3736    ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
3737        let Some(allowlist_id) = network_allowlist else {
3738            return Ok(Vec::new());
3739        };
3740        let catalog = self
3741            .network_allowlist_catalog
3742            .read()
3743            .unwrap_or_else(|poisoned| poisoned.into_inner());
3744        let Some(entry) = catalog.get(allowlist_id) else {
3745            return Err(failure(
3746                NodeFailureCode::UnknownNetworkAllowlist,
3747                "spawn network allowlist is unavailable on this node",
3748            ));
3749        };
3750
3751        // Claude: settings-shaped overlay from permits (not provider_native).
3752        if provider.as_str() == "claude" && !entry.permits.is_empty() {
3753            crate::network_allowlist_catalog::claude_bash_sandbox_network_os_supported().map_err(
3754                |message| {
3755                    failure(
3756                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3757                        &message,
3758                    )
3759                },
3760            )?;
3761            if !matches!(mode, SessionMode::Pty) {
3762                return Err(failure(
3763                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3764                    "Claude sandbox.network --settings overlay requires PTY transport; ACP has no measured settings-path surface (refuse rather than invent RPC)",
3765                ));
3766            }
3767            let Some(home) = provider_home else {
3768                return Err(failure(
3769                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3770                    "Claude sandbox.network --settings overlay requires ProviderHome materialization (CLAUDE_CONFIG_DIR)",
3771                ));
3772            };
3773            let domains =
3774                crate::network_allowlist_catalog::claude_allowed_domains_from_permits(&entry.permits);
3775            if domains.is_empty() {
3776                return Err(failure(
3777                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3778                    "Claude sandbox.network overlay could not map catalog permits to allowedDomains",
3779                ));
3780            }
3781            let settings_path = home.join(
3782                crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
3783            );
3784            let json =
3785                crate::network_allowlist_catalog::claude_station_network_settings_json(&domains);
3786            crate::session_environment::secure_create_file(&settings_path, json.as_bytes()).map_err(
3787                |_| {
3788                    failure(
3789                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3790                        "Claude station network settings sidecar could not be written under ProviderHome",
3791                    )
3792                },
3793            )?;
3794            let overlay =
3795                crate::network_allowlist_catalog::claude_settings_network_overlay_args(&settings_path);
3796            return Ok(overlay.into_iter().map(std::ffi::OsString::from).collect());
3797        }
3798
3799        let Some(native) = entry.provider_native.as_ref() else {
3800            return Ok(Vec::new());
3801        };
3802        let overlay = crate::network_allowlist_catalog::resolve_provider_native_launch_overlay(
3803            provider.as_str(),
3804            approval_level,
3805            native,
3806        )
3807        .map_err(|message| {
3808            failure(
3809                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3810                &message,
3811            )
3812        })?;
3813        if overlay.is_empty() {
3814            return Ok(Vec::new());
3815        }
3816        if !matches!(mode, SessionMode::Pty | SessionMode::Acp) {
3817            return Err(failure(
3818                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3819                "Codex network_access -c overlay requires PTY or ACP transport",
3820            ));
3821        }
3822        Ok(overlay.into_iter().map(std::ffi::OsString::from).collect())
3823    }
3824
3825    /// Backward-compatible alias used by Codex-focused unit tests.
3826    fn resolve_codex_network_access_overlay_args(
3827        &self,
3828        provider: &AgentId,
3829        mode: SessionMode,
3830        approval_level: ApprovalLevel,
3831        network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
3832    ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
3833        self.resolve_network_allowlist_overlay_args(
3834            provider,
3835            mode,
3836            approval_level,
3837            network_allowlist,
3838            None,
3839        )
3840    }
3841
3842    fn resolve_environment_profile(
3843        &self,
3844        resolved: &ResolvedSpawnSpec,
3845    ) -> Result<Option<ResolvedEnvironmentProfileReceipt>, NodeFailure> {
3846        // Station network allowlist catalog enforce (plan
3847        // station-network-and-browser-profile-knobs §2.1 / §4): empty-default
3848        // catalog — unknown ids refuse rather than silent ambient. Runs even
3849        // when no environment_profile_id is present so a bare allowlist still
3850        // cannot bypass the catalog. Never cookies / OAuth / proxy credentials.
3851        if let Some(allowlist_id) = resolved.network_allowlist.as_ref() {
3852            let catalog = self
3853                .network_allowlist_catalog
3854                .read()
3855                .unwrap_or_else(|poisoned| poisoned.into_inner());
3856            let Some(entry) = catalog.get(allowlist_id) else {
3857                return Err(failure(
3858                    NodeFailureCode::UnknownNetworkAllowlist,
3859                    "spawn network allowlist is unavailable on this node",
3860                ));
3861            };
3862            // Track B: refuse provider-native mappings this provider cannot
3863            // honor (e.g. codex_network_access on Claude). Never silent ambient.
3864            if let Some(native) = entry.provider_native.as_ref() {
3865                if let Err(message) = crate::network_allowlist_catalog::provider_native_mapping_supported(
3866                    resolved.provider.as_str(),
3867                    resolved.approval_level,
3868                    native,
3869                ) {
3870                    return Err(failure(
3871                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3872                        &message,
3873                    ));
3874                }
3875            }
3876        }
3877        // Station knobs Track A (dig2browser-station-probe-and-network-permit-set):
3878        // when feature `dig2-station-probe` is on and browser_profile_id is set,
3879        // cheap named-pipe path reachability must succeed (Windows
3880        // `\\.\pipe\{suffix}`); platform-unsupported or unreachable → clear
3881        // NodeFailureCode. Runs even without environment_profile_id (same
3882        // placement spirit as allowlist catalog). Feature off keeps stub echo.
3883        // Never cookies / OAuth / proxy credentials. Exclusive lease is
3884        // acquired later in spawn_session_with_deadline (after instance id).
3885        #[cfg(feature = "dig2-station-probe")]
3886        if resolved.browser_profile_id.is_some() {
3887            use crate::dig2_station_probe::{probe_station_reachable, StationProbeError};
3888            match probe_station_reachable() {
3889                Ok(()) => {}
3890                Err(StationProbeError::UnsupportedPlatform)
3891                | Err(StationProbeError::InvalidPipeSuffix) => {
3892                    return Err(failure(
3893                        NodeFailureCode::BrowserStationProbeUnavailable,
3894                        "dig2browser station probe unavailable on this platform",
3895                    ));
3896                }
3897                Err(StationProbeError::Unreachable) => {
3898                    return Err(failure(
3899                        NodeFailureCode::BrowserStationUnreachable,
3900                        "dig2browser station named-pipe is unreachable",
3901                    ));
3902                }
3903            }
3904        }
3905        let Some(profile_id) = resolved.environment_profile_id.as_ref() else {
3906            return Ok(None);
3907        };
3908        let profiles = self
3909            .environment_profiles
3910            .read()
3911            .unwrap_or_else(|poisoned| poisoned.into_inner());
3912        let binding = profiles.get(profile_id).ok_or_else(|| {
3913            failure(
3914                NodeFailureCode::UnknownEnvironmentProfile,
3915                "spawn environment profile is unavailable on this node",
3916            )
3917        })?;
3918        if binding.provider != resolved.provider
3919            || binding.native_profile_id(resolved.mode).is_none()
3920        {
3921            return Err(failure(
3922                NodeFailureCode::EnvironmentProfileBindingMismatch,
3923                "spawn environment profile does not match the provider and mode",
3924            ));
3925        }
3926        // Echo opaque network_allowlist / browser_profile_id onto the receipt
3927        // after catalog + optional dig2 probe above. Empty/whitespace ids
3928        // already refuse at Spawn*Id construction.
3929        Ok(Some(ResolvedEnvironmentProfileReceipt {
3930            profile_id: binding.id.clone(),
3931            profile_revision: binding.revision.clone(),
3932            network_allowlist: resolved.network_allowlist.clone(),
3933            browser_profile_id: resolved.browser_profile_id.clone(),
3934        }))
3935    }
3936
3937    fn resolve_bundle(
3938        &self,
3939        resolved: &ResolvedSpawnSpec,
3940        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3941    ) -> Result<Option<ResolvedBundleReceipt>, NodeFailure> {
3942        let Some(bundle_id) = resolved.bundle_id.as_ref() else {
3943            return Ok(None);
3944        };
3945        let catalog = self.bundle_catalog
3946            .read()
3947            .unwrap_or_else(|poisoned| poisoned.into_inner());
3948        let bundle = catalog.get(bundle_id).ok_or_else(|| {
3949                failure(
3950                    NodeFailureCode::UnknownBundle,
3951                    "spawn bundle is unavailable on this node",
3952                )
3953            })?;
3954        self.bundle_layout(
3955            &resolved.provider,
3956            resolved.mode,
3957            environment_profile,
3958            bundle,
3959        )?;
3960        Ok(Some(bundle.receipt()))
3961    }
3962
3963    fn begin_delivery_stage(
3964        &self,
3965        manifest: DeliveryBundleManifestV2,
3966    ) -> Result<NodeResponse, NodeFailure> {
3967        let mut store = self
3968            .delivery_store
3969            .lock()
3970            .unwrap_or_else(|poisoned| poisoned.into_inner());
3971        let store = store.as_mut().ok_or_else(|| {
3972            failure(
3973                NodeFailureCode::DeliveryStageStorageFailed,
3974                "node delivery store is unavailable",
3975            )
3976        })?;
3977        let begun = store.begin(manifest).map_err(delivery_failure)?;
3978        Ok(NodeResponse::DeliveryStageBegun {
3979            stage_id: begun.stage_id,
3980            manifest_digest: begun.manifest_digest,
3981            missing_blobs: begun.missing_blobs,
3982        })
3983    }
3984
3985    fn put_delivery_blob_chunk(
3986        &self,
3987        stage_id: DeliveryStageId,
3988        blob_digest: DeliveryBlobDigestV1,
3989        offset: u64,
3990        chunk_hex: DeliveryBlobChunkHexV1,
3991    ) -> Result<NodeResponse, NodeFailure> {
3992        let mut store = self
3993            .delivery_store
3994            .lock()
3995            .unwrap_or_else(|poisoned| poisoned.into_inner());
3996        let store = store.as_mut().ok_or_else(|| {
3997            failure(
3998                NodeFailureCode::DeliveryStageStorageFailed,
3999                "node delivery store is unavailable",
4000            )
4001        })?;
4002        let accepted = store
4003            .put_chunk(&stage_id, &blob_digest, offset, &chunk_hex.decode())
4004            .map_err(delivery_failure)?;
4005        Ok(NodeResponse::DeliveryBlobChunkAccepted {
4006            stage_id: accepted.stage_id,
4007            blob_digest: accepted.blob_digest,
4008            next_offset: accepted.next_offset,
4009        })
4010    }
4011
4012    fn commit_delivery_stage(
4013        &self,
4014        stage_id: DeliveryStageId,
4015    ) -> Result<DeliveryCommitReceiptV1, NodeFailure> {
4016        let mut store = self
4017            .delivery_store
4018            .lock()
4019            .unwrap_or_else(|poisoned| poisoned.into_inner());
4020        let store = store.as_mut().ok_or_else(|| {
4021            failure(
4022                NodeFailureCode::DeliveryStageStorageFailed,
4023                "node delivery store is unavailable",
4024            )
4025        })?;
4026        let prepared = store.prepare_commit(&stage_id).map_err(delivery_failure)?;
4027        let mut catalog = self
4028            .bundle_catalog
4029            .write()
4030            .unwrap_or_else(|poisoned| poisoned.into_inner());
4031        let mut next_catalog = catalog.clone();
4032        next_catalog
4033            .insert_idempotent(prepared.bundle().clone())
4034            .map_err(|_| {
4035                failure(
4036                    NodeFailureCode::DeliveryStageConflict,
4037                    "delivery bundle identity conflicts with installed content",
4038                )
4039            })?;
4040        let receipt = store.publish_commit(prepared).map_err(delivery_failure)?;
4041        *catalog = next_catalog;
4042        Ok(receipt)
4043    }
4044
4045    fn abort_delivery_stage(
4046        &self,
4047        stage_id: &DeliveryStageId,
4048    ) -> Result<(), NodeFailure> {
4049        let mut store = self
4050            .delivery_store
4051            .lock()
4052            .unwrap_or_else(|poisoned| poisoned.into_inner());
4053        let store = store.as_mut().ok_or_else(|| {
4054            failure(
4055                NodeFailureCode::DeliveryStageStorageFailed,
4056                "node delivery store is unavailable",
4057            )
4058        })?;
4059        store.abort(stage_id).map_err(delivery_failure)
4060    }
4061
4062    fn resolve_context(
4063        &self,
4064        resolved: &ResolvedSpawnSpec,
4065    ) -> Result<Option<ResolvedContextPackReceipt>, NodeFailure> {
4066        let Some(context_id) = resolved.context_id.as_ref() else {
4067            return Ok(None);
4068        };
4069        self.context_catalog
4070            .read()
4071            .unwrap_or_else(|poisoned| poisoned.into_inner())
4072            .get(context_id)
4073            .map(|context| context.receipt().clone())
4074            .ok_or_else(|| {
4075                failure(
4076                    NodeFailureCode::UnknownContextPack,
4077                    "spawn context pack is unavailable on this node",
4078                )
4079            })
4080            .map(Some)
4081    }
4082
4083    fn bundle_layout(
4084        &self,
4085        provider: &AgentId,
4086        mode: SessionMode,
4087        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4088        bundle: &NodeBundle,
4089    ) -> Result<BundleProviderLayout, NodeFailure> {
4090        let layout = validate_bundle_binding(provider, mode, bundle).map_err(|_| {
4091            failure(
4092                NodeFailureCode::BundleBindingMismatch,
4093                "spawn bundle does not match the provider and mode",
4094            )
4095        })?;
4096        if layout != BundleProviderLayout::Codex {
4097            return Ok(layout);
4098        }
4099        let profile = environment_profile.and_then(|receipt| {
4100            let profiles = self
4101                .environment_profiles
4102                .read()
4103                .unwrap_or_else(|poisoned| poisoned.into_inner());
4104            let binding = profiles.get(&receipt.profile_id)?;
4105            if binding.revision != receipt.profile_revision
4106                || &binding.provider != provider
4107                || binding.native_profile_id(mode).is_none()
4108            {
4109                return None;
4110            }
4111            self.environment_materialization_profiles
4112                .read()
4113                .unwrap_or_else(|poisoned| poisoned.into_inner())
4114                .get(&receipt.profile_id)
4115                .cloned()
4116        });
4117        if !profile.is_some_and(|profile| profile.supports_bundle_layout(layout)) {
4118            return Err(failure(
4119                NodeFailureCode::BundleBindingMismatch,
4120                "Codex bundle requires an exact isolated CODEX_HOME profile",
4121            ));
4122        }
4123        Ok(layout)
4124    }
4125
4126    fn select_environment_profile(
4127        &self,
4128        instance_id: AgentInstanceId,
4129        provider: &AgentId,
4130        mode: SessionMode,
4131        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4132    ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
4133        let Some(environment_profile) = environment_profile else {
4134            return Ok(None);
4135        };
4136        let native_profile_id = {
4137            let profiles = self
4138                .environment_profiles
4139                .read()
4140                .unwrap_or_else(|poisoned| poisoned.into_inner());
4141            let binding = profiles
4142                .get(&environment_profile.profile_id)
4143                .ok_or_else(|| {
4144                    failure(
4145                        NodeFailureCode::UnknownEnvironmentProfile,
4146                        "session environment profile is unavailable on this node",
4147                    )
4148                })?;
4149            if &binding.revision != &environment_profile.profile_revision
4150                || &binding.provider != provider
4151            {
4152                return Err(failure(
4153                    NodeFailureCode::EnvironmentProfileBindingMismatch,
4154                    "session environment profile revision or provider changed",
4155                ));
4156            }
4157            binding.native_profile_id(mode).cloned().ok_or_else(|| {
4158                failure(
4159                    NodeFailureCode::EnvironmentProfileBindingMismatch,
4160                    "session environment profile does not support this mode",
4161                )
4162            })?
4163        };
4164        let control = self.native_launch_profile_control.clone().ok_or_else(|| {
4165            failure(
4166                NodeFailureCode::BackendOperationFailed,
4167                "native environment profile controller is unavailable",
4168            )
4169        })?;
4170        control
4171            .select_native_launch_profile(instance_id, native_profile_id)
4172            .map_err(|_| {
4173                failure(
4174                    NodeFailureCode::BackendOperationFailed,
4175                    "native environment profile selection failed",
4176                )
4177            })?;
4178        Ok(Some(NativeEnvironmentSelectionGuard {
4179            control: Some(control),
4180            instance_id,
4181        }))
4182    }
4183
4184    fn install_prepared_launch_overlay(
4185        &self,
4186        instance_id: AgentInstanceId,
4187        overlay: PreparedNativeLaunchOverlay,
4188    ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
4189        let control = self.native_launch_profile_control.clone().ok_or_else(|| {
4190            failure(
4191                NodeFailureCode::BackendOperationFailed,
4192                "native launch profile controller is unavailable",
4193            )
4194        })?;
4195        match overlay {
4196            PreparedNativeLaunchOverlay::Environment(overlay) => {
4197                control
4198                    .install_native_launch_environment_overlay(instance_id, overlay)
4199                    .map_err(|_| failure(
4200                        NodeFailureCode::BackendOperationFailed,
4201                        "session environment overlay installation failed",
4202                    ))?;
4203                Ok(None)
4204            }
4205            PreparedNativeLaunchOverlay::Instance(overlay) => {
4206                control
4207                    .install_native_instance_launch_overlay(instance_id, overlay)
4208                    .map_err(|_| failure(
4209                        NodeFailureCode::BundleBindingMismatch,
4210                        "session bundle launch overlay installation failed",
4211                    ))?;
4212                Ok(Some(NativeInstanceOverlayGuard {
4213                    control: Some(control),
4214                    instance_id,
4215                }))
4216            }
4217        }
4218    }
4219
4220    fn environment_materialization_profile(
4221        &self,
4222        environment_profile: &ResolvedEnvironmentProfileReceipt,
4223    ) -> Option<NodeSessionMaterializationProfile> {
4224        self.environment_materialization_profiles
4225            .read()
4226            .unwrap_or_else(|poisoned| poisoned.into_inner())
4227            .get(&environment_profile.profile_id)
4228            .cloned()
4229    }
4230
4231    fn materialization_profile(
4232        &self,
4233        provider: &AgentId,
4234        mode: SessionMode,
4235        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4236        bundle: Option<&ResolvedBundleReceipt>,
4237        context: Option<&ResolvedContextPackReceipt>,
4238    ) -> Result<Option<(
4239        NodeSessionMaterializationProfile,
4240        bool,
4241        Option<BundleProviderLayout>,
4242    )>, NodeFailure> {
4243        let environment = environment_profile
4244            .and_then(|receipt| self.environment_materialization_profile(receipt));
4245        let (bundle, bundle_layout) = match bundle {
4246            Some(receipt) => {
4247                let catalog = self.bundle_catalog.read()
4248                    .unwrap_or_else(|poisoned| poisoned.into_inner());
4249                let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
4250                    NodeFailureCode::UnknownBundle,
4251                    "session bundle is unavailable on this node",
4252                ))?;
4253                if installed.receipt() != *receipt {
4254                    return Err(failure(
4255                        NodeFailureCode::BundleBindingMismatch,
4256                        "session bundle revision or digest changed",
4257                    ));
4258                }
4259                let layout = self.bundle_layout(
4260                    provider,
4261                    mode,
4262                    environment_profile,
4263                    installed,
4264                )?;
4265                (Some(installed.clone()), Some(layout))
4266            }
4267            None => (None, None),
4268        };
4269        let has_environment_overlay = environment.is_some() || context.is_some();
4270        let profile = match (environment, bundle.as_ref(), bundle_layout) {
4271            (None, None, None) => None,
4272            (Some(profile), None, None) => Some(Ok(profile)),
4273            (None, Some(bundle), Some(layout)) => {
4274                Some(NodeSessionMaterializationProfile::from_bundle(bundle, layout))
4275            }
4276            (Some(profile), Some(bundle), Some(layout)) => {
4277                Some(profile.with_bundle(bundle, layout))
4278            }
4279            _ => {
4280                return Err(failure(
4281                    NodeFailureCode::BundleBindingMismatch,
4282                    "session bundle provider layout is unavailable",
4283                ));
4284            }
4285        };
4286        let profile = profile
4287            .transpose()
4288            .map_err(|_| failure(
4289                NodeFailureCode::BundleMaterializationFailed,
4290                "session bundle materialization profile is invalid",
4291            ))?;
4292        let profile = match (profile, context) {
4293            (Some(profile), Some(receipt)) => {
4294                let catalog = self.context_catalog
4295                    .read()
4296                    .unwrap_or_else(|poisoned| poisoned.into_inner());
4297                let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
4298                    NodeFailureCode::UnknownContextPack,
4299                    "session context pack is unavailable on this node",
4300                ))?;
4301                if pack.receipt() != receipt {
4302                    return Err(failure(
4303                        NodeFailureCode::ContextPackMaterializationFailed,
4304                        "session context pack identity changed",
4305                    ));
4306                }
4307                profile.with_context(pack)
4308            }
4309            (None, Some(receipt)) => {
4310                let catalog = self.context_catalog
4311                    .read()
4312                    .unwrap_or_else(|poisoned| poisoned.into_inner());
4313                let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
4314                    NodeFailureCode::UnknownContextPack,
4315                    "session context pack is unavailable on this node",
4316                ))?;
4317                if pack.receipt() != receipt {
4318                    return Err(failure(
4319                        NodeFailureCode::ContextPackMaterializationFailed,
4320                        "session context pack identity changed",
4321                    ));
4322                }
4323                NodeSessionMaterializationProfile::from_context(pack)
4324            }
4325            (Some(profile), None) => Ok(profile),
4326            (None, None) => return Ok(None),
4327        }
4328        .map_err(|_| failure(
4329            NodeFailureCode::ContextPackMaterializationFailed,
4330            "session context pack materialization profile is invalid",
4331        ))?;
4332        Ok(Some((profile, has_environment_overlay, bundle_layout)))
4333    }
4334
4335    fn prepare_session_materialization<'a>(
4336        &'a self,
4337        address: &SessionAddress,
4338        provider: &AgentId,
4339        mode: SessionMode,
4340        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4341        bundle: Option<&ResolvedBundleReceipt>,
4342        context: Option<&ResolvedContextPackReceipt>,
4343        managed_lease_id: Option<ManagedWorktreeLeaseId>,
4344    ) -> Result<Option<(Option<PreparedNativeLaunchOverlay>, SessionMaterializationGuard<'a>)>, NodeFailure> {
4345        let Some((profile, has_environment_overlay, bundle_layout)) =
4346            self.materialization_profile(provider, mode, environment_profile, bundle, context)?
4347        else {
4348            return Ok(None);
4349        };
4350        let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
4351            failure(
4352                NodeFailureCode::BackendOperationFailed,
4353                "session environment materializer is unavailable",
4354            )
4355        })?;
4356        let materialization_failure_code = if context.is_some() {
4357            NodeFailureCode::ContextPackMaterializationFailed
4358        } else if bundle.is_some() {
4359            NodeFailureCode::BundleMaterializationFailed
4360        } else {
4361            NodeFailureCode::BackendOperationFailed
4362        };
4363        let id = MaterializationId::new(format!(
4364            "mat-{}-{}-{}",
4365            self.incarnation_id,
4366            address.session.instance_id.0,
4367            address.session.generation.0,
4368        ))
4369        .map_err(|_| {
4370            failure(
4371                NodeFailureCode::BackendOperationFailed,
4372                "session environment materialization identity failed",
4373            )
4374        })?;
4375        let owner = MaterializationOwner::Session {
4376            incarnation_id: self.incarnation_id,
4377            instance_id: address.session.instance_id,
4378            generation: address.session.generation,
4379        };
4380        let mut ownership = materializer
4381            .begin(
4382                id.clone(),
4383                environment_profile.cloned(),
4384                bundle.cloned(),
4385                context.cloned(),
4386                owner,
4387                managed_lease_id,
4388                &profile,
4389                unix_time_ms(),
4390            )
4391            .map_err(|_| {
4392                failure(
4393                    materialization_failure_code,
4394                    "session materialization preparation failed",
4395                )
4396            })?;
4397        {
4398            let _transaction = self
4399                .state_transaction
4400                .lock()
4401                .unwrap_or_else(|poisoned| poisoned.into_inner());
4402            let duplicate = self
4403                .materializations
4404                .lock()
4405                .unwrap_or_else(|poisoned| poisoned.into_inner())
4406                .insert(id.clone(), ownership.clone());
4407            if duplicate.is_some() {
4408                return Err(failure(
4409                    NodeFailureCode::BackendOperationFailed,
4410                    "session environment materialization identity conflict",
4411                ));
4412            }
4413            if let Err(error) = self.persist_state_locked() {
4414                self.materializations
4415                    .lock()
4416                    .unwrap_or_else(|poisoned| poisoned.into_inner())
4417                    .remove(&id);
4418                return Err(persistence_failure(error));
4419            }
4420        }
4421        let environment = match materializer.materialize(&mut ownership, &profile, unix_time_ms()) {
4422            Ok(environment) => environment,
4423            Err(_) => {
4424                let transaction = self
4425                    .state_transaction
4426                    .lock()
4427                    .unwrap_or_else(|poisoned| poisoned.into_inner());
4428                self.materializations
4429                    .lock()
4430                    .unwrap_or_else(|poisoned| poisoned.into_inner())
4431                    .insert(id.clone(), ownership.clone());
4432                if self.persist_state_locked().is_err() {
4433                    self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
4434                }
4435                drop(transaction);
4436                if ownership.state() == MaterializationState::CleanupRequired {
4437                    let _ = self.cleanup_materialization(&id);
4438                }
4439                return Err(failure(
4440                    materialization_failure_code,
4441                    "session materialization failed",
4442                ));
4443            }
4444        };
4445        {
4446            let _transaction = self
4447                .state_transaction
4448                .lock()
4449                .unwrap_or_else(|poisoned| poisoned.into_inner());
4450            self.materializations
4451                .lock()
4452                .unwrap_or_else(|poisoned| poisoned.into_inner())
4453                .insert(id.clone(), ownership.clone());
4454            if let Err(error) = self.persist_state_locked() {
4455                drop(_transaction);
4456                let _ = self.cleanup_materialization(&id);
4457                return Err(persistence_failure(error));
4458            }
4459        }
4460        let guard = SessionMaterializationGuard {
4461            shared: self,
4462            id: Some(id),
4463            provider_home: Some(ownership.provider_home().to_path_buf()),
4464        };
4465        let bundle_arguments = match bundle {
4466            Some(receipt) => {
4467                let catalog = self.bundle_catalog.read()
4468                    .unwrap_or_else(|poisoned| poisoned.into_inner());
4469                let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
4470                    NodeFailureCode::UnknownBundle,
4471                    "session bundle is unavailable on this node",
4472                ))?;
4473                if installed.receipt() != *receipt {
4474                    return Err(failure(
4475                        NodeFailureCode::BundleBindingMismatch,
4476                        "session bundle revision or digest changed",
4477                    ));
4478                }
4479                let layout = bundle_layout.ok_or_else(|| failure(
4480                    NodeFailureCode::BundleBindingMismatch,
4481                    "materialized bundle provider layout is unavailable",
4482                ))?;
4483                materializer.revalidate_bundle(&ownership, installed, layout).map_err(|_| {
4484                    failure(
4485                        NodeFailureCode::BundleMaterializationFailed,
4486                        "session bundle byte revalidation failed",
4487                    )
4488                })?;
4489                Some(bundle_launch_arguments(
4490                    layout,
4491                    ownership.bundle_root(),
4492                    ownership.provider_home(),
4493                ).map_err(|_| failure(
4494                    NodeFailureCode::BundleBindingMismatch,
4495                    "session bundle launch binding is invalid",
4496                ))?)
4497            }
4498            None => None,
4499        };
4500        if let Some(receipt) = context {
4501            materializer
4502                .revalidate_context(&ownership, receipt)
4503                .map_err(|_| failure(
4504                    NodeFailureCode::ContextPackMaterializationFailed,
4505                    "session context pack byte revalidation failed",
4506                ))?;
4507        }
4508        let transport = match mode {
4509            SessionMode::Pty => TransportKind::Pty,
4510            SessionMode::Inline => TransportKind::Pipe,
4511            SessionMode::Acp => TransportKind::Acp,
4512        };
4513        let overlay = if let Some(extra_args) = bundle_arguments {
4514            Some(PreparedNativeLaunchOverlay::Instance(
4515                NativeInstanceLaunchOverlay::new(
4516                    provider.clone(),
4517                    transport,
4518                    if has_environment_overlay {
4519                        environment
4520                    } else {
4521                        Vec::new()
4522                    },
4523                    extra_args,
4524                )
4525                .map_err(|_| {
4526                    failure(
4527                        NodeFailureCode::BundleBindingMismatch,
4528                        "session bundle launch overlay is invalid",
4529                    )
4530                })?,
4531            ))
4532        } else if has_environment_overlay {
4533            let context_only = environment_profile.is_none()
4534                && bundle.is_none()
4535                && context.is_some();
4536            let overlay = if context_only {
4537                NativeLaunchEnvironmentOverlay::new_context_root(
4538                    provider.clone(),
4539                    transport,
4540                    environment,
4541                )
4542                .map_err(|_| {
4543                    failure(
4544                        NodeFailureCode::ContextPackMaterializationFailed,
4545                        "context-only launch overlay is invalid",
4546                    )
4547                })?
4548            } else {
4549                NativeLaunchEnvironmentOverlay::new(
4550                    provider.clone(),
4551                    transport,
4552                    environment,
4553                )
4554                .map_err(|_| {
4555                    failure(
4556                        NodeFailureCode::BackendOperationFailed,
4557                        "session environment overlay is invalid",
4558                    )
4559                })?
4560            };
4561            Some(PreparedNativeLaunchOverlay::Environment(overlay))
4562        } else {
4563            None
4564        };
4565        Ok(Some((overlay, guard)))
4566    }
4567
4568    fn cleanup_materialization(&self, id: &MaterializationId) -> Result<(), NodeFailure> {
4569        let Some(mut ownership) = self
4570            .materializations
4571            .lock()
4572            .unwrap_or_else(|poisoned| poisoned.into_inner())
4573            .get(id)
4574            .cloned()
4575        else {
4576            return Ok(());
4577        };
4578        if ownership.state() != MaterializationState::CleanupRequired {
4579            ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
4580                failure(
4581                    NodeFailureCode::BackendOperationFailed,
4582                    "session environment cleanup state is invalid",
4583                )
4584            })?;
4585        }
4586        {
4587            let _transaction = self
4588                .state_transaction
4589                .lock()
4590                .unwrap_or_else(|poisoned| poisoned.into_inner());
4591            let previous = self
4592                .materializations
4593                .lock()
4594                .unwrap_or_else(|poisoned| poisoned.into_inner())
4595                .insert(id.clone(), ownership.clone());
4596            if let Err(error) = self.persist_state_locked() {
4597                let mut materializations = self
4598                    .materializations
4599                    .lock()
4600                    .unwrap_or_else(|poisoned| poisoned.into_inner());
4601                if let Some(previous) = previous {
4602                    materializations.insert(id.clone(), previous);
4603                }
4604                return Err(persistence_failure(error));
4605            }
4606        }
4607        let cleanup = self
4608            .session_environment_materializer
4609            .as_ref()
4610            .ok_or(())
4611            .and_then(|materializer| materializer.cleanup(&ownership).map_err(|_| ()));
4612        if cleanup.is_err() {
4613            let _ = ownership.mark_recovery_required(unix_time_ms());
4614            self.materializations
4615                .lock()
4616                .unwrap_or_else(|poisoned| poisoned.into_inner())
4617                .insert(id.clone(), ownership);
4618            if self.persist_state().is_err() {
4619                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
4620            }
4621            return Err(failure(
4622                NodeFailureCode::BackendOperationFailed,
4623                "session environment cleanup requires recovery",
4624            ));
4625        }
4626        let _transaction = self
4627            .state_transaction
4628            .lock()
4629            .unwrap_or_else(|poisoned| poisoned.into_inner());
4630        self.materializations
4631            .lock()
4632            .unwrap_or_else(|poisoned| poisoned.into_inner())
4633            .remove(id);
4634        if let Err(error) = self.persist_state_locked() {
4635            self.materializations
4636                .lock()
4637                .unwrap_or_else(|poisoned| poisoned.into_inner())
4638                .insert(id.clone(), ownership);
4639            return Err(persistence_failure(error));
4640        }
4641        Ok(())
4642    }
4643
4644    fn resolve_record_materialization(
4645        &self,
4646        record_id: &SessionRecordId,
4647        provider: &AgentId,
4648        mode: SessionMode,
4649        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4650        bundle: Option<&ResolvedBundleReceipt>,
4651        context: Option<&ResolvedContextPackReceipt>,
4652    ) -> Result<Option<(MaterializationId, Option<PreparedNativeLaunchOverlay>)>, NodeFailure> {
4653        let ownership = {
4654            let materializations = self
4655                .materializations
4656                .lock()
4657                .unwrap_or_else(|poisoned| poisoned.into_inner());
4658            let mut matches = materializations.values().filter(|record| {
4659                record.owner()
4660                    == &MaterializationOwner::Record {
4661                        record_id: record_id.clone(),
4662                    }
4663            });
4664            let ownership = matches.next().cloned();
4665            if matches.next().is_some() {
4666                return Err(failure(
4667                    NodeFailureCode::BackendOperationFailed,
4668                    "managed session has conflicting environment ownership",
4669                ));
4670            }
4671            ownership
4672        };
4673        if let (Some(ownership), Some(receipt)) = (ownership.as_ref(), context) {
4674            let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
4675                failure(
4676                    NodeFailureCode::ContextPackMaterializationFailed,
4677                    "session context materializer is unavailable",
4678                )
4679            })?;
4680            let pack = materializer
4681                .revalidate_context(ownership, receipt)
4682                .map_err(|_| {
4683                    self.mark_materialization_recovery_required(ownership.id());
4684                    failure(
4685                        NodeFailureCode::ContextPackMaterializationFailed,
4686                        "managed session context pack revalidation failed",
4687                    )
4688                })?;
4689            self.context_catalog
4690                .write()
4691                .unwrap_or_else(|poisoned| poisoned.into_inner())
4692                .insert(pack)
4693                .map_err(|_| failure(
4694                    NodeFailureCode::ContextPackMaterializationFailed,
4695                    "managed session context pack catalog restore failed",
4696                ))?;
4697        }
4698        let materialization = self.materialization_profile(
4699            provider,
4700            mode,
4701            environment_profile,
4702            bundle,
4703            context,
4704        )?;
4705        match (ownership, materialization) {
4706            (None, None) => Ok(None),
4707            (None, Some(_)) | (Some(_), None) => Err(failure(
4708                NodeFailureCode::BackendOperationFailed,
4709                "managed session materialization is unavailable",
4710            )),
4711            (Some(ownership), Some((profile, has_environment_materialization, bundle_layout))) => {
4712                if ownership.environment_profile() != environment_profile
4713                    || ownership.bundle() != bundle
4714                    || ownership.context() != context
4715                    || ownership.state() != MaterializationState::Ready
4716                {
4717                    self.mark_materialization_recovery_required(ownership.id());
4718                    return Err(failure(
4719                        NodeFailureCode::BackendOperationFailed,
4720                        "managed session environment ownership requires recovery",
4721                    ));
4722                }
4723                let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
4724                    failure(
4725                        NodeFailureCode::BackendOperationFailed,
4726                        "session environment materializer is unavailable",
4727                    )
4728                })?;
4729                if materializer.revalidate(&ownership).is_err() {
4730                    self.mark_materialization_recovery_required(ownership.id());
4731                    return Err(failure(
4732                        NodeFailureCode::BackendOperationFailed,
4733                        "managed session environment revalidation failed",
4734                    ));
4735                }
4736                let bundle_arguments = if let Some(bundle_receipt) = bundle {
4737                    let catalog = self.bundle_catalog.read()
4738                        .unwrap_or_else(|poisoned| poisoned.into_inner());
4739                    let Some(installed) = catalog.get(&bundle_receipt.id) else {
4740                        return Err(failure(
4741                            NodeFailureCode::UnknownBundle,
4742                            "managed session bundle is unavailable",
4743                        ));
4744                    };
4745                    let layout = bundle_layout.ok_or_else(|| failure(
4746                        NodeFailureCode::BundleBindingMismatch,
4747                        "record bundle provider layout is unavailable",
4748                    ))?;
4749                    if materializer.revalidate_bundle(&ownership, installed, layout).is_err() {
4750                        drop(catalog);
4751                        self.mark_materialization_recovery_required(ownership.id());
4752                        return Err(failure(
4753                            NodeFailureCode::BundleMaterializationFailed,
4754                            "managed session bundle revalidation failed",
4755                        ));
4756                    }
4757                    Some(bundle_launch_arguments(
4758                        layout,
4759                        ownership.bundle_root(),
4760                        ownership.provider_home(),
4761                    ).map_err(|_| failure(
4762                        NodeFailureCode::BundleBindingMismatch,
4763                        "managed session bundle launch binding is invalid",
4764                    ))?)
4765                } else {
4766                    None
4767                };
4768                if !has_environment_materialization {
4769                    let overlay = bundle_arguments.map(|extra_args| {
4770                        NativeInstanceLaunchOverlay::new(
4771                            provider.clone(),
4772                            TransportKind::Pty,
4773                            Vec::new(),
4774                            extra_args,
4775                        )
4776                        .map(PreparedNativeLaunchOverlay::Instance)
4777                        .map_err(|_| failure(
4778                            NodeFailureCode::BundleBindingMismatch,
4779                            "managed session bundle launch overlay is invalid",
4780                        ))
4781                    }).transpose()?;
4782                    return Ok(Some((ownership.id().clone(), overlay)));
4783                }
4784                let environment = match materializer.resolve_environment(&ownership, &profile) {
4785                    Ok(environment) => environment,
4786                    Err(
4787                        SessionEnvironmentMaterializeError::SecretUnavailable
4788                        | SessionEnvironmentMaterializeError::SecretDenied
4789                        | SessionEnvironmentMaterializeError::InvalidSecretValue,
4790                    ) => {
4791                        return Err(failure(
4792                            NodeFailureCode::BackendOperationFailed,
4793                            "managed session environment resolution failed",
4794                        ));
4795                    }
4796                    Err(
4797                        SessionEnvironmentMaterializeError::InvalidRoot
4798                        | SessionEnvironmentMaterializeError::OwnershipMismatch
4799                        | SessionEnvironmentMaterializeError::Filesystem(_)
4800                        | SessionEnvironmentMaterializeError::Record(_),
4801                    ) => {
4802                        self.mark_materialization_recovery_required(ownership.id());
4803                        return Err(failure(
4804                            NodeFailureCode::BackendOperationFailed,
4805                            "managed session environment resolution failed",
4806                        ));
4807                    }
4808                };
4809                let transport = match mode {
4810                    SessionMode::Pty => TransportKind::Pty,
4811                    SessionMode::Inline => TransportKind::Pipe,
4812                    SessionMode::Acp => TransportKind::Acp,
4813                };
4814                let overlay = match bundle_arguments {
4815                    Some(extra_args) => PreparedNativeLaunchOverlay::Instance(
4816                        NativeInstanceLaunchOverlay::new(
4817                            provider.clone(),
4818                            transport,
4819                            environment,
4820                            extra_args,
4821                        )
4822                        .map_err(|_| failure(
4823                            NodeFailureCode::BundleBindingMismatch,
4824                            "managed session bundle launch overlay is invalid",
4825                        ))?,
4826                    ),
4827                    None => PreparedNativeLaunchOverlay::Environment(
4828                        NativeLaunchEnvironmentOverlay::new(
4829                            provider.clone(),
4830                            transport,
4831                            environment,
4832                        )
4833                        .map_err(|_| {
4834                            failure(
4835                                NodeFailureCode::BackendOperationFailed,
4836                                "managed session environment overlay is invalid",
4837                            )
4838                        })?,
4839                    ),
4840                };
4841                Ok(Some((ownership.id().clone(), Some(overlay))))
4842            }
4843        }
4844    }
4845
4846    fn destroy_record_materialization(
4847        &self,
4848        record_id: &SessionRecordId,
4849    ) -> Result<bool, NodeFailure> {
4850        let ids = self
4851            .materializations
4852            .lock()
4853            .unwrap_or_else(|poisoned| poisoned.into_inner())
4854            .values()
4855            .filter_map(|record| {
4856                (record.owner()
4857                    == &MaterializationOwner::Record {
4858                        record_id: record_id.clone(),
4859                    })
4860                .then(|| record.id().clone())
4861            })
4862            .collect::<Vec<_>>();
4863        if ids.len() > 1 {
4864            return Err(failure(
4865                NodeFailureCode::BackendOperationFailed,
4866                "managed session has conflicting environment ownership",
4867            ));
4868        }
4869        let Some(id) = ids.first().cloned() else {
4870            return Ok(false);
4871        };
4872        let (terminal_record, mut cleanup_ownership) = {
4873            let _transaction = self
4874                .state_transaction
4875                .lock()
4876                .unwrap_or_else(|poisoned| poisoned.into_inner());
4877            let original_record = self.record(record_id)?;
4878            let mut terminal_record = original_record.clone();
4879            terminal_record.provider_session = None;
4880            terminal_record.active_session = None;
4881            terminal_record.state = ManagedSessionState::Unavailable;
4882            terminal_record.last_error = Some("environment-profile-unavailable".to_owned());
4883            terminal_record.updated_at_unix_ms = unix_time_ms();
4884            let original_ownership = self
4885                .materializations
4886                .lock()
4887                .unwrap_or_else(|poisoned| poisoned.into_inner())
4888                .get(&id)
4889                .cloned()
4890                .ok_or_else(|| {
4891                    failure(
4892                        NodeFailureCode::BackendOperationFailed,
4893                        "managed session environment ownership disappeared during cleanup",
4894                    )
4895                })?;
4896            let mut cleanup_ownership = original_ownership.clone();
4897            if cleanup_ownership.state() != MaterializationState::CleanupRequired {
4898                cleanup_ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
4899                    failure(
4900                        NodeFailureCode::BackendOperationFailed,
4901                        "managed session environment cleanup state is invalid",
4902                    )
4903                })?;
4904            }
4905            self.session_records
4906                .lock()
4907                .unwrap_or_else(|poisoned| poisoned.into_inner())
4908                .records
4909                .insert(record_id.clone(), terminal_record.clone());
4910            self.materializations
4911                .lock()
4912                .unwrap_or_else(|poisoned| poisoned.into_inner())
4913                .insert(id.clone(), cleanup_ownership.clone());
4914            if let Err(error) = self.persist_state_locked() {
4915                self.session_records
4916                    .lock()
4917                    .unwrap_or_else(|poisoned| poisoned.into_inner())
4918                    .records
4919                    .insert(record_id.clone(), original_record);
4920                self.materializations
4921                    .lock()
4922                    .unwrap_or_else(|poisoned| poisoned.into_inner())
4923                    .insert(id.clone(), original_ownership);
4924                return Err(persistence_failure(error));
4925            }
4926            (terminal_record, cleanup_ownership)
4927        };
4928        let cleanup = self
4929            .session_environment_materializer
4930            .as_ref()
4931            .ok_or(())
4932            .and_then(|materializer| materializer.cleanup(&cleanup_ownership).map_err(|_| ()));
4933        if cleanup.is_err() {
4934            let _ = cleanup_ownership.mark_recovery_required(unix_time_ms());
4935            self.materializations
4936                .lock()
4937                .unwrap_or_else(|poisoned| poisoned.into_inner())
4938                .insert(id.clone(), cleanup_ownership);
4939            if self.persist_state().is_err() {
4940                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
4941            }
4942            return Err(failure(
4943                NodeFailureCode::BackendOperationFailed,
4944                "managed session environment cleanup requires recovery",
4945            ));
4946        }
4947        let _transaction = self
4948            .state_transaction
4949            .lock()
4950            .unwrap_or_else(|poisoned| poisoned.into_inner());
4951        self.remove_record_memory(record_id);
4952        self.materializations
4953            .lock()
4954            .unwrap_or_else(|poisoned| poisoned.into_inner())
4955            .remove(&id);
4956        if let Err(error) = self.persist_state_locked() {
4957            self.session_records
4958                .lock()
4959                .unwrap_or_else(|poisoned| poisoned.into_inner())
4960                .records
4961                .insert(record_id.clone(), terminal_record);
4962            self.materializations
4963                .lock()
4964                .unwrap_or_else(|poisoned| poisoned.into_inner())
4965                .insert(id, cleanup_ownership);
4966            return Err(persistence_failure(error));
4967        }
4968        Ok(true)
4969    }
4970
4971    fn mark_materialization_recovery_required(&self, id: &MaterializationId) {
4972        let changed = {
4973            let mut materializations = self
4974                .materializations
4975                .lock()
4976                .unwrap_or_else(|poisoned| poisoned.into_inner());
4977            let Some(ownership) = materializations.get_mut(id) else {
4978                return;
4979            };
4980            ownership.mark_recovery_required(unix_time_ms()).is_ok()
4981        };
4982        if changed && self.persist_state().is_err() {
4983            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
4984        }
4985    }
4986
4987    fn reconcile_materializations(&self) {
4988        let records = self
4989            .materializations
4990            .lock()
4991            .unwrap_or_else(|poisoned| poisoned.into_inner())
4992            .values()
4993            .cloned()
4994            .collect::<Vec<_>>();
4995        for mut ownership in records {
4996            let result = match (ownership.state(), ownership.owner()) {
4997                (MaterializationState::Preparing, _)
4998                | (MaterializationState::CleanupRequired, _) => {
4999                    self.cleanup_materialization(ownership.id())
5000                }
5001                (MaterializationState::Ready, MaterializationOwner::Session { .. }) => {
5002                    if ownership.mark_recovery_required(unix_time_ms()).is_ok() {
5003                        self.materializations
5004                            .lock()
5005                            .unwrap_or_else(|poisoned| poisoned.into_inner())
5006                            .insert(ownership.id().clone(), ownership.clone());
5007                        self.persist_state().map_err(persistence_failure)
5008                    } else {
5009                        Err(failure(
5010                            NodeFailureCode::BackendOperationFailed,
5011                            "session environment reconciliation failed",
5012                        ))
5013                    }
5014                }
5015                (MaterializationState::Ready, MaterializationOwner::Record { record_id }) => self
5016                    .session_environment_materializer
5017                    .as_ref()
5018                    .ok_or_else(|| {
5019                        failure(
5020                            NodeFailureCode::BackendOperationFailed,
5021                            "session environment materializer is unavailable",
5022                        )
5023                    })
5024                    .and_then(|materializer| {
5025                        materializer.revalidate(&ownership).map_err(|_| failure(
5026                            NodeFailureCode::BackendOperationFailed,
5027                            "session materialization revalidation failed",
5028                        ))?;
5029                        let record = self.record(record_id)?;
5030                        if record.environment_profile.as_ref() != ownership.environment_profile()
5031                            || record.bundle.as_ref() != ownership.bundle()
5032                            || record.context.as_ref() != ownership.context()
5033                        {
5034                            return Err(failure(
5035                                NodeFailureCode::BackendOperationFailed,
5036                                "session materialization ownership changed after restart",
5037                            ));
5038                        }
5039                        if let Some(receipt) = ownership.bundle() {
5040                            let catalog = self.bundle_catalog.read()
5041                                .unwrap_or_else(|poisoned| poisoned.into_inner());
5042                            let bundle = catalog.get(&receipt.id).ok_or_else(|| failure(
5043                                NodeFailureCode::UnknownBundle,
5044                                "session bundle is unavailable after restart",
5045                            ))?;
5046                            if bundle.receipt() != *receipt {
5047                                return Err(failure(
5048                                    NodeFailureCode::BundleBindingMismatch,
5049                                    "session bundle changed after restart",
5050                                ));
5051                            }
5052                            let layout = self.bundle_layout(
5053                                &record.provider,
5054                                record.mode,
5055                                record.environment_profile.as_ref(),
5056                                bundle,
5057                            )?;
5058                            materializer
5059                                .revalidate_bundle(&ownership, bundle, layout)
5060                                .map_err(|_| failure(
5061                                    NodeFailureCode::BundleMaterializationFailed,
5062                                    "session bundle revalidation failed",
5063                                ))?;
5064                        }
5065                        if let Some(receipt) = ownership.context() {
5066                            let pack = materializer
5067                                .revalidate_context(&ownership, receipt)
5068                                .map_err(|_| {
5069                                    failure(
5070                                        NodeFailureCode::ContextPackMaterializationFailed,
5071                                        "session context revalidation failed",
5072                                    )
5073                                })?;
5074                            self.context_catalog
5075                                .write()
5076                                .unwrap_or_else(|poisoned| poisoned.into_inner())
5077                                .insert(pack)
5078                                .map_err(|_| {
5079                                    failure(
5080                                        NodeFailureCode::ContextPackMaterializationFailed,
5081                                        "session context catalog restore failed",
5082                                    )
5083                                })?;
5084                        }
5085                        Ok(())
5086                    }),
5087                (MaterializationState::RecoveryRequired, _) => Ok(()),
5088            };
5089            if result.is_err() && ownership.state() != MaterializationState::RecoveryRequired {
5090                let _ = ownership.mark_recovery_required(unix_time_ms());
5091                self.materializations
5092                    .lock()
5093                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5094                    .insert(ownership.id().clone(), ownership);
5095                if self.persist_state().is_err() {
5096                    self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5097                }
5098            }
5099        }
5100        let materialized_profiles = self
5101            .environment_materialization_profiles
5102            .read()
5103            .unwrap_or_else(|poisoned| poisoned.into_inner())
5104            .keys()
5105            .cloned()
5106            .collect::<Vec<_>>();
5107        let ownership = self
5108            .materializations
5109            .lock()
5110            .unwrap_or_else(|poisoned| poisoned.into_inner())
5111            .values()
5112            .cloned()
5113            .collect::<Vec<_>>();
5114        let changed = {
5115            let mut session_records = self
5116                .session_records
5117                .lock()
5118                .unwrap_or_else(|poisoned| poisoned.into_inner());
5119            let mut changed = false;
5120            for record in session_records.records.values_mut() {
5121                let environment_profile = record.environment_profile.as_ref();
5122                if environment_profile.is_none()
5123                    && record.bundle.is_none()
5124                    && record.context.is_none()
5125                {
5126                    continue;
5127                }
5128                if record.bundle.is_none()
5129                    && environment_profile.is_some_and(|profile| {
5130                        !materialized_profiles.contains(&profile.profile_id)
5131                    })
5132                {
5133                    continue;
5134                }
5135                let has_exact_ownership = ownership.iter().any(|candidate| {
5136                    candidate.state() == MaterializationState::Ready
5137                        && candidate.environment_profile() == environment_profile
5138                        && candidate.bundle() == record.bundle.as_ref()
5139                        && candidate.context() == record.context.as_ref()
5140                        && candidate.owner()
5141                            == &MaterializationOwner::Record {
5142                                record_id: record.record_id.clone(),
5143                            }
5144                });
5145                if !has_exact_ownership {
5146                    record.provider_session = None;
5147                    record.active_session = None;
5148                    record.state = ManagedSessionState::Unavailable;
5149                    record.last_error = Some(if record.bundle.is_some() {
5150                        "bundle-unavailable".to_owned()
5151                    } else if record.context.is_some() {
5152                        "context-unavailable".to_owned()
5153                    } else {
5154                        "environment-profile-unavailable".to_owned()
5155                    });
5156                    record.updated_at_unix_ms = unix_time_ms();
5157                    changed = true;
5158                }
5159            }
5160            changed
5161        };
5162        if changed && self.persist_state().is_err() {
5163            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5164        }
5165    }
5166
5167    fn replay_spawn_spec(
5168        &self,
5169        spec: &SpawnSpec,
5170        now: Instant,
5171    ) -> Result<Option<Result<ResolvedSpawnReceipt, NodeFailure>>, NodeFailure> {
5172        let mut cache = self
5173            .spawn_idempotency
5174            .lock()
5175            .unwrap_or_else(|poisoned| poisoned.into_inner());
5176        cache.entries.retain(|_, entry| entry.expires_at > now);
5177        if let Some(entry) = cache.entries.get(&spec.idempotency_key) {
5178            let SpawnIdempotencyValue::Standard { spec: existing, result } = &entry.value else {
5179                return Err(failure(
5180                    NodeFailureCode::SpawnIdempotencyConflict,
5181                    "spawn idempotency key was reused across spawn kinds",
5182                ));
5183            };
5184            if existing != spec {
5185                return Err(failure(
5186                    NodeFailureCode::SpawnIdempotencyConflict,
5187                    "spawn idempotency key was reused with a different specification",
5188                ));
5189            }
5190            return Ok(Some(result.clone()));
5191        }
5192        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5193            return Err(failure(
5194                NodeFailureCode::SpawnIdempotencyCapacity,
5195                "spawn idempotency cache reached its bounded capacity",
5196            ));
5197        }
5198        Ok(None)
5199    }
5200
5201    fn remember_spawn_spec(
5202        &self,
5203        spec: SpawnSpec,
5204        result: Result<ResolvedSpawnReceipt, NodeFailure>,
5205        accepted_at: Instant,
5206    ) {
5207        self
5208            .spawn_idempotency
5209            .lock()
5210            .unwrap_or_else(|poisoned| poisoned.into_inner())
5211            .entries
5212            .insert(
5213                spec.idempotency_key.clone(),
5214                SpawnIdempotencyEntry {
5215                    value: SpawnIdempotencyValue::Standard { spec, result },
5216                    expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5217                },
5218            );
5219    }
5220
5221    fn claim_harness_mcp_spawn(
5222        &self,
5223        _reservation_id: &HarnessMcpReservationId,
5224        _activation_digest: &HarnessMcpActivationDigest,
5225        spec: &SpawnSpec,
5226        now: Instant,
5227    ) -> Result<(), NodeFailure> {
5228        let mut cache = self.spawn_idempotency.lock()
5229            .unwrap_or_else(|poisoned| poisoned.into_inner());
5230        cache.entries.retain(|_, entry| entry.expires_at > now);
5231        if cache.entries.contains_key(&spec.idempotency_key) {
5232            return Err(failure(
5233                NodeFailureCode::SpawnIdempotencyConflict,
5234                "spawn idempotency key was reused across spawn kinds",
5235            ));
5236        }
5237        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5238            return Err(failure(
5239                NodeFailureCode::SpawnIdempotencyCapacity,
5240                "spawn idempotency cache reached its bounded capacity",
5241            ));
5242        }
5243        cache.entries.insert(spec.idempotency_key.clone(), SpawnIdempotencyEntry {
5244            value: SpawnIdempotencyValue::HarnessMcp,
5245            expires_at: now + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5246        });
5247        Ok(())
5248    }
5249
5250    fn replay_managed_spawn(
5251        &self,
5252        request: &ManagedWorktreeSpawnRequest,
5253        now: Instant,
5254    ) -> Result<Option<Result<ManagedWorktreeSpawnReceipt, NodeFailure>>, NodeFailure> {
5255        let mut cache = self.spawn_idempotency.lock()
5256            .unwrap_or_else(|poisoned| poisoned.into_inner());
5257        cache.entries.retain(|_, entry| entry.expires_at > now);
5258        if let Some(entry) = cache.entries.get(&request.spawn_spec.idempotency_key) {
5259            let SpawnIdempotencyValue::Managed { request: existing, result } = &entry.value else {
5260                return Err(failure(
5261                    NodeFailureCode::SpawnIdempotencyConflict,
5262                    "spawn idempotency key was reused across spawn kinds",
5263                ));
5264            };
5265            if existing != request {
5266                return Err(failure(
5267                    NodeFailureCode::SpawnIdempotencyConflict,
5268                    "spawn idempotency key was reused with a different managed specification",
5269                ));
5270            }
5271            return Ok(Some(result.clone()));
5272        }
5273        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5274            return Err(failure(
5275                NodeFailureCode::SpawnIdempotencyCapacity,
5276                "spawn idempotency cache reached its bounded capacity",
5277            ));
5278        }
5279        Ok(None)
5280    }
5281
5282    fn remember_managed_spawn(
5283        &self,
5284        request: ManagedWorktreeSpawnRequest,
5285        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
5286        accepted_at: Instant,
5287    ) {
5288        self.spawn_idempotency.lock()
5289            .unwrap_or_else(|poisoned| poisoned.into_inner())
5290            .entries
5291            .insert(
5292                request.spawn_spec.idempotency_key.clone(),
5293                SpawnIdempotencyEntry {
5294                    value: SpawnIdempotencyValue::Managed { request, result },
5295                    expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5296                },
5297            );
5298    }
5299
5300    fn remember_managed_spawn_attempt(
5301        &self,
5302        durable_v2_key: Option<&SpawnIdempotencyKey>,
5303        request: ManagedWorktreeSpawnRequest,
5304        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
5305        accepted_at: Instant,
5306    ) {
5307        if durable_v2_key.is_none() {
5308            self.remember_managed_spawn(request, result, accepted_at);
5309        }
5310    }
5311
5312    fn acquire_durable_managed_spawn_v2(
5313        &self,
5314        request: &ManagedWorktreeSpawnRequestV2,
5315        request_digest: String,
5316    ) -> Result<DurableManagedSpawnReplayDecision, NodeFailure> {
5317        if self.state_path.is_none() {
5318            return Err(failure(
5319                NodeFailureCode::BackendOperationFailed,
5320                "managed worktree V2 requires durable node state",
5321            ));
5322        }
5323        let _transaction = self.state_transaction
5324            .lock()
5325            .unwrap_or_else(|poisoned| poisoned.into_inner());
5326        let key = request.spawn_spec.idempotency_key.clone();
5327        let mut previous = None;
5328        {
5329            let mut replays = self.managed_spawn_replays
5330                .lock()
5331                .unwrap_or_else(|poisoned| poisoned.into_inner());
5332            if let Some(record) = replays.get_mut(&key) {
5333                if record.request_digest != request_digest {
5334                    return Err(failure(
5335                        NodeFailureCode::SpawnIdempotencyConflict,
5336                        "managed worktree V2 idempotency key was reused with a different request",
5337                    ));
5338                }
5339                match &record.state {
5340                    ManagedWorktreeSpawnReplayStateV10::Pending {
5341                        owner_incarnation_id,
5342                        lease_id: None,
5343                    } if owner_incarnation_id != &self.incarnation_id => {
5344                        previous = Some(record.clone());
5345                        record.state = ManagedWorktreeSpawnReplayStateV10::Pending {
5346                            owner_incarnation_id: self.incarnation_id,
5347                            lease_id: None,
5348                        };
5349                        record.updated_at_unix_ms = unix_time_ms();
5350                    }
5351                    ManagedWorktreeSpawnReplayStateV10::Pending {
5352                        lease_id: None,
5353                        ..
5354                    } => {
5355                        return Err(failure(
5356                            NodeFailureCode::BackendBusy,
5357                            "managed worktree V2 reservation is already active",
5358                        ));
5359                    }
5360                    ManagedWorktreeSpawnReplayStateV10::Pending {
5361                        lease_id: Some(_),
5362                        ..
5363                    } => {
5364                        return Ok(DurableManagedSpawnReplayDecision::PendingLinked);
5365                    }
5366                    ManagedWorktreeSpawnReplayStateV10::Committed { receipt } => {
5367                        return Ok(DurableManagedSpawnReplayDecision::Committed(
5368                            receipt.clone(),
5369                        ));
5370                    }
5371                }
5372            } else if replays.len() >= MAX_MANAGED_WORKTREE_SPAWN_REPLAYS {
5373                return Err(failure(
5374                    NodeFailureCode::SpawnIdempotencyCapacity,
5375                    "durable managed worktree replay ledger reached its bounded capacity",
5376                ));
5377            } else {
5378                let now = unix_time_ms();
5379                replays.insert(key.clone(), ManagedWorktreeSpawnReplayRecordV10 {
5380                    idempotency_key: key.clone(),
5381                    request_digest,
5382                    source_workspace_id: request.spawn_spec.target.workspace_id.clone(),
5383                    profile_id: request.worktree_profile_id.clone(),
5384                    expected_profile_revision: request.expected_profile_revision.clone(),
5385                    state: ManagedWorktreeSpawnReplayStateV10::Pending {
5386                        owner_incarnation_id: self.incarnation_id,
5387                        lease_id: None,
5388                    },
5389                    created_at_unix_ms: now,
5390                    updated_at_unix_ms: now,
5391                });
5392            }
5393        }
5394        if let Err(error) = self.persist_state_locked() {
5395            let mut replays = self.managed_spawn_replays
5396                .lock()
5397                .unwrap_or_else(|poisoned| poisoned.into_inner());
5398            match previous {
5399                Some(previous) => {
5400                    replays.insert(key, previous);
5401                }
5402                None => {
5403                    replays.remove(&key);
5404                }
5405            }
5406            return Err(persistence_failure(error));
5407        }
5408        Ok(DurableManagedSpawnReplayDecision::Reserved)
5409    }
5410
5411    fn link_durable_managed_spawn_v2_lease_locked(
5412        &self,
5413        key: &SpawnIdempotencyKey,
5414        lease_id: &ManagedWorktreeLeaseId,
5415    ) -> Result<ManagedWorktreeSpawnReplayRecordV10, NodeFailure> {
5416        let mut replays = self.managed_spawn_replays
5417            .lock()
5418            .unwrap_or_else(|poisoned| poisoned.into_inner());
5419        let record = replays.get_mut(key).ok_or_else(|| {
5420            failure(
5421                NodeFailureCode::ManagedWorktreeRecoveryRequired,
5422                "managed worktree V2 reservation disappeared before allocation",
5423            )
5424        })?;
5425        let previous = record.clone();
5426        match &mut record.state {
5427            ManagedWorktreeSpawnReplayStateV10::Pending {
5428                lease_id: current,
5429                ..
5430            }
5431                if current.is_none() =>
5432            {
5433                *current = Some(lease_id.clone());
5434                record.updated_at_unix_ms = unix_time_ms();
5435                Ok(previous)
5436            }
5437            _ => Err(failure(
5438                NodeFailureCode::ManagedWorktreeRecoveryRequired,
5439                "managed worktree V2 reservation is not allocation-ready",
5440            )),
5441        }
5442    }
5443
5444    fn restore_durable_managed_spawn_v2_locked(
5445        &self,
5446        key: &SpawnIdempotencyKey,
5447        previous: ManagedWorktreeSpawnReplayRecordV10,
5448    ) {
5449        self.managed_spawn_replays
5450            .lock()
5451            .unwrap_or_else(|poisoned| poisoned.into_inner())
5452            .insert(key.clone(), previous);
5453    }
5454
5455    fn commit_durable_managed_spawn_v2(
5456        &self,
5457        request: &ManagedWorktreeSpawnRequestV2,
5458        receipt: ManagedWorktreeSpawnReceipt,
5459    ) -> Result<(), NodeFailure> {
5460        let _transaction = self.state_transaction
5461            .lock()
5462            .unwrap_or_else(|poisoned| poisoned.into_inner());
5463        let key = &request.spawn_spec.idempotency_key;
5464        let previous = {
5465            let mut replays = self.managed_spawn_replays
5466                .lock()
5467                .unwrap_or_else(|poisoned| poisoned.into_inner());
5468            let record = replays.get_mut(key).ok_or_else(|| {
5469                failure(
5470                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
5471                    "managed worktree V2 reservation disappeared before commit",
5472                )
5473            })?;
5474            let previous = record.clone();
5475            match &record.state {
5476                ManagedWorktreeSpawnReplayStateV10::Pending {
5477                    lease_id: Some(lease_id),
5478                    ..
5479                } if lease_id == &receipt.lease.lease_id => {}
5480                _ => {
5481                    return Err(failure(
5482                        NodeFailureCode::ManagedWorktreeRecoveryRequired,
5483                        "managed worktree V2 reservation is not linked to the spawned lease",
5484                    ));
5485                }
5486            }
5487            record.state = ManagedWorktreeSpawnReplayStateV10::Committed { receipt };
5488            record.updated_at_unix_ms = unix_time_ms();
5489            previous
5490        };
5491        if let Err(error) = self.persist_state_locked() {
5492            self.restore_durable_managed_spawn_v2_locked(key, previous);
5493            return Err(persistence_failure(error));
5494        }
5495        Ok(())
5496    }
5497
5498    fn remove_unallocated_durable_managed_spawn_v2(
5499        &self,
5500        request: &ManagedWorktreeSpawnRequestV2,
5501    ) -> Result<(), NodeFailure> {
5502        let _transaction = self.state_transaction
5503            .lock()
5504            .unwrap_or_else(|poisoned| poisoned.into_inner());
5505        let key = &request.spawn_spec.idempotency_key;
5506        let removed = {
5507            let mut replays = self.managed_spawn_replays
5508                .lock()
5509                .unwrap_or_else(|poisoned| poisoned.into_inner());
5510            match replays.get(key).map(|record| &record.state) {
5511                Some(ManagedWorktreeSpawnReplayStateV10::Pending {
5512                    lease_id: None,
5513                    ..
5514                }) => {
5515                    replays.remove(key)
5516                }
5517                _ => None,
5518            }
5519        };
5520        let Some(removed) = removed else {
5521            return Ok(());
5522        };
5523        if let Err(error) = self.persist_state_locked() {
5524            self.managed_spawn_replays
5525                .lock()
5526                .unwrap_or_else(|poisoned| poisoned.into_inner())
5527                .insert(key.clone(), removed);
5528            return Err(persistence_failure(error));
5529        }
5530        Ok(())
5531    }
5532
5533    fn resolve_spawn_spec(&self, spec: &SpawnSpec) -> Result<ResolvedSpawnSpec, NodeFailure> {
5534        if spec.target.node_id != self.node_id {
5535            return Err(failure(
5536                NodeFailureCode::SpawnTargetMismatch,
5537                "spawn target does not match this node",
5538            ));
5539        }
5540        let defaults = self.spawn_profiles.get(&spec.profile_id).ok_or_else(|| {
5541            failure(
5542                NodeFailureCode::UnknownSpawnProfile,
5543                "spawn profile is unavailable on this node",
5544            )
5545        })?;
5546        let resolved = spec.resolve(defaults).map_err(|error| match error {
5547            SpawnSpecResolveError::ProfileRevisionMismatch { .. } => failure(
5548                NodeFailureCode::SpawnProfileRevisionMismatch,
5549                "spawn profile revision does not match the loaded profile",
5550            ),
5551            _ => failure(
5552                NodeFailureCode::InvalidRequest,
5553                "spawn specification could not be resolved",
5554            ),
5555        })?;
5556        if resolved.target.worktree_id.is_some() {
5557            self.require_worktree_service(&resolved.target.workspace_id)?;
5558        }
5559        let environment_profile = self.resolve_environment_profile(&resolved)?;
5560        self.resolve_bundle(&resolved, environment_profile.as_ref())?;
5561        self.resolve_context(&resolved)?;
5562        Ok(resolved)
5563    }
5564
5565    async fn resolve_spawn_workspace(
5566        &self,
5567        resolved: &ResolvedSpawnSpec,
5568        deadline: Instant,
5569    ) -> Result<WorkspaceId, NodeFailure> {
5570        let Some(worktree_id) = resolved.target.worktree_id.as_ref() else {
5571            return Ok(resolved.target.workspace_id.clone());
5572        };
5573        if self.managed_worktrees.lock()
5574            .unwrap_or_else(|poisoned| poisoned.into_inner())
5575            .lease_for_workspace(worktree_id)
5576            .is_some()
5577        {
5578            return Err(failure(
5579                NodeFailureCode::ManagedWorktreeOwnershipConflict,
5580                "managed worktree targets may only be spawned through their lease request",
5581            ));
5582        }
5583        self.require_worktree_service(&resolved.target.workspace_id)?;
5584        let source_root = self.workspace_root(&resolved.target.workspace_id)?;
5585        let selected_root = self.workspace_root(worktree_id)?;
5586        let remaining = spawn_deadline_remaining(deadline)?;
5587        let worktrees = timeout(remaining, list_git_worktrees(&source_root))
5588            .await
5589            .map_err(|_| {
5590                failure(
5591                    NodeFailureCode::SpawnDeadlineExceeded,
5592                    "Git worktree selection exceeded the spawn deadline",
5593                )
5594            })?
5595            .map_err(git_worktree_failure)?;
5596        validate_selected_worktree(&source_root, &selected_root, &worktrees)?;
5597        Ok(worktree_id.clone())
5598    }
5599
5600    async fn spawn_from_spec(
5601        &self,
5602        spec: SpawnSpec,
5603    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
5604        self.spawn_from_spec_with_proxy(spec, None).await
5605    }
5606
5607    async fn spawn_from_spec_with_proxy(
5608        &self,
5609        spec: SpawnSpec,
5610        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
5611    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
5612        let accepted_at = Instant::now();
5613        if harness_mcp.is_none() {
5614            if let Some(replayed) = self.replay_spawn_spec(&spec, accepted_at)? {
5615                return replayed;
5616            }
5617        }
5618        let resolved = self.resolve_spawn_spec(&spec)?;
5619        let environment_profile = self.resolve_environment_profile(&resolved)?;
5620        let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
5621        let context = self.resolve_context(&resolved)?;
5622        let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
5623        let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
5624        let spawn_workspace_id = self.resolve_spawn_workspace(&resolved, deadline).await;
5625        let spawn_workspace_id = match spawn_workspace_id {
5626            Ok(workspace_id) => workspace_id,
5627            Err(error) => {
5628                let result = Err(error);
5629                if harness_mcp.is_none() {
5630                    self.remember_spawn_spec(spec, result.clone(), accepted_at);
5631                }
5632                return result;
5633            }
5634        };
5635        if self.managed_worktrees.lock()
5636            .unwrap_or_else(|poisoned| poisoned.into_inner())
5637            .lease_for_workspace(&spawn_workspace_id)
5638            .is_some()
5639        {
5640            let result = Err(failure(
5641                NodeFailureCode::ManagedWorktreeOwnershipConflict,
5642                "managed worktree targets may only be spawned through their lease request",
5643            ));
5644            if harness_mcp.is_none() {
5645                self.remember_spawn_spec(spec, result.clone(), accepted_at);
5646            }
5647            return result;
5648        }
5649        let result = self
5650            .spawn_session_with_deadline(
5651                spawn_workspace_id,
5652                resolved.provider.clone(),
5653                resolved.mode,
5654                resolved.terminal_size,
5655                resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
5656                environment_profile.clone(),
5657                bundle.clone(),
5658                context.clone(),
5659                None,
5660                None,
5661                SpawnRecordPolicy::Always,
5662                Some(deadline),
5663                &required_capabilities,
5664                harness_mcp,
5665                resolved.approval_level,
5666                resolved.network_allowlist.clone(),
5667                resolved.browser_profile_id.clone(),
5668            )
5669            .await
5670            .map(|(session, _runtime_policy)| {
5671                resolved.receipt_with_materialization(
5672                    self.incarnation_id,
5673                    session,
5674                    environment_profile,
5675                    bundle,
5676                    context,
5677                )
5678            });
5679        if harness_mcp.is_none() {
5680            self.remember_spawn_spec(spec, result.clone(), accepted_at);
5681        }
5682        result
5683    }
5684
5685    async fn spawn_from_spec_with_harness_mcp(
5686        &self,
5687        reservation_id: HarnessMcpReservationId,
5688        activation_digest: HarnessMcpActivationDigest,
5689        spec: SpawnSpec,
5690        deadline_unix_ms: u64,
5691    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
5692        let registry = self.harness_mcp_registry.as_ref().ok_or_else(|| {
5693            failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
5694        })?;
5695        if let Some(receipt) = registry
5696            .replay_spawn(&reservation_id, &activation_digest, &spec)
5697            .map_err(harness_mcp_failure)?
5698        {
5699            return Ok(receipt);
5700        }
5701        let resolved = self.resolve_spawn_spec(&spec)?;
5702        let prepared = registry
5703            .prepare_spawn(
5704                &reservation_id,
5705                &activation_digest,
5706                &spec,
5707                deadline_unix_ms,
5708                resolved.provider.clone(),
5709            )
5710            .map_err(harness_mcp_failure)?;
5711        self.claim_harness_mcp_spawn(
5712            &reservation_id,
5713            &activation_digest,
5714            &spec,
5715            Instant::now(),
5716        )?;
5717        let spawn = self.spawn_from_spec_with_proxy(spec, Some(&prepared)).await;
5718        let mut receipt = match spawn {
5719            Ok(receipt) => receipt,
5720            Err(error) => {
5721                let _ = registry.abort(&reservation_id, &activation_digest);
5722                return Err(error);
5723            }
5724        };
5725        let record_id = self
5726            .session_bindings
5727            .lock()
5728            .unwrap_or_else(|poisoned| poisoned.into_inner())
5729            .get(&receipt.session.session.instance_id)
5730            .and_then(|binding| binding.record_id.clone());
5731        let record_id = match record_id {
5732            Some(record_id) => record_id,
5733            None => {
5734                let rollback = self.remove_session(&receipt.session).await;
5735                let _ = registry.abort(&reservation_id, &activation_digest);
5736                if let Err(rollback_error) = rollback {
5737                    return Err(rollback_error);
5738                }
5739                return Err(failure(
5740                    NodeFailureCode::BindingMismatch,
5741                    "harness MCP spawn did not retain its exact managed record",
5742                ));
5743            }
5744        };
5745        receipt.harness_mcp_proxy = Some(ResolvedHarnessMcpProxyReceiptV1 {
5746            reservation_id: reservation_id.clone(),
5747            activation_digest: activation_digest.clone(),
5748        });
5749        if let Err(error) = registry.mark_spawned(
5750            &prepared,
5751            receipt.session.clone(),
5752            record_id,
5753            receipt.clone(),
5754        ) {
5755            let rollback = self.remove_session(&receipt.session).await;
5756            let _ = registry.abort(&reservation_id, &activation_digest);
5757            if let Err(rollback_error) = rollback {
5758                return Err(rollback_error);
5759            }
5760            return Err(harness_mcp_failure(error));
5761        }
5762        Ok(receipt)
5763    }
5764
5765    async fn spawn_managed_worktree(
5766        &self,
5767        request: ManagedWorktreeSpawnRequest,
5768    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
5769        self.spawn_managed_worktree_inner(request, None).await
5770    }
5771
5772    async fn spawn_managed_worktree_inner(
5773        &self,
5774        request: ManagedWorktreeSpawnRequest,
5775        durable_v2_key: Option<&SpawnIdempotencyKey>,
5776    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
5777        let accepted_at = Instant::now();
5778        if durable_v2_key.is_none() {
5779            if let Some(replayed) = self.replay_managed_spawn(&request, accepted_at)? {
5780                return replayed;
5781            }
5782        }
5783        let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
5784        if request.spawn_spec.target.worktree_id.is_some() {
5785            let result = Err(failure(
5786                NodeFailureCode::InvalidRequest,
5787                "managed worktree spawn must not select a caller-provided worktree",
5788            ));
5789            self.remember_managed_spawn_attempt(
5790                durable_v2_key,
5791                request,
5792                result.clone(),
5793                accepted_at,
5794            );
5795            return result;
5796        }
5797        let environment_profile = self.resolve_environment_profile(&resolved)?;
5798        let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
5799        let context = self.resolve_context(&resolved)?;
5800        let source_workspace_id = resolved.target.workspace_id.clone();
5801        let profile = self.managed_profile(&source_workspace_id, &request.worktree_profile_id)?;
5802        let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
5803        let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
5804        let runtime_requirement = match (resolved.mode, resolved.prompt.is_some()) {
5805            (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
5806            (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
5807            (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
5808            (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
5809        };
5810        let runtime_policy = timeout(
5811            spawn_deadline_remaining(deadline)?,
5812            self.admit_provider_runtime(&resolved.provider, runtime_requirement),
5813        ).await.map_err(|_| failure(
5814            NodeFailureCode::SpawnDeadlineExceeded,
5815            "provider admission exceeded the managed spawn deadline",
5816        ))??;
5817        if required_capabilities.iter().any(|capability| !runtime_policy.admits(*capability)) {
5818            return Err(failure(
5819                NodeFailureCode::UnsupportedSpawnCapability,
5820                "provider runtime does not admit a required spawn capability",
5821            ));
5822        }
5823        self.ensure_binding_capacity()?;
5824        let source_root = self.workspace_root(&source_workspace_id)?;
5825        profile.validate_for_workspace(&source_root)
5826            .map_err(|message| failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message))?;
5827        let base_timeout_ms = u64::try_from(
5828            spawn_deadline_remaining(deadline)?.as_millis(),
5829        ).unwrap_or(u64::MAX).max(1);
5830        let base_commit = resolve_base_commit_with_timeout(
5831            &source_root,
5832            profile.base(),
5833            base_timeout_ms,
5834        ).await.map_err(|error| {
5835            if Instant::now() >= deadline || error.message.contains("timed out") {
5836                failure(
5837                    NodeFailureCode::SpawnDeadlineExceeded,
5838                    "managed worktree base resolution exceeded the spawn deadline",
5839                )
5840            } else {
5841                managed_git_worktree_failure(error)
5842            }
5843        })?;
5844
5845        let lease = {
5846            let _transaction = self.state_transaction.lock()
5847                .unwrap_or_else(|poisoned| poisoned.into_inner());
5848            let previous = self.managed_worktrees.lock()
5849                .unwrap_or_else(|poisoned| poisoned.into_inner()).clone();
5850            let lease = self.managed_worktrees.lock()
5851                .unwrap_or_else(|poisoned| poisoned.into_inner())
5852                .allocate(source_workspace_id.clone(), &profile, base_commit.clone(), unix_time_ms())
5853                .map_err(|message| failure(NodeFailureCode::BackendBusy, &message))?;
5854            let previous_replay = match durable_v2_key
5855                .map(|key| self.link_durable_managed_spawn_v2_lease_locked(key, &lease.lease_id))
5856                .transpose()
5857            {
5858                Ok(previous) => previous,
5859                Err(error) => {
5860                    *self.managed_worktrees.lock()
5861                        .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
5862                    return Err(error);
5863                }
5864            };
5865            if let Err(error) = self.persist_state_locked() {
5866                *self.managed_worktrees.lock()
5867                    .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
5868                if let (Some(key), Some(previous_replay)) =
5869                    (durable_v2_key, previous_replay)
5870                {
5871                    self.restore_durable_managed_spawn_v2_locked(key, previous_replay);
5872                }
5873                return Err(persistence_failure(error));
5874            }
5875            lease
5876        };
5877        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() });
5878        if let Err(message) = profile.validate_target_authority(&lease.lease_id, &lease.target_root) {
5879            self.managed_worktrees.lock()
5880                .unwrap_or_else(|poisoned| poisoned.into_inner())
5881                .remove_unmutated(&lease.lease_id);
5882            if self.persist_state().is_err() {
5883                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5884            }
5885            let result = Err(failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message));
5886            self.remember_managed_spawn_attempt(
5887                durable_v2_key,
5888                request,
5889                result.clone(),
5890                accepted_at,
5891            );
5892            return result;
5893        }
5894        let mutation_timeout_ms = match spawn_deadline_remaining(deadline) {
5895            Ok(remaining) => u64::try_from(remaining.as_millis()).unwrap_or(u64::MAX).max(1),
5896            Err(error) => {
5897                self.reconcile_failed_allocation(&lease, &source_root).await;
5898                let result = Err(error);
5899                self.remember_managed_spawn_attempt(
5900                    durable_v2_key,
5901                    request,
5902                    result.clone(),
5903                    accepted_at,
5904                );
5905                return result;
5906            }
5907        };
5908        let created = match create_git_worktree_with_timeout(
5909                &source_root,
5910                &lease.target_root,
5911                &lease.branch,
5912                Some(&base_commit),
5913                mutation_timeout_ms,
5914            ).await {
5915            Ok(created) => created,
5916            Err(error) => {
5917                self.reconcile_failed_allocation(&lease, &source_root).await;
5918                let result = Err(if Instant::now() >= deadline
5919                    || error.message.contains("timed out")
5920                    || error.message.contains("deadline")
5921                {
5922                    failure(
5923                        NodeFailureCode::SpawnDeadlineExceeded,
5924                        "managed worktree creation exceeded the spawn deadline",
5925                    )
5926                } else {
5927                    managed_git_worktree_failure(error)
5928                });
5929                self.remember_managed_spawn_attempt(
5930                    durable_v2_key,
5931                    request,
5932                    result.clone(),
5933                    accepted_at,
5934                );
5935                return result;
5936            }
5937        };
5938        if !exact_created_worktree(&lease, &created.path, created.branch.as_deref(), &created.head) {
5939            self.mark_managed_recovery_required(&lease.lease_id, ManagedWorktreeCleanupFailure::OwnershipConflict);
5940            let result = Err(failure(
5941                NodeFailureCode::ManagedWorktreeRecoveryRequired,
5942                "created worktree identity did not match its durable lease",
5943            ));
5944            self.remember_managed_spawn_attempt(
5945                durable_v2_key,
5946                request,
5947                result.clone(),
5948                accepted_at,
5949            );
5950            return result;
5951        }
5952        {
5953            let mut registry = self.managed_worktrees.lock()
5954                .unwrap_or_else(|poisoned| poisoned.into_inner());
5955            let current = registry.get_mut(&lease.lease_id)
5956                .expect("allocated managed worktree lease remains present");
5957            current.expected_head = Some(created.head.clone());
5958            current.state = ManagedWorktreeLeaseState::Ready;
5959            current.updated_at_unix_ms = unix_time_ms();
5960        }
5961        if let Err(error) = self.persist_state() {
5962            self.mark_managed_recovery_required(
5963                &lease.lease_id,
5964                ManagedWorktreeCleanupFailure::Backend,
5965            );
5966            let result = Err(persistence_failure(error));
5967            self.remember_managed_spawn_attempt(
5968                durable_v2_key,
5969                request,
5970                result.clone(),
5971                accepted_at,
5972            );
5973            return result;
5974        }
5975        let ready = self.managed_worktrees.lock()
5976            .unwrap_or_else(|poisoned| poisoned.into_inner())
5977            .get(&lease.lease_id)
5978            .expect("ready managed worktree lease remains present")
5979            .snapshot();
5980        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: ready });
5981        if let Err(error) = self.register_workspace(lease.workspace_id.clone(), created.path.clone()).await {
5982            let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
5983            let result = Err(error);
5984            self.remember_managed_spawn_attempt(
5985                durable_v2_key,
5986                request,
5987                result.clone(),
5988                accepted_at,
5989            );
5990            return result;
5991        }
5992        let spawn = self.spawn_session_with_deadline(
5993            lease.workspace_id.clone(),
5994            resolved.provider.clone(),
5995            resolved.mode,
5996            resolved.terminal_size,
5997            resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
5998            environment_profile.clone(),
5999            bundle.clone(),
6000            context.clone(),
6001            Some(lease.lease_id.clone()),
6002            Some(runtime_policy),
6003            SpawnRecordPolicy::Always,
6004            Some(deadline),
6005            &required_capabilities,
6006            None,
6007            resolved.approval_level,
6008            resolved.network_allowlist.clone(),
6009            resolved.browser_profile_id.clone(),
6010        ).await;
6011        let (session, effective_runtime_policy) = match spawn {
6012            Ok(spawned) => spawned,
6013            Err(error) => {
6014                if self
6015                    .cleanup_managed_worktree(&lease.lease_id, true)
6016                    .await
6017                    .is_err()
6018                {
6019                    self.mark_managed_recovery_required(
6020                        &lease.lease_id,
6021                        ManagedWorktreeCleanupFailure::Backend,
6022                    );
6023                }
6024                let result = Err(error);
6025                self.remember_managed_spawn_attempt(
6026                    durable_v2_key,
6027                    request,
6028                    result.clone(),
6029                    accepted_at,
6030                );
6031                return result;
6032            }
6033        };
6034        let (record_id, raw_inventory_record_id) = {
6035            let mut bindings = self.session_bindings.lock()
6036                .unwrap_or_else(|poisoned| poisoned.into_inner());
6037            let binding = bindings.get_mut(&session.session.instance_id)
6038                .expect("successful spawn retains its session binding");
6039            binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
6040            managed_spawn_record_ownership(binding, effective_runtime_policy)
6041        };
6042        let bound = self.managed_worktrees.lock()
6043            .unwrap_or_else(|poisoned| poisoned.into_inner())
6044            .bind_session(
6045                &lease.lease_id,
6046                ManagedWorktreeSessionHolder {
6047                    incarnation_id: self.incarnation_id,
6048                    instance_id: session.session.instance_id,
6049                    generation: session.session.generation,
6050                },
6051                record_id,
6052                unix_time_ms(),
6053            )
6054            .map_err(|message| failure(NodeFailureCode::ManagedWorktreeRecoveryRequired, &message));
6055        let snapshot = match bound {
6056            Ok(snapshot) => snapshot,
6057            Err(error) => {
6058                if raw_inventory_record_id
6059                    .as_ref()
6060                    .is_some_and(|record_id| self.discard_record(record_id).is_err())
6061                {
6062                    self.mark_managed_recovery_required(
6063                        &lease.lease_id,
6064                        ManagedWorktreeCleanupFailure::Backend,
6065                    );
6066                }
6067                let _ = self.remove_session(&session).await;
6068                self.mark_managed_recovery_required(
6069                    &lease.lease_id,
6070                    ManagedWorktreeCleanupFailure::Backend,
6071                );
6072                let result = Err(error);
6073                self.remember_managed_spawn_attempt(
6074                    durable_v2_key,
6075                    request,
6076                    result.clone(),
6077                    accepted_at,
6078                );
6079                return result;
6080            }
6081        };
6082        if let Err(error) = self.persist_state() {
6083            if raw_inventory_record_id
6084                .as_ref()
6085                .is_some_and(|record_id| self.discard_record(record_id).is_err())
6086            {
6087                self.mark_managed_recovery_required(
6088                    &lease.lease_id,
6089                    ManagedWorktreeCleanupFailure::Backend,
6090                );
6091            }
6092            let _ = self.remove_session(&session).await;
6093            if self
6094                .cleanup_managed_worktree(&lease.lease_id, true)
6095                .await
6096                .is_err()
6097            {
6098                self.mark_managed_recovery_required(
6099                    &lease.lease_id,
6100                    ManagedWorktreeCleanupFailure::Backend,
6101                );
6102            }
6103            let result = Err(persistence_failure(error));
6104            self.remember_managed_spawn_attempt(
6105                durable_v2_key,
6106                request,
6107                result.clone(),
6108                accepted_at,
6109            );
6110            return result;
6111        }
6112        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
6113        let receipt = managed_spawn_receipt(
6114            &resolved,
6115            self.incarnation_id,
6116            session,
6117            snapshot,
6118            environment_profile,
6119            bundle,
6120            context,
6121        );
6122        let result = Ok(receipt);
6123        self.remember_managed_spawn_attempt(
6124            durable_v2_key,
6125            request,
6126            result.clone(),
6127            accepted_at,
6128        );
6129        result
6130    }
6131
6132    async fn spawn_managed_worktree_v2(
6133        &self,
6134        request: ManagedWorktreeSpawnRequestV2,
6135    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6136        if request.spawn_spec.target.node_id != self.node_id {
6137            return Err(failure(
6138                NodeFailureCode::SpawnTargetMismatch,
6139                "spawn target does not match this node",
6140            ));
6141        }
6142        if request.spawn_spec.target.worktree_id.is_some() {
6143            return Err(failure(
6144                NodeFailureCode::InvalidRequest,
6145                "managed worktree spawn must not select a caller-provided worktree",
6146            ));
6147        }
6148        let request_digest = managed_spawn_request_digest_v2(&request)?;
6149        let replay = self.acquire_durable_managed_spawn_v2(&request, request_digest)?;
6150        match replay {
6151            DurableManagedSpawnReplayDecision::Committed(receipt) => return Ok(receipt),
6152            DurableManagedSpawnReplayDecision::PendingLinked => {
6153                return Err(failure(
6154                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
6155                    "managed worktree V2 reservation has an incomplete durable allocation",
6156                ));
6157            }
6158            DurableManagedSpawnReplayDecision::Reserved => {}
6159        }
6160        let validated = (|| {
6161            let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6162            let profile = self.managed_profile(
6163                &resolved.target.workspace_id,
6164                &request.worktree_profile_id,
6165            )?;
6166            if profile.revision() != &request.expected_profile_revision {
6167                return Err(failure(
6168                    NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
6169                    "managed worktree profile revision does not match the loaded profile",
6170                ));
6171            }
6172            Ok(())
6173        })();
6174        match validated {
6175            Ok(()) => {}
6176            Err(error) => {
6177                self.remove_unallocated_durable_managed_spawn_v2(&request)?;
6178                return Err(error);
6179            }
6180        }
6181        let legacy = ManagedWorktreeSpawnRequest {
6182            spawn_spec: request.spawn_spec.clone(),
6183            worktree_profile_id: request.worktree_profile_id.clone(),
6184        };
6185        match self
6186            .spawn_managed_worktree_inner(
6187                legacy,
6188                Some(&request.spawn_spec.idempotency_key),
6189            )
6190            .await
6191        {
6192            Ok(receipt) => {
6193                self.commit_durable_managed_spawn_v2(&request, receipt.clone())?;
6194                Ok(receipt)
6195            }
6196            Err(error) => {
6197                self.remove_unallocated_durable_managed_spawn_v2(&request)?;
6198                Err(error)
6199            }
6200        }
6201    }
6202
6203    fn managed_profile(
6204        &self,
6205        workspace_id: &WorkspaceId,
6206        profile_id: &WorktreeProfileId,
6207    ) -> Result<ManagedWorktreeProfile, NodeFailure> {
6208        if self.worktree_service_modes.get(workspace_id).copied()
6209            != Some(WorktreeServiceMode::Managed)
6210        {
6211            return Err(failure(
6212                NodeFailureCode::UnsupportedSpawnCapability,
6213                "workspace does not enable managed worktrees",
6214            ));
6215        }
6216        self.managed_worktree_profiles.get(workspace_id)
6217            .and_then(|profiles| profiles.get(profile_id))
6218            .cloned()
6219            .ok_or_else(|| failure(
6220                NodeFailureCode::InvalidRequest,
6221                "managed worktree profile is unavailable for this workspace",
6222            ))
6223    }
6224
6225    async fn reconcile_failed_allocation(
6226        &self,
6227        lease: &ManagedWorktreeLeaseRecord,
6228        source_root: &str,
6229    ) {
6230        match list_git_worktrees(source_root).await {
6231            Ok(worktrees) => {
6232                match worktrees.iter().find(|item| worktree_paths_equal(&item.path, &lease.target_root)) {
6233                    None => {
6234                        match std::fs::symlink_metadata(&lease.target_root) {
6235                            Err(error) if error.kind() == io::ErrorKind::NotFound => {
6236                                self.managed_worktrees.lock()
6237                                    .unwrap_or_else(|poisoned| poisoned.into_inner())
6238                                    .remove_unmutated(&lease.lease_id);
6239                                let _ = self.persist_state();
6240                            }
6241                            _ => self.mark_managed_recovery_required(
6242                                &lease.lease_id,
6243                                ManagedWorktreeCleanupFailure::OwnershipConflict,
6244                            ),
6245                        }
6246                    }
6247                    Some(item) if exact_owned_worktree(
6248                        lease,
6249                        &item.path,
6250                        item.branch.as_deref(),
6251                        &item.head,
6252                    ) => {
6253                        let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
6254                    }
6255                    Some(_) => self.mark_managed_recovery_required(
6256                        &lease.lease_id,
6257                        ManagedWorktreeCleanupFailure::OwnershipConflict,
6258                    ),
6259                }
6260            }
6261            Err(_) => self.mark_managed_recovery_required(
6262                &lease.lease_id,
6263                ManagedWorktreeCleanupFailure::Backend,
6264            ),
6265        }
6266    }
6267
6268    async fn reconcile_managed_worktrees(&self) {
6269        let leases = self.managed_worktrees.lock()
6270            .unwrap_or_else(|poisoned| poisoned.into_inner())
6271            .active_records()
6272            .cloned()
6273            .collect::<Vec<_>>();
6274        for lease in leases {
6275            let profile = match self.managed_profile(&lease.source_workspace_id, &lease.profile_id) {
6276                Ok(profile) if profile.revision() == &lease.profile_revision => profile,
6277                _ => {
6278                    self.mark_managed_recovery_required(
6279                        &lease.lease_id,
6280                        ManagedWorktreeCleanupFailure::OwnershipConflict,
6281                    );
6282                    continue;
6283                }
6284            };
6285            if profile.validate_target_authority(&lease.lease_id, &lease.target_root).is_err() {
6286                self.mark_managed_recovery_required(
6287                    &lease.lease_id,
6288                    ManagedWorktreeCleanupFailure::OwnershipConflict,
6289                );
6290                continue;
6291            }
6292            let source_root = match self.workspace_root(&lease.source_workspace_id) {
6293                Ok(root) => root,
6294                Err(_) => {
6295                    self.mark_managed_recovery_required(
6296                        &lease.lease_id,
6297                        ManagedWorktreeCleanupFailure::OwnershipConflict,
6298                    );
6299                    continue;
6300                }
6301            };
6302            let worktrees = match list_git_worktrees(&source_root).await {
6303                Ok(worktrees) => worktrees,
6304                Err(_) => {
6305                    self.mark_managed_recovery_required(
6306                        &lease.lease_id,
6307                        ManagedWorktreeCleanupFailure::Backend,
6308                    );
6309                    continue;
6310                }
6311            };
6312            let listed = worktrees.iter()
6313                .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
6314            let Some(listed) = listed else {
6315                match std::fs::symlink_metadata(&lease.target_root) {
6316                    Err(error) if error.kind() == io::ErrorKind::NotFound
6317                        && lease.state == ManagedWorktreeLeaseState::Allocating
6318                        && !lease.has_holders() => {
6319                            let removed = self.managed_worktrees.lock()
6320                                .unwrap_or_else(|poisoned| poisoned.into_inner())
6321                                .tombstone(&lease.lease_id, unix_time_ms());
6322                            if self.persist_state().is_ok() && removed.is_some() {
6323                                self.publish(NodeEvent::ManagedWorktreeRemoved {
6324                                    lease_id: lease.lease_id.clone(),
6325                                });
6326                            }
6327                        }
6328                    _ => self.mark_managed_recovery_required(
6329                        &lease.lease_id,
6330                        ManagedWorktreeCleanupFailure::OwnershipConflict,
6331                    ),
6332                }
6333                continue;
6334            };
6335            if listed.is_main || listed.is_bare || !exact_owned_worktree(
6336                &lease,
6337                &listed.path,
6338                listed.branch.as_deref(),
6339                &listed.head,
6340            ) {
6341                self.mark_managed_recovery_required(
6342                    &lease.lease_id,
6343                    ManagedWorktreeCleanupFailure::OwnershipConflict,
6344                );
6345                continue;
6346            }
6347            if !self.workspaces.read()
6348                .unwrap_or_else(|poisoned| poisoned.into_inner())
6349                .contains_key(&lease.workspace_id)
6350            {
6351                if self.register_workspace(lease.workspace_id.clone(), listed.path.clone()).await.is_err() {
6352                    self.mark_managed_recovery_required(
6353                        &lease.lease_id,
6354                        ManagedWorktreeCleanupFailure::Backend,
6355                    );
6356                    continue;
6357                }
6358            }
6359            let snapshot = {
6360                let mut registry = self.managed_worktrees.lock()
6361                    .unwrap_or_else(|poisoned| poisoned.into_inner());
6362                let current = registry.get_mut(&lease.lease_id)
6363                    .expect("reconciled managed lease remains present");
6364                current.expected_head = Some(listed.head.clone());
6365                current.cleanup_failure = None;
6366                current.state = if current.has_holders() {
6367                    ManagedWorktreeLeaseState::InUse
6368                } else if current.retention == ManagedWorktreeRetention::Retain {
6369                    ManagedWorktreeLeaseState::Retained
6370                } else {
6371                    ManagedWorktreeLeaseState::Ready
6372                };
6373                current.updated_at_unix_ms = unix_time_ms();
6374                current.snapshot()
6375            };
6376            if self.persist_state().is_err() {
6377                self.mark_managed_recovery_required(
6378                    &lease.lease_id,
6379                    ManagedWorktreeCleanupFailure::Backend,
6380                );
6381                continue;
6382            }
6383            self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
6384            if snapshot.state == ManagedWorktreeLeaseState::Ready {
6385                let _ = self.cleanup_managed_worktree(&lease.lease_id, false).await;
6386            }
6387        }
6388    }
6389
6390    /// Crash-recovery startup sweep: durably capture the pack for every
6391    /// `Live` managed record whose most recent session has not yet produced
6392    /// one (recovers a session whose `Exited{0}` event was observed but the
6393    /// Node crashed before the export finished). The reactive fast path for
6394    /// a single just-exited record does not go through this — see
6395    /// `drive_runtime_until_shutdown`, which resolves and spawns directly so
6396    /// it can capture the exact `SessionAddress` before `publish_control`
6397    /// clears it, rather than re-deriving it from (by then likely already
6398    /// `Dormant`) record state the way a rescan naturally would.
6399    ///
6400    /// Returns as soon as every eligible record's export has been *spawned*,
6401    /// not completed — each one runs on its own detached task (see
6402    /// `reconcile_context_pack_export_for_record`'s own doc comment for why:
6403    /// `NativeRuntime::tick()` has not even started its own loop yet at the
6404    /// point this is called from `NodeServer::run()`, so nothing could ever
6405    /// drain the history round trip if this awaited it inline here either).
6406    /// Best-effort throughout: never panics, no unwrap/expect; every failure
6407    /// is a silent skip for THIS record — an unusable source or a racing
6408    /// record is not surfaced to any caller.
6409    async fn reconcile_context_pack_exports(self: &Arc<Self>) {
6410        let candidates: Vec<(SessionRecordId, SessionAddress)> = self
6411            .session_records
6412            .lock()
6413            .unwrap_or_else(|poisoned| poisoned.into_inner())
6414            .records
6415            .values()
6416            .filter(|record| {
6417                record.state == ManagedSessionState::Live
6418                    && record.exported_context.is_none()
6419                    && record.provider_session.is_some()
6420            })
6421            .filter_map(|record| {
6422                record
6423                    .active_session
6424                    .clone()
6425                    .map(|session| (record.record_id.clone(), session))
6426            })
6427            .collect();
6428        for (record_id, session) in candidates {
6429            let shared = Arc::clone(self);
6430            tokio::spawn(async move {
6431                shared
6432                    .reconcile_context_pack_export_for_record(&record_id, session)
6433                    .await;
6434            });
6435        }
6436    }
6437
6438    /// Runs on its own detached task (`tokio::spawn`, see both call sites),
6439    /// never inline on `drive_runtime_until_shutdown`'s own task: the history
6440    /// round trip this performs (`discover_history`/`load_history`, via
6441    /// `export_context_pack_for_session_record`) can only ever settle once
6442    /// `NativeRuntime::tick()` runs again to drain the authority worker's
6443    /// completion — and `tick()` is exclusively owned by, and only ever
6444    /// called from, that same drive-loop task. Awaiting this inline there
6445    /// would starve `tick()` and this call could never observe its own
6446    /// command complete.
6447    ///
6448    /// Because this now genuinely races the drive loop's own
6449    /// `publish_control` (which downgrades this exact record from `Live` to
6450    /// `Dormant`, clearing `active_session`, for the very `Exited` event that
6451    /// triggered this reconcile), the record legitimately may already be
6452    /// `Dormant` by the time this reads it, or may transition mid-flight —
6453    /// `allow_clean_detachment: true` on the downstream export call is what
6454    /// tolerates exactly that transition (and only that transition; see
6455    /// `session_record_export_target_matches`).
6456    async fn reconcile_context_pack_export_for_record(
6457        &self,
6458        record_id: &SessionRecordId,
6459        session: SessionAddress,
6460    ) {
6461        let Ok(record) = self.record(record_id) else {
6462            return;
6463        };
6464        if !matches!(
6465            record.state,
6466            ManagedSessionState::Live | ManagedSessionState::Dormant
6467        ) || record.exported_context.is_some()
6468            || record.provider_session.is_none()
6469        {
6470            return;
6471        }
6472        // Pack bytes are already durably committed inside this call, via
6473        // commit_context_pack_for_session_record's own durable
6474        // write-through (context_pack_store) — no separate step here.
6475        // Unclean exits (non-zero/forced/no loadable history) are already
6476        // rejected by context_pack_source_status_is_usable inside
6477        // materialize_context_pack: this simply returns Err and is skipped.
6478        let Ok(receipt) = self
6479            .export_context_pack_for_session_record(&record.record_id, &session, true)
6480            .await
6481        else {
6482            return;
6483        };
6484        self.set_exported_context(&record.record_id, receipt);
6485    }
6486
6487    /// Records the record's most recent clean-exit pack. No-op (not an
6488    /// error) if the record disappeared, or if it already carries an
6489    /// `exported_context` — first-write-wins, matching the harness-side
6490    /// idempotent reconciliation that consumes this field.
6491    fn set_exported_context(&self, record_id: &SessionRecordId, receipt: ResolvedContextPackReceipt) {
6492        let transaction = self
6493            .state_transaction
6494            .lock()
6495            .unwrap_or_else(|poisoned| poisoned.into_inner());
6496        let (previous, updated) = {
6497            let mut records = self
6498                .session_records
6499                .lock()
6500                .unwrap_or_else(|poisoned| poisoned.into_inner());
6501            let Some(record) = records.records.get_mut(record_id) else {
6502                return;
6503            };
6504            if record.exported_context.is_some() {
6505                return;
6506            }
6507            let previous = record.clone();
6508            record.exported_context = Some(receipt);
6509            record.updated_at_unix_ms = unix_time_ms().max(record.updated_at_unix_ms);
6510            (previous, record.clone())
6511        };
6512        if self.persist_state_locked().is_err() {
6513            self.session_records
6514                .lock()
6515                .unwrap_or_else(|poisoned| poisoned.into_inner())
6516                .records
6517                .insert(record_id.clone(), previous);
6518            return;
6519        }
6520        drop(transaction);
6521        self.publish_record(updated);
6522    }
6523
6524    /// Resolves the managed record and its exact `SessionAddress` currently
6525    /// bound to `instance_id` at exactly `generation`, used by the reactive
6526    /// `Exited{0}` hook in `drive_runtime_until_shutdown` to target a single
6527    /// record without a full rescan. Mirrors the lookup already inlined in
6528    /// `publish_control`. Captured synchronously, before `publish_control`
6529    /// for the same event clears the record's own `active_session` — the
6530    /// caller passes the resolved address through to a detached
6531    /// reconciliation task rather than re-deriving it from the record later,
6532    /// after that race has necessarily already happened.
6533    fn managed_record_export_target_for_instance(
6534        &self,
6535        instance_id: AgentInstanceId,
6536        generation: SessionGeneration,
6537    ) -> Option<(SessionRecordId, SessionAddress)> {
6538        let bindings = self
6539            .session_bindings
6540            .lock()
6541            .unwrap_or_else(|poisoned| poisoned.into_inner());
6542        let binding = bindings.get(&instance_id).filter(|binding| binding.generation == generation)?;
6543        let record_id = binding.record_id.clone()?;
6544        Some((
6545            record_id,
6546            SessionAddress {
6547                workspace_id: binding.workspace_id.clone(),
6548                session: SessionKey { instance_id, generation },
6549            },
6550        ))
6551    }
6552
6553    fn mark_managed_recovery_required(
6554        &self,
6555        lease_id: &ManagedWorktreeLeaseId,
6556        failure_kind: ManagedWorktreeCleanupFailure,
6557    ) {
6558        let snapshot = {
6559            let mut registry = self.managed_worktrees.lock()
6560                .unwrap_or_else(|poisoned| poisoned.into_inner());
6561            let Some(lease) = registry.get_mut(lease_id) else { return };
6562            lease.state = ManagedWorktreeLeaseState::RecoveryRequired;
6563            lease.cleanup_failure = Some(failure_kind);
6564            lease.updated_at_unix_ms = unix_time_ms();
6565            lease.snapshot()
6566        };
6567        let _ = self.persist_state();
6568        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
6569    }
6570
6571    fn set_persistence_error(&self, error: Option<String>) {
6572        *self
6573            .persistence_error
6574            .write()
6575            .unwrap_or_else(|poisoned| poisoned.into_inner()) = error
6576            .map(|message| session_registry::sanitized_persistence_summary(&message));
6577    }
6578
6579    fn persist_state(&self) -> io::Result<()> {
6580        let _transaction = self
6581            .state_transaction
6582            .lock()
6583            .unwrap_or_else(|poisoned| poisoned.into_inner());
6584        self.persist_state_locked()
6585    }
6586
6587    fn persist_state_locked(&self) -> io::Result<()> {
6588        let workspaces = self
6589            .workspaces
6590            .read()
6591            .unwrap_or_else(|poisoned| poisoned.into_inner())
6592            .clone();
6593        let records = self
6594            .session_records
6595            .lock()
6596            .unwrap_or_else(|poisoned| poisoned.into_inner())
6597            .records
6598            .values()
6599            .cloned()
6600            .collect::<Vec<_>>();
6601        let mut managed = self.managed_worktrees
6602            .lock()
6603            .unwrap_or_else(|poisoned| poisoned.into_inner());
6604        managed.reattach_record_holders(
6605            &managed_worktree_record_holders(&records),
6606            unix_time_ms(),
6607        );
6608        let materializations = self
6609            .materializations
6610            .lock()
6611            .unwrap_or_else(|poisoned| poisoned.into_inner())
6612            .values()
6613            .cloned()
6614            .collect::<Vec<_>>();
6615        let managed_spawn_replays = self.managed_spawn_replays
6616            .lock()
6617            .unwrap_or_else(|poisoned| poisoned.into_inner())
6618            .values()
6619            .cloned()
6620            .collect::<Vec<_>>();
6621        let result = session_registry::save_v10(
6622            self.state_path.as_deref(),
6623            &self.node_id,
6624            &workspaces,
6625            &records,
6626            &managed.records(),
6627            &managed.tombstones(),
6628            &materializations,
6629            &managed_spawn_replays,
6630        );
6631        match &result {
6632            Ok(warning) => self.set_persistence_error(warning.clone()),
6633            Err(_) => self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned())),
6634        }
6635        result.map(|_| ())
6636    }
6637
6638    async fn begin_shutdown(&self) -> Result<(), NodeFailure> {
6639        let _mutation_guard = self.mutation_gate.lock().await;
6640        self.begin_shutdown_locked().await
6641    }
6642
6643    async fn begin_shutdown_locked(&self) -> Result<(), NodeFailure> {
6644        if self.shutdown.swap(true, Ordering::AcqRel) {
6645            return Ok(());
6646        }
6647        if let Some(registry) = self.harness_mcp_registry.as_ref() {
6648            let instances = registry.shutdown();
6649            if let Some(control) = self.native_launch_profile_control.as_ref() {
6650                for instance_id in instances {
6651                    control.clear_native_mcp_server_launch_overlay(instance_id);
6652                }
6653            }
6654        }
6655        let deadline = Instant::now() + Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
6656        let mut first_error = None;
6657        for session in &self.handle.snapshot().sessions {
6658            let remaining = deadline.saturating_duration_since(Instant::now());
6659            if remaining.is_zero() {
6660                first_error.get_or_insert_with(|| {
6661                    failure(NodeFailureCode::BackendBusy, "stop-all dispatch exceeded its bounded deadline")
6662                });
6663                break;
6664            }
6665            if let Err(error) = self
6666                .dispatch_bounded(
6667                    ControlCommand::Stop {
6668                        instance_id: session.instance_id,
6669                        force: true,
6670                    },
6671                    remaining,
6672                )
6673                .await
6674            {
6675                first_error.get_or_insert(error);
6676            }
6677        }
6678        self.shutdown_notify.notify_waiters();
6679        match first_error {
6680            Some(error) => Err(error),
6681            None => Ok(()),
6682        }
6683    }
6684
6685    fn ensure_binding_capacity(&self) -> Result<(), NodeFailure> {
6686        let bindings = self
6687            .session_bindings
6688            .lock()
6689            .unwrap_or_else(|poisoned| poisoned.into_inner());
6690        if bindings.len() >= CONTROL_SESSIONS_MAX {
6691            Err(failure(
6692                NodeFailureCode::BackendBusy,
6693                "node session-binding capacity is full; remove an existing session first",
6694            ))
6695        } else {
6696            Ok(())
6697        }
6698    }
6699
6700    /// One-owner workspace/env collision guard: a live session on
6701    /// `workspace_id` with a different `environment_profile` (option
6702    /// equality on the resolved receipt, including Some vs None) blocks
6703    /// another spawn on that same cwd. Same profile may still share the
6704    /// workspace; distinct profiles need distinct workspace ids (managed
6705    /// worktree).
6706    fn ensure_workspace_environment_profile_compatible(
6707        &self,
6708        workspace_id: &WorkspaceId,
6709        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
6710    ) -> Result<(), NodeFailure> {
6711        let bindings = self
6712            .session_bindings
6713            .lock()
6714            .unwrap_or_else(|poisoned| poisoned.into_inner());
6715        for binding in bindings.values() {
6716            if &binding.workspace_id != workspace_id {
6717                continue;
6718            }
6719            if binding.environment_profile.as_ref() != environment_profile {
6720                return Err(failure(
6721                    NodeFailureCode::WorkspaceBusy,
6722                    "workspace already has a live session with a different environment profile; use a managed worktree for concurrent distinct ProviderHome/env bindings on one repo",
6723                ));
6724            }
6725        }
6726        Ok(())
6727    }
6728
6729    #[cfg(test)]
6730    fn bind_session(
6731        &self,
6732        instance_id: AgentInstanceId,
6733        workspace_id: WorkspaceId,
6734        generation: SessionGeneration,
6735    ) {
6736        self.bind_session_with_policy(
6737            &SessionAddress {
6738                workspace_id,
6739                session: SessionKey { instance_id, generation },
6740            },
6741            ProviderRuntimePolicy::raw_pty(),
6742            None,
6743        );
6744    }
6745
6746    #[cfg(test)]
6747    fn bind_session_with_policy(
6748        &self,
6749        address: &SessionAddress,
6750        runtime_policy: ProviderRuntimePolicy,
6751        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
6752    ) {
6753        self.bind_session_with_materialization(
6754            address,
6755            runtime_policy,
6756            environment_profile,
6757            None,
6758            None,
6759            None,
6760        );
6761    }
6762
6763    fn bind_session_with_materialization(
6764        &self,
6765        address: &SessionAddress,
6766        runtime_policy: ProviderRuntimePolicy,
6767        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
6768        bundle: Option<ResolvedBundleReceipt>,
6769        context: Option<ResolvedContextPackReceipt>,
6770        materialization_id: Option<MaterializationId>,
6771    ) {
6772        let mut bindings = self
6773            .session_bindings
6774            .lock()
6775            .unwrap_or_else(|poisoned| poisoned.into_inner());
6776        debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
6777        debug_assert!(!bindings.contains_key(&address.session.instance_id));
6778        bindings.insert(address.session.instance_id, SessionBinding {
6779            workspace_id: address.workspace_id.clone(),
6780            generation: address.session.generation,
6781            runtime_policy,
6782            pending_resume: None,
6783            record_id: None,
6784            managed_worktree_lease_id: None,
6785            environment_profile,
6786            bundle,
6787            context,
6788            materialization_id,
6789        });
6790    }
6791
6792    #[cfg(test)]
6793    fn bind_managed_session(
6794        &self,
6795        address: &SessionAddress,
6796        record_id: SessionRecordId,
6797        runtime_policy: ProviderRuntimePolicy,
6798        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
6799    ) {
6800        self.bind_managed_session_with_materialization(
6801            address,
6802            record_id,
6803            runtime_policy,
6804            environment_profile,
6805            None,
6806            None,
6807            None,
6808        );
6809    }
6810
6811    fn bind_managed_session_with_materialization(
6812        &self,
6813        address: &SessionAddress,
6814        record_id: SessionRecordId,
6815        runtime_policy: ProviderRuntimePolicy,
6816        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
6817        bundle: Option<ResolvedBundleReceipt>,
6818        context: Option<ResolvedContextPackReceipt>,
6819        materialization_id: Option<MaterializationId>,
6820    ) {
6821        let mut bindings = self
6822            .session_bindings
6823            .lock()
6824            .unwrap_or_else(|poisoned| poisoned.into_inner());
6825        debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
6826        debug_assert!(!bindings.contains_key(&address.session.instance_id));
6827        bindings.insert(
6828            address.session.instance_id,
6829            SessionBinding {
6830                workspace_id: address.workspace_id.clone(),
6831                generation: address.session.generation,
6832                runtime_policy,
6833                pending_resume: None,
6834                record_id: Some(record_id),
6835                managed_worktree_lease_id: None,
6836                environment_profile,
6837                bundle,
6838                context,
6839                materialization_id,
6840            },
6841        );
6842    }
6843
6844    #[cfg(test)]
6845    fn bind_spawn_session(
6846        &self,
6847        address: &SessionAddress,
6848        provider: AgentId,
6849        mode: SessionMode,
6850        runtime_policy: ProviderRuntimePolicy,
6851        record_policy: SpawnRecordPolicy,
6852        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
6853    ) -> Result<Option<SessionRecordId>, NodeFailure> {
6854        self.bind_spawn_session_with_materialization(
6855            address,
6856            provider,
6857            mode,
6858            runtime_policy,
6859            record_policy,
6860            environment_profile,
6861            None,
6862            None,
6863            None,
6864        )
6865    }
6866
6867    fn bind_spawn_session_with_materialization(
6868        &self,
6869        address: &SessionAddress,
6870        provider: AgentId,
6871        mode: SessionMode,
6872        runtime_policy: ProviderRuntimePolicy,
6873        record_policy: SpawnRecordPolicy,
6874        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
6875        bundle: Option<ResolvedBundleReceipt>,
6876        context: Option<ResolvedContextPackReceipt>,
6877        materialization_id: Option<MaterializationId>,
6878    ) -> Result<Option<SessionRecordId>, NodeFailure> {
6879        if record_policy == SpawnRecordPolicy::ProviderIdentityOnly
6880            && !runtime_policy.provider_session_identity
6881        {
6882            self.bind_session_with_materialization(
6883                address,
6884                runtime_policy,
6885                environment_profile,
6886                bundle,
6887                context,
6888                materialization_id,
6889            );
6890            return Ok(None);
6891        }
6892        let record_owns_lifecycle = runtime_policy.provider_session_identity;
6893        let record = self.new_record(
6894            address,
6895            provider,
6896            mode,
6897            active_record_state(runtime_policy.provider_session_identity, false),
6898            None,
6899            record_owns_lifecycle.then(|| environment_profile.clone()).flatten(),
6900            record_owns_lifecycle.then(|| bundle.clone()).flatten(),
6901            record_owns_lifecycle.then(|| context.clone()).flatten(),
6902        )?;
6903        let record_id = record.record_id.clone();
6904        let transaction = self
6905            .state_transaction
6906            .lock()
6907            .unwrap_or_else(|poisoned| poisoned.into_inner());
6908        self.insert_record(record.clone())?;
6909        let previous_materialization = if record_owns_lifecycle {
6910            if let Some(materialization_id) = materialization_id.as_ref() {
6911                let mut materializations = self
6912                    .materializations
6913                    .lock()
6914                    .unwrap_or_else(|poisoned| poisoned.into_inner());
6915                let Some(ownership) = materializations.get_mut(materialization_id) else {
6916                    drop(materializations);
6917                    self.remove_record_memory(&record_id);
6918                    return Err(failure(
6919                        NodeFailureCode::BackendOperationFailed,
6920                        "session environment ownership disappeared before binding",
6921                    ));
6922                };
6923                let previous = ownership.clone();
6924                if ownership
6925                    .transfer_to_record(record_id.clone(), unix_time_ms())
6926                    .is_err()
6927                {
6928                    drop(materializations);
6929                    self.remove_record_memory(&record_id);
6930                    return Err(failure(
6931                        NodeFailureCode::BackendOperationFailed,
6932                        "session environment ownership transfer failed",
6933                    ));
6934                }
6935                Some(previous)
6936            } else {
6937                None
6938            }
6939        } else {
6940            None
6941        };
6942        self.bind_managed_session_with_materialization(
6943            address,
6944            record_id.clone(),
6945            runtime_policy,
6946            environment_profile,
6947            bundle,
6948            context,
6949            materialization_id.clone(),
6950        );
6951        if let Err(error) = self.persist_state_locked() {
6952            self.remove_binding(address);
6953            self.remove_record_memory(&record_id);
6954            if let (Some(materialization_id), Some(previous)) =
6955                (materialization_id.as_ref(), previous_materialization)
6956            {
6957                self.materializations
6958                    .lock()
6959                    .unwrap_or_else(|poisoned| poisoned.into_inner())
6960                    .insert(materialization_id.clone(), previous);
6961            }
6962            return Err(persistence_failure(error));
6963        }
6964        drop(transaction);
6965        self.publish_record(record);
6966        Ok(Some(record_id))
6967    }
6968
6969    fn allocate_record_id(&self) -> Result<SessionRecordId, NodeFailure> {
6970        for _ in 0..8 {
6971            let bytes = random_nonce().map_err(|error| {
6972                failure(
6973                    NodeFailureCode::BackendOperationFailed,
6974                    &format!("session record identity generation failed: {error}"),
6975                )
6976            })?;
6977            let mut value = String::with_capacity(27);
6978            value.push_str("sr-");
6979            for byte in &bytes[..12] {
6980                use std::fmt::Write as _;
6981                let _ = write!(&mut value, "{byte:02x}");
6982            }
6983            let record_id = SessionRecordId::new(value).map_err(|error| {
6984                failure(NodeFailureCode::BackendOperationFailed, &error.to_string())
6985            })?;
6986            let records = self
6987                .session_records
6988                .lock()
6989                .unwrap_or_else(|poisoned| poisoned.into_inner());
6990            if !records.records.contains_key(&record_id) {
6991                return Ok(record_id);
6992            }
6993        }
6994        Err(failure(
6995            NodeFailureCode::BackendBusy,
6996            "could not allocate a unique session record ID",
6997        ))
6998    }
6999
7000    fn allocate_task_id(&self) -> Result<TaskId, NodeFailure> {
7001        for _ in 0..8 {
7002            let bytes = random_nonce().map_err(|error| {
7003                failure(
7004                    NodeFailureCode::BackendOperationFailed,
7005                    &format!("task identity generation failed: {error}"),
7006                )
7007            })?;
7008            let mut nonce = [0_u8; 12];
7009            nonce.copy_from_slice(&bytes[..12]);
7010            let task_id = TaskId::from_nonce(nonce);
7011            let records = self
7012                .session_records
7013                .lock()
7014                .unwrap_or_else(|poisoned| poisoned.into_inner());
7015            if !records.records.values().any(|record| {
7016                record.task_binding.as_ref().and_then(|binding| binding.task_id.as_ref())
7017                    == Some(&task_id)
7018            }) {
7019                return Ok(task_id);
7020            }
7021        }
7022        Err(failure(
7023            NodeFailureCode::BackendBusy,
7024            "could not allocate a unique task ID",
7025        ))
7026    }
7027
7028    fn default_record_name(&self, provider: &AgentId) -> String {
7029        let records = self
7030            .session_records
7031            .lock()
7032            .unwrap_or_else(|poisoned| poisoned.into_inner());
7033        let ordinal = records
7034            .records
7035            .values()
7036            .filter(|record| &record.provider == provider)
7037            .count()
7038            .saturating_add(1);
7039        format!("{} #{ordinal}", provider.as_str())
7040    }
7041
7042    fn new_record(
7043        &self,
7044        address: &SessionAddress,
7045        provider: AgentId,
7046        mode: SessionMode,
7047        state: ManagedSessionState,
7048        provider_session: Option<gate4agent_types::ProviderSessionIdentity>,
7049        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7050        bundle: Option<ResolvedBundleReceipt>,
7051        context: Option<ResolvedContextPackReceipt>,
7052    ) -> Result<ManagedSessionRecord, NodeFailure> {
7053        let canonical_root = self.workspace_root(&address.workspace_id)?;
7054        let now = unix_time_ms();
7055        Ok(ManagedSessionRecord {
7056            record_id: self.allocate_record_id()?,
7057            display_name: self.default_record_name(&provider),
7058            provider,
7059            mode,
7060            state,
7061            workspace_id: address.workspace_id.clone(),
7062            canonical_root: opaque_windows_path(canonical_root),
7063            provider_session,
7064            active_session: Some(address.clone()),
7065            environment_profile,
7066            bundle,
7067            context_id: context.as_ref().map(|receipt| receipt.id.clone()),
7068            context,
7069            exported_context: None,
7070            task_binding: None,
7071            created_at_unix_ms: now,
7072            updated_at_unix_ms: now,
7073            last_error: None,
7074        })
7075    }
7076
7077    fn insert_record(&self, record: ManagedSessionRecord) -> Result<(), NodeFailure> {
7078        let mut records = self
7079            .session_records
7080            .lock()
7081            .unwrap_or_else(|poisoned| poisoned.into_inner());
7082        if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
7083            return Err(failure(
7084                NodeFailureCode::BackendBusy,
7085                "managed session record capacity is full; forget an inactive record first",
7086            ));
7087        }
7088        if let Some(identity) = record.provider_session.as_ref() {
7089            if records.records.values().any(|candidate| {
7090                candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
7091                    session_registry::same_provider_session(
7092                        &candidate.provider,
7093                        candidate_identity,
7094                        &record.provider,
7095                        identity,
7096                    )
7097                })
7098            }) {
7099                return Err(failure(
7100                    NodeFailureCode::SessionRecordConflict,
7101                    "provider session is already represented by another managed record",
7102                ));
7103            }
7104        }
7105        match records.records.entry(record.record_id.clone()) {
7106            std::collections::btree_map::Entry::Vacant(entry) => {
7107                entry.insert(record);
7108            }
7109            std::collections::btree_map::Entry::Occupied(_) => {
7110                return Err(failure(
7111                    NodeFailureCode::SessionRecordConflict,
7112                    "session record ID collision",
7113                ));
7114            }
7115        }
7116        Ok(())
7117    }
7118
7119    fn record(&self, record_id: &SessionRecordId) -> Result<ManagedSessionRecord, NodeFailure> {
7120        self.session_records
7121            .lock()
7122            .unwrap_or_else(|poisoned| poisoned.into_inner())
7123            .records
7124            .get(record_id)
7125            .cloned()
7126            .ok_or_else(|| {
7127                failure(
7128                    NodeFailureCode::UnknownSessionRecord,
7129                    "managed session record does not exist",
7130                )
7131            })
7132    }
7133
7134    fn revalidate_session_record_preview(
7135        &self,
7136        expected: &ManagedSessionRecord,
7137        identity: &ProviderSessionIdentity,
7138    ) -> Result<(), NodeFailure> {
7139        let current = self.record(&expected.record_id)?;
7140        let current_root = self.workspace_root(&current.workspace_id)?;
7141        if current.provider != expected.provider
7142            || current.workspace_id != expected.workspace_id
7143            || current.canonical_root != expected.canonical_root
7144            || current.provider_session.as_ref() != Some(identity)
7145            || !platform::roots_equal(
7146                &current_root,
7147                windows_path_text(&current.canonical_root),
7148            )
7149        {
7150            return Err(failure(
7151                NodeFailureCode::SessionRecordConflict,
7152                "managed session changed while its preview was loading",
7153            ));
7154        }
7155        Ok(())
7156    }
7157
7158    fn revalidate_session_record_context_export(
7159        &self,
7160        expected: &ManagedSessionRecord,
7161        identity: Option<&ProviderSessionIdentity>,
7162        session: &SessionAddress,
7163        allow_clean_detachment: bool,
7164    ) -> Result<(), NodeFailure> {
7165        let current = self.record(&expected.record_id)?;
7166        let current_root = self.workspace_root(&current.workspace_id)?;
7167        let snapshot = self.internal_session_snapshot(session)?;
7168        let binding = self
7169            .session_bindings
7170            .lock()
7171            .unwrap_or_else(|poisoned| poisoned.into_inner())
7172            .get(&session.session.instance_id)
7173            .cloned()
7174            .ok_or_else(|| {
7175                failure(
7176                    NodeFailureCode::SessionRecordConflict,
7177                    "managed session runtime binding disappeared during context export",
7178                )
7179            })?;
7180        if !session_record_context_export_binding_is_exact(
7181            expected,
7182            &current,
7183            identity,
7184            session,
7185            &binding,
7186            &snapshot.agent_id,
7187            &current_root,
7188            allow_clean_detachment,
7189        ) || !session_record_context_export_source_is_usable(
7190            &current,
7191            &snapshot.status,
7192            allow_clean_detachment,
7193        ) {
7194            return Err(failure(
7195                NodeFailureCode::SessionRecordConflict,
7196                "managed session binding changed during context export",
7197            ));
7198        }
7199        Ok(())
7200    }
7201
7202    fn revalidate_loaded_history(
7203        &self,
7204        session: &SessionAddress,
7205        candidate_id: &str,
7206        loaded: &HistorySessionRecord,
7207    ) -> Result<(), NodeFailure> {
7208        let snapshot = self.internal_session_snapshot(session)?;
7209        if snapshot.history.pending.is_some()
7210            || snapshot.history.loaded_candidate_id.as_deref() != Some(candidate_id)
7211            || snapshot.history.loaded.as_ref() != Some(loaded)
7212        {
7213            return Err(failure(
7214                NodeFailureCode::SessionRecordConflict,
7215                "managed session history changed during context export",
7216            ));
7217        }
7218        Ok(())
7219    }
7220
7221    fn remove_record_memory(&self, record_id: &SessionRecordId) -> Option<ManagedSessionRecord> {
7222        self.session_records
7223            .lock()
7224            .unwrap_or_else(|poisoned| poisoned.into_inner())
7225            .records
7226            .remove(record_id)
7227    }
7228
7229    fn discard_record(&self, record_id: &SessionRecordId) -> Result<(), NodeFailure> {
7230        if self.destroy_record_materialization(record_id)? {
7231            self.publish(NodeEvent::SessionRecordRemoved {
7232                record_id: record_id.clone(),
7233            });
7234            return Ok(());
7235        }
7236        let transaction = self
7237            .state_transaction
7238            .lock()
7239            .unwrap_or_else(|poisoned| poisoned.into_inner());
7240        let Some(removed) = self.remove_record_memory(record_id) else {
7241            return Ok(());
7242        };
7243        if let Err(error) = self.persist_state_locked() {
7244            let _ = self.insert_record(removed);
7245            return Err(persistence_failure(error));
7246        }
7247        drop(transaction);
7248        self.publish(NodeEvent::SessionRecordRemoved {
7249            record_id: record_id.clone(),
7250        });
7251        Ok(())
7252    }
7253
7254    fn publish_record(&self, record: ManagedSessionRecord) {
7255        self.publish(NodeEvent::SessionRecordUpserted { record });
7256    }
7257
7258    fn mark_record_error(
7259        &self,
7260        record_id: &SessionRecordId,
7261        message: &str,
7262    ) -> Result<(), NodeFailure> {
7263        let transaction = self
7264            .state_transaction
7265            .lock()
7266            .unwrap_or_else(|poisoned| poisoned.into_inner());
7267        let previous = {
7268            let mut records = self
7269                .session_records
7270                .lock()
7271                .unwrap_or_else(|poisoned| poisoned.into_inner());
7272            let Some(record) = records.records.get_mut(record_id) else {
7273                return Ok(());
7274            };
7275            let previous = record.clone();
7276            record.state = ManagedSessionState::Unavailable;
7277            record.last_error = Some(session_registry::sanitized_record_error_summary(message));
7278            record.updated_at_unix_ms = unix_time_ms();
7279            previous
7280        };
7281        if let Err(error) = self.persist_state_locked() {
7282            self.session_records
7283                .lock()
7284                .unwrap_or_else(|poisoned| poisoned.into_inner())
7285                .records
7286                .insert(record_id.clone(), previous);
7287            return Err(persistence_failure(error));
7288        }
7289        let record = self.record(record_id)?;
7290        drop(transaction);
7291        self.publish_record(record);
7292        Ok(())
7293    }
7294
7295    fn rename_session_record(
7296        &self,
7297        record_id: &SessionRecordId,
7298        display_name: String,
7299    ) -> Result<ManagedSessionRecord, NodeFailure> {
7300        validate_display_name(&display_name)
7301            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
7302        let _transaction = self
7303            .state_transaction
7304            .lock()
7305            .unwrap_or_else(|poisoned| poisoned.into_inner());
7306        let previous = {
7307            let mut records = self
7308                .session_records
7309                .lock()
7310                .unwrap_or_else(|poisoned| poisoned.into_inner());
7311            let record = records.records.get_mut(record_id).ok_or_else(|| {
7312                failure(
7313                    NodeFailureCode::UnknownSessionRecord,
7314                    "managed session record does not exist",
7315                )
7316            })?;
7317            let previous = record.clone();
7318            record.display_name = display_name;
7319            record.updated_at_unix_ms = unix_time_ms();
7320            previous
7321        };
7322        if let Err(error) = self.persist_state_locked() {
7323            self.session_records
7324                .lock()
7325                .unwrap_or_else(|poisoned| poisoned.into_inner())
7326                .records
7327                .insert(record_id.clone(), previous);
7328            return Err(persistence_failure(error));
7329        }
7330        let updated = self.record(record_id)?;
7331        self.publish_record(updated.clone());
7332        Ok(updated)
7333    }
7334
7335    fn set_session_task(
7336        &self,
7337        record_id: &SessionRecordId,
7338        expected_revision: u64,
7339        target: SessionTaskTargetV1,
7340    ) -> Result<ManagedSessionRecord, NodeFailure> {
7341        let transaction = self
7342            .state_transaction
7343            .lock()
7344            .unwrap_or_else(|poisoned| poisoned.into_inner());
7345        let current = self.record(record_id)?;
7346        let current_task_id = current
7347            .task_binding
7348            .as_ref()
7349            .and_then(|binding| binding.task_id.as_ref());
7350        let current_revision = current
7351            .task_binding
7352            .as_ref()
7353            .map_or(0, |binding| binding.revision);
7354        if expected_revision != current_revision {
7355            return Err(failure(
7356                NodeFailureCode::SessionRecordConflict,
7357                "managed session task binding revision changed",
7358            ));
7359        }
7360        let exact = match &target {
7361            SessionTaskTargetV1::Existing { task_id } => current_task_id == Some(task_id),
7362            SessionTaskTargetV1::Clear => current_task_id.is_none(),
7363            SessionTaskTargetV1::New => false,
7364        };
7365        if exact {
7366            return Ok(current);
7367        }
7368        let revision = current_revision.checked_add(1).ok_or_else(|| {
7369            failure(
7370                NodeFailureCode::SessionRecordConflict,
7371                "managed session task binding revision is exhausted",
7372            )
7373        })?;
7374        let task_id = match target {
7375            SessionTaskTargetV1::New => Some(self.allocate_task_id()?),
7376            SessionTaskTargetV1::Existing { task_id } => Some(task_id),
7377            SessionTaskTargetV1::Clear => None,
7378        };
7379        let changed_at_unix_ms = unix_time_ms()
7380            .max(current.updated_at_unix_ms.checked_add(1).ok_or_else(|| {
7381                failure(
7382                    NodeFailureCode::SessionRecordConflict,
7383                    "managed session record timestamp is exhausted",
7384                )
7385            })?);
7386        let updated = {
7387            let mut records = self
7388                .session_records
7389                .lock()
7390                .unwrap_or_else(|poisoned| poisoned.into_inner());
7391            let record = records.records.get_mut(record_id).ok_or_else(|| {
7392                failure(
7393                    NodeFailureCode::UnknownSessionRecord,
7394                    "managed session record does not exist",
7395                )
7396            })?;
7397            record.task_binding = Some(SessionTaskBindingV1 {
7398                revision,
7399                task_id,
7400                changed_at_unix_ms,
7401            });
7402            record.updated_at_unix_ms = changed_at_unix_ms;
7403            record.clone()
7404        };
7405        if let Err(error) = self.persist_state_locked() {
7406            self.session_records
7407                .lock()
7408                .unwrap_or_else(|poisoned| poisoned.into_inner())
7409                .records
7410                .insert(record_id.clone(), current);
7411            return Err(persistence_failure(error));
7412        }
7413        drop(transaction);
7414        self.publish_record(updated.clone());
7415        Ok(updated)
7416    }
7417
7418    fn index_provider_session(
7419        &self,
7420        workspace_id: WorkspaceId,
7421        provider: AgentId,
7422        identity: ProviderSessionIdentity,
7423        display_name: String,
7424    ) -> Result<ManagedSessionRecord, NodeFailure> {
7425        self.index_provider_session_with_policy(
7426            workspace_id,
7427            provider,
7428            identity,
7429            display_name,
7430            false,
7431        )
7432    }
7433
7434    fn index_provider_session_with_policy(
7435        &self,
7436        workspace_id: WorkspaceId,
7437        provider: AgentId,
7438        identity: ProviderSessionIdentity,
7439        display_name: String,
7440        allow_validated_transcript_path: bool,
7441    ) -> Result<ManagedSessionRecord, NodeFailure> {
7442        validate_display_name(&display_name)
7443            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
7444        identity
7445            .validate()
7446            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
7447        if identity.transcript_path.is_some() && !allow_validated_transcript_path {
7448            return Err(failure(
7449                NodeFailureCode::InvalidRequest,
7450                "provider session reference index v1 does not accept transcript paths",
7451            ));
7452        }
7453        let canonical_root = self.workspace_root(&workspace_id)?;
7454        if !self.enabled_providers.contains(&provider) {
7455            return Err(failure(
7456                NodeFailureCode::InvalidRequest,
7457                "provider is not enabled on this node",
7458            ));
7459        }
7460
7461        let transaction = self
7462            .state_transaction
7463            .lock()
7464            .unwrap_or_else(|poisoned| poisoned.into_inner());
7465        {
7466            let records = self
7467                .session_records
7468                .lock()
7469                .unwrap_or_else(|poisoned| poisoned.into_inner());
7470            if let Some(existing) = records.records.values().find(|candidate| {
7471                candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
7472                    candidate_identity.key == identity.key && candidate_identity.id == identity.id
7473                })
7474            }) {
7475                if existing.provider == provider && existing.workspace_id == workspace_id {
7476                    return Ok(existing.clone());
7477                }
7478                return Err(failure(
7479                    NodeFailureCode::SessionRecordConflict,
7480                    "provider session reference conflicts with another provider or workspace",
7481                ));
7482            }
7483            if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
7484                return Err(failure(
7485                    NodeFailureCode::BackendBusy,
7486                    "managed session record capacity is full; forget an inactive record first",
7487                ));
7488            }
7489        }
7490
7491        let now = unix_time_ms();
7492        let record = ManagedSessionRecord {
7493            record_id: self.allocate_record_id()?,
7494            display_name,
7495            provider,
7496            mode: SessionMode::Pty,
7497            state: ManagedSessionState::Dormant,
7498            workspace_id,
7499            canonical_root: opaque_windows_path(canonical_root),
7500            provider_session: Some(identity),
7501            active_session: None,
7502            environment_profile: None,
7503            bundle: None,
7504            context_id: None,
7505            context: None,
7506            exported_context: None,
7507            task_binding: None,
7508            created_at_unix_ms: now,
7509            updated_at_unix_ms: now,
7510            last_error: None,
7511        };
7512        self.insert_record(record.clone())?;
7513        if let Err(error) = self.persist_state_locked() {
7514            self.remove_record_memory(&record.record_id);
7515            return Err(persistence_failure(error));
7516        }
7517        drop(transaction);
7518        self.publish_record(record.clone());
7519        Ok(record)
7520    }
7521
7522    fn bound_address_for_record(&self, record_id: &SessionRecordId) -> Option<SessionAddress> {
7523        self.session_bindings
7524            .lock()
7525            .unwrap_or_else(|poisoned| poisoned.into_inner())
7526            .iter()
7527            .find_map(|(instance_id, binding)| {
7528                (binding.record_id.as_ref() == Some(record_id)).then(|| SessionAddress {
7529                    workspace_id: binding.workspace_id.clone(),
7530                    session: SessionKey {
7531                        instance_id: *instance_id,
7532                        generation: binding.generation,
7533                    },
7534                })
7535            })
7536    }
7537
7538    async fn forget_session_record(
7539        &self,
7540        record_id: &SessionRecordId,
7541    ) -> Result<(), NodeFailure> {
7542        let record = self.record(record_id)?;
7543        if record.active_session.is_some()
7544            || matches!(
7545                record.state,
7546                ManagedSessionState::Live | ManagedSessionState::IdentityPending
7547            )
7548        {
7549            return Err(failure(
7550                NodeFailureCode::SessionRecordBusy,
7551                "stop the managed session before forgetting it",
7552            ));
7553        }
7554        if let Some(address) = self.bound_address_for_record(record_id) {
7555            self.remove_session(&address).await?;
7556        }
7557        self.remove_managed_session_record(record_id, &record.workspace_id).await
7558    }
7559
7560    /// Removes one managed session record from durable state and notifies
7561    /// every listener -- the shared transaction body behind both
7562    /// `forget_session_record`'s operator-driven path (busy-checked above
7563    /// it, and clears any still-live session binding before calling in
7564    /// here) and the retention sweep (`retire_unavailable_session_records`,
7565    /// which only ever selects `ManagedSessionState::Unavailable` records
7566    /// that are already known never-busy and never bound, so it calls
7567    /// straight into this with neither check). Cleans a managed-worktree
7568    /// lease if the record held one, removes the record's environment
7569    /// materialization (or, absent one, removes it from memory directly),
7570    /// persists under `state_transaction` with rollback on persist failure,
7571    /// then publishes `NodeEvent::SessionRecordRemoved` so c2's own
7572    /// legacy-provider bookkeeping (`record_is_legacy`) stays in sync
7573    /// exactly as it does for an operator-driven forget.
7574    async fn remove_managed_session_record(
7575        &self,
7576        record_id: &SessionRecordId,
7577        workspace_id: &WorkspaceId,
7578    ) -> Result<(), NodeFailure> {
7579        let managed_lease_id = self.managed_worktrees.lock()
7580            .unwrap_or_else(|poisoned| poisoned.into_inner())
7581            .lease_for_workspace(workspace_id);
7582        if !self.destroy_record_materialization(record_id)? {
7583            let transaction = self
7584                .state_transaction
7585                .lock()
7586                .unwrap_or_else(|poisoned| poisoned.into_inner());
7587            let removed = self.remove_record_memory(record_id).ok_or_else(|| {
7588                failure(
7589                    NodeFailureCode::UnknownSessionRecord,
7590                    "managed session record does not exist",
7591                )
7592            })?;
7593            if let Err(error) = self.persist_state_locked() {
7594                let _ = self.insert_record(removed);
7595                return Err(persistence_failure(error));
7596            }
7597            drop(transaction);
7598        }
7599        self.publish(NodeEvent::SessionRecordRemoved {
7600            record_id: record_id.clone(),
7601        });
7602        if let Some(lease_id) = managed_lease_id {
7603            if let Some(snapshot) = self.managed_worktrees.lock()
7604                .unwrap_or_else(|poisoned| poisoned.into_inner())
7605                .get(&lease_id)
7606                .map(ManagedWorktreeLeaseRecord::snapshot)
7607            {
7608                self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
7609            }
7610            let _ = self.cleanup_managed_worktree(&lease_id, false).await;
7611        }
7612        Ok(())
7613    }
7614
7615    /// Retires `ManagedSessionState::Unavailable` records against the
7616    /// node's configured `session_record_retention` age/keep-N policy.
7617    /// Never touches `Live`, `IdentityPending`, or `Dormant` records --
7618    /// `Dormant` is resumable and folding it into this sweep would delete
7619    /// records a caller can still act on. Both policy fields default to
7620    /// `0` (disabled); when both are `0` this is a no-op. When both are
7621    /// set, a record is retired only if it fails BOTH tests (older than
7622    /// `age_ms` AND beyond the newest `keep_per_workspace` in its
7623    /// workspace); when only one is set, that one alone decides. Called
7624    /// once at startup (alongside `reconcile_managed_worktrees`) and then
7625    /// on a `SESSION_RECORD_RETENTION_SWEEP_PERIOD` cadence from the drive
7626    /// loop -- see both call sites.
7627    async fn retire_unavailable_session_records(&self) {
7628        let policy = self.session_record_retention;
7629        let age_enabled = policy.age_ms > 0;
7630        let keep_enabled = policy.keep_per_workspace > 0;
7631        if !age_enabled && !keep_enabled {
7632            return;
7633        }
7634        let now = unix_time_ms();
7635        let candidates: Vec<(SessionRecordId, WorkspaceId, AgentId, u64)> = {
7636            self.session_records
7637                .lock()
7638                .unwrap_or_else(|poisoned| poisoned.into_inner())
7639                .records
7640                .values()
7641                .filter(|record| record.state == ManagedSessionState::Unavailable)
7642                .map(|record| {
7643                    (
7644                        record.record_id.clone(),
7645                        record.workspace_id.clone(),
7646                        record.provider.clone(),
7647                        record.updated_at_unix_ms,
7648                    )
7649                })
7650                .collect()
7651        };
7652        if candidates.is_empty() {
7653            return;
7654        }
7655        let mut by_workspace: BTreeMap<WorkspaceId, Vec<(SessionRecordId, AgentId, u64)>> =
7656            BTreeMap::new();
7657        for (record_id, workspace_id, provider, updated_at_unix_ms) in candidates {
7658            by_workspace
7659                .entry(workspace_id)
7660                .or_default()
7661                .push((record_id, provider, updated_at_unix_ms));
7662        }
7663        let reason = if age_enabled && keep_enabled {
7664            "age+keep-n"
7665        } else if age_enabled {
7666            "age"
7667        } else {
7668            "keep-n"
7669        };
7670        let mut retired: u64 = 0;
7671        for (workspace_id, mut records) in by_workspace {
7672            // Newest `updated_at_unix_ms` first, so index `>=
7673            // keep_per_workspace` names exactly "beyond the newest N".
7674            records.sort_by(|left, right| right.2.cmp(&left.2));
7675            for (index, (record_id, provider, updated_at_unix_ms)) in
7676                records.into_iter().enumerate()
7677            {
7678                let record_age_ms = now.saturating_sub(updated_at_unix_ms);
7679                let age_hit = age_enabled && record_age_ms >= policy.age_ms;
7680                let keep_hit = keep_enabled && index >= policy.keep_per_workspace as usize;
7681                let should_retire = if age_enabled && keep_enabled {
7682                    age_hit && keep_hit
7683                } else {
7684                    age_hit || keep_hit
7685                };
7686                if !should_retire {
7687                    continue;
7688                }
7689                match self.remove_managed_session_record(&record_id, &workspace_id).await {
7690                    Ok(()) => {
7691                        self.retired_records_total.fetch_add(1, Ordering::Relaxed);
7692                        retired += 1;
7693                        tracing::info!(
7694                            node_id = %self.node_id,
7695                            record_id = %record_id,
7696                            workspace_id = %workspace_id,
7697                            provider = %provider,
7698                            age_ms = record_age_ms,
7699                            reason,
7700                            "managed session record retired"
7701                        );
7702                    }
7703                    Err(error) => tracing::warn!(
7704                        node_id = %self.node_id,
7705                        record_id = %record_id,
7706                        workspace_id = %workspace_id,
7707                        code = ?error.code,
7708                        cause = %error.message,
7709                        "session record retention sweep failed to retire a record"
7710                    ),
7711                }
7712            }
7713        }
7714        if retired > 0 {
7715            let remaining_unavailable = self
7716                .session_records
7717                .lock()
7718                .unwrap_or_else(|poisoned| poisoned.into_inner())
7719                .records
7720                .values()
7721                .filter(|record| record.state == ManagedSessionState::Unavailable)
7722                .count();
7723            tracing::info!(
7724                node_id = %self.node_id,
7725                retired,
7726                remaining_unavailable,
7727                "session record retention sweep completed"
7728            );
7729        }
7730    }
7731
7732    async fn resume_session_record(
7733        &self,
7734        record_id: &SessionRecordId,
7735        terminal_size: gate4agent_types::TerminalSize,
7736        initial_prompt: Option<String>,
7737    ) -> Result<(ManagedSessionRecord, SessionAddress), NodeFailure> {
7738        let record = self.record(record_id)?;
7739        if record.active_session.is_some()
7740            || matches!(
7741                record.state,
7742                ManagedSessionState::Live | ManagedSessionState::IdentityPending
7743            )
7744        {
7745            return Err(failure(
7746                NodeFailureCode::SessionRecordBusy,
7747                "managed session already has a live runtime binding",
7748            ));
7749        }
7750        let identity = record.provider_session.clone().ok_or_else(|| {
7751            failure(
7752                NodeFailureCode::SessionRecordNotResumable,
7753                "managed session has no verified provider session identity",
7754            )
7755        })?;
7756        let working_directory = self.workspace_root(&record.workspace_id)?;
7757        if !platform::roots_equal(
7758            &working_directory,
7759            windows_path_text(&record.canonical_root),
7760        ) {
7761            return Err(failure(
7762                NodeFailureCode::SessionRecordConflict,
7763                "managed session workspace root changed; refusing to resume in another directory",
7764            ));
7765        }
7766        {
7767            let records = self
7768                .session_records
7769                .lock()
7770                .unwrap_or_else(|poisoned| poisoned.into_inner());
7771            if records.records.values().any(|candidate| {
7772                candidate.record_id != *record_id
7773                    && candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
7774                        session_registry::same_provider_session(
7775                            &candidate.provider,
7776                            candidate_identity,
7777                            &record.provider,
7778                            &identity,
7779                        )
7780                    })
7781                    && (candidate.active_session.is_some()
7782                        || matches!(
7783                            candidate.state,
7784                            ManagedSessionState::Live | ManagedSessionState::IdentityPending
7785                        ))
7786            }) {
7787                return Err(failure(
7788                    NodeFailureCode::SessionRecordConflict,
7789                    "provider session is already active through another managed record",
7790                ));
7791            }
7792        }
7793        let runtime_requirement = if initial_prompt.is_some() {
7794            ProviderRuntimeRequirement::ResumeWithPrompt
7795        } else {
7796            ProviderRuntimeRequirement::Resume
7797        };
7798        let runtime_policy = self.admit_provider_runtime(
7799            &record.provider,
7800            runtime_requirement,
7801        )
7802        .await?;
7803        let request = ResumeLaunchRequest {
7804            working_directory,
7805            terminal_size,
7806            initial_prompt,
7807        };
7808        request
7809            .validate()
7810            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
7811        self.ensure_binding_capacity()?;
7812        let resolved_materialization = match self.resolve_record_materialization(
7813            record_id,
7814            &record.provider,
7815            record.mode,
7816            record.environment_profile.as_ref(),
7817            record.bundle.as_ref(),
7818            record.context.as_ref(),
7819        ) {
7820            Ok(materialization) => materialization,
7821            Err(error) => {
7822                let label = match error.code {
7823                    NodeFailureCode::UnknownBundle => "bundle-unavailable",
7824                    NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
7825                    NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
7826                    _ => "environment-profile-unavailable",
7827                };
7828                self.mark_record_error(record_id, label)?;
7829                return Err(error);
7830            }
7831        };
7832        let (materialization_id, environment_overlay) = match resolved_materialization {
7833            Some((id, overlay)) => (Some(id), overlay),
7834            None => (None, None),
7835        };
7836        let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
7837        let mut environment_selection = match self.select_environment_profile(
7838            instance_id,
7839            &record.provider,
7840            record.mode,
7841            record.environment_profile.as_ref(),
7842        ) {
7843            Ok(selection) => selection,
7844            Err(error) => {
7845                self.mark_record_error(record_id, "environment-profile-unavailable")?;
7846                return Err(error);
7847            }
7848        };
7849        let mut instance_overlay = environment_overlay
7850            .map(|overlay| self.install_prepared_launch_overlay(instance_id, overlay))
7851            .transpose()?
7852            .flatten();
7853        if let Some(stale_address) = self.bound_address_for_record(record_id) {
7854            self.remove_session(&stale_address).await?;
7855        }
7856        let address = SessionAddress {
7857            workspace_id: record.workspace_id.clone(),
7858            session: SessionKey {
7859                instance_id,
7860                generation: SessionGeneration(1),
7861            },
7862        };
7863        let transaction = self
7864            .state_transaction
7865            .lock()
7866            .unwrap_or_else(|poisoned| poisoned.into_inner());
7867        let current = self.record(record_id)?;
7868        if current.active_session.is_some()
7869            || matches!(
7870                current.state,
7871                ManagedSessionState::Live | ManagedSessionState::IdentityPending
7872            )
7873        {
7874            return Err(failure(
7875                NodeFailureCode::SessionRecordBusy,
7876                "managed session became active while resume was being prepared",
7877            ));
7878        }
7879        self.bind_managed_session_with_materialization(
7880            &address,
7881            record_id.clone(),
7882            runtime_policy,
7883            record.environment_profile.clone(),
7884            record.bundle.clone(),
7885            record.context.clone(),
7886            materialization_id,
7887        );
7888        if let Some(selection) = environment_selection.take() {
7889            selection.retain();
7890        }
7891        if let Some(overlay) = instance_overlay.take() {
7892            overlay.retain();
7893        }
7894        drop(transaction);
7895
7896        let transport = match record.mode {
7897            SessionMode::Pty => TransportKind::Pty,
7898            SessionMode::Inline => TransportKind::Pipe,
7899            SessionMode::Acp => TransportKind::Acp,
7900        };
7901        let agent_id = record.provider.clone();
7902        let dispatch_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
7903        if let Err(error) = self
7904            .dispatch_bounded(
7905                ControlCommand::Register {
7906                    instance_id,
7907                    agent_id,
7908                    transport,
7909                },
7910                dispatch_timeout,
7911            )
7912            .await
7913        {
7914            if let Some(binding) = self.remove_binding(&address) {
7915                self.cleanup_session_owned_materialization(&address, &binding)?;
7916            }
7917            return Err(error);
7918        }
7919        let command = self.prepare_command(ControlCommand::Resume {
7920            instance_id,
7921            target: ResumeTarget::ProviderSession { identity },
7922            runtime_policy,
7923            request,
7924        });
7925        let started_after = self.current_sequence();
7926        if let Err(error) = self.arm_resume(&address, command.id, runtime_policy) {
7927            self.rollback_spawn(&address, dispatch_timeout).await?;
7928            return Err(error);
7929        }
7930        if let Err(error) = self.dispatch_envelope(command) {
7931            self.clear_armed_resume(&address);
7932            self.rollback_spawn(&address, dispatch_timeout).await?;
7933            return Err(error);
7934        }
7935        match self
7936            .wait_for_record_resume(&address, started_after)
7937            .await
7938        {
7939            Ok(settled_address) => {
7940                let managed_lease_id = {
7941                    self.managed_worktrees.lock()
7942                        .unwrap_or_else(|poisoned| poisoned.into_inner())
7943                        .lease_for_workspace(&record.workspace_id)
7944                };
7945                if let Some(lease_id) = managed_lease_id {
7946                    {
7947                        let mut bindings = self.session_bindings.lock()
7948                            .unwrap_or_else(|poisoned| poisoned.into_inner());
7949                        let binding = bindings.get_mut(&settled_address.session.instance_id)
7950                            .ok_or_else(|| failure(
7951                                NodeFailureCode::ManagedWorktreeRecoveryRequired,
7952                                "resumed managed session lost its exact binding",
7953                            ))?;
7954                        if binding.generation != settled_address.session.generation {
7955                            return Err(failure(
7956                                NodeFailureCode::ManagedWorktreeRecoveryRequired,
7957                                "resumed managed session generation diverged",
7958                            ));
7959                        }
7960                        binding.managed_worktree_lease_id = Some(lease_id.clone());
7961                    }
7962                    let snapshot = self.managed_worktrees.lock()
7963                        .unwrap_or_else(|poisoned| poisoned.into_inner())
7964                        .bind_session(
7965                            &lease_id,
7966                            ManagedWorktreeSessionHolder {
7967                                incarnation_id: self.incarnation_id,
7968                                instance_id: settled_address.session.instance_id,
7969                                generation: settled_address.session.generation,
7970                            },
7971                            Some(record_id.clone()),
7972                            unix_time_ms(),
7973                        ).map_err(|message| failure(
7974                            NodeFailureCode::ManagedWorktreeRecoveryRequired,
7975                            &message,
7976                        ))?;
7977                    if let Err(error) = self.persist_state() {
7978                        let _ = self.remove_session(&settled_address).await;
7979                        return Err(persistence_failure(error));
7980                    }
7981                    self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
7982                }
7983                Ok((self.record(record_id)?, settled_address))
7984            }
7985            Err(error) => {
7986                if matches!(
7987                    error.code,
7988                    NodeFailureCode::SessionRecordNotResumable
7989                        | NodeFailureCode::BackendOperationFailed
7990                ) {
7991                    if let Some(bound) = self.bound_address_for_record(record_id) {
7992                        let _ = self.remove_session(&bound).await;
7993                    }
7994                }
7995                Err(error)
7996            }
7997        }
7998    }
7999
8000    async fn wait_for_record_resume(
8001        &self,
8002        initial_address: &SessionAddress,
8003        after_sequence: u64,
8004    ) -> Result<SessionAddress, NodeFailure> {
8005        let deadline = Instant::now()
8006            + Duration::from_millis(MANAGED_RESUME_SETTLE_TIMEOUT_MS);
8007        let mut scan_after = after_sequence;
8008        loop {
8009            let events = self
8010                .history
8011                .lock()
8012                .unwrap_or_else(|poisoned| poisoned.into_inner())
8013                .events
8014                .iter()
8015                .filter(|event| event.sequence > scan_after)
8016                .cloned()
8017                .collect::<Vec<_>>();
8018            for envelope in events {
8019                scan_after = scan_after.max(envelope.sequence);
8020                let NodeEvent::Control { address, event } = envelope.event else {
8021                    continue;
8022                };
8023                if address.session.instance_id != initial_address.session.instance_id {
8024                    continue;
8025                }
8026                match event.event {
8027                    ControlEventKind::Resumed { .. } => return Ok(address),
8028                    ControlEventKind::ResumeDenied { reason } => {
8029                        return Err(failure(
8030                            NodeFailureCode::SessionRecordNotResumable,
8031                            &reason,
8032                        ));
8033                    }
8034                    ControlEventKind::ResumeFailed { message }
8035                    | ControlEventKind::CommandRejected { message } => {
8036                        return Err(failure(
8037                            NodeFailureCode::BackendOperationFailed,
8038                            &message,
8039                        ));
8040                    }
8041                    _ => {}
8042                }
8043            }
8044            if Instant::now() >= deadline {
8045                return Err(failure(
8046                    NodeFailureCode::BackendBusy,
8047                    "managed resume did not settle before the bounded deadline",
8048                ));
8049            }
8050            sleep(Duration::from_millis(5)).await;
8051        }
8052    }
8053
8054    fn workspace_root(&self, workspace_id: &WorkspaceId) -> Result<String, NodeFailure> {
8055        self.workspaces
8056            .read()
8057            .unwrap_or_else(|poisoned| poisoned.into_inner())
8058            .get(workspace_id)
8059            .cloned()
8060            .ok_or_else(|| failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"))
8061    }
8062
8063    fn workspace_roots(&self) -> Vec<PathBuf> {
8064        self.workspaces
8065            .read()
8066            .unwrap_or_else(|poisoned| poisoned.into_inner())
8067            .values()
8068            .map(PathBuf::from)
8069            .collect()
8070    }
8071
8072    async fn inspect_workspace(
8073        &self,
8074        workspace_id: WorkspaceId,
8075    ) -> Result<WorkspaceInspection, NodeFailure> {
8076        let _permit = self
8077            .inspection_slots
8078            .clone()
8079            .try_acquire_owned()
8080            .map_err(|_| {
8081                tracing::warn!(
8082                    workspace_id = %workspace_id,
8083                    "workspace inspection rejected: inspection capacity is busy",
8084                );
8085                failure(
8086                    NodeFailureCode::BackendBusy,
8087                    "workspace inspection capacity is busy",
8088                )
8089            })?;
8090        let canonical_root = self.workspace_root(&workspace_id).map_err(|error| {
8091            tracing::warn!(
8092                workspace_id = %workspace_id,
8093                code = ?error.code,
8094                "workspace inspection rejected: workspace is not registered",
8095            );
8096            error
8097        })?;
8098        let tree_root = canonical_root.clone();
8099        let started_at = Instant::now();
8100        let time_budget_ms = workspace_inspection_time_budget_ms();
8101        let entry_cap = workspace_inspection_entry_cap();
8102        let deadline = started_at + Duration::from_millis(time_budget_ms);
8103        let (entries, tree_truncated, walk_budget) = tokio::task::spawn_blocking(move || {
8104            collect_workspace_entries(Path::new(&tree_root), deadline, entry_cap)
8105        })
8106        .await
8107        .map_err(|error| {
8108            tracing::warn!(
8109                workspace_id = %workspace_id,
8110                error = %error,
8111                "workspace inspection rejected: walk task failed",
8112            );
8113            failure(
8114                NodeFailureCode::BackendOperationFailed,
8115                &format!("workspace inspection task failed: {error}"),
8116            )
8117        })?;
8118        let (mut git, git_time_budget_exceeded) =
8119            inspect_git_workspace(&canonical_root, deadline).await;
8120        if !git.worktrees.is_empty() {
8121            let registered = self
8122                .workspaces
8123                .read()
8124                .unwrap_or_else(|poisoned| poisoned.into_inner())
8125                .iter()
8126                .map(|(workspace_id, root)| (workspace_id.clone(), root.clone()))
8127                .collect::<Vec<_>>();
8128            for worktree in &mut git.worktrees {
8129                worktree.workspace_id = registered
8130                    .iter()
8131                    .find(|(_, root)| {
8132                        worktree_paths_equal(root, windows_path_text(&worktree.path))
8133                    })
8134                    .map(|(workspace_id, _)| workspace_id.clone());
8135            }
8136        }
8137        if git.is_repository {
8138            git.managed_worktree = self
8139                .managed_worktrees
8140                .lock()
8141                .unwrap_or_else(|poisoned| poisoned.into_inner())
8142                .git_scope(&workspace_id, git.branch.as_deref());
8143        }
8144        let truncation = workspace_inspection_truncation(
8145            &walk_budget,
8146            git_time_budget_exceeded,
8147            started_at.elapsed(),
8148        );
8149        if let Some(truncation) = &truncation {
8150            tracing::info!(
8151                workspace_id = %workspace_id,
8152                walk_time_budget_exceeded = truncation.walk_time_budget_exceeded,
8153                walk_entry_cap_exceeded = truncation.walk_entry_cap_exceeded,
8154                git_time_budget_exceeded = truncation.git_time_budget_exceeded,
8155                entries_visited = truncation.entries_visited,
8156                elapsed_ms = truncation.elapsed_ms,
8157                "workspace inspection truncated by its inner budget",
8158            );
8159        }
8160        Ok(WorkspaceInspection {
8161            workspace_id,
8162            entries,
8163            tree_truncated,
8164            git,
8165            truncation,
8166        })
8167    }
8168
8169    async fn browse_host_directories(
8170        &self,
8171        directory: Option<OpaqueHostPath>,
8172        after: Option<OpaqueHostPath>,
8173    ) -> Result<HostDirectoryListing, NodeFailure> {
8174        let permit = self
8175            .inspection_slots
8176            .clone()
8177            .try_acquire_owned()
8178            .map_err(|_| {
8179                failure(
8180                    NodeFailureCode::BackendBusy,
8181                    "host directory browse capacity is busy",
8182                )
8183            })?;
8184        let task = tokio::task::spawn_blocking(move || {
8185            let _permit = permit;
8186            browse_host_directories(directory, after)
8187        });
8188        timeout(Duration::from_millis(HOST_DIRECTORY_BROWSE_TIMEOUT_MS), task)
8189            .await
8190            .map_err(|_| {
8191                failure(
8192                    NodeFailureCode::HostDirectoryReadTimedOut,
8193                    "host directory browse exceeded its bounded deadline",
8194                )
8195            })?
8196            .map_err(|_| {
8197                failure(
8198                    NodeFailureCode::HostDirectoryReadFailed,
8199                    "host directory browse task failed",
8200                )
8201            })?
8202            .map_err(host_directory_failure)
8203    }
8204
8205    async fn read_workspace_file(
8206        &self,
8207        workspace_id: WorkspaceId,
8208        path: RepositoryPath,
8209    ) -> Result<WorkspaceFileRead, NodeFailure> {
8210        let canonical_root = self.workspace_root(&workspace_id)?;
8211        let permit = self
8212            .inspection_slots
8213            .clone()
8214            .try_acquire_owned()
8215            .map_err(|_| {
8216                failure(
8217                    NodeFailureCode::BackendBusy,
8218                    "workspace file read capacity is busy",
8219                )
8220            })?;
8221        let path_to_read = path.clone();
8222        let task = tokio::task::spawn_blocking(move || {
8223            let _permit = permit;
8224            read_workspace_file_from_disk(Path::new(&canonical_root), &path_to_read)
8225        });
8226        let content = timeout(
8227            Duration::from_millis(WORKSPACE_FILE_READ_TIMEOUT_MS),
8228            task,
8229        )
8230        .await
8231        .map_err(|_| {
8232            failure(
8233                NodeFailureCode::RepositoryFileReadTimedOut,
8234                "repository file read exceeded its bounded deadline",
8235            )
8236        })?
8237        .map_err(|_| {
8238            failure(
8239                NodeFailureCode::RepositoryFileReadFailed,
8240                "repository file read task failed",
8241            )
8242        })?
8243        .map_err(workspace_file_failure)?;
8244        let content = match content {
8245            WorkspaceFileBytes::Utf8(text) => {
8246                let revision = Some(workspace_file_revision(text.as_bytes()));
8247                return Ok(WorkspaceFileRead {
8248                    workspace_id,
8249                    path,
8250                    content: WorkspaceFileContent::Utf8 {
8251                        byte_len: u32::try_from(text.len())
8252                            .expect("bounded workspace text length must fit u32"),
8253                        text,
8254                    },
8255                    revision,
8256                });
8257            }
8258            WorkspaceFileBytes::NonUtf8 { byte_length } => {
8259                WorkspaceFileContent::NonUtf8 {
8260                    byte_len: u32::try_from(byte_length)
8261                        .expect("bounded workspace file length must fit u32"),
8262                }
8263            }
8264            WorkspaceFileBytes::TooLarge => WorkspaceFileContent::TooLarge {
8265                limit_bytes: MAX_WORKSPACE_FILE_BYTES as u32,
8266            },
8267        };
8268        Ok(WorkspaceFileRead {
8269            workspace_id,
8270            path,
8271            content,
8272            revision: None,
8273        })
8274    }
8275
8276    async fn write_workspace_file(
8277        &self,
8278        workspace_id: WorkspaceId,
8279        path: RepositoryPath,
8280        expected_revision: WorkspaceFileRevision,
8281        text: String,
8282    ) -> Result<WorkspaceFileRead, NodeFailure> {
8283        let canonical_root = self.workspace_root(&workspace_id)?;
8284        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8285            failure(NodeFailureCode::BackendBusy, "workspace file write capacity is busy")
8286        })?;
8287        let path_to_write = path.clone();
8288        let expected = expected_revision.as_str().to_owned();
8289        let text_to_write = text.clone();
8290        let task = tokio::task::spawn_blocking(move || {
8291            let _permit = permit;
8292            write_workspace_file_to_disk(
8293                Path::new(&canonical_root),
8294                &path_to_write,
8295                &expected,
8296                &text_to_write,
8297            )
8298        });
8299        let revision = timeout(Duration::from_millis(WORKSPACE_FILE_WRITE_TIMEOUT_MS), task)
8300            .await
8301            .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteTimedOut, "repository file write exceeded its bounded deadline"))?
8302            .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteFailed, "repository file write task failed"))?
8303            .map_err(workspace_file_write_failure)?;
8304        Ok(WorkspaceFileRead {
8305            workspace_id,
8306            path,
8307            content: WorkspaceFileContent::Utf8 {
8308                byte_len: u32::try_from(text.len()).expect("bounded workspace text length must fit u32"),
8309                text,
8310            },
8311            revision: Some(WorkspaceFileRevision::new(revision)
8312                .expect("workspace writer returns a valid SHA-256 revision")),
8313        })
8314    }
8315
8316    async fn create_workspace_file(
8317        &self,
8318        workspace_id: WorkspaceId,
8319        path: RepositoryPath,
8320    ) -> Result<WorkspaceFileRead, NodeFailure> {
8321        let canonical_root = self.workspace_root(&workspace_id)?;
8322        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8323            failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
8324        })?;
8325        let path_to_create = path.clone();
8326        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
8327        let task_commit_state = Arc::clone(&commit_state);
8328        let task = tokio::task::spawn_blocking(move || {
8329            let _permit = permit;
8330            create_workspace_file_on_disk(
8331                Path::new(&canonical_root),
8332                &path_to_create,
8333                &task_commit_state,
8334            )
8335        });
8336        let revision = settle_workspace_entry_create(
8337            task,
8338            commit_state,
8339            Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
8340            "repository file create exceeded its bounded deadline",
8341            "repository file create task failed",
8342        )
8343        .await?;
8344        Ok(WorkspaceFileRead {
8345            workspace_id,
8346            path,
8347            content: WorkspaceFileContent::Utf8 {
8348                text: String::new(),
8349                byte_len: 0,
8350            },
8351            revision: Some(WorkspaceFileRevision::new(revision)
8352                .expect("workspace creator returns a valid SHA-256 revision")),
8353        })
8354    }
8355
8356    async fn create_workspace_directory(
8357        &self,
8358        workspace_id: WorkspaceId,
8359        path: RepositoryPath,
8360    ) -> Result<WorkspaceEntry, NodeFailure> {
8361        let canonical_root = self.workspace_root(&workspace_id)?;
8362        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8363            failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
8364        })?;
8365        let path_to_create = path.clone();
8366        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
8367        let task_commit_state = Arc::clone(&commit_state);
8368        let task = tokio::task::spawn_blocking(move || {
8369            let _permit = permit;
8370            create_workspace_directory_on_disk(
8371                Path::new(&canonical_root),
8372                &path_to_create,
8373                &task_commit_state,
8374            )
8375        });
8376        settle_workspace_entry_create(
8377            task,
8378            commit_state,
8379            Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
8380            "repository directory create exceeded its bounded deadline",
8381            "repository directory create task failed",
8382        )
8383        .await?;
8384        Ok(WorkspaceEntry {
8385            relative_path: path,
8386            kind: WorkspaceEntryKind::Directory,
8387        })
8388    }
8389
8390    async fn read_git_history(
8391        &self,
8392        workspace_id: WorkspaceId,
8393        path: Option<RepositoryPath>,
8394        before: Option<GitObjectId>,
8395        limit: u16,
8396    ) -> Result<GitHistoryPage, NodeFailure> {
8397        let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8398            failure(NodeFailureCode::BackendBusy, "git history capacity is busy")
8399        })?;
8400        let root = self.workspace_root(&workspace_id)?;
8401        read_git_history_bounded(&root, path.as_ref(), before.as_ref(), limit).await
8402    }
8403
8404    async fn read_git_diff(
8405        &self,
8406        workspace_id: WorkspaceId,
8407        request: GitDiffRequest,
8408    ) -> Result<GitDiff, NodeFailure> {
8409        let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8410            failure(NodeFailureCode::BackendBusy, "git diff capacity is busy")
8411        })?;
8412        let root = self.workspace_root(&workspace_id)?;
8413        read_git_diff_bounded(&root, request).await
8414    }
8415
8416    fn native_session_route_root(
8417        &self,
8418        route: &NativeSessionCatalogRoute,
8419    ) -> Result<Option<String>, NodeFailure> {
8420        route
8421            .validate()
8422            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
8423        match route.scope {
8424            gate4agent_types::NativeSessionCatalogScope::Workspace => route
8425                .workspace_id
8426                .as_ref()
8427                .map(|workspace_id| self.workspace_root(workspace_id))
8428                .transpose(),
8429            gate4agent_types::NativeSessionCatalogScope::Unregistered => Ok(None),
8430        }
8431    }
8432
8433    fn project_native_session_entries(
8434        &self,
8435        route: &NativeSessionCatalogRoute,
8436        entries: Vec<ScopedNativeSessionCatalogEntry>,
8437    ) -> Vec<crate::protocol::NativeSessionCatalogEntry> {
8438        let current_root = route
8439            .workspace_id
8440            .as_ref()
8441            .and_then(|workspace_id| self.workspace_root(workspace_id).ok());
8442        let records = self
8443            .session_records
8444            .lock()
8445            .unwrap_or_else(|poisoned| poisoned.into_inner());
8446        let mut seen_records = std::collections::HashSet::new();
8447        entries
8448            .into_iter()
8449            .map(|entry| {
8450                let record_id = route.workspace_id.as_ref().and_then(|workspace_id| {
8451                    let matches = records
8452                        .records
8453                        .values()
8454                        .filter(|record| {
8455                            record.provider == route.provider
8456                                && record.workspace_id == *workspace_id
8457                                && current_root.as_deref().is_some_and(|root| {
8458                                    platform::roots_equal(
8459                                        root,
8460                                        windows_path_text(&record.canonical_root),
8461                                    )
8462                                })
8463                                && record.provider_session.as_ref().is_some_and(|identity| {
8464                                    session_registry::same_provider_session(
8465                                        &record.provider,
8466                                        identity,
8467                                        &route.provider,
8468                                        &entry.provider_session,
8469                                    )
8470                                })
8471                        })
8472                        .map(|record| record.record_id.clone())
8473                        .collect::<Vec<_>>();
8474                    (matches.len() == 1
8475                        && seen_records.insert(matches[0].clone()))
8476                        .then(|| matches[0].clone())
8477                });
8478                let metadata = entry.metadata;
8479                crate::protocol::NativeSessionCatalogEntry {
8480                    selection_id: metadata.selection_id,
8481                    title: metadata.title,
8482                    modified_at_unix_ms: metadata.modified_at_unix_ms,
8483                    model: metadata.model,
8484                    message_count: metadata.message_count,
8485                    completed_turn_count: metadata.completed_turn_count,
8486                    external_group: entry.external_group,
8487                    record_id,
8488                }
8489            })
8490            .collect()
8491    }
8492
8493    async fn catalog_native_sessions(
8494        &self,
8495        route: NativeSessionCatalogRoute,
8496        limit: u16,
8497    ) -> Result<(
8498        Vec<crate::protocol::NativeSessionCatalogEntry>,
8499        crate::protocol::NativeSessionCatalogSummary,
8500    ), NodeFailure> {
8501        let canonical_root = self.native_session_route_root(&route)?;
8502        let registered_workspace_roots = self.workspace_roots();
8503        let provider = route.provider.clone();
8504        let scope = route.scope;
8505        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
8506            failure(
8507                NodeFailureCode::InvalidRequest,
8508                "native session catalog is not configured",
8509            )
8510        })?;
8511        let _permit = Arc::clone(&self.inspection_slots)
8512            .try_acquire_owned()
8513            .map_err(|_| {
8514                failure(
8515                    NodeFailureCode::BackendBusy,
8516                    "native session catalog capacity is busy",
8517                )
8518            })?;
8519        let page = run_native_session_catalog_operation(
8520            catalog,
8521            "native session catalog",
8522            move |catalog| {
8523                catalog.catalog_initial_for_scope(
8524                    &provider,
8525                    scope,
8526                    canonical_root.as_deref().map(Path::new),
8527                    &registered_workspace_roots,
8528                    limit,
8529                )
8530            },
8531        )
8532        .await?;
8533        let summary = crate::protocol::NativeSessionCatalogSummary {
8534            catalog_revision: page.revision,
8535            recent_cutoff_unix_ms: page.cutoff_unix_ms,
8536            recent_total_count: page.recent_total_count,
8537            older_total_count: page.older_total_count,
8538            recent_next_after_selection_id: page.next_after_selection_id,
8539            recent_has_more: page.remaining_count > 0,
8540        };
8541        Ok((self.project_native_session_entries(&route, page.entries), summary))
8542    }
8543
8544    async fn page_native_sessions(
8545        &self,
8546        route: NativeSessionCatalogRoute,
8547        window: crate::protocol::NativeSessionCatalogWindow,
8548        catalog_revision: u64,
8549        recent_cutoff_unix_ms: u64,
8550        after_selection_id: Option<String>,
8551        limit: u16,
8552    ) -> Result<crate::protocol::NativeSessionCatalogPage, NodeFailure> {
8553        let canonical_root = self.native_session_route_root(&route)?;
8554        let registered_workspace_roots = self.workspace_roots();
8555        let provider = route.provider.clone();
8556        let scope = route.scope;
8557        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
8558            failure(
8559                NodeFailureCode::InvalidRequest,
8560                "native session catalog is not configured",
8561            )
8562        })?;
8563        let _permit = Arc::clone(&self.inspection_slots)
8564            .try_acquire_owned()
8565            .map_err(|_| {
8566                failure(
8567                    NodeFailureCode::BackendBusy,
8568                    "native session catalog capacity is busy",
8569                )
8570            })?;
8571        let page = run_native_session_catalog_operation(
8572            catalog,
8573            "native session catalog page",
8574            move |catalog| {
8575                Ok::<_, std::convert::Infallible>(catalog.catalog_page_for_scope(
8576                    &provider,
8577                    scope,
8578                    canonical_root.as_deref().map(Path::new),
8579                    &registered_workspace_roots,
8580                    window,
8581                    catalog_revision,
8582                    recent_cutoff_unix_ms,
8583                    after_selection_id.as_deref(),
8584                    limit,
8585                ))
8586            },
8587        )
8588        .await?;
8589        let page = page.map_err(|error| match error {
8590            NativeSessionCatalogError::StaleCatalog => failure(
8591                NodeFailureCode::StaleNativeSessionCatalog,
8592                "native session catalog revision or cursor is stale",
8593            ),
8594            _ => failure(
8595                NodeFailureCode::BackendOperationFailed,
8596                "native session catalog page failed",
8597            ),
8598        })?;
8599        let entries = self.project_native_session_entries(&route, page.entries);
8600        Ok(crate::protocol::NativeSessionCatalogPage {
8601            window: page.window,
8602            revision: page.revision,
8603            entries,
8604            next_after_selection_id: page.next_after_selection_id,
8605            remaining_count: page.remaining_count,
8606            has_more: page.remaining_count > 0,
8607        })
8608    }
8609
8610    async fn preview_native_session(
8611        &self,
8612        selection: NativeSessionSelection,
8613        message_limit: u16,
8614    ) -> Result<crate::protocol::NativeSessionPreview, NodeFailure> {
8615        selection
8616            .validate()
8617            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
8618        let canonical_root = self.native_session_route_root(&selection.route)?;
8619        let registered_workspace_roots = self.workspace_roots();
8620        let provider = selection.route.provider.clone();
8621        let scope = selection.route.scope;
8622        let catalog_revision = selection.catalog_revision;
8623        let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
8624        let selection_id = selection.selection_id.clone();
8625        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
8626            failure(
8627                NodeFailureCode::InvalidRequest,
8628                "native session preview is not configured",
8629            )
8630        })?;
8631        let _permit = Arc::clone(&self.inspection_slots)
8632            .try_acquire_owned()
8633            .map_err(|_| {
8634                failure(
8635                    NodeFailureCode::BackendBusy,
8636                    "native session preview capacity is busy",
8637                )
8638            })?;
8639        let preview = run_native_session_catalog_operation(
8640            catalog,
8641            "native session preview",
8642            move |catalog| {
8643                Ok::<_, std::convert::Infallible>(catalog.preview_for_scope(
8644                    &provider,
8645                    scope,
8646                    canonical_root.as_deref().map(Path::new),
8647                    &registered_workspace_roots,
8648                    catalog_revision,
8649                    recent_cutoff_unix_ms,
8650                    &selection_id,
8651                    message_limit,
8652                ))
8653            },
8654        )
8655        .await?;
8656        preview
8657            .map(Into::into)
8658            .map_err(native_session_preview_failure)
8659    }
8660
8661    async fn index_native_session(
8662        &self,
8663        selection: NativeSessionSelection,
8664        display_name: String,
8665    ) -> Result<ManagedSessionRecord, NodeFailure> {
8666        selection
8667            .validate()
8668            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
8669        if selection.route.scope
8670            != gate4agent_types::NativeSessionCatalogScope::Workspace
8671        {
8672            return Err(failure(
8673                NodeFailureCode::WorkspaceRegistrationRequired,
8674                "register the external project as a workspace before indexing or resuming it",
8675            ));
8676        }
8677        let workspace_id = selection
8678            .route
8679            .workspace_id
8680            .clone()
8681            .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
8682        let canonical_root = self
8683            .native_session_route_root(&selection.route)?
8684            .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
8685        let registered_workspace_roots = self.workspace_roots();
8686        let provider = selection.route.provider.clone();
8687        let catalog_revision = selection.catalog_revision;
8688        let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
8689        let selection_id = selection.selection_id.clone();
8690        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
8691            failure(
8692                NodeFailureCode::InvalidRequest,
8693                "native session index is not configured",
8694            )
8695        })?;
8696        let _permit = Arc::clone(&self.inspection_slots)
8697            .try_acquire_owned()
8698            .map_err(|_| {
8699                failure(
8700                    NodeFailureCode::BackendBusy,
8701                    "native session index capacity is busy",
8702                )
8703            })?;
8704        let resolved = run_native_session_catalog_operation(
8705            catalog,
8706            "native session index resolution",
8707            move |catalog| {
8708                Ok::<_, std::convert::Infallible>(catalog.resolve_selection_for_scope(
8709                    &provider,
8710                    gate4agent_types::NativeSessionCatalogScope::Workspace,
8711                    Some(Path::new(&canonical_root)),
8712                    &registered_workspace_roots,
8713                    catalog_revision,
8714                    recent_cutoff_unix_ms,
8715                    &selection_id,
8716                ))
8717            },
8718        )
8719        .await?
8720        .map_err(native_session_preview_failure)?;
8721        self.index_provider_session_with_policy(
8722            workspace_id,
8723            selection.route.provider,
8724            resolved.identity,
8725            display_name,
8726            true,
8727        )
8728    }
8729
8730    async fn preview_native_session_by_session_id(
8731        &self,
8732        workspace_id: WorkspaceId,
8733        provider: AgentId,
8734        session_id: String,
8735        message_limit: u16,
8736    ) -> Result<gate4agent_types::NativeSessionPreview, NodeFailure> {
8737        let canonical_root = self.workspace_root(&workspace_id)?;
8738        let registered_workspace_roots = self.workspace_roots();
8739        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
8740            failure(
8741                NodeFailureCode::InvalidRequest,
8742                "native session preview is not configured",
8743            )
8744        })?;
8745        let _permit = Arc::clone(&self.inspection_slots)
8746            .try_acquire_owned()
8747            .map_err(|_| {
8748                failure(
8749                    NodeFailureCode::BackendBusy,
8750                    "native session preview capacity is busy",
8751                )
8752            })?;
8753        run_native_session_catalog_operation(
8754            catalog,
8755            "native session preview",
8756            move |catalog| {
8757                catalog.preview_session_id_for_workspace(
8758                    &provider,
8759                    Path::new(&canonical_root),
8760                    &registered_workspace_roots,
8761                    &session_id,
8762                    message_limit,
8763                )
8764            },
8765        )
8766        .await
8767    }
8768
8769    async fn register_workspace(
8770        &self,
8771        workspace_id: WorkspaceId,
8772        root: String,
8773    ) -> Result<WorkspaceSnapshot, NodeFailure> {
8774        validate_workspace_request_root(&root)?;
8775        let canonicalize_workspace_id = workspace_id.clone();
8776        let workspace = tokio::task::spawn_blocking(move || {
8777            WorkspaceConfig::new(canonicalize_workspace_id, root)
8778        })
8779        .await
8780        .map_err(|error| {
8781            failure(
8782                NodeFailureCode::BackendOperationFailed,
8783                &format!("workspace canonicalization task failed: {error}"),
8784            )
8785        })?
8786        .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
8787        let transaction = self
8788            .state_transaction
8789            .lock()
8790            .unwrap_or_else(|poisoned| poisoned.into_inner());
8791        let mut workspaces = self
8792            .workspaces
8793            .write()
8794            .unwrap_or_else(|poisoned| poisoned.into_inner());
8795        if workspaces.contains_key(&workspace_id) {
8796            return Err(failure(
8797                NodeFailureCode::DuplicateWorkspaceId,
8798                "workspace ID is already registered",
8799            ));
8800        }
8801        if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
8802            platform::roots_equal(existing_root, workspace.canonical_root())
8803        }) {
8804            return Err(failure(
8805                NodeFailureCode::DuplicateWorkspaceRoot,
8806                &format!("workspace root is already registered as '{existing_id}'"),
8807            ));
8808        }
8809        {
8810            let records = self
8811                .session_records
8812                .lock()
8813                .unwrap_or_else(|poisoned| poisoned.into_inner());
8814            if let Some(record) = records.records.values().find(|record| {
8815                record.workspace_id == workspace_id
8816                    && !record
8817                        .canonical_root
8818                        .as_utf8()
8819                        .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
8820            }) {
8821                return Err(failure(
8822                    NodeFailureCode::SessionRecordConflict,
8823                    &format!(
8824                        "workspace ID is retained by session record '{}' at another root",
8825                        record.record_id,
8826                    ),
8827                ));
8828            }
8829        }
8830        let snapshot = WorkspaceSnapshot {
8831            workspace_id: workspace_id.clone(),
8832            canonical_root: opaque_windows_path(workspace.canonical_root().to_owned()),
8833            sessions: Vec::new(),
8834            worktree_service_mode: Some(WorktreeServiceMode::Manual),
8835            managed_worktree_profiles: Some(WorktreeProfileInventory {
8836                profiles: Vec::new(),
8837            }),
8838        };
8839        workspaces.insert(workspace_id.clone(), workspace.canonical_root().to_owned());
8840        drop(workspaces);
8841        let previous_records = {
8842            let mut records = self
8843                .session_records
8844                .lock()
8845                .unwrap_or_else(|poisoned| poisoned.into_inner());
8846            let previous = records.records.clone();
8847            for record in records.records.values_mut().filter(|record| {
8848                record.workspace_id == workspace_id
8849                    && record
8850                        .canonical_root
8851                        .as_utf8()
8852                        .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
8853            }) {
8854                if record.active_session.is_none() {
8855                    record.state = detached_record_state(record);
8856                }
8857                if record.last_error.as_deref() == Some(WORKSPACE_UNAVAILABLE_ERROR) {
8858                    record.last_error = None;
8859                }
8860                record.updated_at_unix_ms = unix_time_ms();
8861            }
8862            previous
8863        };
8864        if let Err(error) = self.persist_state_locked() {
8865            self.workspaces
8866                .write()
8867                .unwrap_or_else(|poisoned| poisoned.into_inner())
8868                .remove(&workspace_id);
8869            self.session_records
8870                .lock()
8871                .unwrap_or_else(|poisoned| poisoned.into_inner())
8872                .records = previous_records;
8873            return Err(persistence_failure(error));
8874        }
8875        let restored = self
8876            .session_records
8877            .lock()
8878            .unwrap_or_else(|poisoned| poisoned.into_inner())
8879            .records
8880            .values()
8881            .filter(|record| record.workspace_id == workspace_id)
8882            .cloned()
8883            .collect::<Vec<_>>();
8884        drop(transaction);
8885        self.publish(NodeEvent::WorkspaceAdded {
8886            workspace: snapshot.clone(),
8887        });
8888        for record in restored {
8889            self.publish_record(record);
8890        }
8891        Ok(snapshot)
8892    }
8893
8894    async fn create_standalone_workspace(
8895        &self,
8896        workspace_id: WorkspaceId,
8897        root: String,
8898        initial_branch: Option<String>,
8899    ) -> Result<WorkspaceSnapshot, NodeFailure> {
8900        validate_workspace_request_root(&root)?;
8901        validate_git_revision("initial branch", initial_branch.as_deref())?;
8902        let preflight_workspace_id = workspace_id.clone();
8903        let preflight_root = root.clone();
8904        let canonical_candidate = tokio::task::spawn_blocking(move || {
8905            canonical_standalone_workspace_candidate(&preflight_workspace_id, &preflight_root)
8906        })
8907        .await
8908        .map_err(|_| failure(
8909            NodeFailureCode::BackendOperationFailed,
8910            "standalone workspace root preflight task failed",
8911        ))?
8912        .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
8913        {
8914            let workspaces = self
8915                .workspaces
8916                .read()
8917                .unwrap_or_else(|poisoned| poisoned.into_inner());
8918            if workspaces.contains_key(&workspace_id) {
8919                return Err(failure(
8920                    NodeFailureCode::DuplicateWorkspaceId,
8921                    "workspace ID is already registered",
8922                ));
8923            }
8924            if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
8925                platform::roots_equal(existing_root, &canonical_candidate)
8926            }) {
8927                return Err(failure(
8928                    NodeFailureCode::DuplicateWorkspaceRoot,
8929                    &format!("workspace root is already registered as '{existing_id}'"),
8930                ));
8931            }
8932        }
8933        let prepared = prepare_standalone_workspace(root, initial_branch.as_deref())
8934            .await
8935            .map_err(standalone_workspace_failure)?;
8936        prepared
8937            .verify_for_registration()
8938            .await
8939            .map_err(standalone_workspace_failure)?;
8940        match self
8941            .register_workspace(workspace_id, prepared.root().to_owned())
8942            .await
8943        {
8944            Ok(workspace) => Ok(workspace),
8945            Err(registration_error) => {
8946                if prepared.compensate_registration_failure().await {
8947                    Err(registration_error)
8948                } else {
8949                    Err(failure(
8950                        NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
8951                        "standalone workspace initialization succeeded but registration failed; recovery is required",
8952                    ))
8953                }
8954            }
8955        }
8956    }
8957
8958    fn unregister_workspace(&self, workspace_id: &WorkspaceId) -> Result<(), NodeFailure> {
8959        let bindings = self
8960            .session_bindings
8961            .lock()
8962            .unwrap_or_else(|poisoned| poisoned.into_inner());
8963        if bindings.values().any(|binding| &binding.workspace_id == workspace_id) {
8964            return Err(failure(
8965                NodeFailureCode::WorkspaceBusy,
8966                "workspace retains a bound session; remove every session before unregistering it",
8967            ));
8968        }
8969        drop(bindings);
8970        let transaction = self
8971            .state_transaction
8972            .lock()
8973            .unwrap_or_else(|poisoned| poisoned.into_inner());
8974        let mut workspaces = self
8975            .workspaces
8976            .write()
8977            .unwrap_or_else(|poisoned| poisoned.into_inner());
8978        if !workspaces.contains_key(workspace_id) {
8979            return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
8980        }
8981        if workspaces.len() == 1 {
8982            return Err(failure(
8983                NodeFailureCode::LastWorkspace,
8984                "node must retain at least one workspace",
8985            ));
8986        }
8987        let removed_root = workspaces
8988            .remove(workspace_id)
8989            .expect("workspace presence was validated");
8990        drop(workspaces);
8991        let previous_records = {
8992            let mut records = self
8993                .session_records
8994                .lock()
8995                .unwrap_or_else(|poisoned| poisoned.into_inner());
8996            let previous = records.records.clone();
8997            for record in records
8998                .records
8999                .values_mut()
9000                .filter(|record| &record.workspace_id == workspace_id)
9001            {
9002                record.active_session = None;
9003                record.state = ManagedSessionState::Unavailable;
9004                record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
9005                record.updated_at_unix_ms = unix_time_ms();
9006            }
9007            previous
9008        };
9009        if let Err(error) = self.persist_state_locked() {
9010            self.workspaces
9011                .write()
9012                .unwrap_or_else(|poisoned| poisoned.into_inner())
9013                .insert(workspace_id.clone(), removed_root);
9014            self.session_records
9015                .lock()
9016                .unwrap_or_else(|poisoned| poisoned.into_inner())
9017                .records = previous_records;
9018            return Err(persistence_failure(error));
9019        }
9020        let unavailable = self
9021            .session_records
9022            .lock()
9023            .unwrap_or_else(|poisoned| poisoned.into_inner())
9024            .records
9025            .values()
9026            .filter(|record| &record.workspace_id == workspace_id)
9027            .cloned()
9028            .collect::<Vec<_>>();
9029        drop(transaction);
9030        self.publish(NodeEvent::WorkspaceRemoved {
9031            workspace_id: workspace_id.clone(),
9032        });
9033        for record in unavailable {
9034            self.publish_record(record);
9035        }
9036        Ok(())
9037    }
9038
9039    async fn create_worktree(
9040        &self,
9041        source_workspace_id: WorkspaceId,
9042        workspace_id: WorkspaceId,
9043        target_root: String,
9044        branch: String,
9045        base: Option<String>,
9046    ) -> Result<(GitWorktreeSnapshot, WorkspaceSnapshot), NodeFailure> {
9047        self.require_worktree_service(&source_workspace_id)?;
9048        validate_workspace_request_root(&target_root)?;
9049        validate_git_revision("worktree base", base.as_deref())?;
9050        let source_root = self.workspace_root(&source_workspace_id)?;
9051        {
9052            let workspaces = self
9053                .workspaces
9054                .read()
9055                .unwrap_or_else(|poisoned| poisoned.into_inner());
9056            if workspaces.contains_key(&workspace_id) {
9057                return Err(failure(
9058                    NodeFailureCode::DuplicateWorkspaceId,
9059                    "workspace ID is already registered",
9060                ));
9061            }
9062            if let Some((existing_id, _)) = workspaces
9063                .iter()
9064                .find(|(_, root)| worktree_paths_equal(root, &target_root))
9065            {
9066                return Err(failure(
9067                    NodeFailureCode::DuplicateWorkspaceRoot,
9068                    &format!("workspace root is already registered as '{existing_id}'"),
9069                ));
9070            }
9071        }
9072        let mut worktree = create_git_worktree(
9073            &source_root,
9074            &target_root,
9075            &branch,
9076            base.as_deref(),
9077        )
9078        .await
9079        .map_err(git_worktree_failure)?;
9080        let workspace = match self
9081            .register_workspace(workspace_id, worktree.path.clone())
9082            .await
9083        {
9084            Ok(workspace) => workspace,
9085            Err(registration_error) => {
9086                if remove_git_worktree(&source_root, &worktree.path).await.is_err() {
9087                    return Err(failure(
9088                        NodeFailureCode::ManagedWorktreeRecoveryRequired,
9089                        "worktree registration failed and compensation requires recovery",
9090                    ));
9091                }
9092                return Err(failure(
9093                    registration_error.code,
9094                    &registration_error.message,
9095                ));
9096            }
9097        };
9098        worktree.workspace_id = Some(workspace.workspace_id.clone());
9099        Ok((protocol_worktree(worktree), workspace))
9100    }
9101
9102    async fn remove_worktree(
9103        &self,
9104        source_workspace_id: WorkspaceId,
9105        target_root: String,
9106    ) -> Result<Option<WorkspaceId>, NodeFailure> {
9107        self.require_worktree_service(&source_workspace_id)?;
9108        validate_workspace_request_root(&target_root)?;
9109        let source_root = self.workspace_root(&source_workspace_id)?;
9110        let target_root = normalize_worktree_removal_target(&target_root)
9111            .map_err(git_worktree_failure)?;
9112        let target = list_git_worktrees(&source_root)
9113            .await
9114            .map_err(git_worktree_failure)?
9115            .into_iter()
9116            .find(|worktree| worktree_paths_equal(&worktree.path, &target_root))
9117            .ok_or_else(|| {
9118                failure(
9119                    NodeFailureCode::WorktreeProtected,
9120                    "refusing to remove a path that is not in Git's worktree listing",
9121                )
9122            })?;
9123        let registered_workspace_id = {
9124            let workspaces = self
9125                .workspaces
9126                .read()
9127                .unwrap_or_else(|poisoned| poisoned.into_inner());
9128            let matched = workspaces
9129                .iter()
9130                .find(|(_, root)| worktree_paths_equal(root, &target.path))
9131                .map(|(workspace_id, _)| workspace_id.clone());
9132            if matched.is_some() && workspaces.len() == 1 {
9133                return Err(failure(
9134                    NodeFailureCode::LastWorkspace,
9135                    "node must retain at least one workspace",
9136                ));
9137            }
9138            matched
9139        };
9140        if let Some(workspace_id) = registered_workspace_id.as_ref() {
9141            let bindings = self
9142                .session_bindings
9143                .lock()
9144                .unwrap_or_else(|poisoned| poisoned.into_inner());
9145            if bindings
9146                .values()
9147                .any(|binding| &binding.workspace_id == workspace_id)
9148            {
9149                return Err(failure(
9150                    NodeFailureCode::WorkspaceBusy,
9151                    "worktree workspace retains a bound session; remove every session first",
9152                ));
9153            }
9154        }
9155        remove_git_worktree(&source_root, &target.path)
9156            .await
9157            .map_err(git_worktree_failure)?;
9158        if let Some(workspace_id) = registered_workspace_id.as_ref() {
9159            self.unregister_workspace(workspace_id)
9160                .map_err(|error| failure(error.code, &error.message))?;
9161        }
9162        Ok(registered_workspace_id)
9163    }
9164
9165    async fn cleanup_managed_worktree(
9166        &self,
9167        lease_id: &ManagedWorktreeLeaseId,
9168        explicit: bool,
9169    ) -> Result<ManagedWorktreeLeaseSnapshot, NodeFailure> {
9170        let lease = self.managed_worktrees.lock()
9171            .unwrap_or_else(|poisoned| poisoned.into_inner())
9172            .get(lease_id)
9173            .cloned()
9174            .ok_or_else(|| failure(
9175                NodeFailureCode::UnknownManagedWorktreeLease,
9176                "managed worktree lease does not exist",
9177            ))?;
9178        if lease.state == ManagedWorktreeLeaseState::Removed {
9179            return Ok(lease.snapshot());
9180        }
9181        if lease.has_holders() {
9182            return Err(failure(
9183                NodeFailureCode::ManagedWorktreeBusy,
9184                "managed worktree lease still has a session or durable-record holder",
9185            ));
9186        }
9187        if self
9188            .materializations
9189            .lock()
9190            .unwrap_or_else(|poisoned| poisoned.into_inner())
9191            .values()
9192            .any(|record| record.managed_lease_id() == Some(lease_id))
9193        {
9194            return Err(failure(
9195                NodeFailureCode::ManagedWorktreeRecoveryRequired,
9196                "managed worktree cleanup is blocked by session environment ownership",
9197            ));
9198        }
9199        if !explicit && lease.retention == ManagedWorktreeRetention::Retain {
9200            let snapshot = {
9201                let mut registry = self.managed_worktrees.lock()
9202                    .unwrap_or_else(|poisoned| poisoned.into_inner());
9203                let current = registry.get_mut(lease_id)
9204                    .expect("managed worktree lease remains present");
9205                current.state = ManagedWorktreeLeaseState::Retained;
9206                current.cleanup_failure = None;
9207                current.updated_at_unix_ms = unix_time_ms();
9208                current.snapshot()
9209            };
9210            if self.persist_state().is_err() {
9211                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
9212            }
9213            self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
9214            return Ok(snapshot);
9215        }
9216        let profile = self.managed_profile(&lease.source_workspace_id, &lease.profile_id)?;
9217        if profile.revision() != &lease.profile_revision {
9218            self.mark_managed_recovery_required(
9219                lease_id,
9220                ManagedWorktreeCleanupFailure::OwnershipConflict,
9221            );
9222            return Err(failure(
9223                NodeFailureCode::ManagedWorktreeRecoveryRequired,
9224                "managed worktree profile revision changed",
9225            ));
9226        }
9227        profile.validate_target_authority(&lease.lease_id, &lease.target_root).map_err(|message| {
9228            self.mark_managed_recovery_required(
9229                lease_id,
9230                ManagedWorktreeCleanupFailure::OwnershipConflict,
9231            );
9232            failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message)
9233        })?;
9234        let source_root = self.workspace_root(&lease.source_workspace_id)?;
9235        let listed = list_git_worktrees(&source_root).await.map_err(|error| {
9236            self.mark_managed_cleanup_blocked(lease_id, ManagedWorktreeCleanupFailure::Backend);
9237            managed_git_worktree_failure(error)
9238        })?;
9239        let target = listed.iter()
9240            .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
9241        if let Some(target) = target {
9242            if target.is_main || target.is_bare || !exact_owned_worktree(
9243                &lease,
9244                &target.path,
9245                target.branch.as_deref(),
9246                &target.head,
9247            ) {
9248                self.mark_managed_recovery_required(
9249                    lease_id,
9250                    ManagedWorktreeCleanupFailure::OwnershipConflict,
9251                );
9252                return Err(failure(
9253                    NodeFailureCode::ManagedWorktreeOwnershipConflict,
9254                    "Git worktree identity no longer matches the managed lease",
9255                ));
9256            }
9257            if target.prunable {
9258                self.mark_managed_cleanup_blocked(
9259                    lease_id,
9260                    ManagedWorktreeCleanupFailure::Prunable,
9261                );
9262                return Err(failure(
9263                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
9264                    "managed worktree is prunable and requires recovery",
9265                ));
9266            }
9267            if let Err(error) = remove_git_worktree(&source_root, &target.path).await {
9268                let failure_kind = match error.kind {
9269                    GitWorktreeErrorKind::Dirty => ManagedWorktreeCleanupFailure::Dirty,
9270                    GitWorktreeErrorKind::Locked => ManagedWorktreeCleanupFailure::Locked,
9271                    GitWorktreeErrorKind::Protected => ManagedWorktreeCleanupFailure::OwnershipConflict,
9272                    GitWorktreeErrorKind::Conflict => ManagedWorktreeCleanupFailure::Busy,
9273                    GitWorktreeErrorKind::Invalid | GitWorktreeErrorKind::NotRepository
9274                    | GitWorktreeErrorKind::Failed => ManagedWorktreeCleanupFailure::Backend,
9275                };
9276                if failure_kind == ManagedWorktreeCleanupFailure::OwnershipConflict {
9277                    self.mark_managed_recovery_required(lease_id, failure_kind);
9278                } else {
9279                    self.mark_managed_cleanup_blocked(lease_id, failure_kind);
9280                }
9281                return Err(managed_git_worktree_failure(error));
9282            }
9283        } else {
9284            match std::fs::symlink_metadata(&lease.target_root) {
9285                Err(error) if error.kind() == io::ErrorKind::NotFound => {}
9286                Ok(_) => {
9287                    self.mark_managed_recovery_required(
9288                        lease_id,
9289                        ManagedWorktreeCleanupFailure::OwnershipConflict,
9290                    );
9291                    return Err(failure(
9292                        NodeFailureCode::ManagedWorktreeOwnershipConflict,
9293                        "managed target exists but is not owned by Git",
9294                    ));
9295                }
9296                Err(_) => {
9297                    self.mark_managed_cleanup_blocked(
9298                        lease_id,
9299                        ManagedWorktreeCleanupFailure::Backend,
9300                    );
9301                    return Err(failure(
9302                        NodeFailureCode::BackendOperationFailed,
9303                        "managed target identity could not be inspected",
9304                    ));
9305                }
9306            }
9307        }
9308        let registered = self.workspaces.read()
9309            .unwrap_or_else(|poisoned| poisoned.into_inner())
9310            .contains_key(&lease.workspace_id);
9311        if registered {
9312            self.unregister_workspace(&lease.workspace_id)?;
9313        }
9314        let removed = {
9315            self.managed_worktrees.lock()
9316                .unwrap_or_else(|poisoned| poisoned.into_inner())
9317                .tombstone(lease_id, unix_time_ms())
9318                .expect("managed worktree lease remains present during cleanup")
9319        };
9320        self.persist_state().map_err(persistence_failure)?;
9321        self.publish(NodeEvent::ManagedWorktreeRemoved { lease_id: lease_id.clone() });
9322        Ok(removed.snapshot())
9323    }
9324
9325    fn mark_managed_cleanup_blocked(
9326        &self,
9327        lease_id: &ManagedWorktreeLeaseId,
9328        failure_kind: ManagedWorktreeCleanupFailure,
9329    ) {
9330        let snapshot = {
9331            let mut registry = self.managed_worktrees.lock()
9332                .unwrap_or_else(|poisoned| poisoned.into_inner());
9333            let Some(lease) = registry.get_mut(lease_id) else { return };
9334            lease.state = ManagedWorktreeLeaseState::CleanupBlocked;
9335            lease.cleanup_failure = Some(failure_kind);
9336            lease.updated_at_unix_ms = unix_time_ms();
9337            lease.snapshot()
9338        };
9339        let _ = self.persist_state();
9340        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
9341    }
9342
9343    fn require_worktree_service(
9344        &self,
9345        source_workspace_id: &WorkspaceId,
9346    ) -> Result<(), NodeFailure> {
9347        if self.managed_worktrees.lock()
9348            .unwrap_or_else(|poisoned| poisoned.into_inner())
9349            .lease_for_workspace(source_workspace_id)
9350            .is_some()
9351        {
9352            return Err(failure(
9353                NodeFailureCode::ManagedWorktreeOwnershipConflict,
9354                "managed worktree targets are internal-only workspaces",
9355            ));
9356        }
9357        match self
9358            .worktree_service_modes
9359            .get(source_workspace_id)
9360            .copied()
9361            .unwrap_or(WorktreeServiceMode::Manual)
9362        {
9363            WorktreeServiceMode::Manual => Ok(()),
9364            WorktreeServiceMode::Managed | WorktreeServiceMode::Off => Err(failure(
9365                NodeFailureCode::UnsupportedSpawnCapability,
9366                "Git worktree service mode is unavailable in this node build",
9367            )),
9368        }
9369    }
9370
9371    fn managed_reservation_conflict(
9372        &self,
9373        workspace_id: Option<&WorkspaceId>,
9374        root: Option<&str>,
9375    ) -> bool {
9376        self.managed_worktrees.lock()
9377            .unwrap_or_else(|poisoned| poisoned.into_inner())
9378            .active_records()
9379            .any(|lease| {
9380                workspace_id.is_some_and(|workspace_id| {
9381                    workspace_id == &lease.source_workspace_id
9382                        || workspace_id == &lease.workspace_id
9383                }) || root.is_some_and(|root| worktree_paths_equal(root, &lease.target_root))
9384            })
9385    }
9386
9387    fn reject_managed_reservation(
9388        &self,
9389        workspace_id: Option<&WorkspaceId>,
9390        root: Option<&str>,
9391    ) -> Result<(), NodeFailure> {
9392        if self.managed_reservation_conflict(workspace_id, root) {
9393            Err(failure(
9394                NodeFailureCode::ManagedWorktreeOwnershipConflict,
9395                "managed worktree ownership may only be mutated through its lease cleanup",
9396            ))
9397        } else {
9398            Ok(())
9399        }
9400    }
9401
9402    fn bound_workspace_root(&self, address: &SessionAddress) -> Result<String, NodeFailure> {
9403        self.validate_address(address)?;
9404        self.workspace_root(&address.workspace_id)
9405    }
9406
9407    /// `Resume`/`ResumeWithPrompt` are always a raw-PTY relaunch (see
9408    /// `require_policy`'s own doc on those two requirements), so they keep
9409    /// gating on `binding.runtime_policy`'s PTY-terminal-text-inference
9410    /// flags exactly as before; `pipe_transport`/`acp_transport` stay
9411    /// `false` on the `require_policy` call below for them because a PTY
9412    /// resume never needs those facts, not because this method cannot look
9413    /// them up.
9414    ///
9415    /// `SemanticPrompt` -- the requirement `Prompt`/`Paste` share -- reads
9416    /// the SAME `binding.runtime_policy` every other requirement reads here
9417    /// (one computed policy, read by every gate). That policy is no longer
9418    /// PTY-terminal-verification-shaped for an ACP session --
9419    /// `spawn_session_with_deadline` computes it BY TRANSPORT now, through
9420    /// `provider_runtime::policy_for_transport` -- so consulting it for
9421    /// `Prompt`/`Paste` on ACP is sound where it used to gate the wrong fact
9422    /// (this is what made an ACP `Prompt` come back `UnsupportedCapability`
9423    /// even though a prompt is an ACP session's only interface). Pipe keeps
9424    /// reading the same PTY-probe-derived value it always has --
9425    /// `policy_for_transport` passes Pipe through unchanged this pass (see
9426    /// its own doc for why).
9427    ///
9428    /// What this method still adds on top, for ACP/Pipe only, is a
9429    /// turn-in-flight PRECONDITION ahead of that policy check: a session
9430    /// takes one turn at a time, so a `Prompt`/`Paste` is refused by name
9431    /// (`NodeFailureCode::TurnInFlight`) while a turn is already running,
9432    /// read off the same live `SessionSnapshot` state `require_acp_session`
9433    /// already reads for the ACP control verbs -- `TurnStarted` observed
9434    /// with no matching `TurnCompleted` yet is exactly `ProviderSnapshot::
9435    /// lead_activity != Idle`. Sending a second prompt mid-turn would put
9436    /// its fate in vendor-specific behaviour this node cannot observe (one
9437    /// agent may queue it, another drop it, another interleave it), so this
9438    /// refuses it outright rather than gambling on that -- a rule with no
9439    /// PTY analogue, which is why PTY does not take this extra precondition.
9440    fn require_session_runtime_policy(
9441        &self,
9442        address: &SessionAddress,
9443        requirement: ProviderRuntimeRequirement,
9444    ) -> Result<ProviderRuntimePolicy, NodeFailure> {
9445        let runtime_policy = {
9446            let bindings = self
9447                .session_bindings
9448                .lock()
9449                .unwrap_or_else(|poisoned| poisoned.into_inner());
9450            let binding = bindings
9451                .get(&address.session.instance_id)
9452                .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
9453            if binding.workspace_id != address.workspace_id {
9454                return Err(failure(
9455                    NodeFailureCode::SessionWorkspaceMismatch,
9456                    "session belongs to another workspace",
9457                ));
9458            }
9459            if binding.generation != address.session.generation {
9460                return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
9461            }
9462            binding.runtime_policy
9463        };
9464        if requirement == ProviderRuntimeRequirement::SemanticPrompt {
9465            let snapshot = self.internal_session_snapshot(address)?;
9466            if matches!(snapshot.transport, TransportKind::Acp | TransportKind::Pipe)
9467                && snapshot.provider.lead_activity != ProviderActivity::Idle
9468            {
9469                return Err(failure(
9470                    NodeFailureCode::TurnInFlight,
9471                    "session already has a turn in flight; wait for it to complete before sending another prompt",
9472                ));
9473            }
9474        }
9475        require_policy(runtime_policy, false, false, requirement)
9476            .map(|()| runtime_policy)
9477            .map_err(|_| failure(
9478                NodeFailureCode::UnsupportedCapability,
9479                "session runtime policy does not admit this semantic operation",
9480            ))
9481    }
9482
9483    fn remove_binding(&self, address: &SessionAddress) -> Option<SessionBinding> {
9484        let mut bindings = self
9485            .session_bindings
9486            .lock()
9487            .unwrap_or_else(|poisoned| poisoned.into_inner());
9488        if bindings.get(&address.session.instance_id).is_some_and(|binding| {
9489            binding.workspace_id == address.workspace_id
9490                && binding.generation == address.session.generation
9491        }) {
9492            let removed = bindings.remove(&address.session.instance_id);
9493            // Exclusive dig2browser station profile lease is node-local; clear
9494            // on every session unbind / rollback. Never ImportSession/cookies.
9495            #[cfg(feature = "dig2-station-probe")]
9496            {
9497                crate::dig2_station_lease::release_lease_for_holder(
9498                    &self.browser_station_leases,
9499                    address.session.instance_id,
9500                );
9501            }
9502            self.clear_terminal_frame_watermark(address);
9503            if let Some(control) = self.native_launch_profile_control.as_ref() {
9504                control.clear_native_mcp_server_launch_overlay(
9505                    address.session.instance_id,
9506                );
9507            }
9508            if let Some(registry) = self.harness_mcp_registry.as_ref() {
9509                registry.revoke_session(address);
9510            }
9511            if removed
9512                .as_ref()
9513                .is_some_and(|binding| {
9514                    binding.environment_profile.is_some()
9515                        || binding.bundle.is_some()
9516                        || binding.context.is_some()
9517                })
9518            {
9519                if let Some(control) = self.native_launch_profile_control.as_ref() {
9520                    control.clear_native_launch_profile_selection(
9521                        address.session.instance_id,
9522                    );
9523                    control.clear_native_instance_launch_overlay(
9524                        address.session.instance_id,
9525                    );
9526                }
9527            }
9528            return removed;
9529        }
9530        None
9531    }
9532
9533    fn cleanup_session_owned_materialization(
9534        &self,
9535        address: &SessionAddress,
9536        binding: &SessionBinding,
9537    ) -> Result<(), NodeFailure> {
9538        let Some(id) = binding.materialization_id.as_ref() else {
9539            return Ok(());
9540        };
9541        let owner = self
9542            .materializations
9543            .lock()
9544            .unwrap_or_else(|poisoned| poisoned.into_inner())
9545            .get(id)
9546            .map(|record| record.owner().clone());
9547        match owner {
9548            None | Some(MaterializationOwner::Record { .. }) => Ok(()),
9549            Some(MaterializationOwner::Session {
9550                incarnation_id,
9551                instance_id,
9552                generation,
9553            }) if incarnation_id == self.incarnation_id
9554                && instance_id == address.session.instance_id
9555                && generation == address.session.generation =>
9556            {
9557                self.cleanup_materialization(id)
9558            }
9559            Some(MaterializationOwner::Session { .. }) => Err(failure(
9560                NodeFailureCode::BackendOperationFailed,
9561                "session environment ownership requires recovery",
9562            )),
9563        }
9564    }
9565
9566    fn clear_terminal_frame_watermark(&self, address: &SessionAddress) {
9567        let mut watermarks = self
9568            .terminal_frame_watermarks
9569            .lock()
9570            .unwrap_or_else(|poisoned| poisoned.into_inner());
9571        if watermarks
9572            .get(&address.session.instance_id)
9573            .is_some_and(|(current, _)| current == address)
9574        {
9575            watermarks.remove(&address.session.instance_id);
9576        }
9577    }
9578
9579    #[cfg(test)]
9580    fn address_for(
9581        &self,
9582        instance_id: AgentInstanceId,
9583        generation: SessionGeneration,
9584    ) -> Option<SessionAddress> {
9585        let binding = self.session_bindings
9586            .lock()
9587            .unwrap_or_else(|poisoned| poisoned.into_inner())
9588            .get(&instance_id)
9589            .cloned()?;
9590        (binding.generation == generation).then_some(SessionAddress {
9591            workspace_id: binding.workspace_id,
9592            session: SessionKey { instance_id, generation },
9593        })
9594    }
9595
9596    fn arm_resume(
9597        &self,
9598        address: &SessionAddress,
9599        command_id: CommandId,
9600        runtime_policy: ProviderRuntimePolicy,
9601    ) -> Result<(), NodeFailure> {
9602        let mut bindings = self
9603            .session_bindings
9604            .lock()
9605            .unwrap_or_else(|poisoned| poisoned.into_inner());
9606        let binding = bindings
9607            .get_mut(&address.session.instance_id)
9608            .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
9609        if binding.workspace_id != address.workspace_id {
9610            return Err(failure(
9611                NodeFailureCode::SessionWorkspaceMismatch,
9612                "session belongs to another workspace",
9613            ));
9614        }
9615        if binding.generation != address.session.generation {
9616            return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
9617        }
9618        binding.pending_resume = Some((binding.generation, command_id, runtime_policy));
9619        Ok(())
9620    }
9621
9622    fn clear_armed_resume(&self, address: &SessionAddress) {
9623        let mut bindings = self
9624            .session_bindings
9625            .lock()
9626            .unwrap_or_else(|poisoned| poisoned.into_inner());
9627        if let Some(binding) = bindings.get_mut(&address.session.instance_id) {
9628            if binding.workspace_id == address.workspace_id
9629                && binding.generation == address.session.generation
9630            {
9631                binding.pending_resume = None;
9632            }
9633        }
9634    }
9635
9636    fn publish_control(&self, event: ControlEvent) {
9637        let revokes_harness_mcp = matches!(
9638            &event.event,
9639            ControlEventKind::Exited { .. }
9640                | ControlEventKind::Failed { .. }
9641                | ControlEventKind::Removed
9642        );
9643        let (address, previous_address, record_id, identity_admitted, resume_command_rejected) = {
9644            let mut bindings = self
9645                .session_bindings
9646                .lock()
9647                .unwrap_or_else(|poisoned| poisoned.into_inner());
9648            let Some(binding) = bindings.get_mut(&event.instance_id) else {
9649                return;
9650            };
9651            let previous_address = SessionAddress {
9652                workspace_id: binding.workspace_id.clone(),
9653                session: SessionKey {
9654                    instance_id: event.instance_id,
9655                    generation: binding.generation,
9656                },
9657            };
9658            let mut resume_command_rejected = false;
9659            if binding.generation == event.generation {
9660                if let Some((pending_generation, pending_command_id, _)) = binding.pending_resume {
9661                    let rejected_resume = matches!(
9662                        &event.event,
9663                        ControlEventKind::CommandRejected { .. }
9664                    ) && event.command_id == Some(pending_command_id);
9665                    let resume_failed = matches!(
9666                        &event.event,
9667                        ControlEventKind::ResumeDenied { .. }
9668                            | ControlEventKind::ResumeFailed { .. }
9669                    );
9670                    if pending_generation == binding.generation
9671                        && (rejected_resume || resume_failed)
9672                    {
9673                        resume_command_rejected = rejected_resume;
9674                        binding.pending_resume = None;
9675                    }
9676                }
9677            } else {
9678                let expected = binding.generation.0.checked_add(1);
9679                let authorized = matches!(&event.event, ControlEventKind::ResumeAuthorized { .. });
9680                if !binding.pending_resume.is_some_and(|(generation, _, _)| {
9681                    generation == binding.generation
9682                })
9683                    || expected != Some(event.generation.0)
9684                    || !authorized
9685                {
9686                    return;
9687                }
9688                let (_, _, runtime_policy) = binding
9689                    .pending_resume
9690                    .expect("authorized resume retains its pending runtime policy");
9691                binding.generation = event.generation;
9692                binding.runtime_policy = runtime_policy;
9693                binding.pending_resume = None;
9694            }
9695            (
9696                SessionAddress {
9697                    workspace_id: binding.workspace_id.clone(),
9698                    session: SessionKey {
9699                        instance_id: event.instance_id,
9700                        generation: event.generation,
9701                    },
9702                },
9703                (previous_address.session.generation != event.generation)
9704                    .then_some(previous_address),
9705                binding.record_id.clone(),
9706                binding.runtime_policy.provider_session_identity,
9707                resume_command_rejected,
9708            )
9709        };
9710        if let Some(previous_address) = previous_address.as_ref() {
9711            self.clear_terminal_frame_watermark(previous_address);
9712        }
9713        if let Some(record_id) = record_id {
9714            self.reconcile_managed_record(
9715                &record_id,
9716                &address,
9717                &event,
9718                identity_admitted,
9719                resume_command_rejected,
9720            );
9721        }
9722        // Every state change a session's process lifecycle can reach gets a
9723        // line here: this is the single chokepoint every `ControlEventKind`
9724        // passes through regardless of transport (PTY/pipe/ACP) or whether it
9725        // came from a fresh spawn or a resume. Per-message provider chatter
9726        // (`ProviderEvent`/`ProviderGap`/terminal frames) is deliberately not
9727        // matched here -- that is exactly the per-frame noise this crate
9728        // family's level discipline forbids at INFO.
9729        match &event.event {
9730            ControlEventKind::Running { process_id } => {
9731                tracing::info!(
9732                    node_id = %self.node_id,
9733                    workspace_id = %address.workspace_id,
9734                    session = ?address.session,
9735                    pid = ?process_id,
9736                    "session process is running",
9737                );
9738            }
9739            ControlEventKind::Exited { exit_code, forced } => {
9740                tracing::info!(
9741                    node_id = %self.node_id,
9742                    workspace_id = %address.workspace_id,
9743                    session = ?address.session,
9744                    exit_code = ?exit_code,
9745                    forced = forced,
9746                    "session process exited",
9747                );
9748            }
9749            ControlEventKind::Failed { message } => {
9750                tracing::warn!(
9751                    node_id = %self.node_id,
9752                    workspace_id = %address.workspace_id,
9753                    session = ?address.session,
9754                    cause = %message,
9755                    "session failed",
9756                );
9757            }
9758            ControlEventKind::CommandRejected { message } => {
9759                tracing::warn!(
9760                    node_id = %self.node_id,
9761                    workspace_id = %address.workspace_id,
9762                    session = ?address.session,
9763                    cause = %message,
9764                    "session command rejected",
9765                );
9766            }
9767            ControlEventKind::Removed => {
9768                tracing::info!(
9769                    node_id = %self.node_id,
9770                    workspace_id = %address.workspace_id,
9771                    session = ?address.session,
9772                    "session removed",
9773                );
9774            }
9775            ControlEventKind::ResumeAuthorized { session: provider_session } => {
9776                tracing::info!(
9777                    node_id = %self.node_id,
9778                    workspace_id = %address.workspace_id,
9779                    session = ?address.session,
9780                    provider_session = ?provider_session,
9781                    "session resume authorized",
9782                );
9783            }
9784            ControlEventKind::Resumed { session: provider_session, process_id } => {
9785                tracing::info!(
9786                    node_id = %self.node_id,
9787                    workspace_id = %address.workspace_id,
9788                    session = ?address.session,
9789                    provider_session = ?provider_session,
9790                    pid = ?process_id,
9791                    "session resumed",
9792                );
9793            }
9794            ControlEventKind::ResumeDenied { reason } => {
9795                tracing::warn!(
9796                    node_id = %self.node_id,
9797                    workspace_id = %address.workspace_id,
9798                    session = ?address.session,
9799                    cause = %reason,
9800                    "session resume denied",
9801                );
9802            }
9803            ControlEventKind::ResumeFailed { message } => {
9804                tracing::warn!(
9805                    node_id = %self.node_id,
9806                    workspace_id = %address.workspace_id,
9807                    session = ?address.session,
9808                    cause = %message,
9809                    "session resume failed",
9810                );
9811            }
9812            _ => {}
9813        }
9814        let stream_chunk = agent_stream_chunk(&event);
9815        self.publish(NodeEvent::Control {
9816            address: address.clone(),
9817            event,
9818        });
9819        if let Some(chunk) = stream_chunk {
9820            self.publish_agent_stream_chunk(address.clone(), chunk);
9821        }
9822        if revokes_harness_mcp {
9823            if let Some(registry) = self.harness_mcp_registry.as_ref() {
9824                registry.revoke_session(&address);
9825            }
9826            if let Some(control) = self.native_launch_profile_control.as_ref() {
9827                control.clear_native_mcp_server_launch_overlay(
9828                    address.session.instance_id,
9829                );
9830            }
9831        }
9832    }
9833
9834    fn reconcile_managed_record(
9835        &self,
9836        record_id: &SessionRecordId,
9837        address: &SessionAddress,
9838        event: &ControlEvent,
9839        identity_admitted: bool,
9840        resume_command_rejected: bool,
9841    ) {
9842        let _transaction = self
9843            .state_transaction
9844            .lock()
9845            .unwrap_or_else(|poisoned| poisoned.into_inner());
9846        let observed_identity = match &event.event {
9847            ControlEventKind::ProviderEvent {
9848                event: gate4agent_types::ProviderEvent::SessionIdentityObserved { identity },
9849                ..
9850            } if identity_admitted => Some(identity.clone()),
9851            _ => None,
9852        };
9853        let identity_was_observed = observed_identity.is_some();
9854        let replacement_id = observed_identity
9855            .as_ref()
9856            .and_then(|_| self.allocate_record_id().ok());
9857        let mut upserts = Vec::new();
9858        let mut removals = Vec::new();
9859        let mut rebind = None;
9860        let original_records;
9861        let original_materializations = self
9862            .materializations
9863            .lock()
9864            .unwrap_or_else(|poisoned| poisoned.into_inner())
9865            .clone();
9866        {
9867            let mut records = self
9868                .session_records
9869                .lock()
9870                .unwrap_or_else(|poisoned| poisoned.into_inner());
9871            original_records = records.records.clone();
9872            let Some(current_snapshot) = records.records.get(record_id).cloned() else {
9873                return;
9874            };
9875            if let Some(identity) = observed_identity {
9876                let existing_id = records
9877                    .records
9878                    .values()
9879                    .find(|record| {
9880                        record.record_id != *record_id
9881                            && record.provider_session.as_ref().is_some_and(|record_identity| {
9882                                session_registry::same_provider_session(
9883                                    &record.provider,
9884                                    record_identity,
9885                                    &current_snapshot.provider,
9886                                    &identity,
9887                                )
9888                            })
9889                    })
9890                    .map(|record| record.record_id.clone());
9891                if let Some(existing_id) = existing_id {
9892                    let scope_matches = records.records.get(&existing_id).is_some_and(|record| {
9893                        record.mode == current_snapshot.mode
9894                            && record.workspace_id == current_snapshot.workspace_id
9895                            && record.environment_profile == current_snapshot.environment_profile
9896                            && record.bundle == current_snapshot.bundle
9897                            && record.context_id == current_snapshot.context_id
9898                            && record.context == current_snapshot.context
9899                            && record
9900                                .canonical_root
9901                                .as_utf8()
9902                                .zip(current_snapshot.canonical_root.as_utf8())
9903                                .is_some_and(|(left, right)| platform::roots_equal(left, right))
9904                    });
9905                    if !scope_matches {
9906                        if let Some(current) = records.records.get_mut(record_id) {
9907                            current.state = ManagedSessionState::Unavailable;
9908                            current.last_error =
9909                                Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR.to_owned());
9910                            current.updated_at_unix_ms = unix_time_ms();
9911                            upserts.push(current.clone());
9912                        }
9913                    } else {
9914                        let existing_is_active = records
9915                            .records
9916                            .get(&existing_id)
9917                            .is_some_and(|record| record.active_session.is_some());
9918                        if existing_is_active {
9919                            if let Some(current) = records.records.get_mut(record_id) {
9920                                current.state = ManagedSessionState::Unavailable;
9921                                current.last_error =
9922                                    Some(PROVIDER_SESSION_LIVE_CONFLICT_ERROR.to_owned());
9923                                current.updated_at_unix_ms = unix_time_ms();
9924                                upserts.push(current.clone());
9925                            }
9926                        } else {
9927                            if current_snapshot.provider_session.is_some() {
9928                                if let Some(current) = records.records.get_mut(record_id) {
9929                                    current.active_session = None;
9930                                    current.state = ManagedSessionState::Dormant;
9931                                    current.updated_at_unix_ms = unix_time_ms();
9932                                    upserts.push(current.clone());
9933                                }
9934                            } else {
9935                                records.records.remove(record_id);
9936                                removals.push(record_id.clone());
9937                            }
9938                            if let Some(existing) = records.records.get_mut(&existing_id) {
9939                                existing.provider_session = Some(identity.clone());
9940                                existing.active_session = Some(address.clone());
9941                                existing.state = ManagedSessionState::Live;
9942                                existing.updated_at_unix_ms = unix_time_ms();
9943                                existing.last_error = None;
9944                                upserts.push(existing.clone());
9945                            }
9946                            rebind = Some(existing_id);
9947                        }
9948                    }
9949                } else if current_snapshot
9950                    .provider_session
9951                    .as_ref()
9952                    .is_some_and(|current| {
9953                        !session_registry::same_provider_session(
9954                            &current_snapshot.provider,
9955                            current,
9956                            &current_snapshot.provider,
9957                            &identity,
9958                        )
9959                    })
9960                {
9961                    if let Some(current) = records.records.get_mut(record_id) {
9962                        current.active_session = None;
9963                        if current.bundle.is_some() || current.context.is_some() {
9964                            let had_bundle = current.bundle.is_some();
9965                            current.provider_session = None;
9966                            current.bundle = None;
9967                            current.context_id = None;
9968                            current.context = None;
9969                            current.state = ManagedSessionState::Unavailable;
9970                            current.last_error = Some(if had_bundle {
9971                                "bundle-unavailable".to_owned()
9972                            } else {
9973                                "context-unavailable".to_owned()
9974                            });
9975                        } else {
9976                            current.state = ManagedSessionState::Dormant;
9977                        }
9978                        current.updated_at_unix_ms = unix_time_ms();
9979                        upserts.push(current.clone());
9980                    }
9981                    if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
9982                        if let Some(current) = records.records.get_mut(record_id) {
9983                            current.state = ManagedSessionState::Unavailable;
9984                            current.last_error =
9985                                Some(MANAGED_SESSION_CAPACITY_ERROR.to_owned());
9986                            current.updated_at_unix_ms = unix_time_ms();
9987                            upserts.push(current.clone());
9988                        }
9989                    } else if let Some(new_record_id) = replacement_id {
9990                        let now = unix_time_ms();
9991                        let replacement = ManagedSessionRecord {
9992                            record_id: new_record_id.clone(),
9993                            display_name: format!(
9994                                "{} #{}",
9995                                current_snapshot.provider.as_str(),
9996                                records
9997                                    .records
9998                                    .values()
9999                                    .filter(|record| {
10000                                        record.provider == current_snapshot.provider
10001                                    })
10002                                    .count()
10003                                    .saturating_add(1),
10004                            ),
10005                            provider: current_snapshot.provider.clone(),
10006                            mode: current_snapshot.mode,
10007                            state: ManagedSessionState::Live,
10008                            workspace_id: current_snapshot.workspace_id,
10009                            canonical_root: current_snapshot.canonical_root,
10010                            provider_session: Some(identity),
10011                            active_session: Some(address.clone()),
10012                            environment_profile: current_snapshot.environment_profile,
10013                            bundle: current_snapshot.bundle,
10014                            context_id: current_snapshot.context_id,
10015                            context: current_snapshot.context,
10016                            // This is a fresh record_id representing the session under its
10017                            // newly-observed identity; it has not yet produced any clean
10018                            // exit of its own, so it starts with no exported pack (the old
10019                            // record, still addressable under its own record_id, keeps
10020                            // whatever exported_context it already had).
10021                            exported_context: None,
10022                            task_binding: current_snapshot.task_binding,
10023                            created_at_unix_ms: now,
10024                            updated_at_unix_ms: now,
10025                            last_error: None,
10026                        };
10027                        records
10028                            .records
10029                            .insert(new_record_id.clone(), replacement.clone());
10030                        upserts.push(replacement);
10031                        rebind = Some(new_record_id);
10032                    } else if let Some(current) = records.records.get_mut(record_id) {
10033                        current.state = ManagedSessionState::Unavailable;
10034                        current.last_error =
10035                            Some(PROVIDER_IDENTITY_ALLOCATION_ERROR.to_owned());
10036                        upserts.push(current.clone());
10037                    }
10038                } else if let Some(current) = records.records.get_mut(record_id) {
10039                    current.provider_session = Some(identity);
10040                    current.active_session = Some(address.clone());
10041                    current.state = ManagedSessionState::Live;
10042                    current.updated_at_unix_ms = unix_time_ms();
10043                    current.last_error = None;
10044                    upserts.push(current.clone());
10045                }
10046            } else if let Some(current) = records.records.get_mut(record_id) {
10047                if resume_command_rejected {
10048                    current.active_session = None;
10049                    current.state = detached_record_state(current);
10050                    current.updated_at_unix_ms = unix_time_ms();
10051                    current.last_error =
10052                        Some(PROVIDER_RESUME_REJECTED_ERROR.to_owned());
10053                    upserts.push(current.clone());
10054                } else {
10055                    match &event.event {
10056                    ControlEventKind::Running { .. }
10057                    | ControlEventKind::ResumeAuthorized { .. }
10058                    | ControlEventKind::Resumed { .. } => {
10059                        current.active_session = Some(address.clone());
10060                        current.state = active_record_state(
10061                            identity_admitted,
10062                            current.provider_session.is_some(),
10063                        );
10064                        current.updated_at_unix_ms = unix_time_ms();
10065                        current.last_error = None;
10066                        upserts.push(current.clone());
10067                    }
10068                    ControlEventKind::Exited { .. } | ControlEventKind::Removed => {
10069                        current.active_session = None;
10070                        current.state = detached_record_state(current);
10071                        current.updated_at_unix_ms = unix_time_ms();
10072                        upserts.push(current.clone());
10073                    }
10074                    ControlEventKind::Failed { message }
10075                    | ControlEventKind::ResumeFailed { message } => {
10076                        current.active_session = None;
10077                        current.state = detached_record_state(current);
10078                        current.updated_at_unix_ms = unix_time_ms();
10079                        current.last_error = Some(
10080                            session_registry::sanitized_record_error_summary(message),
10081                        );
10082                        upserts.push(current.clone());
10083                    }
10084                    ControlEventKind::ResumeDenied { reason } => {
10085                        current.active_session = None;
10086                        current.state = detached_record_state(current);
10087                        current.updated_at_unix_ms = unix_time_ms();
10088                        current.last_error = Some(
10089                            session_registry::sanitized_record_error_summary(reason),
10090                        );
10091                        upserts.push(current.clone());
10092                    }
10093                        _ => {}
10094                    }
10095                }
10096            }
10097        }
10098        let materialization_owner_record = identity_was_observed.then(|| {
10099            rebind.clone().unwrap_or_else(|| record_id.clone())
10100        }).and_then(|candidate| {
10101            self.session_records
10102                .lock()
10103                .unwrap_or_else(|poisoned| poisoned.into_inner())
10104                .records
10105                .get(&candidate)
10106                .filter(|record| {
10107                    record.provider_session.is_some()
10108                        && record.active_session.as_ref() == Some(address)
10109                })
10110                .map(|_| candidate)
10111        });
10112        if let Some(owner_record_id) = materialization_owner_record {
10113            let materialization_id = self
10114                .session_bindings
10115                .lock()
10116                .unwrap_or_else(|poisoned| poisoned.into_inner())
10117                .get(&address.session.instance_id)
10118                .and_then(|binding| binding.materialization_id.clone());
10119            if let Some(materialization_id) = materialization_id {
10120                let mut transfer_failed = false;
10121                {
10122                    let mut materializations = self
10123                        .materializations
10124                        .lock()
10125                        .unwrap_or_else(|poisoned| poisoned.into_inner());
10126                    if let Some(ownership) = materializations.get_mut(&materialization_id) {
10127                        match ownership.owner() {
10128                            MaterializationOwner::Session {
10129                                incarnation_id,
10130                                instance_id,
10131                                generation,
10132                            } if *incarnation_id == self.incarnation_id
10133                                && *instance_id == address.session.instance_id
10134                                && *generation == address.session.generation =>
10135                            {
10136                                transfer_failed = ownership
10137                                    .transfer_to_record(owner_record_id.clone(), unix_time_ms())
10138                                    .is_err();
10139                            }
10140                            MaterializationOwner::Record { record_id }
10141                                if record_id == &owner_record_id => {}
10142                            MaterializationOwner::Record { record_id } => {
10143                                let expected = record_id.clone();
10144                                transfer_failed = ownership
10145                                    .transfer_record_owner(
10146                                        &expected,
10147                                        owner_record_id.clone(),
10148                                        unix_time_ms(),
10149                                    )
10150                                    .is_err();
10151                            }
10152                            _ => transfer_failed = true,
10153                        }
10154                        if transfer_failed {
10155                            let _ = ownership.mark_recovery_required(unix_time_ms());
10156                        }
10157                    } else {
10158                        transfer_failed = true;
10159                    }
10160                }
10161                if transfer_failed {
10162                    if let Some(record) = self
10163                        .session_records
10164                        .lock()
10165                        .unwrap_or_else(|poisoned| poisoned.into_inner())
10166                        .records
10167                        .get_mut(&owner_record_id)
10168                    {
10169                        record.state = ManagedSessionState::Unavailable;
10170                        record.last_error = Some("environment-profile-unavailable".to_owned());
10171                        record.updated_at_unix_ms = unix_time_ms();
10172                        upserts.push(record.clone());
10173                    }
10174                }
10175            }
10176        }
10177        if let Some(new_record_id) = rebind.clone() {
10178            if let Some(binding) = self
10179                .session_bindings
10180                .lock()
10181                .unwrap_or_else(|poisoned| poisoned.into_inner())
10182                .get_mut(&address.session.instance_id)
10183            {
10184                binding.record_id = Some(new_record_id);
10185            }
10186        }
10187        if upserts.is_empty() && removals.is_empty() {
10188            return;
10189        }
10190        if self.persist_state_locked().is_err() {
10191            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
10192            let mut records = self
10193                .session_records
10194                .lock()
10195                .unwrap_or_else(|poisoned| poisoned.into_inner());
10196            records.records = original_records;
10197            *self
10198                .materializations
10199                .lock()
10200                .unwrap_or_else(|poisoned| poisoned.into_inner()) = original_materializations;
10201            if let Some(current) = records.records.get_mut(record_id) {
10202                current.state = ManagedSessionState::Unavailable;
10203                current.last_error = Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned());
10204                current.updated_at_unix_ms = unix_time_ms();
10205                upserts = vec![current.clone()];
10206            } else {
10207                upserts.clear();
10208            }
10209            removals.clear();
10210            drop(records);
10211            if let Some(binding) = self
10212                .session_bindings
10213                .lock()
10214                .unwrap_or_else(|poisoned| poisoned.into_inner())
10215                .get_mut(&address.session.instance_id)
10216            {
10217                binding.record_id = Some(record_id.clone());
10218            }
10219            eprintln!("gate4agent-node: {DURABLE_STATE_COMMIT_FAILED_ERROR}");
10220        }
10221        for record in upserts {
10222            self.publish_record(record);
10223        }
10224        for record_id in removals {
10225            self.publish(NodeEvent::SessionRecordRemoved { record_id });
10226        }
10227    }
10228
10229    fn snapshot(&self) -> NodeSnapshot {
10230        let control = self.handle.snapshot();
10231        let bindings = self
10232            .session_bindings
10233            .lock()
10234            .unwrap_or_else(|poisoned| poisoned.into_inner())
10235            .clone();
10236        let workspace_roots = self
10237            .workspaces
10238            .read()
10239            .unwrap_or_else(|poisoned| poisoned.into_inner())
10240            .clone();
10241        let environment_profiles = self
10242            .environment_profiles
10243            .read()
10244            .unwrap_or_else(|poisoned| poisoned.into_inner());
10245        let mut workspaces = workspace_roots
10246            .iter()
10247            .map(|(workspace_id, canonical_root)| {
10248                (
10249                    workspace_id.clone(),
10250                    WorkspaceSnapshot {
10251                        workspace_id: workspace_id.clone(),
10252                        canonical_root: opaque_windows_path(canonical_root.clone()),
10253                        sessions: Vec::new(),
10254                        worktree_service_mode: Some(
10255                            self.worktree_service_modes
10256                                .get(workspace_id)
10257                                .copied()
10258                                .unwrap_or(WorktreeServiceMode::Manual),
10259                        ),
10260                        managed_worktree_profiles: Some(WorktreeProfileInventory {
10261                            profiles: self
10262                                .managed_worktree_profiles
10263                                .get(workspace_id)
10264                                .into_iter()
10265                                .flat_map(BTreeMap::values)
10266                                .map(|profile| ManagedWorktreeProfileSummary {
10267                                    id: profile.profile_id().clone(),
10268                                    revision: profile.revision().clone(),
10269                                    retention: profile.retention(),
10270                                })
10271                                .collect(),
10272                        }),
10273                    },
10274                )
10275            })
10276            .collect::<BTreeMap<_, _>>();
10277        let mut agent_progress = Vec::new();
10278        for session in &control.sessions {
10279            let Some(binding) = bindings.get(&session.instance_id) else {
10280                continue;
10281            };
10282            if binding.generation != session.generation {
10283                continue;
10284            }
10285            if let Some(workspace) = workspaces.get_mut(&binding.workspace_id) {
10286                let address = SessionAddress {
10287                    workspace_id: binding.workspace_id.clone(),
10288                    session: SessionKey {
10289                        instance_id: session.instance_id,
10290                        generation: session.generation,
10291                    },
10292                };
10293                if let Some(progress) =
10294                    agent_progress_from_provider_snapshot(address, &session.provider)
10295                {
10296                    agent_progress.push(progress);
10297                }
10298                workspace.sessions.push(session.clone());
10299            }
10300        }
10301        NodeSnapshot {
10302            node_id: self.node_id.clone(),
10303            enabled_providers: self.enabled_providers.clone(),
10304            provider_runtime_statuses: {
10305                let updates = self
10306                    .provider_runtime_status_updates
10307                    .read()
10308                    .unwrap_or_else(|poisoned| poisoned.into_inner());
10309                ProviderRuntimeStatuses::new(
10310                    self.provider_runtime_statuses
10311                        .iter()
10312                        .filter(|status| !updates.contains_key(status.provider()))
10313                        .cloned()
10314                        .chain(updates.values().cloned()),
10315                )
10316                .expect("refreshed provider runtime inventory remains bounded and unique")
10317            },
10318            workspaces: workspaces.into_values().collect(),
10319            session_records: self
10320                .session_records
10321                .lock()
10322                .unwrap_or_else(|poisoned| poisoned.into_inner())
10323                .records
10324                .values()
10325                .cloned()
10326                .collect(),
10327            managed_worktrees: self.managed_worktrees
10328                .lock()
10329                .unwrap_or_else(|poisoned| poisoned.into_inner())
10330                .snapshots(),
10331            launch_inventory: Some(LaunchInventory {
10332                spawn_profiles: Some(
10333                    self.spawn_profiles
10334                        .iter()
10335                        .filter_map(|profile| {
10336                            resolved_spawn_profile_summary(profile, &environment_profiles)
10337                        })
10338                        .collect(),
10339                ),
10340                bundles: Some(
10341                    self.bundle_catalog
10342                        .read()
10343                        .unwrap_or_else(|poisoned| poisoned.into_inner())
10344                        .iter()
10345                        .map(NodeBundle::receipt)
10346                        .collect(),
10347                ),
10348                // Opaque station allowlist ids from empty-default catalog /
10349                // optional startup loader. Never secrets. Dig2 probe stays stub.
10350                network_allowlists: {
10351                    let catalog = self
10352                        .network_allowlist_catalog
10353                        .read()
10354                        .unwrap_or_else(|poisoned| poisoned.into_inner());
10355                    if catalog.is_empty() {
10356                        None
10357                    } else {
10358                        Some(catalog.ids().cloned().collect())
10359                    }
10360                },
10361            }),
10362            agent_progress,
10363        }
10364    }
10365
10366    fn publish(&self, event: NodeEvent) -> NodeEventEnvelope {
10367        assert!(
10368            !matches!(
10369                &event,
10370                NodeEvent::TerminalFrame { .. } | NodeEvent::AgentStream { .. }
10371            ),
10372            "terminal frame and agent stream events must use their own dedicated live channel",
10373        );
10374        let mut history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
10375        let sequence = history
10376            .last_sequence
10377            .checked_add(1)
10378            .expect("node event sequence exhausted");
10379        if history.events.len() == NODE_EVENT_HISTORY_MAX {
10380            if let Some(evicted) = history.events.pop_front() {
10381                history.replay_floor_sequence = evicted
10382                    .sequence
10383                    .checked_add(1)
10384                    .expect("node replay floor sequence exhausted");
10385                history.removed_record_providers.remove(&evicted.sequence);
10386            }
10387        }
10388        match &event {
10389            NodeEvent::HarnessMcpReadCall { .. } => {
10390                panic!("transient harness MCP calls must not enter durable event history")
10391            }
10392            NodeEvent::SessionRecordUpserted { record } => {
10393                history
10394                    .record_providers
10395                    .insert(record.record_id.clone(), record.provider.clone());
10396            }
10397            NodeEvent::SessionRecordRemoved { record_id } => {
10398                if let Some(provider) = history.record_providers.remove(record_id) {
10399                    history.removed_record_providers.insert(sequence, provider);
10400                }
10401            }
10402            NodeEvent::ManagedWorktreeUpserted { .. }
10403            | NodeEvent::ManagedWorktreeRemoved { .. }
10404            | NodeEvent::Control { .. }
10405            | NodeEvent::ControllerChanged { .. }
10406            | NodeEvent::SessionRecordHistorySummarized { .. }
10407            | NodeEvent::WorkspaceAdded { .. }
10408            | NodeEvent::WorkspaceRemoved { .. }
10409            | NodeEvent::ResyncRequired { .. }
10410            | NodeEvent::AgentStream { .. }
10411            | NodeEvent::TerminalFrame { .. } => {}
10412        }
10413        let envelope = NodeEventEnvelope { sequence, event };
10414        history.events.push_back(envelope.clone());
10415        history.last_sequence = sequence;
10416        let _ = self.event_tx.send(envelope.clone());
10417        drop(history);
10418        envelope
10419    }
10420
10421    /// Publishes a transient (never durable) `NodeEvent` -- today only
10422    /// `NodeEvent::HarnessMcpReadCall` -- onto the dedicated
10423    /// `harness_mcp_proxy_event_tx` broadcast (see that field's own doc for
10424    /// why this must never ride the shared `event_tx` bus). Returns whether
10425    /// the send reached at least one subscriber: `broadcast::Sender::send`
10426    /// fails in exactly one way, no active receiver at that instant, and
10427    /// the caller (`HarnessMcpProxyRegistry::begin_call`) uses a `false`
10428    /// return to fail the pending call immediately rather than leave it to
10429    /// expire silently at `LOCAL_READ_TIMEOUT`/
10430    /// `MAX_HARNESS_MCP_CALL_DEADLINE_MS`.
10431    fn publish_transient(&self, event: NodeEvent) -> bool {
10432        assert!(event.requires_harness_mcp_proxy_capability());
10433        assert!(event.harness_mcp_contract_is_valid_at(unix_time_ms()));
10434        let envelope = NodeEventEnvelope { sequence: 0, event };
10435        match self.harness_mcp_proxy_event_tx.send(Arc::new(vec![envelope])) {
10436            Ok(_) => true,
10437            Err(broadcast::error::SendError(undelivered)) => {
10438                if let Some(NodeEventEnvelope {
10439                    event: NodeEvent::HarnessMcpReadCall { call_id, request, .. },
10440                    ..
10441                }) = undelivered.first() {
10442                    // `request.content_type` only, never a per-tool label --
10443                    // this crate does not decode `request.body` and does not
10444                    // hold `hatchery-harness-api` (Nested Control Plane
10445                    // doctrine, Law 3; see this crate's dependency on
10446                    // `gate4agent-node-protocol`'s own `Forbidden:` line).
10447                    tracing::warn!(
10448                        call_id = call_id.as_str(),
10449                        content_type = ?request.content_type,
10450                        cause = "no-subscribers",
10451                        "harness MCP proxy event could not be published",
10452                    );
10453                }
10454                false
10455            }
10456        }
10457    }
10458
10459    fn publish_terminal_frames(&self) {
10460        let snapshot = self.handle.snapshot();
10461        let bindings = self
10462            .session_bindings
10463            .lock()
10464            .unwrap_or_else(|poisoned| poisoned.into_inner());
10465        let candidates = snapshot
10466            .sessions
10467            .iter()
10468            .filter_map(|session| {
10469                let binding = bindings.get(&session.instance_id)?;
10470                if binding.generation != session.generation {
10471                    return None;
10472                }
10473                let frame = session.terminal_frame.clone()?;
10474                Some((
10475                    SessionAddress {
10476                        workspace_id: binding.workspace_id.clone(),
10477                        session: SessionKey {
10478                            instance_id: session.instance_id,
10479                            generation: session.generation,
10480                        },
10481                    },
10482                    frame,
10483                ))
10484            })
10485            .collect();
10486        drop(bindings);
10487        self.publish_terminal_frame_candidates(candidates);
10488    }
10489
10490    fn publish_terminal_frame_candidates(
10491        &self,
10492        candidates: Vec<(SessionAddress, TerminalFrame)>,
10493    ) {
10494        let mut watermarks = self
10495            .terminal_frame_watermarks
10496            .lock()
10497            .unwrap_or_else(|poisoned| poisoned.into_inner());
10498        let mut events = Vec::with_capacity(candidates.len());
10499        for (address, frame) in candidates {
10500            let is_first_frame = !watermarks.contains_key(&address.session.instance_id);
10501            let advanced = watermarks
10502                .get(&address.session.instance_id)
10503                .map_or(true, |(current_address, sequence)| {
10504                    current_address != &address || frame.sequence > *sequence
10505                });
10506            if !advanced {
10507                continue;
10508            }
10509            if is_first_frame {
10510                // This is the first PTY output this node has observed for
10511                // this session -- a one-time event per spawn, not per-frame
10512                // chatter (the watermark makes sure of that). A session that
10513                // reaches this line with no controller attached anywhere on
10514                // the node is running unobserved: exactly how processes from
10515                // repeated spawn attempts went orphaned for hours without a
10516                // single line about it.
10517                if self.controller_state().is_none() {
10518                    tracing::warn!(
10519                        node_id = %self.node_id,
10520                        workspace_id = %address.workspace_id,
10521                        session = ?address.session,
10522                        terminal_size = ?frame.size,
10523                        "session producing output with no controller attached",
10524                    );
10525                } else {
10526                    tracing::info!(
10527                        node_id = %self.node_id,
10528                        workspace_id = %address.workspace_id,
10529                        session = ?address.session,
10530                        terminal_size = ?frame.size,
10531                        "session produced its first output",
10532                    );
10533                }
10534            }
10535            watermarks.insert(
10536                address.session.instance_id,
10537                (address.clone(), frame.sequence),
10538            );
10539            events.push(NodeEvent::TerminalFrame { address, frame });
10540        }
10541        drop(watermarks);
10542        if events.is_empty() {
10543            return;
10544        }
10545        let mut history = self
10546            .history
10547            .lock()
10548            .unwrap_or_else(|poisoned| poisoned.into_inner());
10549        let mut envelopes = Vec::with_capacity(events.len());
10550        for event in events {
10551            let sequence = history
10552                .last_sequence
10553                .checked_add(1)
10554                .expect("node event sequence exhausted");
10555            history.last_sequence = sequence;
10556            envelopes.push(NodeEventEnvelope { sequence, event });
10557        }
10558        let _ = self.terminal_event_tx.send(Arc::new(envelopes));
10559        drop(history);
10560    }
10561
10562    /// Publishes one `AgentStreamChunkV1` onto the dedicated agent-stream
10563    /// channel, mirroring `publish_terminal_frame_candidates` above: the
10564    /// envelope's `sequence` is drawn from the same `history.last_sequence`
10565    /// counter every other event kind shares, never a counter of its own --
10566    /// `queue_connection_event`/`queue_connection_event_batch` compare
10567    /// `envelope.sequence` against one `discard_events_through` watermark
10568    /// regardless of which broadcast channel it arrived on, so a
10569    /// per-channel counter would desynchronize that comparison.
10570    fn publish_agent_stream_chunk(&self, address: SessionAddress, chunk: AgentStreamChunkV1) {
10571        let mut history = self
10572            .history
10573            .lock()
10574            .unwrap_or_else(|poisoned| poisoned.into_inner());
10575        let sequence = history
10576            .last_sequence
10577            .checked_add(1)
10578            .expect("node event sequence exhausted");
10579        history.last_sequence = sequence;
10580        drop(history);
10581        // Debug, not info/warn -- this fires on every single chunk, but it
10582        // is exactly the counter the next drop-measurement pass needs: what
10583        // the node actually emitted (`source_sequence`, `kind`) versus what
10584        // a harness subscriber's own forwarder later logs having received
10585        // (`hatchery-harness-service::runtime`'s "forwarding agent stream
10586        // event"), so a gap between the two logs names itself instead of
10587        // needing a new counter added ad hoc.
10588        tracing::debug!(
10589            source_sequence = chunk.source_sequence,
10590            kind = agent_stream_chunk_kind_label(&chunk.kind),
10591            envelope_sequence = sequence,
10592            "publishing agent stream chunk",
10593        );
10594        let envelope = NodeEventEnvelope {
10595            sequence,
10596            event: NodeEvent::AgentStream { address, chunk },
10597        };
10598        let _ = self.agent_stream_event_tx.send(Arc::new(vec![envelope]));
10599    }
10600
10601    fn current_sequence(&self) -> u64 {
10602        self.history
10603            .lock()
10604            .unwrap_or_else(|poisoned| poisoned.into_inner())
10605            .last_sequence
10606    }
10607
10608    fn controller_state(&self) -> Option<ControllerState> {
10609        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
10610        let lease = controller.as_ref().copied()?;
10611        let remaining = lease.expires_at.saturating_duration_since(Instant::now());
10612        if remaining.is_zero() {
10613            *controller = None;
10614            return None;
10615        }
10616        Some(ControllerState {
10617            connection_id: lease.connection_id,
10618            lease_remaining_ms: remaining.as_millis().min(u64::MAX as u128) as u64,
10619        })
10620    }
10621
10622    fn acquire_controller(&self, connection_id: u64, role: ClientRole, lease_ms: u64) -> Result<ControllerState, NodeFailure> {
10623        if role != ClientRole::Operator {
10624            tracing::warn!(
10625                node_id = %self.node_id,
10626                connection_id,
10627                "controller lease rejected: observer connections cannot acquire control",
10628            );
10629            return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections cannot acquire control"));
10630        }
10631        let lease_ms = if lease_ms == 0 {
10632            DEFAULT_CONTROLLER_LEASE_MS
10633        } else {
10634            lease_ms.clamp(MIN_CONTROLLER_LEASE_MS, MAX_CONTROLLER_LEASE_MS)
10635        };
10636        let now = Instant::now();
10637        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
10638        if let Some(active) = controller.as_ref() {
10639            if active.connection_id != connection_id && active.expires_at > now {
10640                tracing::warn!(
10641                    node_id = %self.node_id,
10642                    connection_id,
10643                    holder_connection_id = active.connection_id,
10644                    "controller lease rejected: another operator holds it",
10645                );
10646                return Err(failure(NodeFailureCode::ControllerBusy, "another operator holds the controller lease"));
10647            }
10648        }
10649        // A reconnect (this connection already holds the lease, renewing
10650        // before expiry) happens on a periodic cadence; logging it at INFO
10651        // every renewal would be exactly the heartbeat chatter this crate
10652        // family's level discipline forbids. A genuine attach -- the
10653        // controller identity actually changing -- is a state change and
10654        // gets INFO; a same-connection renewal gets DEBUG.
10655        let is_new_attach = match controller.as_ref() {
10656            Some(active) => active.connection_id != connection_id,
10657            None => true,
10658        };
10659        *controller = Some(ControllerLease {
10660            connection_id,
10661            expires_at: now + Duration::from_millis(lease_ms),
10662        });
10663        drop(controller);
10664        let state = ControllerState { connection_id, lease_remaining_ms: lease_ms };
10665        if is_new_attach {
10666            tracing::info!(
10667                node_id = %self.node_id,
10668                connection_id,
10669                lease_ms,
10670                "controller lease acquired",
10671            );
10672        } else {
10673            tracing::debug!(
10674                node_id = %self.node_id,
10675                connection_id,
10676                lease_ms,
10677                "controller lease renewed",
10678            );
10679        }
10680        self.publish(NodeEvent::ControllerChanged { controller: Some(state.clone()) });
10681        Ok(state)
10682    }
10683
10684    fn release_controller(&self, connection_id: u64) -> bool {
10685        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
10686        let released = controller.as_ref().is_some_and(|active| active.connection_id == connection_id);
10687        if released {
10688            *controller = None;
10689        }
10690        drop(controller);
10691        if released {
10692            tracing::info!(
10693                node_id = %self.node_id,
10694                connection_id,
10695                "controller lease released",
10696            );
10697            self.publish(NodeEvent::ControllerChanged { controller: None });
10698        }
10699        released
10700    }
10701
10702    fn require_controller(&self, connection_id: u64, role: ClientRole) -> Result<(), NodeFailure> {
10703        if role != ClientRole::Operator {
10704            return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections are read-only"));
10705        }
10706        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
10707        let now = Instant::now();
10708        match controller.as_ref().copied() {
10709            Some(active) if active.connection_id == connection_id && active.expires_at > now => Ok(()),
10710            Some(active) if active.expires_at <= now => {
10711                *controller = None;
10712                Err(failure(NodeFailureCode::ControllerRequired, "controller lease expired"))
10713            }
10714            _ => Err(failure(NodeFailureCode::ControllerRequired, "controller lease is required")),
10715        }
10716    }
10717
10718    fn validate_address(&self, address: &SessionAddress) -> Result<AgentId, NodeFailure> {
10719        if !self
10720            .workspaces
10721            .read()
10722            .unwrap_or_else(|poisoned| poisoned.into_inner())
10723            .contains_key(&address.workspace_id)
10724        {
10725            return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
10726        }
10727        let binding = self
10728            .session_bindings
10729            .lock()
10730            .unwrap_or_else(|poisoned| poisoned.into_inner())
10731            .get(&address.session.instance_id)
10732            .cloned()
10733            .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10734        if binding.workspace_id != address.workspace_id {
10735            return Err(failure(
10736                NodeFailureCode::SessionWorkspaceMismatch,
10737                "session belongs to another workspace",
10738            ));
10739        }
10740        if binding.generation != address.session.generation {
10741            return Err(failure(
10742                NodeFailureCode::StaleGeneration,
10743                &format!(
10744                    "stale session generation: expected {}, received {}",
10745                    binding.generation.0,
10746                    address.session.generation.0,
10747                ),
10748            ));
10749        }
10750        let snapshot = self.handle.snapshot();
10751        let Some(current) = snapshot
10752            .sessions
10753            .iter()
10754            .find(|item| item.instance_id == address.session.instance_id)
10755        else {
10756            return Err(failure(NodeFailureCode::UnknownSession, "session instance does not exist"));
10757        };
10758        if current.generation != address.session.generation {
10759            return Err(failure(
10760                NodeFailureCode::StaleGeneration,
10761                &format!(
10762                    "stale session generation: expected {}, received {}",
10763                    current.generation.0,
10764                    address.session.generation.0,
10765                ),
10766            ));
10767        }
10768        Ok(current.agent_id.clone())
10769    }
10770
10771    fn dispatch(&self, command: ControlCommand) -> Result<CommandId, NodeFailure> {
10772        self.dispatch_envelope(self.prepare_command(command))
10773    }
10774
10775    fn prepare_command(&self, command: ControlCommand) -> CommandEnvelope {
10776        let id = self.next_command_id.fetch_add(1, Ordering::AcqRel);
10777        CommandEnvelope {
10778            id: CommandId(id),
10779            command,
10780        }
10781    }
10782
10783    fn dispatch_envelope(&self, envelope: CommandEnvelope) -> Result<CommandId, NodeFailure> {
10784        let command_id = envelope.id;
10785        self.handle
10786            .dispatch(envelope)
10787            .map_err(|error| match error {
10788                PortDispatchError::Full => failure(NodeFailureCode::BackendBusy, "backend command ingress is full"),
10789                PortDispatchError::Disconnected => failure(NodeFailureCode::BackendDisconnected, "backend command ingress is disconnected"),
10790            })?;
10791        Ok(command_id)
10792    }
10793
10794    /// Records the kernel's own reason for asynchronously refusing
10795    /// `command_id` -- see `rejected_command_reasons`'s doc for why a
10796    /// spawn-dispatch waiter otherwise has no way to learn it. Bounded by
10797    /// `REJECTED_COMMAND_REASON_MAX_ENTRIES`: expired entries are swept
10798    /// first, and if the map is still at capacity after that, the single
10799    /// oldest surviving entry is evicted -- `CommandId` is monotonically
10800    /// assigned (`next_command_id.fetch_add`), so the `BTreeMap`'s first key
10801    /// is always the oldest.
10802    fn record_rejected_command(
10803        &self,
10804        command_id: CommandId,
10805        message: String,
10806        unsupported_transport: bool,
10807    ) {
10808        let now = Instant::now();
10809        let mut reasons = self.rejected_command_reasons.lock()
10810            .unwrap_or_else(|poisoned| poisoned.into_inner());
10811        reasons.retain(|_, reason| reason.expires_at > now);
10812        if reasons.len() >= REJECTED_COMMAND_REASON_MAX_ENTRIES {
10813            if let Some(&oldest) = reasons.keys().next() {
10814                reasons.remove(&oldest);
10815            }
10816        }
10817        reasons.insert(
10818            command_id,
10819            RejectedCommandReason {
10820                message,
10821                unsupported_transport,
10822                expires_at: now + Duration::from_millis(REJECTED_COMMAND_REASON_TTL_MS),
10823            },
10824        );
10825    }
10826
10827    /// Removes and returns the kernel's reason for refusing `command_id`,
10828    /// if the drive loop has recorded one and it has not expired -- see
10829    /// `rejected_command_reasons`'s doc. Consumed at most once: a spawn
10830    /// wait loop calls this instead of blindly polling out its whole
10831    /// deadline when the command it is waiting on turns out to have been
10832    /// refused rather than merely slow.
10833    fn take_rejected_command_reason(&self, command_id: CommandId) -> Option<RejectedCommandReason> {
10834        let now = Instant::now();
10835        let mut reasons = self.rejected_command_reasons.lock()
10836            .unwrap_or_else(|poisoned| poisoned.into_inner());
10837        match reasons.remove(&command_id) {
10838            Some(reason) if reason.expires_at > now => Some(reason),
10839            _ => None,
10840        }
10841    }
10842
10843    async fn dispatch_bounded(
10844        &self,
10845        command: ControlCommand,
10846        timeout_duration: Duration,
10847    ) -> Result<CommandId, NodeFailure> {
10848        let deadline = Instant::now() + timeout_duration;
10849        loop {
10850            match self.dispatch(command.clone()) {
10851                Ok(command_id) => return Ok(command_id),
10852                Err(error) if error.code == NodeFailureCode::BackendBusy && Instant::now() < deadline => {
10853                    sleep(Duration::from_millis(2)).await;
10854                }
10855                Err(error) => return Err(error),
10856            }
10857        }
10858    }
10859
10860    fn internal_session_snapshot(
10861        &self,
10862        address: &SessionAddress,
10863    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
10864        self.validate_address(address)?;
10865        self.handle
10866            .snapshot()
10867            .sessions
10868            .clone()
10869            .into_iter()
10870            .find(|session| {
10871                session.instance_id == address.session.instance_id
10872                    && session.generation == address.session.generation
10873            })
10874            .ok_or_else(|| {
10875                failure(
10876                    NodeFailureCode::UnknownSession,
10877                    "session snapshot is unavailable",
10878                )
10879            })
10880    }
10881
10882    /// Confirms `address` names a live session spawned over the ACP
10883    /// transport and returns its current snapshot -- the gate every
10884    /// `acp-control-v1` verb shares. `require_session_runtime_policy`
10885    /// cannot answer this by itself: `ProviderRuntimePolicy` carries no
10886    /// transport bit for `Acp`/`Inline` (see that method's own doc), only
10887    /// the session's own recorded `TransportKind` does, and that lives on
10888    /// the live snapshot, not the binding.
10889    fn require_acp_session(
10890        &self,
10891        address: &SessionAddress,
10892    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
10893        let snapshot = self.internal_session_snapshot(address)?;
10894        if snapshot.transport != TransportKind::Acp {
10895            return Err(failure(
10896                NodeFailureCode::UnsupportedTransport,
10897                "this verb requires a session spawned over the ACP transport",
10898            ));
10899        }
10900        Ok(snapshot)
10901    }
10902
10903    async fn dispatch_history_bounded(
10904        &self,
10905        address: &SessionAddress,
10906        command: ControlCommand,
10907        operation: HistoryOperation,
10908    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
10909        self.validate_address(address)?;
10910        let started_after = self.current_sequence();
10911        let command_id = self
10912            .dispatch_bounded(
10913                command,
10914                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
10915            )
10916            .await?;
10917        let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
10918        let mut scan_after = started_after;
10919        let mut accepted_after = None;
10920        loop {
10921            let events = self
10922                .history
10923                .lock()
10924                .unwrap_or_else(|poisoned| poisoned.into_inner())
10925                .events
10926                .iter()
10927                .filter(|envelope| envelope.sequence > scan_after)
10928                .cloned()
10929                .collect::<Vec<_>>();
10930            for envelope in events {
10931                scan_after = scan_after.max(envelope.sequence);
10932                let NodeEvent::Control {
10933                    address: event_address,
10934                    event,
10935                } = envelope.event
10936                else {
10937                    continue;
10938                };
10939                if event_address != *address {
10940                    continue;
10941                }
10942                if event.command_id == Some(command_id) {
10943                    match &event.event {
10944                        ControlEventKind::CommandRejected { .. } => {
10945                            return Err(failure(
10946                                NodeFailureCode::InvalidRequest,
10947                                "history request was rejected",
10948                            ));
10949                        }
10950                        ControlEventKind::HistoryRequested {
10951                            operation: requested,
10952                            ..
10953                        } if requested == &operation => {
10954                            accepted_after = Some(envelope.sequence);
10955                            continue;
10956                        }
10957                        _ => {}
10958                    }
10959                }
10960                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
10961                    match (&operation, &event.event) {
10962                        (
10963                            HistoryOperation::Discover { .. },
10964                            ControlEventKind::HistoryDiscovered { .. },
10965                        )
10966                        | (
10967                            HistoryOperation::Load { .. },
10968                            ControlEventKind::HistoryLoaded { .. },
10969                        ) => return self.internal_session_snapshot(address),
10970                        (_, ControlEventKind::HistoryFailed { .. }) => {
10971                            return Err(failure(
10972                                NodeFailureCode::BackendOperationFailed,
10973                                "history operation failed",
10974                            ));
10975                        }
10976                        _ => {}
10977                    }
10978                }
10979            }
10980            if Instant::now() >= deadline {
10981                return Err(failure(
10982                    NodeFailureCode::BackendBusy,
10983                    if accepted_after.is_some() {
10984                        "history operation did not settle before the bounded deadline"
10985                    } else {
10986                        "history operation was not accepted before the bounded deadline"
10987                    },
10988                ));
10989            }
10990            sleep(Duration::from_millis(2)).await;
10991        }
10992    }
10993
10994    async fn discover_history(
10995        &self,
10996        address: &SessionAddress,
10997        limit: u16,
10998    ) -> Result<Vec<HistoryCandidateSummary>, NodeFailure> {
10999        let query = HistoryQuery {
11000            working_directory: Some(self.workspace_root(&address.workspace_id)?),
11001            limit,
11002        };
11003        query
11004            .validate()
11005            .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history query"))?;
11006        let snapshot = self
11007            .dispatch_history_bounded(
11008                address,
11009                ControlCommand::DiscoverHistory {
11010                    instance_id: address.session.instance_id,
11011                    query: query.clone(),
11012                },
11013                HistoryOperation::Discover { query },
11014            )
11015            .await?;
11016        if snapshot.history.pending.is_some() || snapshot.history.loaded.is_some() {
11017            return Err(failure(
11018                NodeFailureCode::BackendOperationFailed,
11019                "history discovery settled into an invalid state",
11020            ));
11021        }
11022        Ok(snapshot.history.candidates)
11023    }
11024
11025    async fn load_history(
11026        &self,
11027        address: &SessionAddress,
11028        candidate_id: String,
11029    ) -> Result<HistorySessionRecord, NodeFailure> {
11030        validate_candidate_id(&candidate_id)
11031            .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history candidate"))?;
11032        let snapshot = self
11033            .dispatch_history_bounded(
11034                address,
11035                ControlCommand::LoadHistory {
11036                    instance_id: address.session.instance_id,
11037                    candidate_id: candidate_id.clone(),
11038                },
11039                HistoryOperation::Load {
11040                    candidate_id: candidate_id.clone(),
11041                },
11042            )
11043            .await?;
11044        if snapshot.history.pending.is_some()
11045            || snapshot.history.loaded_candidate_id.as_ref() != Some(&candidate_id)
11046        {
11047            return Err(failure(
11048                NodeFailureCode::BackendOperationFailed,
11049                "history load settled into an invalid state",
11050            ));
11051        }
11052        snapshot.history.loaded.ok_or_else(|| {
11053            failure(
11054                NodeFailureCode::BackendOperationFailed,
11055                "history load produced no bounded session",
11056            )
11057        })
11058    }
11059
11060    /// `allow_clean_detachment`: `false` for the on-demand
11061    /// `NodeRequest::ExportContextPackForSessionRecord` API path (the
11062    /// managed record must stay untouched, still live, for the whole call);
11063    /// `true` only for the reactive auto-export-at-exit path (`§2.1`), which
11064    /// reacts to the very clean-exit event that detaches the record out from
11065    /// under this same call — see `session_record_export_target_matches`.
11066    async fn export_context_pack_for_session_record(
11067        &self,
11068        record_id: &SessionRecordId,
11069        session: &SessionAddress,
11070        allow_clean_detachment: bool,
11071    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11072        // C2 replaces a relayed failure's prose with a canned per-code
11073        // string (`C2NodeFailure`'s own `From`), so this path's dozen
11074        // distinct refusals are indistinguishable to anyone downstream --
11075        // and a refusal here expires a continuation, which keeps no field
11076        // for why. The prose stays on the node, where it was written.
11077        let outcome = self
11078            .export_context_pack_for_session_record_inner(record_id, session, allow_clean_detachment)
11079            .await;
11080        if let Err(failure) = &outcome {
11081            tracing::warn!(
11082                record = %record_id,
11083                code = ?failure.code,
11084                reason = %failure.message,
11085                "session-record context-pack export refused",
11086            );
11087        }
11088        outcome
11089    }
11090
11091    async fn export_context_pack_for_session_record_inner(
11092        &self,
11093        record_id: &SessionRecordId,
11094        session: &SessionAddress,
11095        allow_clean_detachment: bool,
11096    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11097        // Several steps here refuse with the same BackendOperationFailed
11098        // code and no prose of their own, so which step failed is
11099        // otherwise unrecoverable -- and a refusal here expires a
11100        // continuation.
11101        let step = |name: &'static str| move |failure: NodeFailure| {
11102            tracing::warn!(step = name, code = ?failure.code, "context-pack export step refused");
11103            failure
11104        };
11105        let record = self.record(record_id).map_err(step("record-lookup"))?;
11106        // `provider_session` is `Some` only for a provider whose resolved
11107        // runtime policy admits live identity verification (raw PTY plus a
11108        // semantic PTY adapter -- `provider_runtime::policy_from_capability_flags`).
11109        // The PTY-sidecar-observation shape (no fleet provider today, but
11110        // capability-keyed rather than vendor-locked) hardcodes
11111        // `provider_session_identity: false` in `provider_runtime::evaluate`,
11112        // so its records go straight to `Live` with `provider_session: None`
11113        // and stay that way for the record's entire life -- that is this
11114        // shape's permanent resting state, not a race with observation, and
11115        // `discover_history`/`load_history` below do not need an identity to
11116        // do their job for it. `IdentityPending` is the actual race (an
11117        // identity-admitted provider whose observation has not landed yet):
11118        // it keeps refusing here rather than falling back, because that
11119        // provider's own history the fallback would otherwise trust is not
11120        // guaranteed to be exact without the identity it is still waiting on.
11121        let identity = match record.provider_session.clone() {
11122            Some(identity) => {
11123                if identity.key != ProviderSessionKey::SessionId {
11124                    return Err(step("provider-identity-key")(failure(
11125                        NodeFailureCode::BackendOperationFailed,
11126                        "managed session provider identity cannot bind history exactly",
11127                    )));
11128                }
11129                Some(identity)
11130            }
11131            None if record.state == ManagedSessionState::IdentityPending => {
11132                return Err(step("provider-session-identity")(failure(
11133                    NodeFailureCode::BackendOperationFailed,
11134                    "managed session has not yet observed its provider session identity",
11135                )));
11136            }
11137            None => None,
11138        };
11139        self.revalidate_session_record_context_export(
11140            &record,
11141            identity.as_ref(),
11142            session,
11143            allow_clean_detachment,
11144        ).map_err(step("revalidate-before-discovery"))?;
11145        let candidates = self
11146            .discover_history(session, HISTORY_DISCOVERY_LIMIT_MAX)
11147            .await
11148            .map_err(step("discover-history"))?;
11149        // With no verified identity to filter by, the discovered set must
11150        // still resolve to exactly one candidate: the same ambiguity refusal
11151        // an identity filter would otherwise produce is the only safety
11152        // property left available for this shape.
11153        let candidate_id = select_history_candidate(
11154            &candidates,
11155            identity.as_ref().map(|identity| identity.id.as_str()),
11156        ).map_err(step("unique-candidate"))?;
11157        let loaded = self.load_history(session, candidate_id.clone()).await
11158            .map_err(step("load-history"))?;
11159        if let Some(identity) = identity.as_ref() {
11160            if loaded.session_id != identity.id {
11161                return Err(failure(
11162                    NodeFailureCode::SessionRecordConflict,
11163                    "loaded history identity does not match the managed session record",
11164                ));
11165            }
11166        }
11167        self.revalidate_session_record_context_export(
11168            &record,
11169            identity.as_ref(),
11170            session,
11171            allow_clean_detachment,
11172        )?;
11173        self.revalidate_loaded_history(session, &candidate_id, &loaded)
11174            .map_err(step("revalidate-loaded-history"))?;
11175        let pack = self.materialize_context_pack(session).await
11176            .map_err(step("materialize-context-pack"))?;
11177        self.commit_context_pack_for_session_record(
11178            &record,
11179            identity.as_ref(),
11180            session,
11181            &candidate_id,
11182            &loaded,
11183            pack,
11184            allow_clean_detachment,
11185        )
11186    }
11187
11188    async fn export_context_pack(
11189        &self,
11190        address: &SessionAddress,
11191    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11192        let pack = self.materialize_context_pack(address).await?;
11193        self.commit_context_pack(pack)
11194    }
11195
11196    async fn materialize_context_pack(
11197        &self,
11198        address: &SessionAddress,
11199    ) -> Result<NodeContextPack, NodeFailure> {
11200        let snapshot = self.internal_session_snapshot(address)?;
11201        if !context_pack_source_status_is_usable(&snapshot.status) {
11202            return Err(failure(
11203                NodeFailureCode::InvalidRequest,
11204                "context export requires a running or successfully exited source session",
11205            ));
11206        }
11207        if snapshot.history.pending.is_some()
11208            || snapshot.history.loaded_candidate_id.is_none()
11209        {
11210            return Err(failure(
11211                NodeFailureCode::InvalidRequest,
11212                "context export requires settled loaded history",
11213            ));
11214        }
11215        let loaded = snapshot.history.loaded.clone().ok_or_else(|| {
11216            failure(
11217                NodeFailureCode::InvalidRequest,
11218                "context export requires settled loaded history",
11219            )
11220        })?;
11221        let source_provider = snapshot.agent_id.clone();
11222        let loaded_candidate_id = snapshot.history.loaded_candidate_id.clone();
11223        let repository = self
11224            .context_pack_repository(&address.workspace_id)
11225            .await?;
11226        let current = self.internal_session_snapshot(address)?;
11227        if !context_pack_source_status_is_usable(&current.status)
11228            || current.agent_id != source_provider
11229            || current.history.pending.is_some()
11230            || current.history.loaded_candidate_id != loaded_candidate_id
11231            || current.history.loaded.as_ref() != Some(&loaded)
11232        {
11233            return Err(failure(
11234                NodeFailureCode::ContextPackMaterializationFailed,
11235                "context source changed during bounded repository capture",
11236            ));
11237        }
11238        NodeContextPack::export_with_repository(
11239            ContextPackLineageReceipt {
11240                source_node_id: self.node_id.clone(),
11241                source_session: address.clone(),
11242                source_provider,
11243            },
11244            &loaded,
11245            Some(repository),
11246        )
11247        .map_err(|_| {
11248            failure(
11249                NodeFailureCode::ContextPackMaterializationFailed,
11250                "bounded context pack export failed",
11251            )
11252        })
11253    }
11254
11255    /// Write-through to the durable [`ContextPackStore`], if one is
11256    /// configured (`state_path` is `Some`). A node with no durable state
11257    /// path — fixtures, tests, ephemeral runs — has no `ContextPackStore`
11258    /// open at all and this is a no-op, matching `persist_state`'s own
11259    /// "durability optional, degrade gracefully when unconfigured" idiom.
11260    fn commit_context_pack_durably(&self, pack: &NodeContextPack) -> Result<(), NodeFailure> {
11261        let mut store = self
11262            .context_pack_store
11263            .lock()
11264            .unwrap_or_else(|poisoned| poisoned.into_inner());
11265        let Some(store) = store.as_mut() else {
11266            return Ok(());
11267        };
11268        store.commit(pack).map_err(|_| {
11269            failure(
11270                NodeFailureCode::ContextPackMaterializationFailed,
11271                "durable context pack commit failed",
11272            )
11273        })
11274    }
11275
11276    fn commit_context_pack(
11277        &self,
11278        pack: NodeContextPack,
11279    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11280        self.commit_context_pack_durably(&pack)?;
11281        self.context_catalog
11282            .write()
11283            .unwrap_or_else(|poisoned| poisoned.into_inner())
11284            .insert(pack)
11285            .map_err(|_| {
11286                failure(
11287                    NodeFailureCode::ContextPackMaterializationFailed,
11288                    "context pack catalog is full",
11289                )
11290            })
11291    }
11292
11293    fn commit_context_pack_for_session_record(
11294        &self,
11295        expected: &ManagedSessionRecord,
11296        identity: Option<&ProviderSessionIdentity>,
11297        session: &SessionAddress,
11298        candidate_id: &str,
11299        loaded: &HistorySessionRecord,
11300        pack: NodeContextPack,
11301        allow_clean_detachment: bool,
11302    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11303        let current_root = self.workspace_root(&expected.workspace_id)?;
11304        let _transaction = self
11305            .state_transaction
11306            .lock()
11307            .unwrap_or_else(|poisoned| poisoned.into_inner());
11308        let records = self
11309            .session_records
11310            .lock()
11311            .unwrap_or_else(|poisoned| poisoned.into_inner());
11312        let current = records.records.get(&expected.record_id).ok_or_else(|| {
11313            failure(
11314                NodeFailureCode::SessionRecordConflict,
11315                "managed session record disappeared during context export",
11316            )
11317        })?;
11318        if !session_record_export_target_matches(expected, current, session, allow_clean_detachment) {
11319            return Err(failure(
11320                NodeFailureCode::SessionRecordConflict,
11321                "managed session record changed during context export",
11322            ));
11323        }
11324        let bindings = self
11325            .session_bindings
11326            .lock()
11327            .unwrap_or_else(|poisoned| poisoned.into_inner());
11328        let binding = bindings.get(&session.session.instance_id).ok_or_else(|| {
11329            failure(
11330                NodeFailureCode::SessionRecordConflict,
11331                "managed session runtime binding disappeared during context export",
11332            )
11333        })?;
11334        let runtime = self
11335            .handle
11336            .snapshot()
11337            .sessions
11338            .iter()
11339            .find(|snapshot| {
11340                snapshot.instance_id == session.session.instance_id
11341                    && snapshot.generation == session.session.generation
11342            })
11343            .cloned()
11344            .ok_or_else(|| {
11345                failure(
11346                    NodeFailureCode::SessionRecordConflict,
11347                    "managed session runtime disappeared during context export",
11348                )
11349            })?;
11350        let receipt = pack.receipt();
11351        if !session_record_context_export_binding_is_exact(
11352            expected,
11353            current,
11354            identity,
11355            session,
11356            binding,
11357            &runtime.agent_id,
11358            &current_root,
11359            allow_clean_detachment,
11360        ) || !session_record_context_export_source_is_usable(
11361            current,
11362            &runtime.status,
11363            allow_clean_detachment,
11364        ) || runtime.history.pending.is_some()
11365            || runtime.history.loaded_candidate_id.as_deref() != Some(candidate_id)
11366            || runtime.history.loaded.as_ref() != Some(loaded)
11367            || receipt.lineage.source_node_id != self.node_id
11368            || receipt.lineage.source_session != *session
11369            || receipt.lineage.source_provider != current.provider
11370            || receipt.source_message_count != loaded.message_count
11371        {
11372            return Err(failure(
11373                NodeFailureCode::SessionRecordConflict,
11374                "managed session source changed before context catalog commit",
11375            ));
11376        }
11377        self.commit_context_pack_durably(&pack)?;
11378        self.context_catalog
11379            .write()
11380            .unwrap_or_else(|poisoned| poisoned.into_inner())
11381            .insert(pack)
11382            .map_err(|_| {
11383                failure(
11384                    NodeFailureCode::ContextPackMaterializationFailed,
11385                    "context pack catalog is full",
11386                )
11387            })
11388    }
11389
11390    async fn context_pack_repository(
11391        &self,
11392        workspace_id: &WorkspaceId,
11393    ) -> Result<ContextPackRepository, NodeFailure> {
11394        let first = self
11395            .context_pack_repository_observation(workspace_id)
11396            .await?;
11397        let second = self
11398            .context_pack_repository_observation(workspace_id)
11399            .await?;
11400        stable_context_pack_repository(first, second)
11401    }
11402
11403    async fn context_pack_repository_observation(
11404        &self,
11405        workspace_id: &WorkspaceId,
11406    ) -> Result<ContextPackRepositoryObservation, NodeFailure> {
11407        let git = self.inspect_workspace(workspace_id.clone()).await?.git;
11408        let head = if git.is_repository {
11409            let root = self.workspace_root(workspace_id)?;
11410            match resolve_base_commit_with_timeout(
11411                &root,
11412                "HEAD",
11413                GIT_COMMAND_TIMEOUT_MS,
11414            )
11415            .await
11416            {
11417                Ok(commit) => ContextPackRepositoryHead::Commit(commit),
11418                Err(error) if error.kind == GitWorktreeErrorKind::Conflict => {
11419                    ContextPackRepositoryHead::Unborn
11420                }
11421                Err(_) => {
11422                    return Err(failure(
11423                        NodeFailureCode::ContextPackMaterializationFailed,
11424                        "context pack HEAD observation failed",
11425                    ));
11426                }
11427            }
11428        } else {
11429            ContextPackRepositoryHead::NotRepository
11430        };
11431        let mut files = Vec::with_capacity(CONTEXT_PACK_SELECTED_FILES.len());
11432        for selected_path in CONTEXT_PACK_SELECTED_FILES {
11433            let path = RepositoryPath::utf8(selected_path.to_owned()).map_err(|_| {
11434                failure(
11435                    NodeFailureCode::InvalidRepositoryPath,
11436                    "context pack selected file path is invalid",
11437                )
11438            })?;
11439            match self.read_workspace_file(workspace_id.clone(), path).await {
11440                Ok(WorkspaceFileRead {
11441                    content: WorkspaceFileContent::Utf8 { text, byte_len },
11442                    ..
11443                }) => files.push(ContextPackRepositoryFileSource::utf8(
11444                    selected_path,
11445                    text,
11446                    byte_len,
11447                )),
11448                Ok(WorkspaceFileRead {
11449                    content: WorkspaceFileContent::NonUtf8 { byte_len },
11450                    ..
11451                }) => files.push(ContextPackRepositoryFileSource::skipped(
11452                    selected_path,
11453                    ContextPackSelectedFileSkipReason::NonUtf8,
11454                    Some(byte_len),
11455                )),
11456                Ok(WorkspaceFileRead {
11457                    content: WorkspaceFileContent::TooLarge { .. },
11458                    ..
11459                }) => files.push(ContextPackRepositoryFileSource::skipped(
11460                    selected_path,
11461                    ContextPackSelectedFileSkipReason::TooLarge,
11462                    None,
11463                )),
11464                Err(error) => {
11465                    let reason = if matches!(
11466                        error.code,
11467                        NodeFailureCode::InvalidRepositoryPath
11468                            | NodeFailureCode::RepositoryFileNotRegular
11469                            | NodeFailureCode::RepositoryPathUnsafe
11470                    ) {
11471                        ContextPackSelectedFileSkipReason::Unsafe
11472                    } else {
11473                        ContextPackSelectedFileSkipReason::Unavailable
11474                    };
11475                    files.push(ContextPackRepositoryFileSource::skipped(
11476                        selected_path,
11477                        reason,
11478                        None,
11479                    ));
11480                }
11481            }
11482        }
11483        Ok(ContextPackRepositoryObservation { head, git, files })
11484    }
11485
11486    fn forget_context_pack(&self, context_id: &SpawnContextId) -> Result<(), NodeFailure> {
11487        let referenced_by_binding = self
11488            .session_bindings
11489            .lock()
11490            .unwrap_or_else(|poisoned| poisoned.into_inner())
11491            .values()
11492            .any(|binding| {
11493                binding.context.as_ref().map(|receipt| &receipt.id) == Some(context_id)
11494            });
11495        let referenced_by_record = self
11496            .session_records
11497            .lock()
11498            .unwrap_or_else(|poisoned| poisoned.into_inner())
11499            .records
11500            .values()
11501            .any(|record| record.context_id.as_ref() == Some(context_id));
11502        let referenced_by_materialization = self
11503            .materializations
11504            .lock()
11505            .unwrap_or_else(|poisoned| poisoned.into_inner())
11506            .values()
11507            .any(|ownership| {
11508                ownership.context().map(|receipt| &receipt.id) == Some(context_id)
11509            });
11510        if referenced_by_binding
11511            || referenced_by_record
11512            || referenced_by_materialization
11513        {
11514            return Err(failure(
11515                NodeFailureCode::ContextPackBusy,
11516                "context pack is still referenced",
11517            ));
11518        }
11519        let mut catalog = self.context_catalog
11520            .write()
11521            .unwrap_or_else(|poisoned| poisoned.into_inner());
11522        if catalog.get(context_id).is_none() {
11523            return Err(failure(
11524                NodeFailureCode::UnknownContextPack,
11525                "context pack is unavailable",
11526            ));
11527        }
11528        self.spawn_idempotency
11529            .lock()
11530            .unwrap_or_else(|poisoned| poisoned.into_inner())
11531            .entries
11532            .retain(|_, entry| !entry.value.references_context(context_id));
11533        let removed = catalog.remove(context_id);
11534        debug_assert!(removed.is_some(), "validated context pack remains present");
11535        Ok(())
11536    }
11537
11538    async fn dispatch_input_bounded(
11539        &self,
11540        address: &SessionAddress,
11541        action: InputAction,
11542    ) -> Result<(), NodeFailure> {
11543        let started_after = self.current_sequence();
11544        let command_id = self
11545            .dispatch_bounded(
11546                ControlCommand::SendInput {
11547                    instance_id: address.session.instance_id,
11548                    action,
11549                },
11550                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
11551            )
11552            .await?;
11553        // A semantic prompt may spend the provider's full readiness budget before
11554        // the runtime emits InputCompleted. Keep the node request deadline bounded,
11555        // but strictly beyond every readiness policy in this node's catalog.
11556        let deadline = Instant::now() + Duration::from_millis(self.input_settle_timeout_ms);
11557        let mut scan_after = started_after;
11558        let mut accepted_after = None;
11559        loop {
11560            let events = self
11561                .history
11562                .lock()
11563                .unwrap_or_else(|poisoned| poisoned.into_inner())
11564                .events
11565                .iter()
11566                .filter(|envelope| envelope.sequence > scan_after)
11567                .cloned()
11568                .collect::<Vec<_>>();
11569            for envelope in events {
11570                scan_after = scan_after.max(envelope.sequence);
11571                let NodeEvent::Control {
11572                    address: event_address,
11573                    event,
11574                } = envelope.event
11575                else {
11576                    continue;
11577                };
11578                if event_address != *address {
11579                    continue;
11580                }
11581                if event.command_id == Some(command_id) {
11582                    match &event.event {
11583                        ControlEventKind::CommandRejected { message } => {
11584                            return Err(failure(NodeFailureCode::InvalidRequest, message));
11585                        }
11586                        ControlEventKind::InputRequested { .. } => {
11587                            accepted_after = Some(envelope.sequence);
11588                            continue;
11589                        }
11590                        _ => {}
11591                    }
11592                }
11593                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
11594                    match &event.event {
11595                        ControlEventKind::InputCompleted { .. } => return Ok(()),
11596                        ControlEventKind::InputFailed { message, .. } => {
11597                            return Err(failure(
11598                                NodeFailureCode::BackendOperationFailed,
11599                                message,
11600                            ));
11601                        }
11602                        _ => {}
11603                    }
11604                }
11605            }
11606            if Instant::now() >= deadline {
11607                return Err(failure(
11608                    NodeFailureCode::BackendBusy,
11609                    if accepted_after.is_some() {
11610                        "input was accepted but did not settle before the bounded deadline"
11611                    } else {
11612                        "input was not accepted before the bounded deadline"
11613                    },
11614                ));
11615            }
11616            sleep(Duration::from_millis(2)).await;
11617        }
11618    }
11619
11620    async fn dispatch_resize_bounded(
11621        &self,
11622        address: &SessionAddress,
11623        size: gate4agent_types::TerminalSize,
11624    ) -> Result<(), NodeFailure> {
11625        let started_after = self.current_sequence();
11626        let command_id = self
11627            .dispatch_bounded(
11628                ControlCommand::Resize {
11629                    instance_id: address.session.instance_id,
11630                    size,
11631                },
11632                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
11633            )
11634            .await?;
11635        let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
11636        let mut scan_after = started_after;
11637        let mut accepted_after = None;
11638        loop {
11639            let events = self
11640                .history
11641                .lock()
11642                .unwrap_or_else(|poisoned| poisoned.into_inner())
11643                .events
11644                .iter()
11645                .filter(|envelope| envelope.sequence > scan_after)
11646                .cloned()
11647                .collect::<Vec<_>>();
11648            for envelope in events {
11649                scan_after = scan_after.max(envelope.sequence);
11650                let NodeEvent::Control {
11651                    address: event_address,
11652                    event,
11653                } = envelope.event
11654                else {
11655                    continue;
11656                };
11657                if event_address != *address {
11658                    continue;
11659                }
11660                if event.command_id == Some(command_id) {
11661                    match &event.event {
11662                        ControlEventKind::CommandRejected { message } => {
11663                            return Err(failure(NodeFailureCode::InvalidRequest, message));
11664                        }
11665                        ControlEventKind::ResizeRequested {
11666                            size: requested_size,
11667                            ..
11668                        } if *requested_size == size => {
11669                            accepted_after = Some(envelope.sequence);
11670                            continue;
11671                        }
11672                        _ => {}
11673                    }
11674                }
11675                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
11676                    match &event.event {
11677                        ControlEventKind::Resized { size: settled_size }
11678                            if *settled_size == size =>
11679                        {
11680                            return Ok(());
11681                        }
11682                        ControlEventKind::ResizeFailed { message } => {
11683                            return Err(failure(
11684                                NodeFailureCode::BackendOperationFailed,
11685                                message,
11686                            ));
11687                        }
11688                        _ => {}
11689                    }
11690                }
11691            }
11692            if Instant::now() >= deadline {
11693                return Err(failure(
11694                    NodeFailureCode::BackendBusy,
11695                    if accepted_after.is_some() {
11696                        "resize was accepted but did not settle before the bounded deadline"
11697                    } else {
11698                        "resize was not accepted before the bounded deadline"
11699                    },
11700                ));
11701            }
11702            sleep(Duration::from_millis(2)).await;
11703        }
11704    }
11705
11706    async fn spawn_session(
11707        &self,
11708        workspace_id: WorkspaceId,
11709        provider: AgentId,
11710        mode: SessionMode,
11711        terminal_size: gate4agent_types::TerminalSize,
11712        initial_prompt: Option<String>,
11713    ) -> Result<SessionAddress, NodeFailure> {
11714        if self.managed_worktrees.lock()
11715            .unwrap_or_else(|poisoned| poisoned.into_inner())
11716            .lease_for_workspace(&workspace_id)
11717            .is_some()
11718        {
11719            return Err(failure(
11720                NodeFailureCode::ManagedWorktreeOwnershipConflict,
11721                "managed worktree targets may only be spawned through their lease request",
11722            ));
11723        }
11724        self
11725            .spawn_session_with_deadline(
11726                workspace_id,
11727                provider,
11728                mode,
11729                terminal_size,
11730                initial_prompt,
11731                None,
11732                None,
11733                None,
11734                None,
11735                None,
11736                SpawnRecordPolicy::ProviderIdentityOnly,
11737                None,
11738                &[],
11739                None,
11740                // `NodeRequest::Spawn` (this method's only caller) carries no
11741                // approval-level selection of its own -- see
11742                // `HarnessOperatorRequestV1::SpawnSession`'s own field for
11743                // the one operator-wire path that can choose something other
11744                // than the axis default.
11745                ApprovalLevel::default(),
11746                None,
11747                None,
11748            )
11749            .await
11750            .map(|(session, _runtime_policy)| session)
11751    }
11752
11753    async fn spawn_session_with_deadline(
11754        &self,
11755        workspace_id: WorkspaceId,
11756        provider: AgentId,
11757        mode: SessionMode,
11758        terminal_size: gate4agent_types::TerminalSize,
11759        initial_prompt: Option<String>,
11760        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
11761        bundle: Option<ResolvedBundleReceipt>,
11762        context: Option<ResolvedContextPackReceipt>,
11763        managed_authority: Option<ManagedWorktreeLeaseId>,
11764        admitted_runtime_policy: Option<ProviderRuntimePolicy>,
11765        record_policy: SpawnRecordPolicy,
11766        deadline: Option<Instant>,
11767        required_capabilities: &[ProviderRuntimeCapability],
11768        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
11769        approval_level: ApprovalLevel,
11770        network_allowlist: Option<crate::protocol::SpawnNetworkAllowlistId>,
11771        // Opaque dig2browser station profile id (ids only). Feature
11772        // `dig2-station-probe`: after probe at resolve, spawn takes an
11773        // exclusive node-local lease; cleared on session end. Never
11774        // ImportSession / cookies on C2. Feature off: ignored (stub echo
11775        // already happened on the receipt path).
11776        browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
11777    ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
11778        let reserved_lease = self.managed_worktrees.lock()
11779            .unwrap_or_else(|poisoned| poisoned.into_inner())
11780            .lease_for_workspace(&workspace_id);
11781        match (reserved_lease.as_ref(), managed_authority.as_ref()) {
11782            (Some(expected), Some(provided)) if expected == provided => {}
11783            (None, None) => {}
11784            _ => {
11785                return Err(failure(
11786                    NodeFailureCode::ManagedWorktreeOwnershipConflict,
11787                    "spawn lacks exact managed worktree lease authority",
11788                ));
11789            }
11790        }
11791        // Refuse concurrent distinct environment profiles (incl. ProviderHome
11792        // bindings) on the same workspace cwd. Parallel path: managed worktree
11793        // spawn so each profile gets its own workspace_id.
11794        self.ensure_workspace_environment_profile_compatible(
11795            &workspace_id,
11796            environment_profile.as_ref(),
11797        )?;
11798        let runtime_requirement = match (mode, initial_prompt.is_some()) {
11799            (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
11800            (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
11801            (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
11802            (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
11803        };
11804        // This session's own transport -- computed once, here, from `mode`,
11805        // and reused both to admit spawn (below) and, further down, to
11806        // compute the final `runtime_policy` this session actually operates
11807        // under (`provider_runtime::policy_for_transport`). Registration
11808        // reuses this exact value rather than recomputing it a second time.
11809        let transport = match mode {
11810            SessionMode::Pty => TransportKind::Pty,
11811            SessionMode::Inline => TransportKind::Pipe,
11812            SessionMode::Acp => TransportKind::Acp,
11813        };
11814        let pipe_transport = self.provider_supports_pipe_transport(&provider);
11815        let acp_transport = self.provider_supports_acp_transport(&provider);
11816        let runtime_policy = if let Some(runtime_policy) = admitted_runtime_policy {
11817            require_policy(runtime_policy, pipe_transport, acp_transport, runtime_requirement).map_err(|_| failure(
11818                NodeFailureCode::UnsupportedSpawnCapability,
11819                "pre-admitted provider runtime no longer satisfies spawn requirements",
11820            ))?;
11821            runtime_policy
11822        } else if let Some(deadline) = deadline {
11823            let remaining = spawn_deadline_remaining(deadline)?;
11824            timeout(
11825                remaining,
11826                self.admit_provider_runtime(&provider, runtime_requirement),
11827            )
11828            .await
11829            .map_err(|_| {
11830                failure(
11831                    NodeFailureCode::SpawnDeadlineExceeded,
11832                    "provider admission exceeded the spawn deadline",
11833                )
11834            })??
11835        } else {
11836            self.admit_provider_runtime(&provider, runtime_requirement).await?
11837        };
11838        let runtime_policy = self.effective_spawn_runtime_policy(runtime_policy);
11839        // The single point where this session's `runtime_policy` becomes
11840        // transport-correct: `admit_provider_runtime` (and the pre-admitted
11841        // branch above) only ever compute a PTY-terminal-verification-shaped
11842        // value, sound outright only for `TransportKind::Pty` (Pipe still
11843        // relies on that same value downstream -- see `policy_for_transport`'s
11844        // own doc for why). `policy_for_transport` is the explicit,
11845        // exhaustive match Item 1 asks for -- this is the ONE place its
11846        // output becomes the session's `runtime_policy`, so every later
11847        // reader (`ControlCommand::Start`, `session_bindings`, and every
11848        // engine gate that reads `state.runtime_policy`) sees the same,
11849        // correctly-computed value.
11850        let runtime_policy =
11851            crate::provider_runtime::policy_for_transport(transport, runtime_policy);
11852        if required_capabilities
11853            .iter()
11854            .any(|capability| !runtime_policy.admits(*capability))
11855        {
11856            return Err(failure(
11857                NodeFailureCode::UnsupportedSpawnCapability,
11858                "provider runtime does not admit a required spawn capability",
11859            ));
11860        }
11861        if let Some(deadline) = deadline {
11862            spawn_deadline_remaining(deadline)?;
11863        }
11864        self.ensure_binding_capacity()?;
11865        let working_directory = self.workspace_root(&workspace_id)?;
11866        let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
11867        let session = SessionKey {
11868            instance_id,
11869            generation: SessionGeneration(1),
11870        };
11871        let address = SessionAddress {
11872            workspace_id: workspace_id.clone(),
11873            session,
11874        };
11875        // This is the request-accepted checkpoint: admission (managed
11876        // worktree authority, runtime capability, dispatch deadline) already
11877        // passed and a concrete session identity is minted. It is a distinct
11878        // state from the process actually running (`ControlEventKind::Running`,
11879        // logged in `publish_control`) or the terminal outcome (`Exited`/
11880        // `Failed`, logged there too) -- this line exists so that gap is no
11881        // longer silent.
11882        tracing::info!(
11883            node_id = %self.node_id,
11884            workspace_id = %workspace_id,
11885            provider = %provider,
11886            environment_profile = ?environment_profile.as_ref().map(|receipt| &receipt.profile_id),
11887            session = ?address.session,
11888            "session spawn request accepted",
11889        );
11890        // Track A follow-on: exclusive node-local BrowserStationLease after
11891        // dig2 probe (feature dig2-station-probe). Opaque profile id only —
11892        // never ImportSession / cookies on C2. Linux/feature-off: probe
11893        // already refused or stub; this block is cfg'd out when feature off.
11894        #[cfg(feature = "dig2-station-probe")]
11895        let mut browser_station_lease_guard = if let Some(profile_id) = browser_profile_id {
11896            use crate::dig2_station_lease::{acquire_lease, BrowserStationLeaseError};
11897            match acquire_lease(
11898                &self.browser_station_leases,
11899                profile_id,
11900                instance_id,
11901                unix_time_ms(),
11902            ) {
11903                Ok(guard) => Some(guard),
11904                Err(BrowserStationLeaseError::ProfileBusy { .. }) => {
11905                    return Err(failure(
11906                        NodeFailureCode::BrowserStationProfileBusy,
11907                        "dig2browser station profile is already leased by another session",
11908                    ));
11909                }
11910                Err(BrowserStationLeaseError::CapacityExceeded) => {
11911                    return Err(failure(
11912                        NodeFailureCode::BrowserStationProfileBusy,
11913                        "dig2browser station profile lease capacity exhausted",
11914                    ));
11915                }
11916                Err(BrowserStationLeaseError::HolderAlreadyLeased) => {
11917                    return Err(failure(
11918                        NodeFailureCode::BrowserStationProfileBusy,
11919                        "session already holds a dig2browser station profile lease",
11920                    ));
11921                }
11922            }
11923        } else {
11924            None
11925        };
11926        #[cfg(not(feature = "dig2-station-probe"))]
11927        let _ = browser_profile_id;
11928        let mut harness_mcp_overlay = if let Some(prepared) = harness_mcp {
11929            // Slice A(ii) of gate4agent-arc-mailbox-and-task-layer: the door
11930            // exists for PTY (env vars) and ACP (`session/new.mcpServers`,
11931            // `gate4agent-shell-native`'s `mcp_server_acp_entry`) alike --
11932            // both read the identical overlay resolved below, one as child
11933            // environment, the other translated into one stdio MCP server
11934            // entry. `Inline`/`Pipe` stays refused: this arc's proof is ACP
11935            // only, and nothing downstream reads a harness-MCP overlay for
11936            // that transport yet.
11937            if !matches!(mode, SessionMode::Pty | SessionMode::Acp) || prepared.provider != provider
11938            {
11939                return Err(failure(
11940                    NodeFailureCode::BindingMismatch,
11941                    "harness MCP reservation does not match the exact PTY/ACP provider",
11942                ));
11943            }
11944            prepared.verify_helper_program().map_err(harness_mcp_failure)?;
11945            let control = self.native_launch_profile_control.clone().ok_or_else(|| {
11946                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP launch control is unavailable")
11947            })?;
11948            // Every name below is the caller's, carried in the reservation's
11949            // launch description: which environment variables receive the
11950            // endpoint, token and helper program, what the MCP server is called,
11951            // which argv it gets and which stale variables are erased.
11952            let launch = prepared.launch();
11953            let mut env_entries = vec![
11954                (
11955                    OsString::from(launch.endpoint_env.as_str()),
11956                    prepared.endpoint().as_os_str().to_os_string(),
11957                ),
11958                (
11959                    OsString::from(launch.token_env.as_str()),
11960                    prepared.token().expose().into(),
11961                ),
11962                (
11963                    OsString::from(launch.program_env.as_str()),
11964                    prepared.helper_program().as_os_str().to_os_string(),
11965                ),
11966            ];
11967            if let Some(trace_entry) = harness_mcp_trace_env_entry(launch) {
11968                env_entries.push(trace_entry);
11969            }
11970            let overlay = NativeMcpServerLaunchOverlay::new(
11971                provider.clone(),
11972                launch.server_name.as_str(),
11973                prepared.helper_program().as_os_str().to_os_string(),
11974                launch.args.iter().map(OsString::from).collect(),
11975                env_entries,
11976                launch.scrub_env.iter().map(OsString::from).collect(),
11977            ).map_err(|_| failure(
11978                NodeFailureCode::HarnessMcpUnavailable,
11979                "harness MCP launch overlay is unavailable",
11980            ))?;
11981            control
11982                .install_native_mcp_server_launch_overlay(instance_id, overlay)
11983                .map_err(|_| failure(
11984                    NodeFailureCode::HarnessMcpUnavailable,
11985                    "harness MCP launch overlay could not be installed",
11986                ))?;
11987            // State-change line: the overlay is installed and will be read
11988            // back by the PTY child's environment or, for ACP,
11989            // `gate4agent-shell-native`'s `mcp_server_acp_entry` at spawn
11990            // time -- no secrets here, only which instance/provider/mode
11991            // now carries a harness-MCP door.
11992            tracing::info!(
11993                node_id = %self.node_id,
11994                instance_id = ?instance_id,
11995                provider = %provider,
11996                mode = ?mode,
11997                "harness MCP overlay installed for spawn",
11998            );
11999            Some(NativeHarnessMcpOverlayGuard { control, instance_id })
12000        } else {
12001            None
12002        };
12003        let prepared_materialization = self.prepare_session_materialization(
12004            &address,
12005            &provider,
12006            mode,
12007            environment_profile.as_ref(),
12008            bundle.as_ref(),
12009            context.as_ref(),
12010            managed_authority.clone(),
12011        )?;
12012        let (environment_overlay, mut materialization_guard) = match prepared_materialization {
12013            Some((overlay, guard)) => (Some(overlay), Some(guard)),
12014            None => (None, None),
12015        };
12016        let materialization_id = materialization_guard
12017            .as_ref()
12018            .and_then(SessionMaterializationGuard::id)
12019            .cloned();
12020        let mut environment_selection = self.select_environment_profile(
12021            instance_id,
12022            &provider,
12023            mode,
12024            environment_profile.as_ref(),
12025        )?;
12026        // Station network overlays: Codex `-c network_access` and/or Claude
12027        // ProviderHome `--settings` sidecar (permits → allowedDomains).
12028        let provider_home = materialization_guard
12029            .as_ref()
12030            .and_then(SessionMaterializationGuard::provider_home);
12031        let network_overlay_args = self.resolve_network_allowlist_overlay_args(
12032            &provider,
12033            mode,
12034            approval_level,
12035            network_allowlist.as_ref(),
12036            provider_home,
12037        )?;
12038        let environment_overlay = match (environment_overlay.flatten(), network_overlay_args) {
12039            (prepared, args) if args.is_empty() => prepared,
12040            (Some(PreparedNativeLaunchOverlay::Instance(mut overlay)), args) => {
12041                overlay.append_extra_args(args).map_err(|_| {
12042                    failure(
12043                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
12044                        "network allowlist overlay could not merge with instance launch args",
12045                    )
12046                })?;
12047                Some(PreparedNativeLaunchOverlay::Instance(overlay))
12048            }
12049            (Some(PreparedNativeLaunchOverlay::Environment(overlay)), args) => {
12050                Some(PreparedNativeLaunchOverlay::Instance(
12051                    overlay
12052                        .into_instance_with_extra_args(args)
12053                        .map_err(|_| {
12054                            failure(
12055                                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
12056                                "network allowlist overlay could not merge with environment overlay",
12057                            )
12058                        })?,
12059                ))
12060            }
12061            (None, args) => Some(PreparedNativeLaunchOverlay::Instance(
12062                NativeInstanceLaunchOverlay::new(
12063                    provider.clone(),
12064                    match mode {
12065                        SessionMode::Pty => TransportKind::Pty,
12066                        SessionMode::Acp => TransportKind::Acp,
12067                        SessionMode::Inline => TransportKind::Pipe,
12068                    },
12069                    Vec::new(),
12070                    args,
12071                )
12072                .map_err(|_| {
12073                    failure(
12074                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
12075                        "network allowlist overlay is invalid for this transport",
12076                    )
12077                })?,
12078            )),
12079        };
12080        let mut instance_overlay = environment_overlay
12081            .map(|overlay| self.install_prepared_launch_overlay(instance_id, overlay))
12082            .transpose()?
12083            .flatten();
12084        let dispatch_timeout = spawn_dispatch_timeout(deadline)?;
12085        let record_id = self.bind_spawn_session_with_materialization(
12086            &address,
12087            provider.clone(),
12088            mode,
12089            runtime_policy,
12090            record_policy,
12091            environment_profile,
12092            bundle,
12093            context,
12094            materialization_id,
12095        )?;
12096        if let Some(selection) = environment_selection.take() {
12097            selection.retain();
12098        }
12099        if let Some(overlay) = instance_overlay.take() {
12100            overlay.retain();
12101        }
12102        if let Some(guard) = materialization_guard.take() {
12103            guard.retain();
12104        }
12105        // `transport` was already computed above, alongside this session's
12106        // `runtime_policy`; reused here for `Register` rather than derived a
12107        // second time.
12108        let agent_id = provider;
12109        let register_command_id = match self.dispatch_bounded(
12110            ControlCommand::Register {
12111                instance_id,
12112                agent_id,
12113                transport,
12114            },
12115            dispatch_timeout,
12116        )
12117        .await {
12118            Ok(command_id) => command_id,
12119            Err(error) => {
12120                if let Some(binding) = self.remove_binding(&address) {
12121                    self.cleanup_session_owned_materialization(&address, &binding)?;
12122                }
12123                if let Some(record_id) = record_id.as_ref() {
12124                    self.discard_record(record_id)?;
12125                }
12126                return Err(spawn_dispatch_error(error, deadline));
12127            }
12128        };
12129        let start_timeout = match spawn_dispatch_timeout(deadline) {
12130            Ok(timeout) => timeout,
12131            Err(error) => {
12132                let recovery = self
12133                    .rollback_spawn(
12134                        &address,
12135                        Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12136                    )
12137                    .await;
12138                return match recovery {
12139                    Ok(()) => {
12140                        if let Some(record_id) = record_id.as_ref() {
12141                            self.discard_record(record_id)?;
12142                        }
12143                        Err(error)
12144                    }
12145                    Err(recovery_error) => {
12146                        if let Some(record_id) = record_id.as_ref() {
12147                            self.mark_record_error(
12148                                record_id,
12149                                "spawn deadline elapsed and registration recovery failed",
12150                            )?;
12151                        }
12152                        Err(recovery_error)
12153                    }
12154                };
12155            }
12156        };
12157        if let Err(start_error) = self.verify_harness_mcp_before_start(harness_mcp) {
12158            if let Err(recovery_error) = self
12159                .rollback_spawn(&address, Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS))
12160                .await
12161            {
12162                return Err(recovery_error);
12163            }
12164            if let Some(record_id) = record_id.as_ref() {
12165                self.discard_record(record_id)?;
12166            }
12167            return Err(start_error);
12168        }
12169        let start_result = self
12170            .dispatch_bounded(
12171                ControlCommand::Start {
12172                    instance_id,
12173                    runtime_policy,
12174                    request: StartRequest {
12175                        working_directory,
12176                        terminal_size,
12177                        initial_prompt,
12178                        session_options: None,
12179                        approval_level,
12180                    },
12181                },
12182                start_timeout,
12183            )
12184            .await;
12185        let start_command_id = match start_result {
12186            Ok(command_id) => command_id,
12187            Err(start_error) => {
12188                let start_error = spawn_dispatch_error(start_error, deadline);
12189                let recovery = self
12190                    .rollback_spawn(
12191                        &address,
12192                        Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12193                    )
12194                    .await;
12195                return match recovery {
12196                    Ok(()) => {
12197                        if let Some(record_id) = record_id.as_ref() {
12198                            self.discard_record(record_id)?;
12199                        }
12200                        Err(start_error)
12201                    }
12202                    Err(recovery_error) => {
12203                        let diagnostic = format!(
12204                            "start failed and registration recovery failed: {}",
12205                            recovery_error.message,
12206                        );
12207                        if let Some(record_id) = record_id.as_ref() {
12208                            self.mark_record_error(record_id, &diagnostic)?;
12209                        }
12210                        Err(failure(recovery_error.code, &diagnostic))
12211                    }
12212                };
12213            }
12214        };
12215
12216        let commit_deadline = deadline.unwrap_or_else(|| Instant::now() + dispatch_timeout);
12217        loop {
12218            // `Register`/`Start` only fail synchronously for a port-level
12219            // ingress problem (see `dispatch_bounded`); the kernel refusing
12220            // either command's content -- an unsupported transport, an
12221            // unknown agent -- lands one drive-loop tick later, and no
12222            // session ever appears in `self.handle.snapshot()` for it to
12223            // notice below. Check for that named reason before falling
12224            // through to the blind presence poll, so a rejected spawn
12225            // reports why instead of just timing out.
12226            if let Some(reason) = self.take_rejected_command_reason(register_command_id)
12227                .or_else(|| self.take_rejected_command_reason(start_command_id))
12228            {
12229                self.rollback_spawn(
12230                    &address,
12231                    Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12232                ).await.map_err(|error| {
12233                        failure(
12234                            error.code,
12235                            &format!(
12236                                "spawn was rejected ({}) and registration recovery failed: {}",
12237                                reason.message, error.message
12238                            ),
12239                        )
12240                    })?;
12241                if let Some(record_id) = record_id.as_ref() {
12242                    self.discard_record(record_id)?;
12243                }
12244                let code = if reason.unsupported_transport {
12245                    NodeFailureCode::UnsupportedTransport
12246                } else {
12247                    NodeFailureCode::BackendOperationFailed
12248                };
12249                return Err(failure(code, &reason.message));
12250            }
12251            if let Some(current) = self
12252                .handle
12253                .snapshot()
12254                .sessions
12255                .iter()
12256                .find(|current| current.instance_id == instance_id)
12257            {
12258                if current.generation != session.generation {
12259                    let divergence = failure(
12260                        NodeFailureCode::StaleGeneration,
12261                        "spawn generation diverged",
12262                    );
12263                    return match self
12264                        .rollback_spawn(
12265                            &address,
12266                            Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12267                        )
12268                        .await
12269                    {
12270                        Ok(()) => {
12271                            if let Some(record_id) = record_id.as_ref() {
12272                                self.discard_record(record_id)?;
12273                            }
12274                            Err(divergence)
12275                        }
12276                        Err(recovery_error) => {
12277                            if let Some(record_id) = record_id.as_ref() {
12278                                self.mark_record_error(
12279                                    record_id,
12280                                    "spawn generation diverged and recovery failed",
12281                                )?;
12282                            }
12283                            Err(recovery_error)
12284                        }
12285                    };
12286                }
12287                if !matches!(current.status, gate4agent_types::SessionStatus::Registered) {
12288                    if let Some(overlay) = harness_mcp_overlay.take() {
12289                        overlay.retain();
12290                    }
12291                    #[cfg(feature = "dig2-station-probe")]
12292                    if let Some(guard) = browser_station_lease_guard.take() {
12293                        guard.retain();
12294                    }
12295                    return Ok((address, runtime_policy));
12296                }
12297            }
12298            if Instant::now() >= commit_deadline {
12299                self.rollback_spawn(
12300                    &address,
12301                    Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12302                ).await.map_err(|error| {
12303                        failure(
12304                            error.code,
12305                            &format!(
12306                                "start did not commit and registration recovery failed: {}",
12307                                error.message
12308                            ),
12309                        )
12310                    })?;
12311                if let Some(record_id) = record_id.as_ref() {
12312                    self.discard_record(record_id)?;
12313                }
12314                return Err(failure(
12315                    if deadline.is_some() {
12316                        NodeFailureCode::SpawnDeadlineExceeded
12317                    } else {
12318                        NodeFailureCode::BackendBusy
12319                    },
12320                    "start did not commit before the bounded deadline; registration was removed",
12321                ));
12322            }
12323            sleep(Duration::from_millis(2)).await;
12324        }
12325    }
12326
12327    fn effective_spawn_runtime_policy(
12328        &self,
12329        runtime_policy: ProviderRuntimePolicy,
12330    ) -> ProviderRuntimePolicy {
12331        #[cfg(feature = "fixture")]
12332        let runtime_policy = if self.fixture_semantic_hook_policy {
12333            ProviderRuntimePolicy::new(true, true, true, true, false, true)
12334                .expect("monitoring Hook fixture policy is internally valid")
12335        } else {
12336            runtime_policy
12337        };
12338        runtime_policy
12339    }
12340
12341    fn verify_harness_mcp_before_start(
12342        &self,
12343        prepared: Option<&PreparedHarnessMcpSpawn>,
12344    ) -> Result<(), NodeFailure> {
12345        prepared
12346            .map(PreparedHarnessMcpSpawn::verify_helper_program)
12347            .transpose()
12348            .map(|_| ())
12349            .map_err(harness_mcp_failure)
12350    }
12351
12352    async fn rollback_spawn(
12353        &self,
12354        address: &SessionAddress,
12355        commit_timeout: Duration,
12356    ) -> Result<(), NodeFailure> {
12357        self.dispatch_bounded(
12358            ControlCommand::Remove {
12359                instance_id: address.session.instance_id,
12360            },
12361            commit_timeout,
12362        )
12363        .await?;
12364        self.wait_until_removed(address, commit_timeout).await?;
12365        if let Some(binding) = self.remove_binding(address) {
12366            self.cleanup_session_owned_materialization(address, &binding)?;
12367        }
12368        Ok(())
12369    }
12370
12371    async fn remove_session(&self, address: &SessionAddress) -> Result<(), NodeFailure> {
12372        let commit_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
12373        self.dispatch_bounded(
12374            ControlCommand::Remove {
12375                instance_id: address.session.instance_id,
12376            },
12377            commit_timeout,
12378        )
12379            .await?;
12380        self.wait_until_removed(address, commit_timeout).await?;
12381        let removed = self.remove_binding(address);
12382        if let Some(binding) = removed.as_ref() {
12383            self.cleanup_session_owned_materialization(address, binding)?;
12384        }
12385        if let Some(lease_id) = removed.and_then(|binding| binding.managed_worktree_lease_id) {
12386            let snapshot = self.managed_worktrees.lock()
12387                .unwrap_or_else(|poisoned| poisoned.into_inner())
12388                .release_session(
12389                    &lease_id,
12390                    self.incarnation_id,
12391                    address.session.instance_id,
12392                    address.session.generation,
12393                    unix_time_ms(),
12394                );
12395            if self.persist_state().is_err() {
12396                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
12397            }
12398            if let Some(snapshot) = snapshot {
12399                self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
12400            }
12401            let _ = self.cleanup_managed_worktree(&lease_id, false).await;
12402        }
12403        Ok(())
12404    }
12405
12406    /// Polls until `address`'s session leaves the engine's own session map,
12407    /// or the bounded deadline expires.
12408    ///
12409    /// A `Remove` sent while the session is not yet in a terminal status
12410    /// (e.g. still `Stopping`, mid-effect right after a `Stop`) is rejected
12411    /// once by the engine (`SessionStatus::allows_remove`) and
12412    /// then discarded -- nothing re-issues it on its own, so the removal
12413    /// that would satisfy this wait never arrives and the caller was
12414    /// guaranteed to time out even though the session settles microseconds
12415    /// later. So this re-dispatches on every poll tick: a session that
12416    /// settles mid-wait -- the common case right after a `Stop` -- still
12417    /// gets removed inside the same bounded deadline.
12418    ///
12419    /// It re-dispatches only once the status will ACCEPT the command. A
12420    /// rejected re-dispatch is not the harmless no-op this loop first
12421    /// assumed: every rejection publishes a `ControlEventKind::Command
12422    /// Rejected` to every subscriber and writes a WARN line. Ungated, one
12423    /// ordinary teardown produced 241 of them in 1.6 seconds -- the whole
12424    /// `Stopping` window at one known-doomed dispatch per 2ms tick, fanned
12425    /// out to the harness and every attached client. Gated, the same
12426    /// teardown sends exactly one `Remove`, at the first tick the engine
12427    /// can act on it.
12428    ///
12429    /// The status is read from the snapshot this loop already takes, so
12430    /// the gate costs nothing extra. It races -- the status can move
12431    /// between the read and the dispatch -- and that is fine: a rare lost
12432    /// race is one rejection, and the next tick retries.
12433    ///
12434    /// Re-dispatch is separately rate-limited to
12435    /// [`REMOVE_REDISPATCH_INTERVAL_MS`], because the status gate alone
12436    /// leaves a second burst behind it. Once the session settles, every
12437    /// 2ms tick sent another `Remove` while the accepted one was still in
12438    /// flight, and each of those landed on an instance the engine had
12439    /// already dropped -- `agent instance … is not registered`, observed
12440    /// live as a tail of rejections immediately after the `session
12441    /// removed` line. The presence poll stays at 2ms so the wait still
12442    /// exits promptly; only the sending slows down.
12443    ///
12444    /// Rate-limited rather than once-only on purpose: the status gate
12445    /// covers the status precondition, but `Gate4AgentEngine::remove` has
12446    /// others (a pending operation, an in-flight capability probe or
12447    /// history request) that clear on their own. A single shot would turn
12448    /// those into a guaranteed `BackendBusy` timeout; at 10Hz they still
12449    /// get their retries, just twenty of them across the deadline instead
12450    /// of a thousand.
12451    async fn wait_until_removed(
12452        &self,
12453        address: &SessionAddress,
12454        commit_timeout: Duration,
12455    ) -> Result<(), NodeFailure> {
12456        let deadline = Instant::now() + commit_timeout;
12457        let mut next_dispatch = Instant::now();
12458        loop {
12459            let snapshot = self.handle.snapshot();
12460            let status = snapshot
12461                .sessions
12462                .iter()
12463                .find(|session| session.instance_id == address.session.instance_id)
12464                .map(|session| session.status.clone());
12465            let Some(status) = status else {
12466                return Ok(());
12467            };
12468            let now = Instant::now();
12469            if now >= deadline {
12470                return Err(failure(
12471                    NodeFailureCode::BackendBusy,
12472                    "remove did not commit before the bounded deadline; session binding was retained",
12473                ));
12474            }
12475            if status.allows_remove() && now >= next_dispatch {
12476                let _ = self.dispatch(ControlCommand::Remove {
12477                    instance_id: address.session.instance_id,
12478                });
12479                next_dispatch = now + Duration::from_millis(REMOVE_REDISPATCH_INTERVAL_MS);
12480            }
12481            sleep(Duration::from_millis(2)).await;
12482        }
12483    }
12484
12485    fn resync(&self, after_sequence: u64) -> NodeResponse {
12486        let history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
12487        let event_sequence = history.last_sequence;
12488        let oldest_available_sequence = history.replay_floor_sequence;
12489        let events = history
12490            .events
12491            .iter()
12492            .filter(|event| event.sequence > after_sequence)
12493            .cloned()
12494            .collect();
12495        drop(history);
12496        NodeResponse::Resync {
12497            event_sequence,
12498            oldest_available_sequence,
12499            snapshot: self.snapshot(),
12500            events,
12501        }
12502    }
12503}
12504
12505/// Coarse label for one `AgentStreamChunkKindV1`, used only by
12506/// `NodeShared::publish_agent_stream_chunk`'s own debug log -- mirrors
12507/// `hatchery-harness-service`'s `agent_stream_event_kind_label` (same
12508/// "name what crossed this boundary" reasoning, one layer upstream of it).
12509fn agent_stream_chunk_kind_label(kind: &AgentStreamChunkKindV1) -> &'static str {
12510    match kind {
12511        AgentStreamChunkKindV1::Text { .. } => "text",
12512        AgentStreamChunkKindV1::Thinking { .. } => "thinking",
12513        AgentStreamChunkKindV1::InteractionPrompt { .. } => "interaction-prompt",
12514        AgentStreamChunkKindV1::ModeCatalog { .. } => "mode-catalog",
12515        AgentStreamChunkKindV1::ConfigOptions { .. } => "config-options",
12516        AgentStreamChunkKindV1::ModelCatalog { .. } => "model-catalog",
12517        AgentStreamChunkKindV1::Blocked { .. } => "blocked",
12518    }
12519}
12520
12521/// Serves this wire on the local endpoint, and -- when `call_home` names a
12522/// relay -- over an outbound connection to it as well.
12523///
12524/// The two live in one future rather than a fourth arm of `run`'s own
12525/// `select!` because they are one concern: this is where the node's wire
12526/// is served, and the direction a given connection was opened in does not
12527/// change what happens on it. It also means the existing graceful drain
12528/// below covers both without being duplicated.
12529///
12530/// Either side finishing ends the pair, which is correct: the listener
12531/// only returns on shutdown or a bind failure, and `call_home_loop` only
12532/// returns on shutdown -- a failed relay connection is retried inside it,
12533/// never propagated out.
12534async fn accept_connections(
12535    endpoint: &str,
12536    call_home: Option<std::net::SocketAddr>,
12537    shared: Arc<NodeShared>,
12538) -> Result<(), NodeServerError> {
12539    let mut connections = JoinSet::new();
12540    let result = match call_home {
12541        None => accept_connections_inner(endpoint, shared, &mut connections).await,
12542        Some(relay) => {
12543            let listener =
12544                accept_connections_inner(endpoint, Arc::clone(&shared), &mut connections);
12545            let dialler = call_home_loop(relay, Arc::clone(&shared));
12546            tokio::pin!(listener);
12547            tokio::pin!(dialler);
12548            tokio::select! {
12549                result = &mut listener => result,
12550                result = &mut dialler => result,
12551            }
12552        }
12553    };
12554    let graceful_deadline = Instant::now() + Duration::from_millis(CONNECTION_SHUTDOWN_GRACE_MS);
12555    while !connections.is_empty() {
12556        let remaining = graceful_deadline.saturating_duration_since(Instant::now());
12557        if remaining.is_zero()
12558            || timeout(remaining, connections.join_next()).await.is_err()
12559        {
12560            break;
12561        }
12562    }
12563    connections.abort_all();
12564    while connections.join_next().await.is_some() {}
12565    result
12566}
12567
12568/// Backoff ladder for a relay that will not answer, capped rather than
12569/// unbounded: a node whose relay is down is not broken and should keep
12570/// trying forever, but it should not spend a core doing it.
12571const CALL_HOME_BACKOFFS_MS: [u64; 5] = [500, 1_000, 2_000, 4_000, 8_000];
12572
12573/// Dials the relay, serves this wire over the connection, and does it
12574/// again when the connection ends.
12575///
12576/// The node is the SERVER on the socket it just opened. That inversion is
12577/// the whole point: `serve_connection` is the same function the local
12578/// listener feeds, and it neither knows nor cares which end placed the
12579/// call. The one thing this direction adds is the preface -- the relay
12580/// accepted a socket and cannot know whose it is, so the node says its
12581/// name before the handshake it would otherwise begin with.
12582///
12583/// Never returns an error. A relay that is down, refusing, or speaking a
12584/// different protocol is a condition to retry, not a reason to take the
12585/// node's local endpoint down with it; the only way out is shutdown.
12586async fn call_home_loop(
12587    relay: std::net::SocketAddr,
12588    shared: Arc<NodeShared>,
12589) -> Result<(), NodeServerError> {
12590    let mut failures = 0_usize;
12591    loop {
12592        if shared.shutdown.load(Ordering::Acquire) {
12593            return Ok(());
12594        }
12595        let permit = tokio::select! {
12596            permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
12597                permit.map_err(|_| NodeServerError::PreauthClosed)?
12598            }
12599            _ = shared.shutdown_notify.notified() => return Ok(()),
12600        };
12601        match dial_relay_and_serve(relay, Arc::clone(&shared), permit).await {
12602            Ok(()) => {
12603                // A clean end is the relay hanging up, which it does on
12604                // its own restart. Reconnect immediately rather than
12605                // serving the backoff a peer that is plainly there.
12606                failures = 0;
12607                tracing::info!(node_id = %shared.node_id, %relay, "call-home connection closed");
12608            }
12609            Err(error) => {
12610                failures = failures.saturating_add(1);
12611                tracing::warn!(
12612                    node_id = %shared.node_id,
12613                    %relay,
12614                    cause = %error,
12615                    attempt = failures,
12616                    "call-home connection failed",
12617                );
12618                let backoff = CALL_HOME_BACKOFFS_MS
12619                    [failures.saturating_sub(1).min(CALL_HOME_BACKOFFS_MS.len() - 1)];
12620                tokio::select! {
12621                    _ = sleep(Duration::from_millis(backoff)) => {}
12622                    _ = shared.shutdown_notify.notified() => return Ok(()),
12623                }
12624            }
12625        }
12626    }
12627}
12628
12629/// One call-home attempt: connect, name this node, then serve.
12630async fn dial_relay_and_serve(
12631    relay: std::net::SocketAddr,
12632    shared: Arc<NodeShared>,
12633    preauth_permit: OwnedSemaphorePermit,
12634) -> Result<(), NodeServerError> {
12635    let mut stream = tokio::net::TcpStream::connect(relay)
12636        .await
12637        .map_err(NodeServerError::CallHomeTransport)?;
12638    // Nagle off for the same reason the rest of this wire wants it off:
12639    // the traffic is small request/response frames, and coalescing them
12640    // adds latency to every one.
12641    let _ = stream.set_nodelay(true);
12642    gate4agent_node_wire::write_call_home_announce(&mut stream, &shared.node_id)
12643        .await
12644        .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
12645    tracing::info!(node_id = %shared.node_id, %relay, "announced this node to the relay");
12646    serve_connection(stream, shared, preauth_permit).await
12647}
12648
12649async fn accept_connections_inner(
12650    endpoint: &str,
12651    shared: Arc<NodeShared>,
12652    connections: &mut JoinSet<Result<(), NodeServerError>>,
12653) -> Result<(), NodeServerError> {
12654    let mut listener = OwnerOnlyLocalListener::bind(endpoint).await?;
12655    loop {
12656        while connections.try_join_next().is_some() {}
12657        if shared.shutdown.load(Ordering::Acquire) {
12658            return Ok(());
12659        }
12660        let preauth_permit = tokio::select! {
12661            permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
12662                permit.map_err(|_| NodeServerError::PreauthClosed)?
12663            }
12664            _ = shared.shutdown_notify.notified() => return Ok(()),
12665        };
12666        let server = tokio::select! {
12667            result = listener.accept() => result?,
12668            _ = shared.shutdown_notify.notified() => return Ok(()),
12669        };
12670        let connection_shared = Arc::clone(&shared);
12671        connections.spawn(async move {
12672            serve_connection(server, connection_shared, preauth_permit).await
12673        });
12674        if shared.shutdown.load(Ordering::Acquire) {
12675            return Ok(());
12676        }
12677    }
12678}
12679
12680/// Serves one connection, whichever end opened it.
12681///
12682/// Generic over the stream rather than typed to `LocalServerStream`
12683/// because a call-home connection arrives on a `TcpStream` the node itself
12684/// dialled. Nothing in the body ever depended on the concrete type -- it
12685/// reads and writes framed JSON and then `tokio::io::split`s -- so this is
12686/// the whole of what welding "who dials" to "who serves" was costing.
12687async fn serve_connection<S>(
12688    mut pipe: S,
12689    shared: Arc<NodeShared>,
12690    preauth_permit: OwnedSemaphorePermit,
12691) -> Result<(), NodeServerError>
12692where
12693    S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
12694{
12695    let hello_frame = timeout(
12696        Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
12697        read_json_frame_limited_body_timeout(
12698            &mut pipe,
12699            MAX_NODE_HELLO_FRAME_BYTES,
12700            Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
12701        ),
12702    )
12703    .await
12704    .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
12705    let ClientFrame::Hello(hello) = hello_frame else {
12706        return Err(NodeServerError::Handshake("first frame must be hello".to_owned()));
12707    };
12708    if hello.build_stamp != BUILD_STAMP {
12709        return Err(NodeServerError::Handshake(format!(
12710            "build stamp mismatch: local={BUILD_STAMP} remote={}",
12711            hello.build_stamp,
12712        )));
12713    }
12714    let compatibility = match hello.compatibility.as_ref() {
12715        Some(offer) => {
12716            let mut selected = node_compatibility_support(&shared)?
12717                .negotiate(offer)
12718                .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
12719            project_negotiated_provider_ids(&mut selected);
12720            Some(selected)
12721        }
12722        None => None,
12723    };
12724    let server_nonce = random_nonce().map_err(NodeServerError::Authentication)?;
12725    let server_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
12726        (Some(offer), Some(selected)) => negotiated_auth_proof(
12727            shared.access_token.as_bytes(),
12728            AuthDirection::Server,
12729            hello.role,
12730            &hello.client_nonce,
12731            &server_nonce,
12732            offer,
12733            selected,
12734        ),
12735        (None, None) => auth_proof(
12736            shared.access_token.as_bytes(),
12737            AuthDirection::Server,
12738            hello.role,
12739            &hello.client_nonce,
12740            &server_nonce,
12741        ),
12742        _ => Err("node compatibility authentication state is inconsistent".to_owned()),
12743    }
12744    .map_err(NodeServerError::Authentication)?;
12745    write_json_frame_limited(
12746        &mut pipe,
12747        &ServerFrame::Challenge(ServerChallenge {
12748            build_stamp: BUILD_STAMP.to_owned(),
12749            server_nonce,
12750            server_proof,
12751            compatibility: compatibility.clone(),
12752        }),
12753        MAX_NODE_HELLO_FRAME_BYTES,
12754    )
12755    .await?;
12756    let authentication = timeout(
12757        Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
12758        read_json_frame_limited_body_timeout(
12759            &mut pipe,
12760            MAX_NODE_HELLO_FRAME_BYTES,
12761            Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
12762        ),
12763    )
12764    .await
12765    .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
12766    let ClientFrame::Authenticate(authentication) = authentication else {
12767        return Err(NodeServerError::Handshake("second frame must authenticate the challenge".to_owned()));
12768    };
12769    let expected_client_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
12770        (Some(offer), Some(selected)) => negotiated_auth_proof(
12771            shared.access_token.as_bytes(),
12772            AuthDirection::Client,
12773            hello.role,
12774            &hello.client_nonce,
12775            &server_nonce,
12776            offer,
12777            selected,
12778        ),
12779        (None, None) => auth_proof(
12780            shared.access_token.as_bytes(),
12781            AuthDirection::Client,
12782            hello.role,
12783            &hello.client_nonce,
12784            &server_nonce,
12785        ),
12786        _ => Err("node compatibility authentication state is inconsistent".to_owned()),
12787    }
12788    .map_err(NodeServerError::Authentication)?;
12789    if !proofs_match(&authentication.client_proof, &expected_client_proof) {
12790        return Err(NodeServerError::Handshake("access denied".to_owned()));
12791    }
12792    let selected_capabilities = compatibility
12793        .as_ref()
12794        .map(|selected| selected.capabilities.clone())
12795        .unwrap_or_default();
12796    let include_provider_runtime_status = selected_capabilities.iter().any(|capability| {
12797        capability.as_str() == NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY
12798    });
12799    let include_open_provider_ids = selected_capabilities.iter().any(|capability| {
12800        capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
12801    });
12802    let include_terminal_frame_events = selected_capabilities.iter().any(|capability| {
12803        capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
12804    });
12805    let include_agent_stream_events = selected_capabilities.iter().any(|capability| {
12806        capability.as_str() == NODE_AGENT_STREAM_EVENTS_CAPABILITY
12807    });
12808    let include_spawn_profiles = selected_capabilities.iter().any(|capability| {
12809        capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
12810    });
12811    let include_managed_worktrees = selected_capabilities.iter().any(|capability| {
12812        capability.as_str() == NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY
12813    }) && selected_capabilities.iter().any(|capability| {
12814        capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
12815    });
12816    let include_child_environment_profiles = selected_capabilities.iter().any(|capability| {
12817        capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
12818    });
12819    let include_session_bundles = selected_capabilities.iter().any(|capability| {
12820        capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
12821    });
12822    let include_history_context_packs = selected_capabilities.iter().any(|capability| {
12823        capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
12824    });
12825    let include_agent_progress = selected_capabilities.iter().any(|capability| {
12826        capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
12827    });
12828    let include_session_task_correlation = selected_capabilities.iter().any(|capability| {
12829        capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
12830    });
12831    let include_harness_mcp_proxy = selected_capabilities.iter().any(|capability| {
12832        capability.as_str() == NODE_HARNESS_MCP_READ_PROXY_CAPABILITY
12833    });
12834    let authenticated_permit = Arc::clone(&shared.authenticated_slots)
12835        .try_acquire_owned()
12836        .map_err(|_| NodeServerError::AuthenticatedConnectionLimit)?;
12837    let connection_id = shared.next_connection_id.fetch_add(1, Ordering::AcqRel);
12838    let _release_guard = ControllerReleaseGuard {
12839        shared: Arc::clone(&shared),
12840        connection_id,
12841    };
12842    drop(preauth_permit);
12843    let _authenticated_permit = authenticated_permit;
12844    let mut event_rx = shared.event_tx.subscribe();
12845    let mut terminal_event_rx = terminal_event_subscription(
12846        &shared,
12847        include_terminal_frame_events,
12848    );
12849    let mut agent_stream_event_rx = agent_stream_event_subscription(
12850        &shared,
12851        include_agent_stream_events,
12852    );
12853    let mut harness_mcp_proxy_event_rx = harness_mcp_proxy_event_subscription(
12854        &shared,
12855        include_harness_mcp_proxy,
12856    );
12857    write_json_frame(
12858        &mut pipe,
12859        &ServerFrame::Hello(NodeHello {
12860            build_stamp: BUILD_STAMP.to_owned(),
12861            incarnation_id: shared.incarnation_id,
12862            connection_id,
12863            role: hello.role,
12864            event_sequence: shared.current_sequence(),
12865            controller: shared.controller_state(),
12866            snapshot: snapshot_for_wire(
12867                &shared,
12868                include_provider_runtime_status,
12869                include_open_provider_ids,
12870                include_spawn_profiles,
12871                include_managed_worktrees,
12872                include_child_environment_profiles,
12873                include_session_bundles,
12874                include_history_context_packs,
12875                include_agent_progress,
12876                include_session_task_correlation,
12877            ),
12878            compatibility,
12879        }),
12880    )
12881    .await?;
12882    let (mut reader, mut writer) = tokio::io::split(pipe);
12883    let (frame_tx, mut frame_rx) = mpsc::channel(16);
12884    let reader_task = tokio::spawn(async move {
12885        loop {
12886            let frame = read_json_frame_limited_body_timeout::<_, ClientFrame>(
12887                &mut reader,
12888                MAX_NODE_CLIENT_FRAME_BYTES,
12889                Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
12890            )
12891            .await;
12892            let terminal = frame.is_err();
12893            if frame_tx.send(frame).await.is_err() || terminal {
12894                break;
12895            }
12896        }
12897    });
12898    let _reader_abort = AbortTaskOnDrop(reader_task.abort_handle());
12899    let mut pending_events = Vec::new();
12900    let mut resync_required = false;
12901    let mut discard_events_through = 0_u64;
12902    loop {
12903        // One tick of the serve loop, whether or not it had anything to do.
12904        // Against `connection_events_sent` this separates "busy" from
12905        // "spinning": a loop turning over thousands of times a second while
12906        // sending nothing is not serving, it is burning a core.
12907        shared.connection_loop_iterations.fetch_add(1, Ordering::Relaxed);
12908        let durable_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
12909            .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
12910        for _ in 0..durable_drain_budget {
12911            match event_rx.try_recv() {
12912                Ok(event) => queue_connection_event(
12913                    &mut pending_events,
12914                    event,
12915                    discard_events_through,
12916                ),
12917                Err(broadcast::error::TryRecvError::Empty) => break,
12918                Err(broadcast::error::TryRecvError::Lagged(_)) => {
12919                    resync_required = true;
12920                    break;
12921                }
12922                Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
12923            }
12924        }
12925        if let Some(receiver) = terminal_event_rx.as_mut() {
12926            match receiver.try_recv() {
12927                Ok(events) => queue_connection_event_batch(
12928                    &mut pending_events,
12929                    &events,
12930                    discard_events_through,
12931                ),
12932                Err(broadcast::error::TryRecvError::Empty) => {}
12933                Err(broadcast::error::TryRecvError::Lagged(_)) => {
12934                    resync_required = true;
12935                }
12936                Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
12937            }
12938        }
12939        if let Some(receiver) = agent_stream_event_rx.as_mut() {
12940            // Loops up to the same remaining-room budget the durable
12941            // `event_rx` drain above uses, rather than one `try_recv` per
12942            // tick: each successful receive here is itself only ONE
12943            // `NodeEventEnvelope` batch (`publish_agent_stream_chunk` always
12944            // sends `vec![envelope]`, never more), so a single-shot drain
12945            // caps this connection's agent-stream throughput at one chunk
12946            // per outer-loop tick regardless of how many are already queued
12947            // -- exactly the kind of self-inflicted stall this crate's own
12948            // durable-event budget exists to avoid.
12949            let agent_stream_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
12950                .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
12951            for _ in 0..agent_stream_drain_budget {
12952                match receiver.try_recv() {
12953                    Ok(events) => queue_connection_event_batch(
12954                        &mut pending_events,
12955                        &events,
12956                        discard_events_through,
12957                    ),
12958                    Err(broadcast::error::TryRecvError::Empty) => break,
12959                    Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
12960                        tracing::warn!(
12961                            connection_id,
12962                            dropped,
12963                            "agent stream event subscription lagged; forcing a resync",
12964                        );
12965                        resync_required = true;
12966                        break;
12967                    }
12968                    Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
12969                }
12970            }
12971        }
12972        if let Some(receiver) = harness_mcp_proxy_event_rx.as_mut() {
12973            // Mirrors the agent-stream drain immediately above: each
12974            // successful receive is one single-envelope batch
12975            // (`publish_transient` always sends `vec![envelope]`), so this
12976            // loops up to the same remaining-room budget rather than
12977            // stopping after one `try_recv`.
12978            let harness_mcp_proxy_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
12979                .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
12980            for _ in 0..harness_mcp_proxy_drain_budget {
12981                match receiver.try_recv() {
12982                    Ok(events) => queue_connection_event_batch(
12983                        &mut pending_events,
12984                        &events,
12985                        discard_events_through,
12986                    ),
12987                    Err(broadcast::error::TryRecvError::Empty) => break,
12988                    Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
12989                        tracing::warn!(
12990                            connection_id,
12991                            dropped,
12992                            "harness MCP proxy event subscription lagged; forcing a resync",
12993                        );
12994                        resync_required = true;
12995                        break;
12996                    }
12997                    Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
12998                }
12999            }
13000        }
13001        if resync_required {
13002            let event = resync_required_event(&shared);
13003            apply_connection_resync_watermark(
13004                &mut pending_events,
13005                &mut discard_events_through,
13006                event.sequence,
13007            );
13008            write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
13009            resync_required = false;
13010            continue;
13011        }
13012        if !pending_events.is_empty() {
13013            pending_events.sort_unstable_by_key(|event| event.sequence);
13014            let burst_len = connection_event_burst_len(pending_events.len());
13015            shared
13016                .connection_events_sent
13017                .fetch_add(burst_len as u64, Ordering::Relaxed);
13018            for event in pending_events.drain(..burst_len) {
13019                let event = if include_managed_worktrees {
13020                    Some(event)
13021                } else {
13022                    project_event_without_managed_worktrees(event)
13023                };
13024                let event = event.and_then(|event| if include_open_provider_ids {
13025                    Some(event)
13026                } else {
13027                    project_event_legacy_provider_ids(&shared, event)
13028                });
13029                let event = event.map(|event| if include_child_environment_profiles {
13030                    event
13031                } else {
13032                    project_event_without_child_environment_profile(event)
13033                });
13034                let event = event.map(|event| if include_session_bundles {
13035                    event
13036                } else {
13037                    project_event_without_session_bundle(event)
13038                });
13039                let event = event.map(|event| {
13040                    project_event_history_for_wire(event, include_history_context_packs)
13041                });
13042                let event = event.map(|event| if include_history_context_packs {
13043                    event
13044                } else {
13045                    project_event_without_context_pack(event)
13046                });
13047                let event = event.map(|event| if include_session_task_correlation {
13048                    event
13049                } else {
13050                    project_event_without_session_task_binding(event)
13051                });
13052                let exact_harness_controller = hello.role == ClientRole::Operator
13053                    && shared.controller_state().is_some_and(|controller| {
13054                        controller.connection_id == connection_id
13055                    });
13056                let event = event.and_then(|event| {
13057                    if include_harness_mcp_proxy && exact_harness_controller {
13058                        Some(event)
13059                    } else {
13060                        project_event_without_harness_mcp_proxy(event)
13061                    }
13062                });
13063                if let Some(event) = event {
13064                    write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
13065                }
13066            }
13067        }
13068        tokio::select! {
13069            biased;
13070            frame = frame_rx.recv() => {
13071                let frame = match frame {
13072                    Some(frame) => frame,
13073                    None => break,
13074                };
13075                let frame = match frame {
13076                    Ok(frame) => frame,
13077                    Err(FrameError::Io(error)) if matches!(error.kind(), io::ErrorKind::BrokenPipe | io::ErrorKind::UnexpectedEof) => break,
13078                    Err(error) => return Err(error.into()),
13079                };
13080                let ClientFrame::Request(request) = frame else {
13081                    return Err(NodeServerError::Handshake("hello may only be sent once".to_owned()));
13082                };
13083                #[cfg(feature = "fixture")]
13084                let probe_spawn_managed_worktree_v2 = matches!(
13085                    &request.request,
13086                    NodeRequest::SpawnManagedWorktreeV2 { .. }
13087                );
13088                let requires_open_provider_ids =
13089                    request_requires_open_provider_ids(&shared, &request.request);
13090                let requires_child_environment_profile =
13091                    request_requires_child_environment_profile(&shared, &request.request);
13092                let requires_session_bundle =
13093                    request_requires_session_bundle(&shared, &request.request);
13094                let requires_history_context_pack =
13095                    request_requires_history_context_pack(&shared, &request.request);
13096                let mut reply = if requires_open_provider_ids && !include_open_provider_ids {
13097                    ResponseEnvelope {
13098                        request_id: request.request_id,
13099                        result: Err(failure(
13100                            NodeFailureCode::UnsupportedCapability,
13101                            "open provider IDs were not negotiated",
13102                        )),
13103                    }
13104                } else if requires_child_environment_profile
13105                    && !include_child_environment_profiles
13106                {
13107                    ResponseEnvelope {
13108                        request_id: request.request_id,
13109                        result: Err(failure(
13110                            NodeFailureCode::UnsupportedCapability,
13111                            "child environment profiles were not negotiated",
13112                        )),
13113                    }
13114                } else if requires_session_bundle && !include_session_bundles {
13115                    ResponseEnvelope {
13116                        request_id: request.request_id,
13117                        result: Err(failure(
13118                            NodeFailureCode::UnsupportedCapability,
13119                            "session bundles were not negotiated",
13120                        )),
13121                    }
13122                } else if requires_history_context_pack && !include_history_context_packs {
13123                    ResponseEnvelope {
13124                        request_id: request.request_id,
13125                        result: Err(failure(
13126                            NodeFailureCode::UnsupportedCapability,
13127                            "history context packs were not negotiated",
13128                        )),
13129                    }
13130                } else if request_uses_unnegotiated_capability(
13131                    &request.request,
13132                    &selected_capabilities,
13133                )
13134                {
13135                    ResponseEnvelope {
13136                        request_id: request.request_id,
13137                        result: Err(failure(
13138                            NodeFailureCode::UnsupportedCapability,
13139                            "request capability was not negotiated",
13140                        )),
13141                    }
13142                } else {
13143                    process_request(&shared, connection_id, hello.role, request).await
13144                };
13145                #[cfg(feature = "fixture")]
13146                if probe_spawn_managed_worktree_v2 {
13147                    shared
13148                        .fixture_spawn_managed_worktree_v2_failure_probe
13149                        .record(reply.result.as_ref().err().map(|failure| failure.code));
13150                }
13151                if !include_provider_runtime_status {
13152                    clear_response_provider_runtime_status(&mut reply);
13153                }
13154                if !include_managed_worktrees {
13155                    project_response_without_managed_worktrees(&mut reply);
13156                }
13157                if !include_open_provider_ids {
13158                    project_response_legacy_provider_ids(&shared, &mut reply);
13159                }
13160                if !include_child_environment_profiles {
13161                    project_response_without_child_environment_profile(&mut reply);
13162                }
13163                if !include_session_bundles {
13164                    project_response_without_session_bundle(&mut reply);
13165                }
13166                project_response_history_for_wire(
13167                    &mut reply,
13168                    include_history_context_packs,
13169                );
13170                if !include_history_context_packs {
13171                    project_response_without_context_pack(&mut reply);
13172                }
13173                if !include_agent_progress {
13174                    project_response_without_agent_progress(&mut reply);
13175                }
13176                if !include_session_task_correlation {
13177                    project_response_without_session_task_binding(&mut reply);
13178                }
13179                write_json_frame(&mut writer, &ServerFrame::Reply(reply)).await?;
13180            }
13181            _ = tokio::task::yield_now(), if !pending_events.is_empty() => {}
13182            event = event_rx.recv() => {
13183                match event {
13184                    Ok(event) => queue_connection_event(
13185                        &mut pending_events,
13186                        event,
13187                        discard_events_through,
13188                    ),
13189                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13190                    Err(broadcast::error::RecvError::Closed) => break,
13191                }
13192            }
13193            events = receive_terminal_event_batch(&mut terminal_event_rx) => {
13194                match events {
13195                    Ok(events) => queue_connection_event_batch(
13196                        &mut pending_events,
13197                        &events,
13198                        discard_events_through,
13199                    ),
13200                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13201                    Err(broadcast::error::RecvError::Closed) => break,
13202                }
13203            }
13204            events = receive_terminal_event_batch(&mut agent_stream_event_rx) => {
13205                match events {
13206                    Ok(events) => queue_connection_event_batch(
13207                        &mut pending_events,
13208                        &events,
13209                        discard_events_through,
13210                    ),
13211                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13212                    Err(broadcast::error::RecvError::Closed) => break,
13213                }
13214            }
13215            events = receive_terminal_event_batch(&mut harness_mcp_proxy_event_rx) => {
13216                match events {
13217                    Ok(events) => queue_connection_event_batch(
13218                        &mut pending_events,
13219                        &events,
13220                        discard_events_through,
13221                    ),
13222                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13223                    Err(broadcast::error::RecvError::Closed) => break,
13224                }
13225            }
13226            _ = shared.shutdown_notify.notified() => break,
13227        }
13228    }
13229    Ok(())
13230}
13231
13232async fn receive_terminal_event_batch(
13233    receiver: &mut Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>>,
13234) -> Result<Arc<Vec<NodeEventEnvelope>>, broadcast::error::RecvError> {
13235    match receiver {
13236        Some(receiver) => receiver.recv().await,
13237        None => std::future::pending().await,
13238    }
13239}
13240
13241fn terminal_event_subscription(
13242    shared: &NodeShared,
13243    enabled: bool,
13244) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13245    enabled.then(|| shared.terminal_event_tx.subscribe())
13246}
13247
13248/// Mirrors `terminal_event_subscription` exactly, over the agent stream's
13249/// own channel -- see `NodeShared::agent_stream_event_tx`'s own doc for why
13250/// this needs a dedicated channel rather than a flag on the terminal one.
13251fn agent_stream_event_subscription(
13252    shared: &NodeShared,
13253    enabled: bool,
13254) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13255    enabled.then(|| shared.agent_stream_event_tx.subscribe())
13256}
13257
13258/// Mirrors `agent_stream_event_subscription` exactly, over the harness MCP
13259/// proxy's own channel -- see `NodeShared::harness_mcp_proxy_event_tx`'s
13260/// own doc for why this needs a dedicated channel rather than a ride on
13261/// the durable `event_tx`.
13262fn harness_mcp_proxy_event_subscription(
13263    shared: &NodeShared,
13264    enabled: bool,
13265) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13266    enabled.then(|| shared.harness_mcp_proxy_event_tx.subscribe())
13267}
13268
13269fn queue_connection_event(
13270    pending: &mut Vec<NodeEventEnvelope>,
13271    event: NodeEventEnvelope,
13272    discard_through: u64,
13273) {
13274    if event.sequence <= discard_through
13275        && !event.event.requires_harness_mcp_proxy_capability()
13276    {
13277        return;
13278    }
13279    if let NodeEvent::TerminalFrame { address, .. } = &event.event {
13280        if let Some(index) = pending.iter().position(|current| {
13281            matches!(
13282                &current.event,
13283                NodeEvent::TerminalFrame {
13284                    address: current_address,
13285                    ..
13286                } if current_address == address
13287            )
13288        }) {
13289            pending.remove(index);
13290        }
13291    }
13292    pending.push(event);
13293}
13294
13295fn project_event_without_harness_mcp_proxy(
13296    envelope: NodeEventEnvelope,
13297) -> Option<NodeEventEnvelope> {
13298    (!envelope.event.requires_harness_mcp_proxy_capability()).then_some(envelope)
13299}
13300
13301fn queue_connection_event_batch(
13302    pending: &mut Vec<NodeEventEnvelope>,
13303    events: &[NodeEventEnvelope],
13304    discard_through: u64,
13305) {
13306    for event in events {
13307        queue_connection_event(pending, event.clone(), discard_through);
13308    }
13309}
13310
13311fn apply_connection_resync_watermark(
13312    pending: &mut Vec<NodeEventEnvelope>,
13313    discard_through: &mut u64,
13314    marker_sequence: u64,
13315) {
13316    *discard_through = (*discard_through).max(marker_sequence);
13317    pending.retain(|event| event.sequence > *discard_through);
13318}
13319
13320fn connection_event_burst_len(pending_len: usize) -> usize {
13321    pending_len.min(NODE_CONNECTION_EVENT_BURST_MAX)
13322}
13323
13324fn resync_required_event(shared: &NodeShared) -> NodeEventEnvelope {
13325    let history = shared
13326        .history
13327        .lock()
13328        .unwrap_or_else(|poisoned| poisoned.into_inner());
13329    let sequence = history.last_sequence;
13330    let oldest_available_sequence = history.replay_floor_sequence;
13331    NodeEventEnvelope {
13332        sequence,
13333        event: NodeEvent::ResyncRequired {
13334            oldest_available_sequence,
13335        },
13336    }
13337}
13338
13339fn node_compatibility_support(
13340    shared: &NodeShared,
13341) -> Result<NodeCompatibilitySupport, NodeServerError> {
13342    let mut support = node_compatibility_support_for_manifest(
13343        &shared.provider_contracts,
13344        &shared.provider_adapter_contracts,
13345    )?;
13346    if shared
13347        .delivery_store
13348        .lock()
13349        .unwrap_or_else(|poisoned| poisoned.into_inner())
13350        .is_some()
13351    {
13352        support.capabilities.push(
13353            CapabilityId::new(NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY)
13354                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13355        );
13356    }
13357    if shared.harness_mcp_registry.is_some() {
13358        support.capabilities.push(
13359            CapabilityId::new(NODE_HARNESS_MCP_READ_PROXY_CAPABILITY)
13360                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13361        );
13362    }
13363    Ok(support)
13364}
13365
13366fn node_compatibility_support_for_manifest(
13367    provider_contracts: &[ProviderContractSupport],
13368    provider_adapter_contracts: &[ProviderAdapterContractSupport],
13369) -> Result<NodeCompatibilitySupport, NodeServerError> {
13370    Ok(NodeCompatibilitySupport {
13371        build_stamp: BUILD_STAMP.to_owned(),
13372        capabilities: baseline_capabilities()?,
13373        host: platform::host_descriptor().map_err(NodeServerError::Handshake)?,
13374        path_semantics: platform::path_semantics(),
13375        local_transport: platform::local_transport(),
13376        state_schema: StateSchemaSupport {
13377            versions: ProtocolRange::new(NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10)
13378                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13379        },
13380        provider_contracts: provider_contracts.to_vec(),
13381        provider_adapter_contracts: provider_adapter_contracts.to_vec(),
13382    })
13383}
13384
13385fn baseline_capabilities() -> Result<Vec<CapabilityId>, NodeServerError> {
13386    let capabilities = [
13387        NODE_COMPATIBILITY_METADATA_CAPABILITY,
13388        CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
13389        NODE_REPOSITORY_PATH_CAPABILITY,
13390        NODE_WORKSPACE_FILE_READ_CAPABILITY,
13391        NODE_WORKSPACE_FILE_WRITE_CAPABILITY,
13392        NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
13393        NODE_GIT_READ_CAPABILITY,
13394        NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY,
13395        NODE_PROVIDER_ID_OPEN_CAPABILITY,
13396        NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
13397        NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY,
13398        NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
13399        NODE_AGENT_STREAM_EVENTS_CAPABILITY,
13400        NODE_ACP_CONTROL_CAPABILITY,
13401        NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
13402        NODE_SPAWN_PROFILE_REVISION_CAPABILITY,
13403        NODE_WORKTREE_SELECTION_CAPABILITY,
13404        NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
13405        NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
13406        NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
13407        NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
13408        NODE_HISTORY_CONTEXT_PACK_CAPABILITY,
13409        NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
13410        NODE_NATIVE_SESSION_CATALOG_CAPABILITY,
13411        NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
13412        NODE_NATIVE_SESSION_INDEX_CAPABILITY,
13413        NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
13414        NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
13415        NODE_SESSION_TASK_CORRELATION_CAPABILITY,
13416    ]
13417        .into_iter()
13418        .map(|capability| {
13419            CapabilityId::new(capability)
13420                .map_err(|error| NodeServerError::Handshake(error.to_string()))
13421        })
13422        .collect::<Result<Vec<_>, _>>()?;
13423    #[cfg(windows)]
13424    let capabilities = {
13425        let mut capabilities = capabilities;
13426        capabilities.push(
13427            CapabilityId::new(NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY)
13428                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13429        );
13430        capabilities
13431    };
13432    Ok(capabilities)
13433}
13434
13435fn request_uses_unnegotiated_capability(
13436    request: &NodeRequest,
13437    selected_capabilities: &[CapabilityId],
13438) -> bool {
13439    let selected = |required: &str| {
13440        selected_capabilities
13441            .iter()
13442            .any(|capability| capability.as_str() == required)
13443    };
13444    request
13445        .required_capability()
13446        .is_some_and(|required| !selected(required))
13447        || (request.requires_worktree_selection_capability()
13448            && !selected(NODE_WORKTREE_SELECTION_CAPABILITY))
13449        || (request.requires_spawn_spec_defaults_overrides_capability()
13450            && !selected(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY))
13451        || (request.requires_spawn_profile_revision_capability()
13452            && !selected(NODE_SPAWN_PROFILE_REVISION_CAPABILITY))
13453        || (request.requires_child_environment_profile_capability()
13454            && !selected(NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY))
13455        || (request.requires_session_bundle_materialization_capability()
13456            && !selected(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY))
13457        || (request.requires_history_context_pack_capability()
13458            && !selected(NODE_HISTORY_CONTEXT_PACK_CAPABILITY))
13459}
13460
13461fn snapshot_for_wire(
13462    shared: &NodeShared,
13463    include_provider_runtime_status: bool,
13464    include_open_provider_ids: bool,
13465    include_spawn_profiles: bool,
13466    include_managed_worktrees: bool,
13467    include_child_environment_profiles: bool,
13468    include_session_bundles: bool,
13469    include_history_context_packs: bool,
13470    include_agent_progress: bool,
13471    include_session_task_correlation: bool,
13472) -> NodeSnapshot {
13473    let mut snapshot = shared.snapshot();
13474    if !include_provider_runtime_status {
13475        snapshot.provider_runtime_statuses.clear();
13476    }
13477    if !include_open_provider_ids {
13478        project_snapshot_legacy_provider_ids(&mut snapshot);
13479    }
13480    if !include_managed_worktrees {
13481        snapshot.managed_worktrees.clear();
13482        for workspace in &mut snapshot.workspaces {
13483            workspace.managed_worktree_profiles = None;
13484        }
13485    }
13486    if let Some(inventory) = snapshot.launch_inventory.as_mut() {
13487        if !include_spawn_profiles {
13488            inventory.spawn_profiles = None;
13489        }
13490        if !include_session_bundles {
13491            inventory.bundles = None;
13492        }
13493        if inventory.spawn_profiles.is_none()
13494            && inventory.bundles.is_none()
13495            && inventory.network_allowlists.is_none()
13496        {
13497            snapshot.launch_inventory = None;
13498        }
13499    }
13500    if !include_child_environment_profiles {
13501        clear_snapshot_child_environment_profiles(&mut snapshot);
13502    }
13503    if !include_session_bundles {
13504        clear_snapshot_session_bundles(&mut snapshot);
13505    }
13506    project_snapshot_history_for_wire(&mut snapshot, include_history_context_packs);
13507    if !include_history_context_packs {
13508        clear_snapshot_context_packs(&mut snapshot);
13509    }
13510    if !include_agent_progress {
13511        snapshot.agent_progress.clear();
13512    }
13513    if !include_session_task_correlation {
13514        clear_snapshot_session_task_bindings(&mut snapshot);
13515    }
13516    snapshot
13517}
13518
13519fn clear_snapshot_session_task_bindings(snapshot: &mut NodeSnapshot) {
13520    for record in &mut snapshot.session_records {
13521        record.task_binding = None;
13522    }
13523}
13524
13525fn project_event_without_session_task_binding(
13526    mut envelope: NodeEventEnvelope,
13527) -> NodeEventEnvelope {
13528    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
13529        record.task_binding = None;
13530    }
13531    envelope
13532}
13533
13534fn project_response_without_session_task_binding(reply: &mut ResponseEnvelope) {
13535    let Ok(response) = reply.result.as_mut() else { return };
13536    match response {
13537        NodeResponse::Snapshot { snapshot, .. } => {
13538            clear_snapshot_session_task_bindings(snapshot);
13539        }
13540        NodeResponse::Resync { snapshot, events, .. } => {
13541            clear_snapshot_session_task_bindings(snapshot);
13542            for event in events {
13543                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
13544                    record.task_binding = None;
13545                }
13546            }
13547        }
13548        NodeResponse::SessionRecordUpdated { record }
13549        | NodeResponse::ProviderSessionIndexed { record }
13550        | NodeResponse::NativeSessionIndexed { record, .. }
13551        | NodeResponse::SessionRecordResumed { record, .. } => {
13552            record.task_binding = None;
13553        }
13554        _ => {}
13555    }
13556}
13557
13558fn project_response_without_agent_progress(reply: &mut ResponseEnvelope) {
13559    let Ok(response) = reply.result.as_mut() else { return };
13560    match response {
13561        NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
13562            snapshot.agent_progress.clear();
13563        }
13564        _ => {}
13565    }
13566}
13567
13568fn project_event_without_managed_worktrees(
13569    mut envelope: NodeEventEnvelope,
13570) -> Option<NodeEventEnvelope> {
13571    if matches!(
13572        envelope.event,
13573        NodeEvent::ManagedWorktreeUpserted { .. }
13574            | NodeEvent::ManagedWorktreeRemoved { .. }
13575    ) {
13576        return None;
13577    }
13578    if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
13579        workspace.managed_worktree_profiles = None;
13580    }
13581    Some(envelope)
13582}
13583
13584fn project_response_without_managed_worktrees(reply: &mut ResponseEnvelope) {
13585    if matches!(
13586        reply.result,
13587        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { .. })
13588            | Ok(NodeResponse::ManagedWorktreeCleanup { .. })
13589    ) {
13590        reply.result = Err(failure(
13591            NodeFailureCode::UnsupportedCapability,
13592            "managed worktree capability was not negotiated",
13593        ));
13594        return;
13595    }
13596    let Ok(response) = reply.result.as_mut() else { return };
13597    match response {
13598        NodeResponse::Snapshot { snapshot, .. } => {
13599            snapshot.managed_worktrees.clear();
13600            for workspace in &mut snapshot.workspaces {
13601                workspace.managed_worktree_profiles = None;
13602            }
13603        }
13604        NodeResponse::Resync { snapshot, events, .. } => {
13605            snapshot.managed_worktrees.clear();
13606            for workspace in &mut snapshot.workspaces {
13607                workspace.managed_worktree_profiles = None;
13608            }
13609            *events = std::mem::take(events)
13610                .into_iter()
13611                .filter_map(project_event_without_managed_worktrees)
13612                .collect();
13613        }
13614        NodeResponse::WorkspaceRegistered { workspace }
13615        | NodeResponse::StandaloneWorkspaceCreated { workspace }
13616        | NodeResponse::WorktreeCreated { workspace, .. } => {
13617            workspace.managed_worktree_profiles = None;
13618        }
13619        NodeResponse::WorkspaceInspected { inspection } => {
13620            inspection.git.managed_worktree = None;
13621        }
13622        _ => {}
13623    }
13624}
13625
13626fn project_negotiated_provider_ids(compatibility: &mut NegotiatedNodeCompatibility) {
13627    let includes_open_provider_ids = compatibility.capabilities.iter().any(|capability| {
13628        capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
13629    });
13630    if includes_open_provider_ids {
13631        return;
13632    }
13633    compatibility
13634        .provider_contracts
13635        .retain(|contract| provider_id_is_legacy(&contract.provider));
13636    compatibility
13637        .provider_adapter_contracts
13638        .retain(|contract| provider_id_is_legacy(&contract.provider));
13639}
13640
13641fn request_requires_open_provider_ids(shared: &NodeShared, request: &NodeRequest) -> bool {
13642    if let Some(spec) = match request {
13643        NodeRequest::SpawnSpec { spec }
13644        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
13645        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
13646        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
13647        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
13648        _ => None,
13649    } {
13650        return shared
13651            .resolve_spawn_spec(spec)
13652            .is_ok_and(|resolved| !provider_id_is_legacy(&resolved.provider));
13653    }
13654    request_requires_open_provider_ids_with(
13655        request,
13656        |session| shared.validate_address(session).ok(),
13657        |record_id| shared.record(record_id).ok().map(|record| record.provider),
13658    )
13659}
13660
13661fn request_requires_child_environment_profile(
13662    shared: &NodeShared,
13663    request: &NodeRequest,
13664) -> bool {
13665    if let Some(spec) = match request {
13666        NodeRequest::SpawnSpec { spec }
13667        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
13668        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
13669        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
13670        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
13671        _ => None,
13672    } {
13673        return shared
13674            .resolve_spawn_spec(spec)
13675            .is_ok_and(|resolved| resolved.environment_profile_id.is_some());
13676    }
13677    match request {
13678        NodeRequest::Resume { session, .. }
13679        | NodeRequest::Prompt { session, .. }
13680        | NodeRequest::Paste { session, .. }
13681        | NodeRequest::Input { session, .. }
13682        | NodeRequest::TerminalBytes { session, .. }
13683        | NodeRequest::TerminalControl { session, .. }
13684        | NodeRequest::Resize { session, .. }
13685        | NodeRequest::Interrupt { session }
13686        | NodeRequest::Stop { session, .. }
13687        | NodeRequest::Remove { session }
13688        | NodeRequest::ResolveInteraction { session, .. }
13689        | NodeRequest::SetSessionMode { session, .. }
13690        | NodeRequest::SetSessionConfigOption { session, .. }
13691        | NodeRequest::SetSessionModel { session, .. }
13692        | NodeRequest::DiscoverHistory { session, .. }
13693        | NodeRequest::LoadHistory { session, .. }
13694        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
13695        | NodeRequest::ExportContextPack { session } => shared
13696            .session_bindings
13697            .lock()
13698            .unwrap_or_else(|poisoned| poisoned.into_inner())
13699            .get(&session.session.instance_id)
13700            .filter(|binding| {
13701                binding.workspace_id == session.workspace_id
13702                    && binding.generation == session.session.generation
13703            })
13704            .map_or(true, |binding| binding.environment_profile.is_some()),
13705        NodeRequest::RenameSessionRecord { record_id, .. }
13706        | NodeRequest::SetSessionTask { record_id, .. }
13707        | NodeRequest::ResumeSessionRecord { record_id, .. }
13708        | NodeRequest::ForgetSessionRecord { record_id } => shared
13709            .record(record_id)
13710            .map_or(true, |record| record.environment_profile.is_some()),
13711        NodeRequest::ArmHarnessMcpReservation { .. }
13712        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
13713        | NodeRequest::ActivateHarnessMcpReservation { .. }
13714        | NodeRequest::AbortHarnessMcpReservation { .. }
13715        | NodeRequest::PutHarnessMcpReplyChunk { .. }
13716        | NodeRequest::RejectHarnessMcpCall { .. }
13717        | NodeRequest::Snapshot
13718        | NodeRequest::Resync { .. }
13719        | NodeRequest::BeginDeliveryStage { .. }
13720        | NodeRequest::PutDeliveryBlobChunk { .. }
13721        | NodeRequest::CommitDeliveryStage { .. }
13722        | NodeRequest::AbortDeliveryStage { .. }
13723        | NodeRequest::BrowseHostDirectories { .. }
13724        | NodeRequest::InspectWorkspace { .. }
13725        | NodeRequest::ReadWorkspaceFile { .. }
13726        | NodeRequest::WriteWorkspaceFile { .. }
13727        | NodeRequest::CreateWorkspaceFile { .. }
13728        | NodeRequest::CreateWorkspaceDirectory { .. }
13729        | NodeRequest::ReadGitHistory { .. }
13730        | NodeRequest::ReadGitDiff { .. }
13731        | NodeRequest::AcquireController { .. }
13732        | NodeRequest::ReleaseController
13733        | NodeRequest::Spawn { .. }
13734        | NodeRequest::SpawnSpec { .. }
13735        | NodeRequest::RegisterWorkspace { .. }
13736        | NodeRequest::CreateStandaloneWorkspace { .. }
13737        | NodeRequest::UnregisterWorkspace { .. }
13738        | NodeRequest::CreateWorktree { .. }
13739        | NodeRequest::RemoveWorktree { .. }
13740        | NodeRequest::SpawnManagedWorktree { .. }
13741        | NodeRequest::SpawnManagedWorktreeV2 { .. }
13742        | NodeRequest::CleanupManagedWorktree { .. }
13743        | NodeRequest::IndexProviderSession { .. }
13744        | NodeRequest::IndexNativeSession { .. }
13745        | NodeRequest::CatalogNativeSessions { .. }
13746        | NodeRequest::PageNativeSessions { .. }
13747        | NodeRequest::PreviewNativeSession { .. }
13748        | NodeRequest::PreviewSessionRecord { .. }
13749        | NodeRequest::ForgetContextPack { .. }
13750        | NodeRequest::ResolveDurableContextPack { .. }
13751        | NodeRequest::ReadContextPack { .. }
13752        | NodeRequest::Shutdown => false,
13753    }
13754}
13755
13756fn request_requires_session_bundle(shared: &NodeShared, request: &NodeRequest) -> bool {
13757    if let Some(spec) = match request {
13758        NodeRequest::SpawnSpec { spec }
13759        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
13760        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
13761        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
13762        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
13763        _ => None,
13764    } {
13765        return shared
13766            .resolve_spawn_spec(spec)
13767            .is_ok_and(|resolved| resolved.bundle_id.is_some());
13768    }
13769    match request {
13770        NodeRequest::Resume { session, .. }
13771        | NodeRequest::Prompt { session, .. }
13772        | NodeRequest::Paste { session, .. }
13773        | NodeRequest::Input { session, .. }
13774        | NodeRequest::TerminalBytes { session, .. }
13775        | NodeRequest::TerminalControl { session, .. }
13776        | NodeRequest::Resize { session, .. }
13777        | NodeRequest::Interrupt { session }
13778        | NodeRequest::Stop { session, .. }
13779        | NodeRequest::Remove { session }
13780        | NodeRequest::ResolveInteraction { session, .. }
13781        | NodeRequest::SetSessionMode { session, .. }
13782        | NodeRequest::SetSessionConfigOption { session, .. }
13783        | NodeRequest::SetSessionModel { session, .. }
13784        | NodeRequest::DiscoverHistory { session, .. }
13785        | NodeRequest::LoadHistory { session, .. }
13786        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
13787        | NodeRequest::ExportContextPack { session } => shared
13788            .session_bindings
13789            .lock()
13790            .unwrap_or_else(|poisoned| poisoned.into_inner())
13791            .get(&session.session.instance_id)
13792            .filter(|binding| {
13793                binding.workspace_id == session.workspace_id
13794                    && binding.generation == session.session.generation
13795            })
13796            .map_or(true, |binding| binding.bundle.is_some()),
13797        NodeRequest::RenameSessionRecord { record_id, .. }
13798        | NodeRequest::SetSessionTask { record_id, .. }
13799        | NodeRequest::ResumeSessionRecord { record_id, .. }
13800        | NodeRequest::ForgetSessionRecord { record_id } => shared
13801            .record(record_id)
13802            .map_or(true, |record| record.bundle.is_some()),
13803        NodeRequest::ArmHarnessMcpReservation { .. }
13804        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
13805        | NodeRequest::ActivateHarnessMcpReservation { .. }
13806        | NodeRequest::AbortHarnessMcpReservation { .. }
13807        | NodeRequest::PutHarnessMcpReplyChunk { .. }
13808        | NodeRequest::RejectHarnessMcpCall { .. }
13809        | NodeRequest::Snapshot
13810        | NodeRequest::Resync { .. }
13811        | NodeRequest::BeginDeliveryStage { .. }
13812        | NodeRequest::PutDeliveryBlobChunk { .. }
13813        | NodeRequest::CommitDeliveryStage { .. }
13814        | NodeRequest::AbortDeliveryStage { .. }
13815        | NodeRequest::BrowseHostDirectories { .. }
13816        | NodeRequest::InspectWorkspace { .. }
13817        | NodeRequest::ReadWorkspaceFile { .. }
13818        | NodeRequest::WriteWorkspaceFile { .. }
13819        | NodeRequest::CreateWorkspaceFile { .. }
13820        | NodeRequest::CreateWorkspaceDirectory { .. }
13821        | NodeRequest::ReadGitHistory { .. }
13822        | NodeRequest::ReadGitDiff { .. }
13823        | NodeRequest::AcquireController { .. }
13824        | NodeRequest::ReleaseController
13825        | NodeRequest::Spawn { .. }
13826        | NodeRequest::SpawnSpec { .. }
13827        | NodeRequest::RegisterWorkspace { .. }
13828        | NodeRequest::CreateStandaloneWorkspace { .. }
13829        | NodeRequest::UnregisterWorkspace { .. }
13830        | NodeRequest::CreateWorktree { .. }
13831        | NodeRequest::RemoveWorktree { .. }
13832        | NodeRequest::SpawnManagedWorktree { .. }
13833        | NodeRequest::SpawnManagedWorktreeV2 { .. }
13834        | NodeRequest::CleanupManagedWorktree { .. }
13835        | NodeRequest::IndexProviderSession { .. }
13836        | NodeRequest::IndexNativeSession { .. }
13837        | NodeRequest::CatalogNativeSessions { .. }
13838        | NodeRequest::PageNativeSessions { .. }
13839        | NodeRequest::PreviewNativeSession { .. }
13840        | NodeRequest::PreviewSessionRecord { .. }
13841        | NodeRequest::ForgetContextPack { .. }
13842        | NodeRequest::ResolveDurableContextPack { .. }
13843        | NodeRequest::ReadContextPack { .. }
13844        | NodeRequest::Shutdown => false,
13845    }
13846}
13847
13848fn request_requires_history_context_pack(shared: &NodeShared, request: &NodeRequest) -> bool {
13849    if request.requires_history_context_pack_capability() {
13850        return true;
13851    }
13852    if let Some(spec) = match request {
13853        NodeRequest::SpawnSpec { spec }
13854        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
13855        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
13856        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
13857        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
13858        _ => None,
13859    } {
13860        return shared
13861            .resolve_spawn_spec(spec)
13862            .map_or(true, |resolved| resolved.context_id.is_some());
13863    }
13864    match request {
13865        NodeRequest::Resume { session, .. }
13866        | NodeRequest::Prompt { session, .. }
13867        | NodeRequest::Paste { session, .. }
13868        | NodeRequest::Input { session, .. }
13869        | NodeRequest::TerminalBytes { session, .. }
13870        | NodeRequest::TerminalControl { session, .. }
13871        | NodeRequest::Resize { session, .. }
13872        | NodeRequest::Interrupt { session }
13873        | NodeRequest::Stop { session, .. }
13874        | NodeRequest::Remove { session }
13875        | NodeRequest::ResolveInteraction { session, .. }
13876        | NodeRequest::SetSessionMode { session, .. }
13877        | NodeRequest::SetSessionConfigOption { session, .. }
13878        | NodeRequest::SetSessionModel { session, .. } => shared
13879            .session_bindings
13880            .lock()
13881            .unwrap_or_else(|poisoned| poisoned.into_inner())
13882            .get(&session.session.instance_id)
13883            .filter(|binding| {
13884                binding.workspace_id == session.workspace_id
13885                    && binding.generation == session.session.generation
13886            })
13887            .map_or(true, |binding| binding.context.is_some()),
13888        NodeRequest::RenameSessionRecord { record_id, .. }
13889        | NodeRequest::SetSessionTask { record_id, .. }
13890        | NodeRequest::ResumeSessionRecord { record_id, .. }
13891        | NodeRequest::ForgetSessionRecord { record_id } => shared
13892            .record(record_id)
13893            .map_or(true, |record| record.context.is_some()),
13894        NodeRequest::ArmHarnessMcpReservation { .. }
13895        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
13896        | NodeRequest::ActivateHarnessMcpReservation { .. }
13897        | NodeRequest::AbortHarnessMcpReservation { .. }
13898        | NodeRequest::PutHarnessMcpReplyChunk { .. }
13899        | NodeRequest::RejectHarnessMcpCall { .. }
13900        | NodeRequest::Snapshot
13901        | NodeRequest::Resync { .. }
13902        | NodeRequest::BeginDeliveryStage { .. }
13903        | NodeRequest::PutDeliveryBlobChunk { .. }
13904        | NodeRequest::CommitDeliveryStage { .. }
13905        | NodeRequest::AbortDeliveryStage { .. }
13906        | NodeRequest::BrowseHostDirectories { .. }
13907        | NodeRequest::InspectWorkspace { .. }
13908        | NodeRequest::ReadWorkspaceFile { .. }
13909        | NodeRequest::WriteWorkspaceFile { .. }
13910        | NodeRequest::CreateWorkspaceFile { .. }
13911        | NodeRequest::CreateWorkspaceDirectory { .. }
13912        | NodeRequest::ReadGitHistory { .. }
13913        | NodeRequest::ReadGitDiff { .. }
13914        | NodeRequest::AcquireController { .. }
13915        | NodeRequest::ReleaseController
13916        | NodeRequest::Spawn { .. }
13917        | NodeRequest::SpawnSpec { .. }
13918        | NodeRequest::RegisterWorkspace { .. }
13919        | NodeRequest::CreateStandaloneWorkspace { .. }
13920        | NodeRequest::UnregisterWorkspace { .. }
13921        | NodeRequest::CreateWorktree { .. }
13922        | NodeRequest::RemoveWorktree { .. }
13923        | NodeRequest::SpawnManagedWorktree { .. }
13924        | NodeRequest::SpawnManagedWorktreeV2 { .. }
13925        | NodeRequest::CleanupManagedWorktree { .. }
13926        | NodeRequest::IndexProviderSession { .. }
13927        | NodeRequest::IndexNativeSession { .. }
13928        | NodeRequest::CatalogNativeSessions { .. }
13929        | NodeRequest::PageNativeSessions { .. }
13930        | NodeRequest::PreviewNativeSession { .. }
13931        | NodeRequest::PreviewSessionRecord { .. }
13932        | NodeRequest::DiscoverHistory { .. }
13933        | NodeRequest::LoadHistory { .. }
13934        | NodeRequest::ExportContextPackForSessionRecord { .. }
13935        | NodeRequest::ExportContextPack { .. }
13936        | NodeRequest::ForgetContextPack { .. }
13937        | NodeRequest::ResolveDurableContextPack { .. }
13938        | NodeRequest::ReadContextPack { .. }
13939        | NodeRequest::Shutdown => false,
13940    }
13941}
13942
13943fn request_requires_open_provider_ids_with(
13944    request: &NodeRequest,
13945    provider_for_session: impl Fn(&SessionAddress) -> Option<AgentId>,
13946    provider_for_record: impl Fn(&SessionRecordId) -> Option<AgentId>,
13947) -> bool {
13948    match request {
13949        NodeRequest::Spawn { provider, .. } => !provider_id_is_legacy(provider),
13950        NodeRequest::IndexProviderSession { provider, .. } => !provider_id_is_legacy(provider),
13951        NodeRequest::CatalogNativeSessions { route, .. }
13952        | NodeRequest::PageNativeSessions { route, .. } => {
13953            !provider_id_is_legacy(&route.provider)
13954        }
13955        NodeRequest::PreviewNativeSession { selection, .. }
13956        | NodeRequest::IndexNativeSession { selection, .. } => {
13957            !provider_id_is_legacy(&selection.route.provider)
13958        }
13959        NodeRequest::SpawnSpec { .. }
13960        | NodeRequest::SpawnSpecWithHarnessMcp { .. } => false,
13961        NodeRequest::SpawnManagedWorktree { .. }
13962        | NodeRequest::SpawnManagedWorktreeV2 { .. } => false,
13963        NodeRequest::Resume { session, .. }
13964        | NodeRequest::Prompt { session, .. }
13965        | NodeRequest::Paste { session, .. }
13966        | NodeRequest::Input { session, .. }
13967        | NodeRequest::TerminalBytes { session, .. }
13968        | NodeRequest::TerminalControl { session, .. }
13969        | NodeRequest::Resize { session, .. }
13970        | NodeRequest::Interrupt { session }
13971        | NodeRequest::Stop { session, .. }
13972        | NodeRequest::Remove { session }
13973        | NodeRequest::ResolveInteraction { session, .. }
13974        | NodeRequest::SetSessionMode { session, .. }
13975        | NodeRequest::SetSessionConfigOption { session, .. }
13976        | NodeRequest::SetSessionModel { session, .. }
13977        | NodeRequest::DiscoverHistory { session, .. }
13978        | NodeRequest::LoadHistory { session, .. }
13979        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
13980        | NodeRequest::ExportContextPack { session } => provider_for_session(session)
13981            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
13982        NodeRequest::RenameSessionRecord { record_id, .. }
13983        | NodeRequest::SetSessionTask { record_id, .. }
13984        | NodeRequest::ResumeSessionRecord { record_id, .. }
13985        | NodeRequest::ForgetSessionRecord { record_id } => provider_for_record(record_id)
13986            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
13987        NodeRequest::PreviewSessionRecord { record_id, .. } => provider_for_record(record_id)
13988            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
13989        NodeRequest::ArmHarnessMcpReservation { .. }
13990        | NodeRequest::ActivateHarnessMcpReservation { .. }
13991        | NodeRequest::AbortHarnessMcpReservation { .. }
13992        | NodeRequest::PutHarnessMcpReplyChunk { .. }
13993        | NodeRequest::RejectHarnessMcpCall { .. }
13994        | NodeRequest::Snapshot
13995        | NodeRequest::Resync { .. }
13996        | NodeRequest::BeginDeliveryStage { .. }
13997        | NodeRequest::PutDeliveryBlobChunk { .. }
13998        | NodeRequest::CommitDeliveryStage { .. }
13999        | NodeRequest::AbortDeliveryStage { .. }
14000        | NodeRequest::BrowseHostDirectories { .. }
14001        | NodeRequest::InspectWorkspace { .. }
14002        | NodeRequest::ReadWorkspaceFile { .. }
14003        | NodeRequest::WriteWorkspaceFile { .. }
14004        | NodeRequest::CreateWorkspaceFile { .. }
14005        | NodeRequest::CreateWorkspaceDirectory { .. }
14006        | NodeRequest::ReadGitHistory { .. }
14007        | NodeRequest::ReadGitDiff { .. }
14008        | NodeRequest::AcquireController { .. }
14009        | NodeRequest::ReleaseController
14010        | NodeRequest::RegisterWorkspace { .. }
14011        | NodeRequest::CreateStandaloneWorkspace { .. }
14012        | NodeRequest::UnregisterWorkspace { .. }
14013        | NodeRequest::CreateWorktree { .. }
14014        | NodeRequest::RemoveWorktree { .. }
14015        | NodeRequest::CleanupManagedWorktree { .. }
14016        | NodeRequest::Shutdown => false,
14017        NodeRequest::ForgetContextPack { .. } => true,
14018        // No session/record to cheaply resolve a provider from here either;
14019        // the resolved receipt can carry an open-id provider, so require the
14020        // same capability as ForgetContextPack unconditionally. `ReadContextPack`
14021        // is the same digest-keyed lookup one step further into the same
14022        // catalog, so it inherits the same conservative answer.
14023        NodeRequest::ResolveDurableContextPack { .. }
14024        | NodeRequest::ReadContextPack { .. } => true,
14025    }
14026}
14027
14028fn project_snapshot_legacy_provider_ids(snapshot: &mut NodeSnapshot) {
14029    snapshot.enabled_providers.retain(provider_id_is_legacy);
14030    snapshot.provider_runtime_statuses = ProviderRuntimeStatuses::new(
14031        snapshot
14032            .provider_runtime_statuses
14033            .as_slice()
14034            .iter()
14035            .filter(|status| provider_id_is_legacy(status.provider()))
14036            .cloned(),
14037    )
14038    .expect("a filtered provider runtime projection remains valid");
14039    snapshot
14040        .session_records
14041        .retain(|record| provider_id_is_legacy(&record.provider));
14042    for workspace in &mut snapshot.workspaces {
14043        workspace
14044            .sessions
14045            .retain(|session| provider_id_is_legacy(&session.agent_id));
14046    }
14047}
14048
14049fn clear_snapshot_child_environment_profiles(snapshot: &mut NodeSnapshot) {
14050    for record in &mut snapshot.session_records {
14051        record.environment_profile = None;
14052    }
14053    if let Some(profiles) = snapshot
14054        .launch_inventory
14055        .as_mut()
14056        .and_then(|inventory| inventory.spawn_profiles.as_mut())
14057    {
14058        for profile in profiles {
14059            profile.environment_profile = None;
14060        }
14061    }
14062}
14063
14064fn project_event_without_child_environment_profile(
14065    mut envelope: NodeEventEnvelope,
14066) -> NodeEventEnvelope {
14067    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14068        record.environment_profile = None;
14069    }
14070    envelope
14071}
14072
14073fn project_response_without_child_environment_profile(reply: &mut ResponseEnvelope) {
14074    let contains_environment_profile = match reply.result.as_ref() {
14075        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
14076            receipt.environment_profile.is_some()
14077        }
14078        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14079            receipt.spawn.environment_profile.is_some()
14080        }
14081        _ => false,
14082    };
14083    if contains_environment_profile {
14084        reply.result = Err(failure(
14085            NodeFailureCode::UnsupportedCapability,
14086            "child environment profiles were not negotiated",
14087        ));
14088        return;
14089    }
14090    let Ok(response) = reply.result.as_mut() else {
14091        return;
14092    };
14093    match response {
14094        NodeResponse::Snapshot { snapshot, .. } => {
14095            clear_snapshot_child_environment_profiles(snapshot);
14096        }
14097        NodeResponse::Resync { snapshot, events, .. } => {
14098            clear_snapshot_child_environment_profiles(snapshot);
14099            for event in events {
14100                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14101                    record.environment_profile = None;
14102                }
14103            }
14104        }
14105        NodeResponse::SessionRecordUpdated { record }
14106        | NodeResponse::ProviderSessionIndexed { record }
14107        | NodeResponse::NativeSessionIndexed { record, .. }
14108        | NodeResponse::SessionRecordResumed { record, .. } => {
14109            record.environment_profile = None;
14110        }
14111        NodeResponse::Armed { .. }
14112        | NodeResponse::Spawned { .. }
14113        | NodeResponse::Activated { .. }
14114        | NodeResponse::Aborted { .. }
14115        | NodeResponse::ReplyChunkAccepted { .. }
14116        | NodeResponse::CallRejected { .. }
14117        | NodeResponse::DeliveryStageBegun { .. }
14118        | NodeResponse::DeliveryBlobChunkAccepted { .. }
14119        | NodeResponse::DeliveryCommitted { .. }
14120        | NodeResponse::DeliveryStageAborted { .. }
14121        | NodeResponse::WorkspaceInspected { .. }
14122        | NodeResponse::HostDirectoriesBrowsed { .. }
14123        | NodeResponse::WorkspaceFileRead { .. }
14124        | NodeResponse::WorkspaceFileWritten { .. }
14125        | NodeResponse::WorkspaceFileCreated { .. }
14126        | NodeResponse::WorkspaceDirectoryCreated { .. }
14127        | NodeResponse::GitHistoryRead { .. }
14128        | NodeResponse::GitDiffRead { .. }
14129        | NodeResponse::Controller { .. }
14130        | NodeResponse::SpawnAccepted { .. }
14131        | NodeResponse::SpawnSpecAccepted { .. }
14132        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14133        | NodeResponse::ManagedWorktreeCleanup { .. }
14134        | NodeResponse::NativeSessionsCataloged { .. }
14135        | NodeResponse::NativeSessionsPaged { .. }
14136        | NodeResponse::NativeSessionPreviewed { .. }
14137        | NodeResponse::SessionRecordPreviewed { .. }
14138        | NodeResponse::HistoryDiscovered { .. }
14139        | NodeResponse::HistoryLoaded { .. }
14140        | NodeResponse::ContextPackForSessionRecordExported { .. }
14141        | NodeResponse::ContextPackExported { .. }
14142        | NodeResponse::ContextPackForgotten { .. }
14143        | NodeResponse::DurableContextPackResolved { .. }
14144        | NodeResponse::ContextPackBytesRead { .. }
14145        | NodeResponse::SessionRecordForgotten { .. }
14146        | NodeResponse::WorkspaceRegistered { .. }
14147        | NodeResponse::StandaloneWorkspaceCreated { .. }
14148        | NodeResponse::WorkspaceUnregistered { .. }
14149        | NodeResponse::WorktreeCreated { .. }
14150        | NodeResponse::WorktreeRemoved { .. }
14151        | NodeResponse::Accepted
14152        | NodeResponse::ShuttingDown => {}
14153    }
14154}
14155
14156fn clear_snapshot_session_bundles(snapshot: &mut NodeSnapshot) {
14157    for record in &mut snapshot.session_records {
14158        record.bundle = None;
14159    }
14160}
14161
14162fn project_event_without_session_bundle(
14163    mut envelope: NodeEventEnvelope,
14164) -> NodeEventEnvelope {
14165    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14166        record.bundle = None;
14167    }
14168    envelope
14169}
14170
14171fn project_response_without_session_bundle(reply: &mut ResponseEnvelope) {
14172    let contains_bundle = match reply.result.as_ref() {
14173        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.bundle.is_some(),
14174        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14175            receipt.spawn.bundle.is_some()
14176        }
14177        _ => false,
14178    };
14179    if contains_bundle {
14180        reply.result = Err(failure(
14181            NodeFailureCode::UnsupportedCapability,
14182            "session bundles were not negotiated",
14183        ));
14184        return;
14185    }
14186    let Ok(response) = reply.result.as_mut() else {
14187        return;
14188    };
14189    match response {
14190        NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_session_bundles(snapshot),
14191        NodeResponse::Resync { snapshot, events, .. } => {
14192            clear_snapshot_session_bundles(snapshot);
14193            for event in events {
14194                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14195                    record.bundle = None;
14196                }
14197            }
14198        }
14199        NodeResponse::SessionRecordUpdated { record }
14200        | NodeResponse::ProviderSessionIndexed { record }
14201        | NodeResponse::NativeSessionIndexed { record, .. }
14202        | NodeResponse::SessionRecordResumed { record, .. } => record.bundle = None,
14203        NodeResponse::Armed { .. }
14204        | NodeResponse::Spawned { .. }
14205        | NodeResponse::Activated { .. }
14206        | NodeResponse::Aborted { .. }
14207        | NodeResponse::ReplyChunkAccepted { .. }
14208        | NodeResponse::CallRejected { .. }
14209        | NodeResponse::DeliveryStageBegun { .. }
14210        | NodeResponse::DeliveryBlobChunkAccepted { .. }
14211        | NodeResponse::DeliveryCommitted { .. }
14212        | NodeResponse::DeliveryStageAborted { .. }
14213        | NodeResponse::WorkspaceInspected { .. }
14214        | NodeResponse::HostDirectoriesBrowsed { .. }
14215        | NodeResponse::WorkspaceFileRead { .. }
14216        | NodeResponse::WorkspaceFileWritten { .. }
14217        | NodeResponse::WorkspaceFileCreated { .. }
14218        | NodeResponse::WorkspaceDirectoryCreated { .. }
14219        | NodeResponse::GitHistoryRead { .. }
14220        | NodeResponse::GitDiffRead { .. }
14221        | NodeResponse::Controller { .. }
14222        | NodeResponse::SpawnAccepted { .. }
14223        | NodeResponse::SpawnSpecAccepted { .. }
14224        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14225        | NodeResponse::ManagedWorktreeCleanup { .. }
14226        | NodeResponse::NativeSessionsCataloged { .. }
14227        | NodeResponse::NativeSessionsPaged { .. }
14228        | NodeResponse::NativeSessionPreviewed { .. }
14229        | NodeResponse::SessionRecordPreviewed { .. }
14230        | NodeResponse::HistoryDiscovered { .. }
14231        | NodeResponse::HistoryLoaded { .. }
14232        | NodeResponse::ContextPackForSessionRecordExported { .. }
14233        | NodeResponse::ContextPackExported { .. }
14234        | NodeResponse::ContextPackForgotten { .. }
14235        | NodeResponse::DurableContextPackResolved { .. }
14236        | NodeResponse::ContextPackBytesRead { .. }
14237        | NodeResponse::SessionRecordForgotten { .. }
14238        | NodeResponse::WorkspaceRegistered { .. }
14239        | NodeResponse::StandaloneWorkspaceCreated { .. }
14240        | NodeResponse::WorkspaceUnregistered { .. }
14241        | NodeResponse::WorktreeCreated { .. }
14242        | NodeResponse::WorktreeRemoved { .. }
14243        | NodeResponse::Accepted
14244        | NodeResponse::ShuttingDown => {}
14245    }
14246}
14247
14248fn project_snapshot_history_for_wire(
14249    snapshot: &mut NodeSnapshot,
14250    include_history_metadata: bool,
14251) {
14252    for workspace in &mut snapshot.workspaces {
14253        for session in &mut workspace.sessions {
14254            if include_history_metadata {
14255                session.history.loaded = None;
14256            } else {
14257                session.history = Default::default();
14258            }
14259        }
14260    }
14261}
14262
14263fn project_event_history_for_wire(
14264    mut envelope: NodeEventEnvelope,
14265    include_history_metadata: bool,
14266) -> NodeEventEnvelope {
14267    if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
14268        for session in &mut workspace.sessions {
14269            if include_history_metadata {
14270                session.history.loaded = None;
14271            } else {
14272                session.history = Default::default();
14273            }
14274        }
14275    }
14276    envelope
14277}
14278
14279fn project_response_history_for_wire(
14280    reply: &mut ResponseEnvelope,
14281    include_history_metadata: bool,
14282) {
14283    let Ok(response) = reply.result.as_mut() else {
14284        return;
14285    };
14286    match response {
14287        NodeResponse::Snapshot { snapshot, .. } => {
14288            project_snapshot_history_for_wire(snapshot, include_history_metadata);
14289        }
14290        NodeResponse::Resync {
14291            snapshot, events, ..
14292        } => {
14293            project_snapshot_history_for_wire(snapshot, include_history_metadata);
14294            for event in events {
14295                *event = project_event_history_for_wire(
14296                    event.clone(),
14297                    include_history_metadata,
14298                );
14299            }
14300        }
14301        NodeResponse::WorkspaceRegistered { workspace }
14302        | NodeResponse::StandaloneWorkspaceCreated { workspace }
14303        | NodeResponse::WorktreeCreated { workspace, .. } => {
14304            for session in &mut workspace.sessions {
14305                if include_history_metadata {
14306                    session.history.loaded = None;
14307                } else {
14308                    session.history = Default::default();
14309                }
14310            }
14311        }
14312        _ => {}
14313    }
14314}
14315
14316fn clear_snapshot_context_packs(snapshot: &mut NodeSnapshot) {
14317    for record in &mut snapshot.session_records {
14318        record.context_id = None;
14319        record.context = None;
14320        record.exported_context = None;
14321    }
14322}
14323
14324fn project_event_without_context_pack(
14325    mut envelope: NodeEventEnvelope,
14326) -> NodeEventEnvelope {
14327    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14328        record.context_id = None;
14329        record.context = None;
14330        record.exported_context = None;
14331    }
14332    envelope
14333}
14334
14335fn project_response_without_context_pack(reply: &mut ResponseEnvelope) {
14336    let contains_context = match reply.result.as_ref() {
14337        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.context.is_some(),
14338        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14339            receipt.spawn.context.is_some()
14340        }
14341        Ok(NodeResponse::HistoryDiscovered { .. })
14342        | Ok(NodeResponse::HistoryLoaded { .. })
14343        | Ok(NodeResponse::ContextPackForSessionRecordExported { .. })
14344        | Ok(NodeResponse::ContextPackExported { .. })
14345        | Ok(NodeResponse::ContextPackForgotten { .. })
14346        | Ok(NodeResponse::DurableContextPackResolved { .. }) => true,
14347        _ => false,
14348    };
14349    if contains_context {
14350        reply.result = Err(failure(
14351            NodeFailureCode::UnsupportedCapability,
14352            "history context packs were not negotiated",
14353        ));
14354        return;
14355    }
14356    let Ok(response) = reply.result.as_mut() else {
14357        return;
14358    };
14359    match response {
14360        NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_context_packs(snapshot),
14361        NodeResponse::Resync {
14362            snapshot, events, ..
14363        } => {
14364            clear_snapshot_context_packs(snapshot);
14365            for event in events {
14366                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14367                    record.context_id = None;
14368                    record.context = None;
14369                    record.exported_context = None;
14370                }
14371            }
14372        }
14373        NodeResponse::SessionRecordUpdated { record }
14374        | NodeResponse::ProviderSessionIndexed { record }
14375        | NodeResponse::NativeSessionIndexed { record, .. }
14376        | NodeResponse::SessionRecordResumed { record, .. } => {
14377            record.context_id = None;
14378            record.context = None;
14379            record.exported_context = None;
14380        }
14381        _ => {}
14382    }
14383}
14384
14385fn project_response_legacy_provider_ids(shared: &NodeShared, reply: &mut ResponseEnvelope) {
14386    let contains_open_record = match reply.result.as_ref() {
14387        Ok(NodeResponse::SessionRecordUpdated { record })
14388        | Ok(NodeResponse::ProviderSessionIndexed { record })
14389        | Ok(NodeResponse::SessionRecordResumed { record, .. }) => {
14390            !provider_id_is_legacy(&record.provider)
14391        }
14392        Ok(NodeResponse::NativeSessionIndexed { selection, record }) => {
14393            !provider_id_is_legacy(&selection.route.provider)
14394                || !provider_id_is_legacy(&record.provider)
14395        }
14396        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
14397            !provider_id_is_legacy(&receipt.provider)
14398        }
14399        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14400            !provider_id_is_legacy(&receipt.spawn.provider)
14401        }
14402        Ok(NodeResponse::NativeSessionsCataloged { route, .. })
14403        | Ok(NodeResponse::NativeSessionsPaged { route, .. }) => {
14404            !provider_id_is_legacy(&route.provider)
14405        }
14406        Ok(NodeResponse::NativeSessionPreviewed { selection, .. }) => {
14407            !provider_id_is_legacy(&selection.route.provider)
14408        }
14409        _ => false,
14410    };
14411    if contains_open_record {
14412        reply.result = Err(failure(
14413            NodeFailureCode::UnsupportedCapability,
14414            "open provider IDs were not negotiated",
14415        ));
14416        return;
14417    }
14418    let Ok(response) = reply.result.as_mut() else {
14419        return;
14420    };
14421    match response {
14422        NodeResponse::Snapshot { snapshot, .. } => {
14423            project_snapshot_legacy_provider_ids(snapshot);
14424        }
14425        NodeResponse::Resync { snapshot, events, .. } => {
14426            project_snapshot_legacy_provider_ids(snapshot);
14427            *events = std::mem::take(events)
14428                .into_iter()
14429                .filter_map(|event| project_event_legacy_provider_ids(shared, event))
14430                .collect();
14431        }
14432        NodeResponse::WorkspaceRegistered { workspace }
14433        | NodeResponse::StandaloneWorkspaceCreated { workspace }
14434        | NodeResponse::WorktreeCreated { workspace, .. } => {
14435            workspace
14436                .sessions
14437                .retain(|session| provider_id_is_legacy(&session.agent_id));
14438        }
14439        NodeResponse::Armed { .. }
14440        | NodeResponse::Spawned { .. }
14441        | NodeResponse::Activated { .. }
14442        | NodeResponse::Aborted { .. }
14443        | NodeResponse::ReplyChunkAccepted { .. }
14444        | NodeResponse::CallRejected { .. }
14445        | NodeResponse::DeliveryStageBegun { .. }
14446        | NodeResponse::DeliveryBlobChunkAccepted { .. }
14447        | NodeResponse::DeliveryCommitted { .. }
14448        | NodeResponse::DeliveryStageAborted { .. }
14449        | NodeResponse::WorkspaceInspected { .. }
14450        | NodeResponse::HostDirectoriesBrowsed { .. }
14451        | NodeResponse::WorkspaceFileRead { .. }
14452        | NodeResponse::WorkspaceFileWritten { .. }
14453        | NodeResponse::WorkspaceFileCreated { .. }
14454        | NodeResponse::WorkspaceDirectoryCreated { .. }
14455        | NodeResponse::GitHistoryRead { .. }
14456        | NodeResponse::GitDiffRead { .. }
14457        | NodeResponse::Controller { .. }
14458        | NodeResponse::SpawnAccepted { .. }
14459        | NodeResponse::SpawnSpecAccepted { .. }
14460        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14461        | NodeResponse::ManagedWorktreeCleanup { .. }
14462        | NodeResponse::NativeSessionsCataloged { .. }
14463        | NodeResponse::NativeSessionsPaged { .. }
14464        | NodeResponse::NativeSessionPreviewed { .. }
14465        | NodeResponse::SessionRecordPreviewed { .. }
14466        | NodeResponse::HistoryDiscovered { .. }
14467        | NodeResponse::HistoryLoaded { .. }
14468        | NodeResponse::ContextPackForSessionRecordExported { .. }
14469        | NodeResponse::ContextPackExported { .. }
14470        | NodeResponse::ContextPackForgotten { .. }
14471        | NodeResponse::DurableContextPackResolved { .. }
14472        | NodeResponse::ContextPackBytesRead { .. }
14473        | NodeResponse::SessionRecordUpdated { .. }
14474        | NodeResponse::ProviderSessionIndexed { .. }
14475        | NodeResponse::NativeSessionIndexed { .. }
14476        | NodeResponse::SessionRecordResumed { .. }
14477        | NodeResponse::SessionRecordForgotten { .. }
14478        | NodeResponse::WorkspaceUnregistered { .. }
14479        | NodeResponse::WorktreeRemoved { .. }
14480        | NodeResponse::Accepted
14481        | NodeResponse::ShuttingDown => {}
14482    }
14483}
14484
14485fn project_event_legacy_provider_ids(
14486    shared: &NodeShared,
14487    mut envelope: NodeEventEnvelope,
14488) -> Option<NodeEventEnvelope> {
14489    let include = match &mut envelope.event {
14490        NodeEvent::HarnessMcpReadCall { .. } => true,
14491        NodeEvent::Control { address, .. }
14492        | NodeEvent::AgentStream { address, .. }
14493        | NodeEvent::TerminalFrame { address, .. } => shared
14494            .validate_address(address)
14495            .is_ok_and(|provider| provider_id_is_legacy(&provider)),
14496        NodeEvent::WorkspaceAdded { workspace } => {
14497            workspace
14498                .sessions
14499                .retain(|session| provider_id_is_legacy(&session.agent_id));
14500            true
14501        }
14502        NodeEvent::SessionRecordUpserted { record } => {
14503            provider_id_is_legacy(&record.provider)
14504        }
14505        NodeEvent::SessionRecordHistorySummarized { .. } => false,
14506        NodeEvent::SessionRecordRemoved { .. } => shared
14507            .history
14508            .lock()
14509            .unwrap_or_else(|poisoned| poisoned.into_inner())
14510            .removed_record_providers
14511            .get(&envelope.sequence)
14512            .is_some_and(provider_id_is_legacy),
14513        NodeEvent::ControllerChanged { .. }
14514        | NodeEvent::ManagedWorktreeUpserted { .. }
14515        | NodeEvent::ManagedWorktreeRemoved { .. }
14516        | NodeEvent::WorkspaceRemoved { .. }
14517        | NodeEvent::ResyncRequired { .. } => true,
14518    };
14519    include.then_some(envelope)
14520}
14521
14522fn clear_response_provider_runtime_status(reply: &mut ResponseEnvelope) {
14523    let Ok(response) = reply.result.as_mut() else {
14524        return;
14525    };
14526    match response {
14527        NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
14528            snapshot.provider_runtime_statuses.clear();
14529        }
14530        NodeResponse::Armed { .. }
14531        | NodeResponse::Spawned { .. }
14532        | NodeResponse::Activated { .. }
14533        | NodeResponse::Aborted { .. }
14534        | NodeResponse::ReplyChunkAccepted { .. }
14535        | NodeResponse::CallRejected { .. }
14536        | NodeResponse::DeliveryStageBegun { .. }
14537        | NodeResponse::DeliveryBlobChunkAccepted { .. }
14538        | NodeResponse::DeliveryCommitted { .. }
14539        | NodeResponse::DeliveryStageAborted { .. }
14540        | NodeResponse::WorkspaceInspected { .. }
14541        | NodeResponse::HostDirectoriesBrowsed { .. }
14542        | NodeResponse::WorkspaceFileRead { .. }
14543        | NodeResponse::WorkspaceFileWritten { .. }
14544        | NodeResponse::WorkspaceFileCreated { .. }
14545        | NodeResponse::WorkspaceDirectoryCreated { .. }
14546        | NodeResponse::GitHistoryRead { .. }
14547        | NodeResponse::GitDiffRead { .. }
14548        | NodeResponse::Controller { .. }
14549        | NodeResponse::SpawnAccepted { .. }
14550        | NodeResponse::SpawnSpecAccepted { .. }
14551        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14552        | NodeResponse::ManagedWorktreeCleanup { .. }
14553        | NodeResponse::NativeSessionsCataloged { .. }
14554        | NodeResponse::NativeSessionsPaged { .. }
14555        | NodeResponse::NativeSessionPreviewed { .. }
14556        | NodeResponse::SessionRecordPreviewed { .. }
14557        | NodeResponse::HistoryDiscovered { .. }
14558        | NodeResponse::HistoryLoaded { .. }
14559        | NodeResponse::ContextPackForSessionRecordExported { .. }
14560        | NodeResponse::ContextPackExported { .. }
14561        | NodeResponse::ContextPackForgotten { .. }
14562        | NodeResponse::DurableContextPackResolved { .. }
14563        | NodeResponse::ContextPackBytesRead { .. }
14564        | NodeResponse::SessionRecordUpdated { .. }
14565        | NodeResponse::ProviderSessionIndexed { .. }
14566        | NodeResponse::NativeSessionIndexed { .. }
14567        | NodeResponse::SessionRecordResumed { .. }
14568        | NodeResponse::SessionRecordForgotten { .. }
14569        | NodeResponse::WorkspaceRegistered { .. }
14570        | NodeResponse::StandaloneWorkspaceCreated { .. }
14571        | NodeResponse::WorkspaceUnregistered { .. }
14572        | NodeResponse::WorktreeCreated { .. }
14573        | NodeResponse::WorktreeRemoved { .. }
14574        | NodeResponse::Accepted
14575        | NodeResponse::ShuttingDown => {}
14576    }
14577}
14578
14579struct ControllerReleaseGuard {
14580    shared: Arc<NodeShared>,
14581    connection_id: u64,
14582}
14583
14584impl Drop for ControllerReleaseGuard {
14585    fn drop(&mut self) {
14586        self.shared.release_controller(self.connection_id);
14587    }
14588}
14589
14590struct AbortTaskOnDrop(AbortHandle);
14591
14592impl Drop for AbortTaskOnDrop {
14593    fn drop(&mut self) {
14594        self.0.abort();
14595    }
14596}
14597
14598/// Reads an unsigned env-config override, following the pattern of every
14599/// other bounded knob in this module: missing/unparsable/zero falls back to
14600/// `default`, and the resolved value is always clamped to `max` so a bad
14601/// override cannot reopen a bound this budget exists to enforce.
14602fn env_config_bounded(name: &str, default: u64, max: u64) -> u64 {
14603    std::env::var(name)
14604        .ok()
14605        .and_then(|value| value.parse::<u64>().ok())
14606        .filter(|value| *value > 0)
14607        .unwrap_or(default)
14608        .min(max)
14609}
14610
14611fn workspace_inspection_time_budget_ms() -> u64 {
14612    env_config_bounded(
14613        "GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS",
14614        WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT,
14615        WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX,
14616    )
14617}
14618
14619fn workspace_inspection_entry_cap() -> usize {
14620    env_config_bounded(
14621        "GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP",
14622        WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT as u64,
14623        WORKSPACE_INSPECTION_ENTRY_CAP_MAX as u64,
14624    ) as usize
14625}
14626
14627/// Tracks the shared walk-side budget across the whole recursive walk: a
14628/// wall-clock deadline (shared with the git phase that runs after the walk)
14629/// and a cap on directory entries visited, whether or not they were pushed
14630/// to the response. Cheap to check per entry — one `Instant::now()` and one
14631/// integer compare.
14632struct WorkspaceWalkBudget {
14633    deadline: Instant,
14634    entry_cap: usize,
14635    entries_visited: u64,
14636    time_budget_exceeded: bool,
14637    entry_cap_exceeded: bool,
14638}
14639
14640impl WorkspaceWalkBudget {
14641    fn new(deadline: Instant, entry_cap: usize) -> Self {
14642        Self {
14643            deadline,
14644            entry_cap,
14645            entries_visited: 0,
14646            time_budget_exceeded: false,
14647            entry_cap_exceeded: false,
14648        }
14649    }
14650
14651    fn is_exhausted(&self) -> bool {
14652        self.time_budget_exceeded || self.entry_cap_exceeded
14653    }
14654
14655    /// Records one visited directory entry (pushed to the response or not)
14656    /// and reports whether the walk must stop now.
14657    fn record_visit(&mut self) -> bool {
14658        self.entries_visited += 1;
14659        if self.entries_visited as usize >= self.entry_cap {
14660            self.entry_cap_exceeded = true;
14661        }
14662        if Instant::now() >= self.deadline {
14663            self.time_budget_exceeded = true;
14664        }
14665        self.is_exhausted()
14666    }
14667}
14668
14669fn collect_workspace_entries(
14670    root: &Path,
14671    deadline: Instant,
14672    entry_cap: usize,
14673) -> (Vec<WorkspaceEntry>, bool, WorkspaceWalkBudget) {
14674    let mut entries = Vec::new();
14675    let mut truncated = false;
14676    let mut budget = WorkspaceWalkBudget::new(deadline, entry_cap);
14677    walk_workspace_directory(root, None, 0, &mut entries, &mut truncated, &mut budget);
14678    // DFS pre-order emits `x/child` between prefix-named siblings `x` and
14679    // `x-y` ('-' sorts below '/'), while the bounded operator projections
14680    // require strict global byte order over relative paths.
14681    entries.sort_by(|left, right| left.relative_path.as_utf8().cmp(&right.relative_path.as_utf8()));
14682    (entries, truncated, budget)
14683}
14684
14685/// Builds the additive `WorkspaceInspectionTruncationV1` for an
14686/// `inspect_workspace` response — `None` once the walk and git phase both
14687/// completed inside their shared budget.
14688fn workspace_inspection_truncation(
14689    walk_budget: &WorkspaceWalkBudget,
14690    git_time_budget_exceeded: bool,
14691    elapsed: Duration,
14692) -> Option<WorkspaceInspectionTruncationV1> {
14693    if !walk_budget.time_budget_exceeded
14694        && !walk_budget.entry_cap_exceeded
14695        && !git_time_budget_exceeded
14696    {
14697        return None;
14698    }
14699    Some(WorkspaceInspectionTruncationV1 {
14700        walk_time_budget_exceeded: walk_budget.time_budget_exceeded,
14701        walk_entry_cap_exceeded: walk_budget.entry_cap_exceeded,
14702        git_time_budget_exceeded,
14703        entries_visited: walk_budget.entries_visited,
14704        elapsed_ms: u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX),
14705    })
14706}
14707
14708fn walk_workspace_directory(
14709    directory: &Path,
14710    relative_directory: Option<&RepositoryPath>,
14711    depth: usize,
14712    entries: &mut Vec<WorkspaceEntry>,
14713    truncated: &mut bool,
14714    budget: &mut WorkspaceWalkBudget,
14715) {
14716    if budget.is_exhausted() {
14717        *truncated = true;
14718        return;
14719    }
14720    let read_dir = match std::fs::read_dir(directory) {
14721        Ok(read_dir) => read_dir,
14722        Err(_) => {
14723            *truncated = true;
14724            return;
14725        }
14726    };
14727    let mut children = Vec::new();
14728    for child in read_dir {
14729        match child {
14730            Ok(child) => children.push(child),
14731            Err(_) => *truncated = true,
14732        }
14733    }
14734    children.sort_by_key(|child| child.file_name());
14735    for child in children {
14736        if budget.record_visit() {
14737            *truncated = true;
14738            return;
14739        }
14740        if entries.len() >= WORKSPACE_TREE_MAX_ENTRIES {
14741            *truncated = true;
14742            return;
14743        }
14744        let file_type = match child.file_type() {
14745            Ok(file_type) => file_type,
14746            Err(_) => {
14747                *truncated = true;
14748                continue;
14749            }
14750        };
14751        if file_type.is_symlink() {
14752            continue;
14753        }
14754        let name = match child.file_name().into_string() {
14755            Ok(name) => name,
14756            Err(_) => {
14757                *truncated = true;
14758                continue;
14759            }
14760        };
14761        if file_type.is_dir() && is_skipped_workspace_directory(&name, &child.path()) {
14762            continue;
14763        }
14764        let relative_path = match relative_directory {
14765            None => windows_repository_path(name),
14766            Some(parent) => parent
14767                .as_utf8()
14768                .and_then(|parent| windows_repository_path(format!("{parent}/{name}"))),
14769        };
14770        let Some(relative_path) = relative_path else {
14771            *truncated = true;
14772            continue;
14773        };
14774        let kind = if file_type.is_dir() {
14775            WorkspaceEntryKind::Directory
14776        } else if file_type.is_file() {
14777            WorkspaceEntryKind::File
14778        } else {
14779            continue;
14780        };
14781        entries.push(WorkspaceEntry {
14782            relative_path: relative_path.clone(),
14783            kind,
14784        });
14785        if file_type.is_dir() {
14786            if depth + 1 < WORKSPACE_TREE_MAX_DEPTH {
14787                walk_workspace_directory(
14788                    &child.path(),
14789                    Some(&relative_path),
14790                    depth + 1,
14791                    entries,
14792                    truncated,
14793                    budget,
14794                );
14795            } else {
14796                *truncated = true;
14797            }
14798        }
14799    }
14800}
14801
14802fn windows_repository_path(value: String) -> Option<RepositoryPath> {
14803    if value.contains('\\') {
14804        return None;
14805    }
14806    RepositoryPath::utf8(value).ok()
14807}
14808
14809fn windows_repository_path_from_bytes(value: &[u8]) -> Option<RepositoryPath> {
14810    windows_repository_path(std::str::from_utf8(value).ok()?.to_owned())
14811}
14812
14813/// Excludes a directory from the workspace tree walk. The three literal
14814/// names are the common convention-based cases; the `CACHEDIR.TAG` check
14815/// beneath them catches every build-output directory regardless of name
14816/// (cargo writes `CACHEDIR.TAG` into every `--target-dir` it creates, so
14817/// this covers `target-a2`, `target-a2-tui`, and any future renamed build
14818/// directory without maintaining a name heuristic list). The name check
14819/// runs first and returns early so a directory rejected by name never pays
14820/// for the extra `CACHEDIR.TAG` stat.
14821fn is_skipped_workspace_directory(name: &str, path: &Path) -> bool {
14822    if name.eq_ignore_ascii_case(".git")
14823        || name.eq_ignore_ascii_case("target")
14824        || name.eq_ignore_ascii_case("node_modules")
14825    {
14826        return true;
14827    }
14828    path.join("CACHEDIR.TAG").is_file()
14829}
14830
14831fn context_pack_source_status_is_usable(status: &SessionStatus) -> bool {
14832    matches!(
14833        status,
14834        SessionStatus::Running | SessionStatus::Exited { exit_code: Some(0) }
14835    )
14836}
14837
14838#[derive(Clone, Debug, Eq, PartialEq)]
14839enum ContextPackRepositoryHead {
14840    NotRepository,
14841    Unborn,
14842    Commit(String),
14843}
14844
14845#[derive(Clone, Debug, Eq, PartialEq)]
14846struct ContextPackRepositoryObservation {
14847    head: ContextPackRepositoryHead,
14848    git: GitSnapshot,
14849    files: Vec<ContextPackRepositoryFileSource>,
14850}
14851
14852fn stable_context_pack_repository(
14853    first: ContextPackRepositoryObservation,
14854    second: ContextPackRepositoryObservation,
14855) -> Result<ContextPackRepository, NodeFailure> {
14856    if first != second {
14857        return Err(failure(
14858            NodeFailureCode::ContextPackMaterializationFailed,
14859            "context repository changed during bounded capture",
14860        ));
14861    }
14862    Ok(ContextPackRepository::from_sources(
14863        &second.git,
14864        second.files,
14865    ))
14866}
14867
14868/// Appends a "skipped: budget exhausted" diagnostic and returns `true` once
14869/// (and only once) the shared walk+git `deadline` has already passed. Every
14870/// remaining phase in `inspect_git_workspace` checks this immediately
14871/// before issuing its own git call, so a budget spent by the walk (or by an
14872/// earlier git call) skips the rest of the chain instead of paying for it
14873/// anyway.
14874fn git_budget_exhausted(snapshot: &mut GitSnapshot, deadline: Instant, stage: &str) -> bool {
14875    if Instant::now() < deadline {
14876        return false;
14877    }
14878    append_git_diagnostic(
14879        snapshot,
14880        &format!("{stage} skipped: workspace inspection time budget exhausted"),
14881    );
14882    true
14883}
14884
14885/// Deadline-aware sibling of `run_git_bounded`, used only by
14886/// `inspect_git_workspace`'s six-call chain: caps the per-call timeout to
14887/// whatever remains of the shared `deadline` (never more than
14888/// `GIT_COMMAND_TIMEOUT_MS`), so a slow walk phase that already spent most
14889/// of the budget cannot still hand a full per-call timeout to every git
14890/// probe behind it. `inspect_git_workspace` already guards each call site
14891/// with `git_budget_exhausted`, so `deadline` is guaranteed not to have
14892/// passed yet here.
14893async fn run_git_bounded_with_deadline(
14894    root: &str,
14895    arguments: &[&str],
14896    output_limit: usize,
14897    deadline: Instant,
14898) -> io::Result<GitCommandOutput> {
14899    let remaining = deadline.checked_duration_since(Instant::now());
14900    let timeout_ms = remaining
14901        .and_then(|remaining| u64::try_from(remaining.as_millis()).ok())
14902        .unwrap_or(0)
14903        .clamp(1, GIT_COMMAND_TIMEOUT_MS);
14904    run_git_read_bounded(root, arguments, output_limit, timeout_ms).await
14905}
14906
14907/// Runs the bounded six-call git probe chain used by `inspect_workspace`.
14908/// `deadline` is the same absolute instant the preceding directory walk was
14909/// bounded by, so time already spent walking counts against what remains
14910/// for git enrichment. Returns the resulting `GitSnapshot` plus whether the
14911/// shared budget (not an individual per-call timeout) is what cut the git
14912/// phase short.
14913async fn inspect_git_workspace(root: &str, deadline: Instant) -> (GitSnapshot, bool) {
14914    let mut snapshot = GitSnapshot {
14915        is_repository: false,
14916        branch: None,
14917        status: Vec::new(),
14918        recent_commits: Vec::new(),
14919        worktrees: Vec::new(),
14920        managed_worktree: None,
14921        truncated: false,
14922        diagnostic: None,
14923    };
14924    if git_budget_exhausted(&mut snapshot, deadline, "git repository probe") {
14925        return (snapshot, true);
14926    }
14927    let repository = match run_git_bounded_with_deadline(
14928        root,
14929        &["rev-parse", "--is-inside-work-tree"],
14930        4 * 1_024,
14931        deadline,
14932    )
14933    .await
14934    {
14935        Ok(output) => output,
14936        Err(error) => {
14937            append_git_diagnostic(
14938                &mut snapshot,
14939                &format!("git inspection unavailable: {error}"),
14940            );
14941            return (snapshot, false);
14942        }
14943    };
14944    snapshot.truncated |= repository.truncated;
14945    if repository.timed_out {
14946        append_git_diagnostic(&mut snapshot, "git repository probe timed out");
14947        return (snapshot, false);
14948    }
14949    if !repository.success
14950        || String::from_utf8_lossy(&repository.stdout).trim() != "true"
14951    {
14952        let stderr = String::from_utf8_lossy(&repository.stderr);
14953        if !stderr.to_ascii_lowercase().contains("not a git repository") {
14954            append_git_diagnostic(&mut snapshot, stderr.trim());
14955        }
14956        return (snapshot, false);
14957    }
14958    snapshot.is_repository = true;
14959
14960    if git_budget_exhausted(&mut snapshot, deadline, "git branch query") {
14961        return (snapshot, true);
14962    }
14963    match run_git_bounded_with_deadline(root, &["branch", "--show-current"], 4 * 1_024, deadline).await {
14964        Ok(output) => {
14965            snapshot.truncated |= output.truncated;
14966            if output.timed_out {
14967                append_git_diagnostic(&mut snapshot, "git branch query timed out");
14968            } else if output.success {
14969                let branch = String::from_utf8_lossy(&output.stdout).trim().to_owned();
14970                if !branch.is_empty() {
14971                    snapshot.branch = Some(truncate_git_field(
14972                        &branch,
14973                        MAX_REPOSITORY_PATH_BYTES,
14974                        &mut snapshot.truncated,
14975                    ));
14976                }
14977            }
14978        }
14979        Err(error) => append_git_diagnostic(
14980            &mut snapshot,
14981            &format!("git branch query failed: {error}"),
14982        ),
14983    }
14984    if snapshot.branch.is_none() && !git_budget_exhausted(&mut snapshot, deadline, "git detached-HEAD probe") {
14985        if let Ok(output) = run_git_bounded_with_deadline(root, &["rev-parse", "--short", "HEAD"], 4 * 1_024, deadline).await {
14986            snapshot.truncated |= output.truncated;
14987            if output.success && !output.timed_out {
14988                let head = String::from_utf8_lossy(&output.stdout).trim().to_owned();
14989                if !head.is_empty() {
14990                    snapshot.branch = Some(format!("detached:{head}"));
14991                }
14992            }
14993        }
14994    }
14995
14996    if git_budget_exhausted(&mut snapshot, deadline, "git status") {
14997        return (snapshot, true);
14998    }
14999    match run_git_bounded_with_deadline(
15000        root,
15001        &[
15002            "status",
15003            "--porcelain=v1",
15004            "-z",
15005            "--renames",
15006            "--untracked-files=normal",
15007            "--",
15008            ".",
15009        ],
15010        GIT_OUTPUT_MAX_BYTES,
15011        deadline,
15012    )
15013    .await
15014    {
15015        Ok(output) => {
15016            snapshot.truncated |= output.truncated;
15017            if output.timed_out {
15018                append_git_diagnostic(&mut snapshot, "git status timed out");
15019            } else if output.success {
15020                parse_git_status(&output.stdout, &mut snapshot);
15021            } else {
15022                append_git_diagnostic(
15023                    &mut snapshot,
15024                    String::from_utf8_lossy(&output.stderr).trim(),
15025                );
15026            }
15027        }
15028        Err(error) => append_git_diagnostic(
15029            &mut snapshot,
15030            &format!("git status failed: {error}"),
15031        ),
15032    }
15033
15034    if git_budget_exhausted(&mut snapshot, deadline, "git log") {
15035        return (snapshot, true);
15036    }
15037    let log_limit = (GIT_COMMIT_MAX_ENTRIES + 1).to_string();
15038    match run_git_bounded_with_deadline(
15039        root,
15040        &["log", "-n", &log_limit, "--pretty=format:%H%x1f%s", "--", "."],
15041        16 * 1_024,
15042        deadline,
15043    )
15044    .await
15045    {
15046        Ok(output) => {
15047            snapshot.truncated |= output.truncated;
15048            if output.timed_out {
15049                append_git_diagnostic(&mut snapshot, "git log timed out");
15050            } else if output.success {
15051                parse_git_commits(&output.stdout, &mut snapshot);
15052            } else {
15053                let stderr = String::from_utf8_lossy(&output.stderr);
15054                if !stderr.to_ascii_lowercase().contains("does not have any commits") {
15055                    append_git_diagnostic(&mut snapshot, stderr.trim());
15056                }
15057            }
15058        }
15059        Err(error) => append_git_diagnostic(
15060            &mut snapshot,
15061            &format!("git log failed: {error}"),
15062        ),
15063    }
15064
15065    if git_budget_exhausted(&mut snapshot, deadline, "git worktree list") {
15066        return (snapshot, true);
15067    }
15068    match list_git_worktrees_with_deadline(root, deadline).await {
15069        Ok(worktrees) => {
15070            snapshot.worktrees = worktrees.into_iter().map(protocol_worktree).collect()
15071        }
15072        Err(error) => append_git_diagnostic(
15073            &mut snapshot,
15074            &format!("git worktree list failed: {}", error.message),
15075        ),
15076    }
15077    (snapshot, false)
15078}
15079
15080fn parse_git_status(output: &[u8], snapshot: &mut GitSnapshot) {
15081    let mut remaining = output;
15082    while !remaining.is_empty() {
15083        if snapshot.status.len() >= GIT_STATUS_MAX_ENTRIES {
15084            snapshot.truncated = true;
15085            break;
15086        }
15087        let Some(record_end) = remaining.iter().position(|byte| *byte == 0) else {
15088            snapshot.truncated = true;
15089            break;
15090        };
15091        let record = &remaining[..record_end];
15092        remaining = &remaining[record_end + 1..];
15093        let has_previous_path = record
15094            .get(..2)
15095            .is_some_and(|status| status.iter().any(|byte| matches!(*byte, b'R' | b'C')));
15096        let previous_path_bytes = if has_previous_path {
15097            let Some(previous_end) = remaining.iter().position(|byte| *byte == 0) else {
15098                snapshot.truncated = true;
15099                break;
15100            };
15101            let previous = &remaining[..previous_end];
15102            remaining = &remaining[previous_end + 1..];
15103            Some(previous)
15104        } else {
15105            None
15106        };
15107        if record.len() < 4
15108            || record[2] != b' '
15109            || !is_git_status_code(record[0])
15110            || !is_git_status_code(record[1])
15111        {
15112            snapshot.truncated = true;
15113            continue;
15114        }
15115        let Some(path) = windows_repository_path_from_bytes(&record[3..]) else {
15116            snapshot.truncated = true;
15117            continue;
15118        };
15119        let previous_path = match previous_path_bytes {
15120            Some(previous) => {
15121                let Some(previous) = windows_repository_path_from_bytes(previous) else {
15122                    snapshot.truncated = true;
15123                    continue;
15124                };
15125                Some(previous)
15126            }
15127            None => None,
15128        };
15129        snapshot.status.push(GitStatusEntry {
15130            index_status: (record[0] as char).to_string(),
15131            worktree_status: (record[1] as char).to_string(),
15132            path,
15133            previous_path,
15134        });
15135    }
15136}
15137
15138fn is_git_status_code(byte: u8) -> bool {
15139    matches!(byte, b' ' | b'M' | b'T' | b'A' | b'D' | b'R' | b'C' | b'U' | b'?' | b'!')
15140}
15141
15142fn parse_git_commits(output: &[u8], snapshot: &mut GitSnapshot) {
15143    for line in String::from_utf8_lossy(output).lines() {
15144        let Some((id, summary)) = line.split_once('\u{1f}') else {
15145            continue;
15146        };
15147        if snapshot.recent_commits.len() >= GIT_COMMIT_MAX_ENTRIES {
15148            snapshot.truncated = true;
15149            break;
15150        }
15151        let id = truncate_git_field(id, 64, &mut snapshot.truncated);
15152        let summary = truncate_git_field(summary, 512, &mut snapshot.truncated);
15153        snapshot.recent_commits.push(GitCommitSummary { id, summary });
15154    }
15155}
15156
15157fn truncate_git_field(value: &str, max_bytes: usize, truncated: &mut bool) -> String {
15158    if value.len() <= max_bytes {
15159        return value.to_owned();
15160    }
15161    *truncated = true;
15162    let mut boundary = max_bytes;
15163    while boundary > 0 && !value.is_char_boundary(boundary) {
15164        boundary -= 1;
15165    }
15166    value[..boundary].to_owned()
15167}
15168
15169fn append_git_diagnostic(snapshot: &mut GitSnapshot, message: &str) {
15170    if message.is_empty() {
15171        return;
15172    }
15173    let normalized = message
15174        .chars()
15175        .map(|character| if character.is_control() { ' ' } else { character })
15176        .collect::<String>();
15177    let mut diagnostic = snapshot.diagnostic.take().unwrap_or_default();
15178    if !diagnostic.is_empty() {
15179        diagnostic.push_str("; ");
15180    }
15181    diagnostic.push_str(normalized.trim());
15182    snapshot.diagnostic = Some(truncate_git_field(
15183        &diagnostic,
15184        GIT_DIAGNOSTIC_MAX_BYTES,
15185        &mut snapshot.truncated,
15186    ));
15187}
15188
15189async fn run_git_bounded(
15190    root: &str,
15191    arguments: &[&str],
15192    output_limit: usize,
15193) -> io::Result<GitCommandOutput> {
15194    run_git_read_bounded(root, arguments, output_limit, GIT_COMMAND_TIMEOUT_MS).await
15195}
15196
15197async fn read_git_history_bounded(
15198    root: &str,
15199    path: Option<&RepositoryPath>,
15200    before: Option<&GitObjectId>,
15201    limit: u16,
15202) -> Result<GitHistoryPage, NodeFailure> {
15203    let path = path
15204        .map(|path| {
15205            path.as_utf8().ok_or_else(|| {
15206                failure(
15207                    NodeFailureCode::InvalidRepositoryPath,
15208                    "git history path must use UTF-8",
15209                )
15210            })
15211        })
15212        .transpose()?
15213        .unwrap_or(".");
15214    let count = (usize::from(limit) + 1).to_string();
15215    let start = before
15216        .map(|revision| format!("{}^", revision.as_str()))
15217        .unwrap_or_else(|| "HEAD".to_owned());
15218    let arguments = [
15219        "--no-pager",
15220        "log",
15221        "--no-decorate",
15222        "--date=iso-strict",
15223        "--pretty=format:%H%x1f%P%x1f%s%x1f%an%x1f%ae%x1f%aI%x1f%cn%x1f%ce%x1f%cI%x1e",
15224        "-n",
15225        count.as_str(),
15226        start.as_str(),
15227        "--",
15228        path,
15229    ];
15230    let output = run_git_bounded(root, &arguments, 256 * 1_024)
15231        .await
15232        .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git history command failed"))?;
15233    if output.timed_out {
15234        return Err(failure(NodeFailureCode::GitReadTimedOut, "git history exceeded its bounded deadline"));
15235    }
15236    if !output.success {
15237        return Err(failure(NodeFailureCode::GitReadFailed, "git history could not be read"));
15238    }
15239    let mut commits = parse_git_history(&output.stdout);
15240    let has_more = commits.len() > usize::from(limit) || output.truncated;
15241    commits.truncate(usize::from(limit));
15242    let next_before = has_more.then(|| commits.last().map(|commit| commit.id.clone())).flatten();
15243    Ok(GitHistoryPage { commits, next_before, truncated: output.truncated })
15244}
15245
15246fn parse_git_history(output: &[u8]) -> Vec<GitCommitDetails> {
15247    String::from_utf8_lossy(output)
15248        .split('\u{1e}')
15249        .filter_map(|record| {
15250            let fields = record.trim_matches(['\r', '\n']).split('\u{1f}').collect::<Vec<_>>();
15251            if fields.len() != 9 {
15252                return None;
15253            }
15254            let id = GitObjectId::new(fields[0].to_owned()).ok()?;
15255            let parents = if fields[1].is_empty() {
15256                Vec::new()
15257            } else {
15258                fields[1]
15259                    .split(' ')
15260                    .map(|value| GitObjectId::new(value.to_owned()))
15261                    .collect::<Result<Vec<_>, _>>()
15262                    .ok()?
15263            };
15264            Some(GitCommitDetails {
15265                id,
15266                parents,
15267                subject: bounded_git_text(fields[2], 512),
15268                author_name: bounded_git_text(fields[3], 256),
15269                author_email: bounded_git_text(fields[4], 320),
15270                authored_at: bounded_git_text(fields[5], 64),
15271                committer_name: bounded_git_text(fields[6], 256),
15272                committer_email: bounded_git_text(fields[7], 320),
15273                committed_at: bounded_git_text(fields[8], 64),
15274                signature_status: GitSignatureStatus::NoSignature,
15275                signer: None,
15276            })
15277        })
15278        .take(usize::from(MAX_GIT_HISTORY_COMMITS) + 1)
15279        .collect()
15280}
15281
15282fn bounded_git_text(value: &str, max_bytes: usize) -> String {
15283    let normalized = value
15284        .chars()
15285        .map(|character| if character.is_control() { ' ' } else { character })
15286        .collect::<String>();
15287    if normalized.len() <= max_bytes {
15288        return normalized;
15289    }
15290    let mut boundary = max_bytes;
15291    while boundary > 0 && !normalized.is_char_boundary(boundary) {
15292        boundary -= 1;
15293    }
15294    normalized[..boundary].to_owned()
15295}
15296
15297async fn read_git_diff_bounded(root: &str, request: GitDiffRequest) -> Result<GitDiff, NodeFailure> {
15298    let path = request.path.as_ref().map(|path| {
15299        path.as_utf8()
15300            .ok_or_else(|| failure(NodeFailureCode::InvalidRepositoryPath, "git diff path must use UTF-8"))
15301    }).transpose()?;
15302    let mut arguments = match &request.mode {
15303        GitDiffMode::Working => vec!["--no-pager", "-c", "diff.external=", "diff", "--no-ext-diff", "--no-textconv"],
15304        GitDiffMode::Staged => vec!["--no-pager", "-c", "diff.external=", "diff", "--cached", "--no-ext-diff", "--no-textconv"],
15305        GitDiffMode::Commit { .. } => vec!["--no-pager", "-c", "diff.external=", "show", "--format=fuller", "--no-ext-diff", "--no-textconv"],
15306    };
15307    if let GitDiffMode::Commit { revision } = &request.mode {
15308        arguments.push(revision.as_str());
15309    }
15310    arguments.push("--");
15311    if let Some(path) = path {
15312        arguments.push(path);
15313    }
15314    let output = run_git_bounded(root, &arguments, MAX_GIT_DIFF_BYTES)
15315        .await
15316        .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git diff command failed"))?;
15317    if output.timed_out {
15318        return Err(failure(NodeFailureCode::GitReadTimedOut, "git diff exceeded its bounded deadline"));
15319    }
15320    if !output.success {
15321        return Err(failure(NodeFailureCode::GitReadFailed, "git diff could not be read"));
15322    }
15323    Ok(GitDiff {
15324        mode: request.mode,
15325        path: request.path,
15326        text: String::from_utf8_lossy(&output.stdout).into_owned(),
15327        truncated: output.truncated,
15328    })
15329}
15330
15331async fn process_request(shared: &NodeShared, connection_id: u64, role: ClientRole, envelope: RequestEnvelope) -> ResponseEnvelope {
15332    let result = process_request_inner(shared, connection_id, role, envelope.request).await;
15333    ResponseEnvelope { request_id: envelope.request_id, result }
15334}
15335
15336async fn process_request_inner(shared: &NodeShared, connection_id: u64, role: ClientRole, request: NodeRequest) -> Result<NodeResponse, NodeFailure> {
15337    if !request.harness_mcp_contract_is_valid_at(unix_time_ms()) {
15338        return Err(failure(NodeFailureCode::InvalidRequest, "harness MCP request is invalid or stale"));
15339    }
15340    let read_only = matches!(
15341        &request,
15342        NodeRequest::Snapshot
15343            | NodeRequest::Resync { .. }
15344            | NodeRequest::BrowseHostDirectories { .. }
15345            | NodeRequest::InspectWorkspace { .. }
15346            | NodeRequest::ReadWorkspaceFile { .. }
15347            | NodeRequest::ReadGitHistory { .. }
15348            | NodeRequest::ReadGitDiff { .. }
15349            | NodeRequest::CatalogNativeSessions { .. }
15350            | NodeRequest::PageNativeSessions { .. }
15351            | NodeRequest::PreviewNativeSession { .. }
15352            | NodeRequest::PreviewSessionRecord { .. }
15353    );
15354    let _mutation_guard = if read_only {
15355        None
15356    } else {
15357        let guard = shared.mutation_gate.lock().await;
15358        if shared.shutdown.load(Ordering::Acquire) {
15359            return Err(failure(NodeFailureCode::ShuttingDown, "node shutdown has begun"));
15360        }
15361        Some(guard)
15362    };
15363    match request {
15364        NodeRequest::Snapshot => Ok(NodeResponse::Snapshot {
15365            event_sequence: shared.current_sequence(),
15366            controller: shared.controller_state(),
15367            snapshot: shared.snapshot(),
15368        }),
15369        NodeRequest::Resync { after_sequence } => Ok(shared.resync(after_sequence)),
15370        NodeRequest::ArmHarnessMcpReservation {
15371            reservation_id,
15372            activation_digest,
15373            spawn_spec,
15374            launch,
15375            expires_at_unix_ms,
15376        } => {
15377            shared.require_controller(connection_id, role)?;
15378            let registry = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15379                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15380            })?;
15381            let expires_at_unix_ms = registry
15382                .arm(reservation_id.clone(), activation_digest.clone(), spawn_spec, launch, expires_at_unix_ms)
15383                .await
15384                .map_err(harness_mcp_failure)?;
15385            Ok(NodeResponse::Armed { reservation_id, activation_digest, expires_at_unix_ms })
15386        }
15387        NodeRequest::SpawnSpecWithHarnessMcp {
15388            reservation_id,
15389            activation_digest,
15390            spec,
15391            deadline_unix_ms,
15392        } => {
15393            shared.require_controller(connection_id, role)?;
15394            let receipt = shared
15395                .spawn_from_spec_with_harness_mcp(
15396                    reservation_id.clone(), activation_digest.clone(), spec, deadline_unix_ms,
15397                )
15398                .await?;
15399            Ok(NodeResponse::Spawned { reservation_id, activation_digest, receipt })
15400        }
15401        NodeRequest::ActivateHarnessMcpReservation {
15402            reservation_id,
15403            activation_digest,
15404            record_id,
15405            session,
15406        } => {
15407            shared.require_controller(connection_id, role)?;
15408            let provider_root_pid = shared.handle.snapshot().sessions.iter().find(|current| {
15409                current.instance_id == session.session.instance_id
15410                    && current.generation == session.session.generation
15411            }).and_then(|current| current.process_id).ok_or_else(|| {
15412                failure(NodeFailureCode::BindingMismatch, "provider root process is unavailable")
15413            })?;
15414            shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15415                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15416            })?.activate(
15417                &reservation_id, &activation_digest, &record_id, &session, provider_root_pid,
15418            ).map_err(harness_mcp_failure)?;
15419            Ok(NodeResponse::Activated { reservation_id, activation_digest, record_id, session })
15420        }
15421        NodeRequest::AbortHarnessMcpReservation { reservation_id, activation_digest } => {
15422            shared.require_controller(connection_id, role)?;
15423            let instance_id = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15424                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15425            })?.abort(&reservation_id, &activation_digest).map_err(harness_mcp_failure)?;
15426            if let (Some(control), Some(instance_id)) =
15427                (shared.native_launch_profile_control.as_ref(), instance_id)
15428            {
15429                control.clear_native_mcp_server_launch_overlay(instance_id);
15430            }
15431            Ok(NodeResponse::Aborted { reservation_id, activation_digest })
15432        }
15433        NodeRequest::PutHarnessMcpReplyChunk {
15434            reservation_id, activation_digest, record_id, session, call_id,
15435            offset, final_chunk, chunk_hex,
15436        } => {
15437            shared.require_controller(connection_id, role)?;
15438            let (next_offset, completed) = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15439                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15440            })?.put_reply_chunk(
15441                &reservation_id, &activation_digest, &record_id, &session, &call_id,
15442                offset, final_chunk, &chunk_hex,
15443            ).map_err(harness_mcp_failure)?;
15444            Ok(NodeResponse::ReplyChunkAccepted {
15445                reservation_id, activation_digest, record_id, session, call_id,
15446                next_offset, completed,
15447            })
15448        }
15449        NodeRequest::RejectHarnessMcpCall {
15450            reservation_id, activation_digest, record_id, session, call_id, reason,
15451        } => {
15452            shared.require_controller(connection_id, role)?;
15453            shared.harness_mcp_registry.as_ref().ok_or_else(|| {
15454                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
15455            })?.reject_call(
15456                &reservation_id, &activation_digest, &record_id, &session, &call_id, reason,
15457            ).map_err(harness_mcp_failure)?;
15458            Ok(NodeResponse::CallRejected {
15459                reservation_id, activation_digest, record_id, session, call_id,
15460            })
15461        }
15462        NodeRequest::BeginDeliveryStage { manifest } => {
15463            shared.require_controller(connection_id, role)?;
15464            shared.begin_delivery_stage(manifest)
15465        }
15466        NodeRequest::PutDeliveryBlobChunk {
15467            stage_id,
15468            blob_digest,
15469            offset,
15470            chunk_hex,
15471        } => {
15472            shared.require_controller(connection_id, role)?;
15473            shared.put_delivery_blob_chunk(stage_id, blob_digest, offset, chunk_hex)
15474        }
15475        NodeRequest::CommitDeliveryStage { stage_id } => {
15476            shared.require_controller(connection_id, role)?;
15477            let receipt = shared.commit_delivery_stage(stage_id)?;
15478            Ok(NodeResponse::DeliveryCommitted { receipt })
15479        }
15480        NodeRequest::AbortDeliveryStage { stage_id } => {
15481            shared.require_controller(connection_id, role)?;
15482            shared.abort_delivery_stage(&stage_id)?;
15483            Ok(NodeResponse::DeliveryStageAborted { stage_id })
15484        }
15485        NodeRequest::BrowseHostDirectories { directory, after } => {
15486            let listing = shared.browse_host_directories(directory, after).await?;
15487            Ok(NodeResponse::HostDirectoriesBrowsed { listing })
15488        }
15489        NodeRequest::InspectWorkspace { workspace_id } => {
15490            let inspection = shared.inspect_workspace(workspace_id).await?;
15491            Ok(NodeResponse::WorkspaceInspected { inspection })
15492        }
15493        NodeRequest::ReadWorkspaceFile { workspace_id, path } => {
15494            let file = shared.read_workspace_file(workspace_id, path).await?;
15495            Ok(NodeResponse::WorkspaceFileRead { file })
15496        }
15497        NodeRequest::WriteWorkspaceFile { workspace_id, path, expected_revision, text } => {
15498            shared.require_controller(connection_id, role)?;
15499            let file = shared
15500                .write_workspace_file(workspace_id, path, expected_revision, text)
15501                .await?;
15502            Ok(NodeResponse::WorkspaceFileWritten { file })
15503        }
15504        NodeRequest::CreateWorkspaceFile { workspace_id, path } => {
15505            shared.require_controller(connection_id, role)?;
15506            let file = shared.create_workspace_file(workspace_id, path).await?;
15507            Ok(NodeResponse::WorkspaceFileCreated { file })
15508        }
15509        NodeRequest::CreateWorkspaceDirectory { workspace_id, path } => {
15510            shared.require_controller(connection_id, role)?;
15511            let entry = shared
15512                .create_workspace_directory(workspace_id.clone(), path)
15513                .await?;
15514            Ok(NodeResponse::WorkspaceDirectoryCreated { workspace_id, entry })
15515        }
15516        NodeRequest::ReadGitHistory { workspace_id, path, before, limit } => {
15517            if role != ClientRole::Operator {
15518                return Err(failure(NodeFailureCode::ObserverReadOnly, "git history requires an operator connection"));
15519            }
15520            if !(1..=MAX_GIT_HISTORY_COMMITS).contains(&limit) {
15521                return Err(failure(NodeFailureCode::InvalidRequest, "git history limit is invalid"));
15522            }
15523            let page = shared
15524                .read_git_history(workspace_id.clone(), path, before, limit)
15525                .await?;
15526            Ok(NodeResponse::GitHistoryRead { workspace_id, page })
15527        }
15528        NodeRequest::ReadGitDiff { workspace_id, request } => {
15529            if role != ClientRole::Operator {
15530                return Err(failure(NodeFailureCode::ObserverReadOnly, "git diff requires an operator connection"));
15531            }
15532            let diff = shared.read_git_diff(workspace_id.clone(), request).await?;
15533            Ok(NodeResponse::GitDiffRead { workspace_id, diff })
15534        }
15535        NodeRequest::CatalogNativeSessions { route, limit } => {
15536            if role != ClientRole::Operator {
15537                return Err(failure(
15538                    NodeFailureCode::ObserverReadOnly,
15539                    "native session catalog requires an operator connection",
15540                ));
15541            }
15542            if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit) {
15543                return Err(failure(
15544                    NodeFailureCode::InvalidRequest,
15545                    "native session catalog limit is invalid",
15546                ));
15547            }
15548            let (entries, summary) = shared
15549                .catalog_native_sessions(route.clone(), limit)
15550                .await?;
15551            Ok(NodeResponse::NativeSessionsCataloged {
15552                route,
15553                entries,
15554                summary: Some(summary),
15555            })
15556        }
15557        NodeRequest::PageNativeSessions {
15558            route,
15559            window,
15560            catalog_revision,
15561            recent_cutoff_unix_ms,
15562            after_selection_id,
15563            limit,
15564        } => {
15565            if role != ClientRole::Operator {
15566                return Err(failure(
15567                    NodeFailureCode::ObserverReadOnly,
15568                    "native session catalog paging requires an operator connection",
15569                ));
15570            }
15571            if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit)
15572                || after_selection_id
15573                    .as_deref()
15574                    .is_some_and(|cursor| validate_candidate_id(cursor).is_err())
15575            {
15576                return Err(failure(
15577                    NodeFailureCode::InvalidRequest,
15578                    "native session catalog page request is invalid",
15579                ));
15580            }
15581            let page = shared
15582                .page_native_sessions(
15583                    route.clone(),
15584                    window,
15585                    catalog_revision,
15586                    recent_cutoff_unix_ms,
15587                    after_selection_id,
15588                    limit,
15589                )
15590                .await?;
15591            Ok(NodeResponse::NativeSessionsPaged {
15592                route,
15593                page,
15594            })
15595        }
15596        NodeRequest::PreviewNativeSession {
15597            selection,
15598            message_limit,
15599        } => {
15600            if role != ClientRole::Operator {
15601                return Err(failure(
15602                    NodeFailureCode::ObserverReadOnly,
15603                    "native session preview requires an operator connection",
15604                ));
15605            }
15606            if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
15607                .contains(&message_limit)
15608                || selection.validate().is_err()
15609            {
15610                return Err(failure(
15611                    NodeFailureCode::InvalidRequest,
15612                    "native session preview request is invalid",
15613                ));
15614            }
15615            let preview = shared
15616                .preview_native_session(selection.clone(), message_limit)
15617                .await?;
15618            Ok(NodeResponse::NativeSessionPreviewed {
15619                selection,
15620                preview,
15621            })
15622        }
15623        NodeRequest::IndexNativeSession {
15624            selection,
15625            display_name,
15626        } => {
15627            shared.require_controller(connection_id, role)?;
15628            let record = shared
15629                .index_native_session(selection.clone(), display_name)
15630                .await?;
15631            Ok(NodeResponse::NativeSessionIndexed { selection, record })
15632        }
15633        NodeRequest::PreviewSessionRecord {
15634            record_id,
15635            message_limit,
15636        } => {
15637            if role != ClientRole::Operator {
15638                return Err(failure(
15639                    NodeFailureCode::ObserverReadOnly,
15640                    "native session preview requires an operator connection",
15641                ));
15642            }
15643            if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
15644                .contains(&message_limit)
15645            {
15646                return Err(failure(
15647                    NodeFailureCode::InvalidRequest,
15648                    "native session preview request is invalid",
15649                ));
15650            }
15651            let record = shared.record(&record_id)?;
15652            let working_directory = shared.workspace_root(&record.workspace_id)?;
15653            if !platform::roots_equal(
15654                &working_directory,
15655                windows_path_text(&record.canonical_root),
15656            ) {
15657                return Err(failure(
15658                    NodeFailureCode::SessionRecordConflict,
15659                    "managed session workspace root changed; refusing to preview another directory",
15660                ));
15661            }
15662            let identity = record.provider_session.clone().ok_or_else(|| {
15663                failure(
15664                    NodeFailureCode::BackendOperationFailed,
15665                    "native session preview is unavailable for this session record",
15666                )
15667            })?;
15668            if identity.key != ProviderSessionKey::SessionId {
15669                return Err(failure(
15670                    NodeFailureCode::BackendOperationFailed,
15671                    "native session preview is unavailable for this session identity",
15672                ));
15673            }
15674            let preview = shared
15675                .preview_native_session_by_session_id(
15676                    record.workspace_id.clone(), record.provider.clone(), identity.id.clone(), message_limit,
15677                )
15678                .await?;
15679            if preview.session_id != identity.id {
15680                return Err(failure(
15681                    NodeFailureCode::SessionRecordConflict,
15682                    "native session preview identity changed while loading",
15683                ));
15684            }
15685            shared.revalidate_session_record_preview(&record, &identity)?;
15686            let preview: gate4agent_types::SessionRecordPreview = preview.into();
15687            shared.publish(NodeEvent::SessionRecordHistorySummarized {
15688                record_id: record_id.clone(),
15689                summary: SessionHistorySummaryV1::from(&preview),
15690            });
15691            Ok(NodeResponse::SessionRecordPreviewed {
15692                record_id,
15693                preview,
15694            })
15695        }
15696        NodeRequest::AcquireController { lease_ms } => {
15697            let controller = shared.acquire_controller(connection_id, role, lease_ms)?;
15698            Ok(NodeResponse::Controller { controller: Some(controller) })
15699        }
15700        NodeRequest::ReleaseController => {
15701            shared.release_controller(connection_id);
15702            Ok(NodeResponse::Controller { controller: shared.controller_state() })
15703        }
15704        NodeRequest::RegisterWorkspace { workspace_id, root } => {
15705            shared.require_controller(connection_id, role)?;
15706            let root = require_windows_path(root)?;
15707            shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
15708            let workspace = shared.register_workspace(workspace_id, root).await?;
15709            Ok(NodeResponse::WorkspaceRegistered { workspace })
15710        }
15711        NodeRequest::CreateStandaloneWorkspace {
15712            workspace_id,
15713            root,
15714            initial_branch,
15715        } => {
15716            shared.require_controller(connection_id, role)?;
15717            let root = require_windows_path(root)?;
15718            shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
15719            let workspace = shared
15720                .create_standalone_workspace(workspace_id, root, initial_branch)
15721                .await?;
15722            Ok(NodeResponse::StandaloneWorkspaceCreated { workspace })
15723        }
15724        NodeRequest::UnregisterWorkspace { workspace_id } => {
15725            shared.require_controller(connection_id, role)?;
15726            shared.reject_managed_reservation(Some(&workspace_id), None)?;
15727            shared.unregister_workspace(&workspace_id)?;
15728            Ok(NodeResponse::WorkspaceUnregistered { workspace_id })
15729        }
15730        NodeRequest::CreateWorktree {
15731            source_workspace_id,
15732            workspace_id,
15733            target_root,
15734            branch,
15735            base,
15736        } => {
15737            shared.require_controller(connection_id, role)?;
15738            let target_root = require_windows_path(target_root)?;
15739            shared.reject_managed_reservation(Some(&workspace_id), Some(&target_root))?;
15740            let (worktree, workspace) = shared
15741                .create_worktree(
15742                    source_workspace_id,
15743                    workspace_id,
15744                    target_root,
15745                    branch,
15746                    base,
15747                )
15748                .await?;
15749            Ok(NodeResponse::WorktreeCreated { worktree, workspace })
15750        }
15751        NodeRequest::RemoveWorktree {
15752            source_workspace_id,
15753            target_root,
15754        } => {
15755            shared.require_controller(connection_id, role)?;
15756            let native_target_root = require_windows_path(target_root.clone())?;
15757            shared.reject_managed_reservation(None, Some(&native_target_root))?;
15758            let workspace_id = shared
15759                .remove_worktree(source_workspace_id, native_target_root)
15760                .await?;
15761            Ok(NodeResponse::WorktreeRemoved {
15762                target_root,
15763                workspace_id,
15764            })
15765        }
15766        NodeRequest::Spawn { workspace_id, provider, mode, terminal_size, initial_prompt } => {
15767            shared.require_controller(connection_id, role)?;
15768            if !terminal_size.is_valid() {
15769                return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
15770            }
15771            if let Some(prompt) = initial_prompt.as_deref() {
15772                validate_node_text("spawn initial prompt", prompt)?;
15773            }
15774            let session = shared
15775                .spawn_session(workspace_id, provider, mode, terminal_size, initial_prompt)
15776                .await?;
15777            Ok(NodeResponse::SpawnAccepted { session })
15778        }
15779        NodeRequest::SpawnSpec { spec } => {
15780            shared.require_controller(connection_id, role)?;
15781            let receipt = shared.spawn_from_spec(spec).await?;
15782            Ok(NodeResponse::SpawnSpecAccepted { receipt })
15783        }
15784        NodeRequest::SpawnManagedWorktree { request } => {
15785            shared.require_controller(connection_id, role)?;
15786            let receipt = shared.spawn_managed_worktree(request).await?;
15787            Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
15788        }
15789        NodeRequest::SpawnManagedWorktreeV2 { request } => {
15790            shared.require_controller(connection_id, role)?;
15791            let receipt = shared.spawn_managed_worktree_v2(request).await?;
15792            Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
15793        }
15794        NodeRequest::CleanupManagedWorktree { lease_id } => {
15795            shared.require_controller(connection_id, role)?;
15796            let lease = shared.cleanup_managed_worktree(&lease_id, true).await?;
15797            Ok(NodeResponse::ManagedWorktreeCleanup { lease })
15798        }
15799        NodeRequest::DiscoverHistory { session, limit } => {
15800            controlled_session(shared, connection_id, role, &session)?;
15801            let candidates = shared.discover_history(&session, limit).await?;
15802            Ok(NodeResponse::HistoryDiscovered {
15803                session,
15804                candidates,
15805            })
15806        }
15807        NodeRequest::LoadHistory {
15808            session,
15809            candidate_id,
15810        } => {
15811            controlled_session(shared, connection_id, role, &session)?;
15812            let loaded = shared
15813                .load_history(&session, candidate_id)
15814                .await?;
15815            Ok(NodeResponse::HistoryLoaded {
15816                session,
15817                session_id: loaded.session_id,
15818                message_count: loaded.message_count,
15819                completed_turn_count: loaded.completed_turn_count,
15820            })
15821        }
15822        NodeRequest::ExportContextPackForSessionRecord { record_id, session } => {
15823            shared.require_controller(connection_id, role)?;
15824            let context = shared
15825                .export_context_pack_for_session_record(&record_id, &session, false)
15826                .await?;
15827            Ok(NodeResponse::ContextPackForSessionRecordExported {
15828                record_id,
15829                session,
15830                context,
15831            })
15832        }
15833        NodeRequest::ExportContextPack { session } => {
15834            controlled_session(shared, connection_id, role, &session)?;
15835            let context = shared.export_context_pack(&session).await?;
15836            Ok(NodeResponse::ContextPackExported { context })
15837        }
15838        NodeRequest::ForgetContextPack { context_id } => {
15839            shared.require_controller(connection_id, role)?;
15840            shared.forget_context_pack(&context_id)?;
15841            Ok(NodeResponse::ContextPackForgotten { context_id })
15842        }
15843        NodeRequest::ResolveDurableContextPack { context_id } => {
15844            // Read-only, idempotent, no controller lease required: this lets
15845            // a caller preflight "is this durably exported pack still here"
15846            // without holding the Node's mutation controller.
15847            let context = shared
15848                .context_catalog
15849                .read()
15850                .unwrap_or_else(|poisoned| poisoned.into_inner())
15851                .get(&context_id)
15852                .map(|pack| pack.receipt().clone())
15853                .ok_or_else(|| {
15854                    failure(
15855                        NodeFailureCode::UnknownContextPack,
15856                        "context pack is not present",
15857                    )
15858                })?;
15859            Ok(NodeResponse::DurableContextPackResolved { context })
15860        }
15861        NodeRequest::ReadContextPack { digest } => {
15862            // Read-only fetch of the pack's own bytes, keyed by content digest
15863            // rather than the caller's `SpawnContextId`: same no-controller-lease
15864            // rationale as `ResolveDurableContextPack` above, one step further
15865            // down the same catalog.
15866            let (id, byte_len, bytes) = shared
15867                .context_catalog
15868                .read()
15869                .unwrap_or_else(|poisoned| poisoned.into_inner())
15870                .get_by_digest(&digest)
15871                .map(|pack| {
15872                    (pack.receipt().id.clone(), pack.receipt().byte_len, pack.bytes().to_vec())
15873                })
15874                .ok_or_else(|| {
15875                    failure(
15876                        NodeFailureCode::UnknownContextPack,
15877                        &format!("context pack {digest} not on this node"),
15878                    )
15879                })?;
15880            tracing::debug!(
15881                digest = digest.as_str(),
15882                byte_len,
15883                "read context pack bytes",
15884            );
15885            Ok(NodeResponse::ContextPackBytesRead {
15886                pack: ContextPackBytesRead {
15887                    digest,
15888                    id,
15889                    byte_len,
15890                    bytes,
15891                },
15892            })
15893        }
15894        NodeRequest::Resume { session, terminal_size, initial_prompt } => {
15895            let provider = controlled_session(shared, connection_id, role, &session)?;
15896            if let Some(prompt) = initial_prompt.as_deref() {
15897                validate_node_text("resume initial prompt", prompt)?;
15898            }
15899            let runtime_requirement = if initial_prompt.is_some() {
15900                ProviderRuntimeRequirement::ResumeWithPrompt
15901            } else {
15902                ProviderRuntimeRequirement::Resume
15903            };
15904            shared.require_session_runtime_policy(&session, runtime_requirement)?;
15905            let runtime_policy = shared
15906                .admit_provider_runtime(&provider, runtime_requirement)
15907                .await?;
15908            let working_directory = shared.bound_workspace_root(&session)?;
15909            let request = ResumeLaunchRequest {
15910                working_directory,
15911                terminal_size,
15912                initial_prompt,
15913            };
15914            request
15915                .validate()
15916                .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
15917            let command = shared.prepare_command(ControlCommand::Resume {
15918                instance_id: session.session.instance_id,
15919                target: ResumeTarget::CurrentProvider,
15920                runtime_policy,
15921                request,
15922            });
15923            shared.arm_resume(&session, command.id, runtime_policy)?;
15924            let dispatch = shared.dispatch_envelope(command);
15925            if let Err(error) = dispatch {
15926                shared.clear_armed_resume(&session);
15927                return Err(error);
15928            }
15929            Ok(NodeResponse::Accepted)
15930        }
15931        NodeRequest::RenameSessionRecord {
15932            record_id,
15933            display_name,
15934        } => {
15935            shared.require_controller(connection_id, role)?;
15936            let record = shared.rename_session_record(&record_id, display_name)?;
15937            Ok(NodeResponse::SessionRecordUpdated { record })
15938        }
15939        NodeRequest::SetSessionTask {
15940            record_id,
15941            expected_revision,
15942            target,
15943        } => {
15944            shared.require_controller(connection_id, role)?;
15945            let record = shared.set_session_task(&record_id, expected_revision, target)?;
15946            Ok(NodeResponse::SessionRecordUpdated { record })
15947        }
15948        NodeRequest::IndexProviderSession {
15949            workspace_id,
15950            provider,
15951            identity,
15952            display_name,
15953        } => {
15954            shared.require_controller(connection_id, role)?;
15955            let record = shared.index_provider_session(
15956                workspace_id,
15957                provider,
15958                identity,
15959                display_name,
15960            )?;
15961            Ok(NodeResponse::ProviderSessionIndexed { record })
15962        }
15963        NodeRequest::ResumeSessionRecord {
15964            record_id,
15965            terminal_size,
15966            initial_prompt,
15967        } => {
15968            shared.require_controller(connection_id, role)?;
15969            if let Some(prompt) = initial_prompt.as_deref() {
15970                validate_node_text("managed resume initial prompt", prompt)?;
15971            }
15972            let (record, session) = shared
15973                .resume_session_record(
15974                    &record_id,
15975                    terminal_size,
15976                    initial_prompt,
15977                )
15978                .await?;
15979            Ok(NodeResponse::SessionRecordResumed { record, session })
15980        }
15981        NodeRequest::ForgetSessionRecord { record_id } => {
15982            shared.require_controller(connection_id, role)?;
15983            shared.forget_session_record(&record_id).await?;
15984            Ok(NodeResponse::SessionRecordForgotten { record_id })
15985        }
15986        NodeRequest::Prompt { session, text } => {
15987            let agent_id = controlled_session(shared, connection_id, role, &session)?;
15988            validate_node_text("prompt", &text)?;
15989            shared.require_session_runtime_policy(
15990                &session,
15991                ProviderRuntimeRequirement::SemanticPrompt,
15992            )?;
15993            let framing = prompt_framing(&agent_id);
15994            shared
15995                .dispatch_input_bounded(
15996                    &session,
15997                    InputAction::SubmitPrompt(PromptPayload { text, framing }),
15998                )
15999                .await?;
16000            Ok(NodeResponse::Accepted)
16001        }
16002        NodeRequest::Paste { session, text } => {
16003            controlled_session(shared, connection_id, role, &session)?;
16004            validate_node_text("paste", &text)?;
16005            shared.require_session_runtime_policy(
16006                &session,
16007                ProviderRuntimeRequirement::SemanticPrompt,
16008            )?;
16009            shared
16010                .dispatch_input_bounded(
16011                    &session,
16012                    InputAction::InsertDraft(PromptPayload {
16013                        text,
16014                        framing: PromptFraming::BracketedPaste,
16015                    }),
16016                )
16017                .await?;
16018            Ok(NodeResponse::Accepted)
16019        }
16020        NodeRequest::Input { session, text } => {
16021            controlled_session(shared, connection_id, role, &session)?;
16022            validate_node_text("terminal input", &text)?;
16023            shared
16024                .dispatch_input_bounded(
16025                    &session,
16026                    InputAction::TerminalText(TerminalText { text }),
16027                )
16028                .await?;
16029            Ok(NodeResponse::Accepted)
16030        }
16031        NodeRequest::TerminalBytes { session, bytes } => {
16032            controlled_session(shared, connection_id, role, &session)?;
16033            let action = terminal_bytes_action(bytes)?;
16034            shared
16035                .dispatch_input_bounded(&session, action)
16036                .await?;
16037            Ok(NodeResponse::Accepted)
16038        }
16039        NodeRequest::TerminalControl { session, control } => {
16040            controlled_session(shared, connection_id, role, &session)?;
16041            shared
16042                .dispatch_input_bounded(&session, InputAction::TerminalControl(control))
16043                .await?;
16044            Ok(NodeResponse::Accepted)
16045        }
16046        NodeRequest::Resize { session, size } => {
16047            controlled_session(shared, connection_id, role, &session)?;
16048            if !size.is_valid() {
16049                return Err(failure(NodeFailureCode::InvalidRequest, "terminal size is invalid"));
16050            }
16051            shared.dispatch_resize_bounded(&session, size).await?;
16052            Ok(NodeResponse::Accepted)
16053        }
16054        NodeRequest::Interrupt { session } => {
16055            controlled_session(shared, connection_id, role, &session)?;
16056            shared
16057                .dispatch_input_bounded(
16058                    &session,
16059                    InputAction::TerminalControl(TerminalControl::Interrupt),
16060                )
16061                .await?;
16062            Ok(NodeResponse::Accepted)
16063        }
16064        NodeRequest::Stop { session, force } => {
16065            controlled_session(shared, connection_id, role, &session)?;
16066            shared.dispatch(ControlCommand::Stop { instance_id: session.session.instance_id, force })?;
16067            Ok(NodeResponse::Accepted)
16068        }
16069        NodeRequest::Remove { session } => {
16070            controlled_session(shared, connection_id, role, &session)?;
16071            shared.remove_session(&session).await?;
16072            Ok(NodeResponse::Accepted)
16073        }
16074        NodeRequest::ResolveInteraction { session, correlation_id, response } => {
16075            shared.require_controller(connection_id, role)?;
16076            let snapshot = shared.require_acp_session(&session)?;
16077            let interaction = snapshot
16078                .provider
16079                .interactions
16080                .iter()
16081                .find(|interaction| {
16082                    interaction_correlation(
16083                        session.session.instance_id,
16084                        session.session.generation,
16085                        interaction.id.0,
16086                    ) == correlation_id
16087                })
16088                .ok_or_else(|| failure(
16089                    NodeFailureCode::InvalidRequest,
16090                    "unknown interaction correlation",
16091                ))?;
16092            if interaction.status != ProviderInteractionStatus::Pending {
16093                return Err(failure(
16094                    NodeFailureCode::BackendOperationFailed,
16095                    "interaction has already been resolved",
16096                ));
16097            }
16098            response
16099                .validate_for(interaction.interaction_kind)
16100                .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
16101            let interaction_id = interaction.id;
16102            let response_kind = response.kind();
16103            let response_outcome = response.outcome();
16104            shared.dispatch(ControlCommand::ResolveInteraction {
16105                instance_id: session.session.instance_id,
16106                generation: session.session.generation,
16107                interaction_id,
16108                response,
16109            })?;
16110            // The one line that lets an operator compare what THIS node
16111            // recorded against what the provider itself reports doing --
16112            // measured live on a kimi run where our side resolved `true`
16113            // while the agent reported "rejected" and neither layer had a
16114            // line to line up against the other.
16115            tracing::info!(
16116                node_id = %shared.node_id,
16117                session = ?session.session,
16118                correlation_id = %correlation_id,
16119                interaction_id = interaction_id.0,
16120                response_kind = ?response_kind,
16121                outcome = ?response_outcome,
16122                "interaction resolved",
16123            );
16124            Ok(NodeResponse::Accepted)
16125        }
16126        NodeRequest::SetSessionMode { session, mode_id } => {
16127            shared.require_controller(connection_id, role)?;
16128            shared.require_acp_session(&session)?;
16129            shared.dispatch(ControlCommand::SetSessionMode {
16130                instance_id: session.session.instance_id,
16131                mode_id,
16132            })?;
16133            Ok(NodeResponse::Accepted)
16134        }
16135        NodeRequest::SetSessionConfigOption {
16136            session,
16137            option_id,
16138            value_json,
16139        } => {
16140            shared.require_controller(connection_id, role)?;
16141            shared.require_acp_session(&session)?;
16142            shared.dispatch(ControlCommand::SetSessionConfigOption {
16143                instance_id: session.session.instance_id,
16144                option_id,
16145                value_json,
16146            })?;
16147            Ok(NodeResponse::Accepted)
16148        }
16149        NodeRequest::SetSessionModel { session, model_id } => {
16150            shared.require_controller(connection_id, role)?;
16151            shared.require_acp_session(&session)?;
16152            shared.dispatch(ControlCommand::SetSessionModel {
16153                instance_id: session.session.instance_id,
16154                model_id,
16155            })?;
16156            Ok(NodeResponse::Accepted)
16157        }
16158        NodeRequest::Shutdown => {
16159            shared.require_controller(connection_id, role)?;
16160            shared.begin_shutdown_locked().await?;
16161            Ok(NodeResponse::ShuttingDown)
16162        }
16163    }
16164}
16165
16166fn controlled_session(shared: &NodeShared, connection_id: u64, role: ClientRole, session: &SessionAddress) -> Result<AgentId, NodeFailure> {
16167    shared.require_controller(connection_id, role)?;
16168    shared.validate_address(session)
16169}
16170
16171/// Selects the sole history candidate matching `provider_session_id` when
16172/// the managed record carries a verified identity, or the sole candidate in
16173/// the discovered set at all when it does not (see
16174/// `export_context_pack_for_session_record_inner`'s identity match). Either
16175/// way, more than one match refuses instead of guessing which one is exact.
16176fn select_history_candidate(
16177    candidates: &[HistoryCandidateSummary],
16178    provider_session_id: Option<&str>,
16179) -> Result<String, NodeFailure> {
16180    let mut matches = candidates.iter().filter(|candidate| match provider_session_id {
16181        Some(id) => candidate.session_id_hint == id,
16182        None => true,
16183    });
16184    let candidate = matches.next().ok_or_else(|| {
16185        failure(
16186            NodeFailureCode::SessionRecordConflict,
16187            if provider_session_id.is_some() {
16188                "no history candidate matches the managed provider session"
16189            } else {
16190                "no history candidate exists for the managed session"
16191            },
16192        )
16193    })?;
16194    if matches.next().is_some() {
16195        return Err(failure(
16196            NodeFailureCode::SessionRecordConflict,
16197            if provider_session_id.is_some() {
16198                "history identity is ambiguous for the managed provider session"
16199            } else {
16200                "history is ambiguous without a verified provider session identity"
16201            },
16202        ));
16203    }
16204    Ok(candidate.id.clone())
16205}
16206
16207fn resolved_spawn_profile_summary(
16208    profile: &SpawnProfileDefaults,
16209    environment_profiles: &BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>,
16210) -> Option<SpawnProfileSummary> {
16211    let environment_profile = match profile.environment_profile_id.as_ref() {
16212        None => None,
16213        Some(profile_id) => {
16214            let binding = environment_profiles.get(profile_id)?;
16215            if binding.provider != profile.provider
16216                || binding.native_profile_id(profile.mode).is_none()
16217            {
16218                return None;
16219            }
16220            Some(ResolvedEnvironmentProfileReceipt {
16221                profile_id: binding.id.clone(),
16222                profile_revision: binding.revision.clone(),
16223                network_allowlist: None,
16224                browser_profile_id: None,
16225            })
16226        }
16227    };
16228    Some(SpawnProfileSummary {
16229        id: profile.profile_id.clone(),
16230        revision: profile.revision.clone(),
16231        environment_profile,
16232    })
16233}
16234
16235/// True if `current` is an acceptable version of `expected` at export
16236/// revalidation/commit time.
16237///
16238/// When `allow_clean_detachment` is `false` (the on-demand,
16239/// `NodeRequest::ExportContextPackForSessionRecord` API path), `current` must
16240/// be byte-identical to `expected` and still actively bound to `session` —
16241/// the original, unchanged invariant.
16242///
16243/// When `true` (the reactive auto-export-at-exit path, `§2.1`), `current` may
16244/// ALSO be the exact Live -> Dormant transition that the same clean exit this
16245/// export reacts to produces on its own: `state` Live -> Dormant,
16246/// `active_session` `Some(session)` -> `None`, `updated_at_unix_ms`
16247/// advancing. Every other field — including `provider_session`, `context`,
16248/// `bundle`, `task_binding` — must still match exactly. A record that changed
16249/// in any other way (rebind, a different active session, an identity
16250/// conflict, capacity eviction) is rejected either way; this widens nothing
16251/// about which sessions are eligible, only how the record's own expected
16252/// detachment is tolerated mid-export.
16253fn session_record_export_target_matches(
16254    expected: &ManagedSessionRecord,
16255    current: &ManagedSessionRecord,
16256    session: &SessionAddress,
16257    allow_clean_detachment: bool,
16258) -> bool {
16259    if current == expected && current.active_session.as_ref() == Some(session) {
16260        return current.state == ManagedSessionState::Live;
16261    }
16262    allow_clean_detachment
16263        && current.state == ManagedSessionState::Dormant
16264        && current.active_session.is_none()
16265        && current.record_id == expected.record_id
16266        && current.display_name == expected.display_name
16267        && current.provider == expected.provider
16268        && current.mode == expected.mode
16269        && current.workspace_id == expected.workspace_id
16270        && current.canonical_root == expected.canonical_root
16271        && current.provider_session == expected.provider_session
16272        && current.environment_profile == expected.environment_profile
16273        && current.bundle == expected.bundle
16274        && current.context_id == expected.context_id
16275        && current.context == expected.context
16276        && current.exported_context == expected.exported_context
16277        && current.task_binding == expected.task_binding
16278        && current.created_at_unix_ms == expected.created_at_unix_ms
16279        && current.last_error == expected.last_error
16280}
16281
16282fn session_record_context_export_binding_is_exact(
16283    expected: &ManagedSessionRecord,
16284    current: &ManagedSessionRecord,
16285    identity: Option<&ProviderSessionIdentity>,
16286    session: &SessionAddress,
16287    binding: &SessionBinding,
16288    provider: &AgentId,
16289    current_root: &str,
16290    allow_clean_detachment: bool,
16291) -> bool {
16292    session_record_export_target_matches(expected, current, session, allow_clean_detachment)
16293        && current.provider_session.as_ref() == identity
16294        && current.workspace_id == session.workspace_id
16295        && provider == &current.provider
16296        && binding.workspace_id == session.workspace_id
16297        && binding.generation == session.session.generation
16298        && binding.record_id.as_ref() == Some(&current.record_id)
16299        && platform::roots_equal(
16300            current_root,
16301            windows_path_text(&current.canonical_root),
16302        )
16303}
16304
16305fn session_record_context_export_source_is_usable(
16306    record: &ManagedSessionRecord,
16307    status: &SessionStatus,
16308    allow_clean_detachment: bool,
16309) -> bool {
16310    (record.state == ManagedSessionState::Live
16311        || (allow_clean_detachment && record.state == ManagedSessionState::Dormant))
16312        && context_pack_source_status_is_usable(status)
16313}
16314
16315fn prompt_framing(_agent_id: &AgentId) -> PromptFraming {
16316    PromptFraming::BracketedPaste
16317}
16318
16319fn validate_node_text(field: &str, text: &str) -> Result<(), NodeFailure> {
16320    if text.len() > MAX_NODE_TEXT_BYTES {
16321        return Err(failure(
16322            NodeFailureCode::InvalidRequest,
16323            &format!(
16324                "{field} length {} exceeds the {MAX_NODE_TEXT_BYTES}-byte node limit",
16325                text.len(),
16326            ),
16327        ));
16328    }
16329    Ok(())
16330}
16331
16332fn validate_terminal_bytes(bytes: &[u8]) -> Result<(), NodeFailure> {
16333    if bytes.is_empty() || bytes.len() > MAX_NODE_TERMINAL_BYTES {
16334        return Err(failure(
16335            NodeFailureCode::InvalidRequest,
16336            &format!(
16337                "terminal byte sequence must contain 1..={MAX_NODE_TERMINAL_BYTES} bytes; received {}",
16338                bytes.len(),
16339            ),
16340        ));
16341    }
16342    Ok(())
16343}
16344
16345fn terminal_bytes_action(bytes: Vec<u8>) -> Result<InputAction, NodeFailure> {
16346    validate_terminal_bytes(&bytes)?;
16347    Ok(InputAction::TerminalBytes(bytes))
16348}
16349
16350fn validate_workspace_request_root(root: &str) -> Result<(), NodeFailure> {
16351    if root.is_empty()
16352        || root.len() > MAX_WORKSPACE_ROOT_BYTES
16353        || root.chars().any(char::is_control)
16354    {
16355        return Err(failure(
16356            NodeFailureCode::InvalidWorkspaceRoot,
16357            &format!(
16358                "workspace root must contain 1..={MAX_WORKSPACE_ROOT_BYTES} bytes and no control characters",
16359            ),
16360        ));
16361    }
16362    Ok(())
16363}
16364
16365fn canonical_standalone_workspace_candidate(
16366    workspace_id: &WorkspaceId,
16367    root: &str,
16368) -> Result<String, NodeServerError> {
16369    let path = Path::new(root);
16370    match std::fs::symlink_metadata(path) {
16371        Ok(_) => WorkspaceConfig::new(workspace_id.clone(), path)
16372            .map(|workspace| workspace.canonical_root),
16373        Err(error) if error.kind() == io::ErrorKind::NotFound => {
16374            if !path.is_absolute() {
16375                return Err(NodeServerError::InvalidWorkspaceRoot {
16376                    workspace_id: workspace_id.clone(),
16377                    path: root.to_owned(),
16378                    message: "path is not absolute".to_owned(),
16379                });
16380            }
16381            let name = path.file_name().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
16382                workspace_id: workspace_id.clone(),
16383                path: root.to_owned(),
16384                message: "path has no standalone directory name".to_owned(),
16385            })?;
16386            let parent = path.parent().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
16387                workspace_id: workspace_id.clone(),
16388                path: root.to_owned(),
16389                message: "path has no parent directory".to_owned(),
16390            })?;
16391            let canonical = std::fs::canonicalize(parent)
16392                .map(|parent| parent.join(name))
16393                .map_err(|error| NodeServerError::InvalidWorkspaceRoot {
16394                    workspace_id: workspace_id.clone(),
16395                    path: root.to_owned(),
16396                    message: error.to_string(),
16397                })?;
16398            let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
16399                NodeServerError::InvalidWorkspaceRoot {
16400                    workspace_id: workspace_id.clone(),
16401                    path: path.to_string_lossy().into_owned(),
16402                    message: "canonical path is not valid Unicode".to_owned(),
16403                }
16404            })?;
16405            let canonical_root = platform::normalize_canonical_root(canonical_root);
16406            validate_workspace_root(workspace_id, &canonical_root)?;
16407            Ok(canonical_root)
16408        }
16409        Err(error) => Err(NodeServerError::InvalidWorkspaceRoot {
16410            workspace_id: workspace_id.clone(),
16411            path: root.to_owned(),
16412            message: error.to_string(),
16413        }),
16414    }
16415}
16416
16417fn validate_git_revision(field: &str, revision: Option<&str>) -> Result<(), NodeFailure> {
16418    let Some(revision) = revision else {
16419        return Ok(());
16420    };
16421    if revision.is_empty()
16422        || revision.len() > MAX_REPOSITORY_PATH_BYTES
16423        || revision.chars().any(char::is_control)
16424    {
16425        return Err(failure(
16426            NodeFailureCode::InvalidRequest,
16427            &format!(
16428                "{field} must contain 1..={MAX_REPOSITORY_PATH_BYTES} bytes and no control characters",
16429            ),
16430        ));
16431    }
16432    Ok(())
16433}
16434
16435fn git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
16436    let code = match error.kind {
16437        GitWorktreeErrorKind::Invalid => NodeFailureCode::InvalidRequest,
16438        GitWorktreeErrorKind::NotRepository => NodeFailureCode::NotGitRepository,
16439        GitWorktreeErrorKind::Conflict => NodeFailureCode::WorktreeConflict,
16440        GitWorktreeErrorKind::Protected => NodeFailureCode::WorktreeProtected,
16441        GitWorktreeErrorKind::Dirty => NodeFailureCode::WorktreeDirty,
16442        GitWorktreeErrorKind::Locked => NodeFailureCode::WorktreeLocked,
16443        GitWorktreeErrorKind::Failed => NodeFailureCode::BackendOperationFailed,
16444    };
16445    failure(code, &error.message)
16446}
16447
16448fn standalone_workspace_failure(error: StandaloneWorkspaceError) -> NodeFailure {
16449    let code = match error.kind {
16450        StandaloneWorkspaceErrorKind::InvalidRoot => NodeFailureCode::InvalidWorkspaceRoot,
16451        StandaloneWorkspaceErrorKind::InvalidInitialBranch => NodeFailureCode::InvalidRequest,
16452        StandaloneWorkspaceErrorKind::GitInitializationFailed => {
16453            NodeFailureCode::BackendOperationFailed
16454        }
16455        StandaloneWorkspaceErrorKind::RecoveryRequired => {
16456            NodeFailureCode::StandaloneWorkspaceRecoveryRequired
16457        }
16458    };
16459    failure(code, error.message)
16460}
16461
16462fn managed_git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
16463    let (code, message) = match error.kind {
16464        GitWorktreeErrorKind::Invalid => (
16465            NodeFailureCode::InvalidRequest,
16466            "managed Git input is invalid",
16467        ),
16468        GitWorktreeErrorKind::NotRepository => (
16469            NodeFailureCode::NotGitRepository,
16470            "managed source is not a Git repository",
16471        ),
16472        GitWorktreeErrorKind::Conflict => (
16473            NodeFailureCode::WorktreeConflict,
16474            "managed Git identity conflicts with existing repository state",
16475        ),
16476        GitWorktreeErrorKind::Protected => (
16477            NodeFailureCode::ManagedWorktreeOwnershipConflict,
16478            "managed worktree ownership validation failed",
16479        ),
16480        GitWorktreeErrorKind::Dirty => (
16481            NodeFailureCode::WorktreeDirty,
16482            "managed worktree contains uncommitted changes",
16483        ),
16484        GitWorktreeErrorKind::Locked => (
16485            NodeFailureCode::WorktreeLocked,
16486            "managed worktree is locked",
16487        ),
16488        GitWorktreeErrorKind::Failed => (
16489            NodeFailureCode::BackendOperationFailed,
16490            "managed Git operation failed",
16491        ),
16492    };
16493    NodeFailure { code, message: message.to_owned() }
16494}
16495
16496fn managed_spawn_receipt(
16497    resolved: &ResolvedSpawnSpec,
16498    incarnation_id: NodeIncarnationId,
16499    session: SessionAddress,
16500    lease: ManagedWorktreeLeaseSnapshot,
16501    environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
16502    bundle: Option<ResolvedBundleReceipt>,
16503    context: Option<ResolvedContextPackReceipt>,
16504) -> ManagedWorktreeSpawnReceipt {
16505    let mut managed = resolved.clone();
16506    managed.target.worktree_id = Some(lease.workspace_id.clone());
16507    ManagedWorktreeSpawnReceipt {
16508        spawn: managed.receipt_with_materialization(
16509            incarnation_id,
16510            session,
16511            environment_profile,
16512            bundle,
16513            context,
16514        ),
16515        lease,
16516    }
16517}
16518
16519fn validate_selected_worktree(
16520    source_root: &str,
16521    selected_root: &str,
16522    worktrees: &[NativeGitWorktreeSnapshot],
16523) -> Result<(), NodeFailure> {
16524    if !worktrees
16525        .iter()
16526        .any(|worktree| worktree_paths_equal(&worktree.path, source_root) && !worktree.is_bare)
16527    {
16528        return Err(failure(
16529            NodeFailureCode::WorktreeProtected,
16530            "source workspace is not an authoritative non-bare Git worktree root",
16531        ));
16532    }
16533    if !worktrees.iter().any(|worktree| {
16534        worktree_paths_equal(&worktree.path, selected_root)
16535            && !worktree.is_main
16536            && !worktree.is_bare
16537    }) {
16538        return Err(failure(
16539            NodeFailureCode::WorktreeProtected,
16540            "selected workspace is not an eligible Git worktree in the source repository",
16541        ));
16542    }
16543    Ok(())
16544}
16545
16546fn workspace_file_failure(error: WorkspaceFileReadError) -> NodeFailure {
16547    let code = match error.kind() {
16548        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
16549        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
16550            NodeFailureCode::RepositoryPathUnsafe
16551        }
16552        WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
16553        WorkspaceFileReadErrorKind::NotRegularFile => {
16554            NodeFailureCode::RepositoryFileNotRegular
16555        }
16556        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
16557            NodeFailureCode::RepositoryFileReadFailed
16558        }
16559        WorkspaceFileReadErrorKind::RevisionConflict => {
16560            NodeFailureCode::RepositoryFileRevisionConflict
16561        }
16562        WorkspaceFileReadErrorKind::AlreadyExists
16563        | WorkspaceFileReadErrorKind::ParentNotFound
16564        | WorkspaceFileReadErrorKind::ParentNotDirectory
16565        | WorkspaceFileReadErrorKind::Canceled => {
16566            NodeFailureCode::RepositoryFileReadFailed
16567        }
16568    };
16569    failure(code, "repository file read failed")
16570}
16571
16572fn workspace_file_write_failure(error: WorkspaceFileReadError) -> NodeFailure {
16573    let code = match error.kind() {
16574        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
16575        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
16576            NodeFailureCode::RepositoryPathUnsafe
16577        }
16578        WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
16579        WorkspaceFileReadErrorKind::NotRegularFile => {
16580            NodeFailureCode::RepositoryFileNotRegular
16581        }
16582        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
16583            NodeFailureCode::RepositoryFileWriteFailed
16584        }
16585        WorkspaceFileReadErrorKind::RevisionConflict => {
16586            NodeFailureCode::RepositoryFileRevisionConflict
16587        }
16588        WorkspaceFileReadErrorKind::AlreadyExists
16589        | WorkspaceFileReadErrorKind::ParentNotFound
16590        | WorkspaceFileReadErrorKind::ParentNotDirectory
16591        | WorkspaceFileReadErrorKind::Canceled => {
16592            NodeFailureCode::RepositoryFileWriteFailed
16593        }
16594    };
16595    failure(code, "repository file write failed")
16596}
16597
16598async fn settle_workspace_entry_create<T>(
16599    mut task: tokio::task::JoinHandle<Result<T, WorkspaceFileReadError>>,
16600    commit_state: Arc<AtomicU8>,
16601    deadline: Duration,
16602    timeout_message: &'static str,
16603    task_failure_message: &'static str,
16604) -> Result<T, NodeFailure> {
16605    let joined = match timeout(deadline, &mut task).await {
16606        Ok(joined) => joined,
16607        Err(_) => {
16608            if commit_state
16609                .compare_exchange(
16610                    WORKSPACE_ENTRY_CREATE_PENDING,
16611                    WORKSPACE_ENTRY_CREATE_CANCELED,
16612                    Ordering::AcqRel,
16613                    Ordering::Acquire,
16614                )
16615                .is_ok()
16616            {
16617                return Err(failure(
16618                    NodeFailureCode::RepositoryEntryCreateTimedOut,
16619                    timeout_message,
16620                ));
16621            }
16622            debug_assert_eq!(
16623                commit_state.load(Ordering::Acquire),
16624                WORKSPACE_ENTRY_CREATE_COMMITTING,
16625            );
16626            task.await
16627        }
16628    };
16629    joined
16630        .map_err(|_| {
16631            failure(
16632                NodeFailureCode::RepositoryEntryCreateFailed,
16633                task_failure_message,
16634            )
16635        })?
16636        .map_err(workspace_entry_create_failure)
16637}
16638
16639fn workspace_entry_create_failure(error: WorkspaceFileReadError) -> NodeFailure {
16640    let code = match error.kind() {
16641        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
16642        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
16643            NodeFailureCode::RepositoryPathUnsafe
16644        }
16645        WorkspaceFileReadErrorKind::AlreadyExists => {
16646            NodeFailureCode::RepositoryEntryAlreadyExists
16647        }
16648        WorkspaceFileReadErrorKind::ParentNotFound | WorkspaceFileReadErrorKind::NotFound => {
16649            NodeFailureCode::RepositoryParentNotFound
16650        }
16651        WorkspaceFileReadErrorKind::ParentNotDirectory
16652        | WorkspaceFileReadErrorKind::NotRegularFile => {
16653            NodeFailureCode::RepositoryParentNotDirectory
16654        }
16655        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
16656            NodeFailureCode::RepositoryEntryCreateFailed
16657        }
16658        WorkspaceFileReadErrorKind::RevisionConflict => {
16659            NodeFailureCode::RepositoryEntryCreateFailed
16660        }
16661        WorkspaceFileReadErrorKind::Canceled => {
16662            NodeFailureCode::RepositoryEntryCreateTimedOut
16663        }
16664    };
16665    failure(code, "repository entry create failed")
16666}
16667
16668fn workspace_file_revision(bytes: &[u8]) -> WorkspaceFileRevision {
16669    let value = digest(&SHA256, bytes)
16670        .as_ref()
16671        .iter()
16672        .map(|byte| format!("{byte:02x}"))
16673        .collect();
16674    WorkspaceFileRevision::new(value).expect("SHA-256 produces a valid workspace revision")
16675}
16676
16677fn managed_spawn_request_digest_v2(
16678    request: &ManagedWorktreeSpawnRequestV2,
16679) -> Result<String, NodeFailure> {
16680    let encoded = serde_json::to_vec(request).map_err(|_| {
16681        failure(
16682            NodeFailureCode::InvalidRequest,
16683            "managed worktree V2 request could not be canonicalized",
16684        )
16685    })?;
16686    let mut value = String::with_capacity(71);
16687    value.push_str("sha256:");
16688    for byte in digest(&SHA256, &encoded).as_ref() {
16689        use std::fmt::Write as _;
16690        write!(&mut value, "{byte:02x}").expect("writing to a String cannot fail");
16691    }
16692    Ok(value)
16693}
16694
16695fn native_session_preview_failure(error: NativeSessionPreviewError) -> NodeFailure {
16696    let code = match error {
16697        NativeSessionPreviewError::InvalidLimit => NodeFailureCode::InvalidRequest,
16698        NativeSessionPreviewError::WorkspaceUnavailable => NodeFailureCode::UnknownWorkspace,
16699        NativeSessionPreviewError::StaleCatalog => NodeFailureCode::StaleNativeSessionCatalog,
16700        NativeSessionPreviewError::UnsupportedProvider
16701        | NativeSessionPreviewError::PreviewUnavailable
16702        | NativeSessionPreviewError::SessionNotFound
16703        | NativeSessionPreviewError::AmbiguousSession => {
16704            NodeFailureCode::BackendOperationFailed
16705        }
16706    };
16707    failure(code, "native session preview failed")
16708}
16709
16710fn host_directory_failure(error: HostDirectoryBrowseError) -> NodeFailure {
16711    let code = match error.kind() {
16712        HostDirectoryBrowseErrorKind::Invalid => NodeFailureCode::HostDirectoryInvalid,
16713        HostDirectoryBrowseErrorKind::ReadFailed => NodeFailureCode::HostDirectoryReadFailed,
16714    };
16715    failure(code, "host directory browse failed")
16716}
16717
16718fn delivery_failure(error: DeliveryStoreError) -> NodeFailure {
16719    let code = match error {
16720        DeliveryStoreError::InvalidManifest => NodeFailureCode::DeliveryManifestInvalid,
16721        DeliveryStoreError::UnknownStage => NodeFailureCode::UnknownDeliveryStage,
16722        DeliveryStoreError::Capacity | DeliveryStoreError::StageConflict => {
16723            NodeFailureCode::DeliveryStageConflict
16724        }
16725        DeliveryStoreError::UnexpectedBlob => NodeFailureCode::DeliveryBlobUnexpected,
16726        DeliveryStoreError::ChunkOutOfOrder | DeliveryStoreError::ChunkOverflow => {
16727            NodeFailureCode::DeliveryChunkOutOfOrder
16728        }
16729        DeliveryStoreError::BlobDigestMismatch => {
16730            NodeFailureCode::DeliveryBlobDigestMismatch
16731        }
16732        DeliveryStoreError::BundleDigestMismatch => {
16733            NodeFailureCode::DeliveryBundleDigestMismatch
16734        }
16735        DeliveryStoreError::StageIncomplete => NodeFailureCode::DeliveryStageIncomplete,
16736        DeliveryStoreError::Unavailable
16737        | DeliveryStoreError::Corrupt
16738        | DeliveryStoreError::Storage(_) => NodeFailureCode::DeliveryStageStorageFailed,
16739    };
16740    failure(code, "delivery operation failed")
16741}
16742
16743fn validate_workspace_root(
16744    workspace_id: &WorkspaceId,
16745    canonical_root: &str,
16746) -> Result<(), NodeServerError> {
16747    #[cfg(windows)]
16748    if canonical_root.starts_with(r"\\") {
16749        return Err(NodeServerError::InvalidWorkspaceRoot {
16750            workspace_id: workspace_id.clone(),
16751            path: canonical_root.to_owned(),
16752            message: "UNC workspace roots are unsupported by Windows PTY providers".to_owned(),
16753        });
16754    }
16755    if !platform::workspace_root_supported(canonical_root)
16756        || canonical_root.len() > WORKING_DIRECTORY_MAX_BYTES
16757    {
16758        return Err(NodeServerError::InvalidWorkspaceRoot {
16759            workspace_id: workspace_id.clone(),
16760            path: canonical_root.to_owned(),
16761            message: "canonical path must be absolute, bounded, and free of control characters"
16762                .to_owned(),
16763        });
16764    }
16765    Ok(())
16766}
16767
16768fn failure(code: NodeFailureCode, _message: &str) -> NodeFailure {
16769    NodeFailure {
16770        code,
16771        message: node_failure_category(code).to_owned(),
16772    }
16773}
16774
16775fn harness_mcp_failure(error: HarnessMcpProxyError) -> NodeFailure {
16776    let code = match error {
16777        HarnessMcpProxyError::Unavailable => NodeFailureCode::HarnessMcpUnavailable,
16778        HarnessMcpProxyError::NotFound => NodeFailureCode::ReservationNotFound,
16779        HarnessMcpProxyError::Conflict => NodeFailureCode::ReservationConflict,
16780        HarnessMcpProxyError::Expired => NodeFailureCode::ReservationExpired,
16781        HarnessMcpProxyError::BindingMismatch => NodeFailureCode::BindingMismatch,
16782        HarnessMcpProxyError::NotActivated => NodeFailureCode::NotActivated,
16783        HarnessMcpProxyError::CallNotFound => NodeFailureCode::CallNotFound,
16784        HarnessMcpProxyError::ChunkOutOfOrder => NodeFailureCode::ChunkOutOfOrder,
16785        HarnessMcpProxyError::ResponseTooLarge => NodeFailureCode::ResponseTooLarge,
16786    };
16787    failure(code, "harness MCP proxy operation failed")
16788}
16789
16790/// Numbers each harness-MCP overlay this process constructs, in
16791/// construction order, so two overlays built back to back in one node process
16792/// still get distinct trace files.
16793static NEXT_HARNESS_MCP_TRACE_SEQUENCE: AtomicU64 = AtomicU64::new(1);
16794
16795/// Builds the optional trace environment entry of one harness-MCP overlay.
16796/// The caller's launch description names both the entry and the node-process
16797/// variable that opts in: when that variable holds a non-empty directory, the
16798/// entry names a trace file under it. Returns `None` when the launch has no
16799/// trace, or the directory is unset, empty, or not valid Unicode. Never creates
16800/// the directory; that is the operator's job.
16801fn harness_mcp_trace_env_entry(launch: &HarnessMcpLaunchV1) -> Option<(OsString, OsString)> {
16802    let trace = launch.trace.as_ref()?;
16803    let trace_dir = std::env::var(&trace.dir_env).ok()?;
16804    if trace_dir.is_empty() {
16805        return None;
16806    }
16807    let sequence = NEXT_HARNESS_MCP_TRACE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
16808    let path = std::path::Path::new(&trace_dir).join(format!("harness-mcp-{sequence}.trace"));
16809    Some((OsString::from(trace.env.as_str()), path.into_os_string()))
16810}
16811
16812fn spawn_deadline_remaining(deadline: Instant) -> Result<Duration, NodeFailure> {
16813    let remaining = deadline.saturating_duration_since(Instant::now());
16814    if remaining.is_zero() {
16815        Err(failure(
16816            NodeFailureCode::SpawnDeadlineExceeded,
16817            "spawn deadline elapsed",
16818        ))
16819    } else {
16820        Ok(remaining)
16821    }
16822}
16823
16824fn spawn_dispatch_timeout(deadline: Option<Instant>) -> Result<Duration, NodeFailure> {
16825    let bounded = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
16826    match deadline {
16827        Some(deadline) => Ok(spawn_deadline_remaining(deadline)?.min(bounded)),
16828        None => Ok(bounded),
16829    }
16830}
16831
16832fn spawn_dispatch_error(error: NodeFailure, deadline: Option<Instant>) -> NodeFailure {
16833    if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
16834        failure(
16835            NodeFailureCode::SpawnDeadlineExceeded,
16836            "spawn dispatch exceeded the spawn deadline",
16837        )
16838    } else {
16839        error
16840    }
16841}
16842
16843fn spawn_runtime_capabilities(
16844    capabilities: &SpawnRequiredCapabilities,
16845) -> Result<Vec<ProviderRuntimeCapability>, NodeFailure> {
16846    capabilities
16847        .iter()
16848        .map(|capability| match capability.as_str() {
16849            SPAWN_RUNTIME_RAW_PTY_LIFECYCLE => Ok(ProviderRuntimeCapability::RawPtyLifecycle),
16850            SPAWN_RUNTIME_SEMANTIC_READINESS => Ok(ProviderRuntimeCapability::SemanticReadiness),
16851            SPAWN_RUNTIME_STRUCTURED_PROMPT => Ok(ProviderRuntimeCapability::StructuredPrompt),
16852            SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY => {
16853                Ok(ProviderRuntimeCapability::ProviderSessionIdentity)
16854            }
16855            SPAWN_RUNTIME_SEMANTIC_RESUME => Ok(ProviderRuntimeCapability::SemanticResume),
16856            _ => Err(failure(
16857                NodeFailureCode::UnsupportedSpawnCapability,
16858                "spawn required capability is unknown",
16859            )),
16860        })
16861        .collect()
16862}
16863
16864fn persistence_failure(_error: io::Error) -> NodeFailure {
16865    NodeFailure {
16866        code: NodeFailureCode::BackendOperationFailed,
16867        message: DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned(),
16868    }
16869}
16870
16871fn node_failure_category(code: NodeFailureCode) -> &'static str {
16872    match code {
16873        NodeFailureCode::InvalidRequest => "invalid-request",
16874        NodeFailureCode::UnsupportedCapability => "unsupported-capability",
16875        NodeFailureCode::HarnessMcpUnavailable => "harness-mcp-unavailable",
16876        NodeFailureCode::ReservationNotFound => "reservation-not-found",
16877        NodeFailureCode::ReservationConflict => "reservation-conflict",
16878        NodeFailureCode::ReservationExpired => "reservation-expired",
16879        NodeFailureCode::BindingMismatch => "binding-mismatch",
16880        NodeFailureCode::NotActivated => "not-activated",
16881        NodeFailureCode::CallNotFound => "call-not-found",
16882        NodeFailureCode::ChunkOutOfOrder => "chunk-out-of-order",
16883        NodeFailureCode::ResponseTooLarge => "response-too-large",
16884        NodeFailureCode::DeliveryManifestInvalid => "delivery-manifest-invalid",
16885        NodeFailureCode::UnknownDeliveryStage => "unknown-delivery-stage",
16886        NodeFailureCode::DeliveryStageConflict => "delivery-stage-conflict",
16887        NodeFailureCode::DeliveryBlobUnexpected => "delivery-blob-unexpected",
16888        NodeFailureCode::DeliveryChunkOutOfOrder => "delivery-chunk-out-of-order",
16889        NodeFailureCode::DeliveryBlobDigestMismatch => "delivery-blob-digest-mismatch",
16890        NodeFailureCode::DeliveryBundleDigestMismatch => "delivery-bundle-digest-mismatch",
16891        NodeFailureCode::DeliveryStageIncomplete => "delivery-stage-incomplete",
16892        NodeFailureCode::DeliveryStageStorageFailed => "delivery-stage-storage-failed",
16893        NodeFailureCode::Unauthorized => "unauthorized",
16894        NodeFailureCode::ObserverReadOnly => "observer-read-only",
16895        NodeFailureCode::ControllerBusy => "controller-busy",
16896        NodeFailureCode::ControllerRequired => "controller-required",
16897        NodeFailureCode::UnknownWorkspace => "unknown-workspace",
16898        NodeFailureCode::HostDirectoryInvalid => "host-directory-invalid",
16899        NodeFailureCode::HostDirectoryReadFailed => "host-directory-read-failed",
16900        NodeFailureCode::HostDirectoryReadTimedOut => "host-directory-read-timed-out",
16901        NodeFailureCode::InvalidRepositoryPath => "invalid-repository-path",
16902        NodeFailureCode::RepositoryFileNotFound => "repository-file-not-found",
16903        NodeFailureCode::RepositoryFileNotRegular => "repository-file-not-regular",
16904        NodeFailureCode::RepositoryPathUnsafe => "repository-path-unsafe",
16905        NodeFailureCode::RepositoryFileReadTimedOut => "repository-file-read-timed-out",
16906        NodeFailureCode::RepositoryFileReadFailed => "repository-file-read-failed",
16907        NodeFailureCode::RepositoryFileWriteTimedOut => "repository-file-write-timed-out",
16908        NodeFailureCode::RepositoryFileWriteFailed => "repository-file-write-failed",
16909        NodeFailureCode::RepositoryFileRevisionConflict => "repository-file-revision-conflict",
16910        NodeFailureCode::RepositoryEntryAlreadyExists => "repository-entry-already-exists",
16911        NodeFailureCode::RepositoryParentNotFound => "repository-parent-not-found",
16912        NodeFailureCode::RepositoryParentNotDirectory => "repository-parent-not-directory",
16913        NodeFailureCode::RepositoryEntryCreateTimedOut => "repository-entry-create-timed-out",
16914        NodeFailureCode::RepositoryEntryCreateFailed => "repository-entry-create-failed",
16915        NodeFailureCode::GitReadTimedOut => "git-read-timed-out",
16916        NodeFailureCode::GitReadFailed => "git-read-failed",
16917        NodeFailureCode::UnknownSpawnProfile => "unknown-spawn-profile",
16918        NodeFailureCode::SpawnProfileRevisionMismatch => "spawn-profile-revision-mismatch",
16919        NodeFailureCode::SpawnTargetMismatch => "spawn-target-mismatch",
16920        NodeFailureCode::SpawnIdempotencyConflict => "spawn-idempotency-conflict",
16921        NodeFailureCode::SpawnIdempotencyCapacity => "spawn-idempotency-capacity",
16922        NodeFailureCode::SpawnDeadlineExceeded => "spawn-deadline-exceeded",
16923        NodeFailureCode::UnsupportedSpawnCapability => "unsupported-spawn-capability",
16924        NodeFailureCode::UnsupportedTransport => "unsupported-transport",
16925        NodeFailureCode::TurnInFlight => "turn-in-flight",
16926        NodeFailureCode::UnknownEnvironmentProfile => "unknown-environment-profile",
16927        NodeFailureCode::UnknownNetworkAllowlist => "unknown-network-allowlist",
16928        NodeFailureCode::UnsupportedNetworkAllowlistMapping => {
16929            "unsupported-network-allowlist-mapping"
16930        }
16931        NodeFailureCode::BrowserStationProbeUnavailable => {
16932            "browser-station-probe-unavailable"
16933        }
16934        NodeFailureCode::BrowserStationUnreachable => "browser-station-unreachable",
16935        NodeFailureCode::BrowserStationProfileBusy => "browser-station-profile-busy",
16936        NodeFailureCode::EnvironmentProfileBindingMismatch => {
16937            "environment-profile-binding-mismatch"
16938        }
16939        NodeFailureCode::UnknownBundle => "unknown-bundle",
16940        NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
16941        NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
16942        NodeFailureCode::UnknownContextPack => "unknown-context-pack",
16943        NodeFailureCode::ContextPackBusy => "context-pack-busy",
16944        NodeFailureCode::ContextPackMaterializationFailed => {
16945            "context-pack-materialization-failed"
16946        }
16947        NodeFailureCode::InvalidWorkspaceRoot => "invalid-workspace-root",
16948        NodeFailureCode::DuplicateWorkspaceId => "duplicate-workspace-id",
16949        NodeFailureCode::DuplicateWorkspaceRoot => "duplicate-workspace-root",
16950        NodeFailureCode::WorkspaceBusy => "workspace-busy",
16951        NodeFailureCode::LastWorkspace => "last-workspace",
16952        NodeFailureCode::NotGitRepository => "not-git-repository",
16953        NodeFailureCode::WorktreeConflict => "worktree-conflict",
16954        NodeFailureCode::WorktreeProtected => "worktree-protected",
16955        NodeFailureCode::WorktreeDirty => "worktree-dirty",
16956        NodeFailureCode::WorktreeLocked => "worktree-locked",
16957        NodeFailureCode::UnknownManagedWorktreeLease => "unknown-managed-worktree-lease",
16958        NodeFailureCode::ManagedWorktreeBusy => "managed-worktree-busy",
16959        NodeFailureCode::ManagedWorktreeOwnershipConflict => "managed-worktree-ownership-conflict",
16960        NodeFailureCode::ManagedWorktreeProfileRevisionMismatch => {
16961            "managed-worktree-profile-revision-mismatch"
16962        }
16963        NodeFailureCode::ManagedWorktreeRecoveryRequired => "managed-worktree-recovery-required",
16964        NodeFailureCode::StandaloneWorkspaceRecoveryRequired => {
16965            "standalone-workspace-recovery-required"
16966        }
16967        NodeFailureCode::UnknownSession => "unknown-session",
16968        NodeFailureCode::UnknownSessionRecord => "unknown-session-record",
16969        NodeFailureCode::SessionRecordNotResumable => "session-record-not-resumable",
16970        NodeFailureCode::SessionRecordBusy => "session-record-busy",
16971        NodeFailureCode::SessionRecordConflict => "session-record-conflict",
16972        NodeFailureCode::SessionWorkspaceMismatch => "session-workspace-mismatch",
16973        NodeFailureCode::WorkspaceRegistrationRequired => {
16974            "workspace-registration-required"
16975        }
16976        NodeFailureCode::StaleNativeSessionCatalog => "stale-native-session-catalog",
16977        NodeFailureCode::StaleGeneration => "stale-generation",
16978        NodeFailureCode::BackendBusy => "backend-busy",
16979        NodeFailureCode::BackendDisconnected => "backend-disconnected",
16980        NodeFailureCode::BackendOperationFailed => "backend-operation-failed",
16981        NodeFailureCode::ShuttingDown => "shutting-down",
16982    }
16983}
16984
16985fn durable_state_server_error(error: io::Error, category: &'static str) -> NodeServerError {
16986    NodeServerError::DurableState(io::Error::new(error.kind(), category))
16987}
16988
16989fn durable_state_load_error(error: io::Error) -> NodeServerError {
16990    let category = match session_registry::state_load_refusal(&error) {
16991        Some(session_registry::StateLoadRefusal::UnsupportedSchema) => {
16992            DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR
16993        }
16994        Some(session_registry::StateLoadRefusal::PathSemanticsUnsupported) => {
16995            DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR
16996        }
16997        Some(session_registry::StateLoadRefusal::NodeIdentityMismatch) => {
16998            DURABLE_STATE_CONFLICT_ERROR
16999        }
17000        _ => DURABLE_STATE_LOAD_FAILED_ERROR,
17001    };
17002    durable_state_server_error(error, category)
17003}
17004
17005fn unix_time_ms() -> u64 {
17006    std::time::SystemTime::now()
17007        .duration_since(std::time::UNIX_EPOCH)
17008        .unwrap_or_default()
17009        .as_millis()
17010        .min(u64::MAX as u128) as u64
17011}
17012
17013fn detached_record_state(record: &ManagedSessionRecord) -> ManagedSessionState {
17014    if record.provider_session.is_some() {
17015        ManagedSessionState::Dormant
17016    } else {
17017        ManagedSessionState::Unavailable
17018    }
17019}
17020
17021fn active_record_state(
17022    identity_admitted: bool,
17023    provider_session_is_known: bool,
17024) -> ManagedSessionState {
17025    if identity_admitted && !provider_session_is_known {
17026        ManagedSessionState::IdentityPending
17027    } else {
17028        ManagedSessionState::Live
17029    }
17030}
17031
17032fn managed_worktree_record_holders(
17033    records: &[ManagedSessionRecord],
17034) -> Vec<(SessionRecordId, WorkspaceId)> {
17035    records
17036        .iter()
17037        .filter(|record| record.provider_session.is_some())
17038        .map(|record| (record.record_id.clone(), record.workspace_id.clone()))
17039        .collect()
17040}
17041
17042fn managed_spawn_record_ownership(
17043    binding: &SessionBinding,
17044    effective_runtime_policy: ProviderRuntimePolicy,
17045) -> (Option<SessionRecordId>, Option<SessionRecordId>) {
17046    debug_assert_eq!(binding.runtime_policy, effective_runtime_policy);
17047    if effective_runtime_policy.provider_session_identity {
17048        (binding.record_id.clone(), None)
17049    } else {
17050        (None, binding.record_id.clone())
17051    }
17052}
17053
17054#[derive(Debug, Error)]
17055pub enum NodeServerError {
17056    #[error("node endpoint must be a bounded absolute local transport path")]
17057    InvalidEndpoint,
17058    #[error("node access token must contain 1..=4096 bytes")]
17059    InvalidAccessToken,
17060    #[error("node HTTP observer must listen on a loopback address: {0}")]
17061    InvalidApiListen(std::net::SocketAddr),
17062    #[error("node browser bridge must listen on a loopback address: {0}")]
17063    InvalidBridgeListen(std::net::SocketAddr),
17064    #[error("node browser bridge token must contain 1..=4096 bytes")]
17065    InvalidBridgeToken,
17066    #[error("bridge underlay requires --bridge-listen (local HTTP+WS door)")]
17067    BridgeUnderlayRequiresBridgeListen,
17068    #[error("bridge underlay requires GATE4AGENT_BRIDGE_TOKEN (application barrier)")]
17069    BridgeUnderlayRequiresBridgeToken,
17070    #[error("mesh underlay auth token must contain 1..=4096 bytes")]
17071    InvalidBridgeUnderlayToken,
17072    #[error("mesh underlay transport key must be 32 bytes (64 hex digits)")]
17073    InvalidBridgeUnderlayKey,
17074    #[error("node call-home relay must be a loopback address with a nonzero port: {0}")]
17075    InvalidCallHome(std::net::SocketAddr),
17076    #[error("node could not reach its call-home relay: {0}")]
17077    CallHomeTransport(#[source] std::io::Error),
17078    #[error("node durable state path must be an absolute file path: {0}")]
17079    InvalidStatePath(String),
17080    #[error("harness MCP helper must be an exact reviewed absolute regular file")]
17081    InvalidHarnessMcpHelper,
17082    #[error("network allowlist catalog path must be an absolute regular file: {0}")]
17083    InvalidNetworkAllowlistCatalogPath(String),
17084    #[error("network allowlist catalog could not be read: {0}")]
17085    NetworkAllowlistCatalogIo(#[source] std::io::Error),
17086    #[error("network allowlist catalog line {line}: {message}")]
17087    InvalidNetworkAllowlistCatalogEntry { line: usize, message: String },
17088    #[error("network allowlist catalog: {0}")]
17089    InvalidNetworkAllowlistCatalog(String),
17090    #[error("network allowlist catalog exceeds the {max}-entry limit")]
17091    NetworkAllowlistCatalogCapacity { max: usize },
17092    #[error("the local state directory is unavailable; supply an explicit state path")]
17093    LocalStateDirectoryUnavailable,
17094    #[error("the local runtime directory is unavailable; supply an explicit endpoint")]
17095    LocalRuntimeDirectoryUnavailable,
17096    #[error("node durable state failed: {0}")]
17097    DurableState(io::Error),
17098    #[error("node requires at least one configured workspace")]
17099    NoWorkspaces,
17100    #[error("workspace '{workspace_id}' root '{path}' is invalid: {message}")]
17101    InvalidWorkspaceRoot {
17102        workspace_id: WorkspaceId,
17103        path: String,
17104        message: String,
17105    },
17106    #[error("duplicate workspace ID: {0}")]
17107    DuplicateWorkspaceId(WorkspaceId),
17108    #[error("workspaces '{first}' and '{second}' resolve to the same root '{root}'")]
17109    DuplicateWorkspaceRoot {
17110        first: WorkspaceId,
17111        second: WorkspaceId,
17112        root: String,
17113    },
17114    #[error("workspace '{0}' uses managed worktrees but has no local profile")]
17115    ManagedWorktreeProfileRequired(WorkspaceId),
17116    #[error("workspace '{0}' has managed worktree profiles but is not in managed mode")]
17117    ManagedWorktreeProfileModeMismatch(WorkspaceId),
17118    #[error("workspace '{workspace_id}' has duplicate managed worktree profile '{profile_id}'")]
17119    DuplicateManagedWorktreeProfile {
17120        workspace_id: WorkspaceId,
17121        profile_id: WorktreeProfileId,
17122    },
17123    #[error("workspace '{workspace_id}' exceeds the {max}-profile managed worktree limit")]
17124    ManagedWorktreeProfileCapacity {
17125        workspace_id: WorkspaceId,
17126        max: usize,
17127    },
17128    #[error("workspace '{workspace_id}' managed worktree profile '{profile_id}' is invalid: {message}")]
17129    InvalidManagedWorktreeProfile {
17130        workspace_id: WorkspaceId,
17131        profile_id: WorktreeProfileId,
17132        message: String,
17133    },
17134    #[error("duplicate node environment profile: {0}")]
17135    DuplicateEnvironmentProfile(SpawnEnvironmentProfileId),
17136    #[error("node environment profile provider is unavailable: {0}")]
17137    EnvironmentProfileProviderUnavailable(AgentId),
17138    #[error("node environment profile capacity is {max}")]
17139    EnvironmentProfileCapacity { max: usize },
17140    #[error("node environment profiles reuse native profile ID '{0}'")]
17141    DuplicateNativeEnvironmentProfile(String),
17142    #[error("node native environment profile is invalid: {0}")]
17143    NativeEnvironmentProfile(String),
17144    #[error("node session-environment materialization root must be absolute")]
17145    InvalidSessionEnvironmentRoot,
17146    #[error("node session-environment materializer is unavailable")]
17147    SessionEnvironmentMaterializerRequired,
17148    #[error("node session-environment materializer failed to initialize")]
17149    SessionEnvironmentMaterializer,
17150    #[error("node bundle catalog is invalid: {0}")]
17151    BundleCatalog(String),
17152    #[error("node delivery store failed to initialize: {0}")]
17153    DeliveryStore(String),
17154    #[error("node context pack store failed to initialize")]
17155    ContextPackStore,
17156    #[error("active agent registry failed: {0}")]
17157    Registry(String),
17158    #[error("node provider contract manifest is invalid: {0}")]
17159    ProviderContractManifest(String),
17160    #[error("named pipe I/O failed: {0}")]
17161    Io(#[from] io::Error),
17162    #[error("node HTTP observer failed: {0}")]
17163    HttpApi(io::Error),
17164    #[error("node browser bridge failed: {0}")]
17165    BridgeApi(io::Error),
17166    #[error(transparent)]
17167    Frame(#[from] FrameError),
17168    #[error("node handshake failed: {0}")]
17169    Handshake(String),
17170    #[error("node authentication frame was not received before the bounded deadline")]
17171    AuthenticationTimedOut,
17172    #[error("node authentication primitive failed: {0}")]
17173    Authentication(String),
17174    #[error("node incarnation identity generation failed: {0}")]
17175    IncarnationIdentity(String),
17176    #[error("node pre-authentication limiter is closed")]
17177    PreauthClosed,
17178    #[error("node authenticated connection limit was reached")]
17179    AuthenticatedConnectionLimit,
17180    #[error("node Ctrl+C/CtrlBreak signal stream closed")]
17181    SignalStreamClosed,
17182    #[error("node stop-all dispatch failed: {0:?}")]
17183    ShutdownDispatch(NodeFailure),
17184    #[error("node shutdown timed out with {active_native_sessions} physical sessions retained")]
17185    ShutdownTimedOut { active_native_sessions: usize },
17186}
17187
17188impl From<crate::network_allowlist_catalog::NetworkAllowlistCatalogError> for NodeServerError {
17189    fn from(error: crate::network_allowlist_catalog::NetworkAllowlistCatalogError) -> Self {
17190        use crate::network_allowlist_catalog::NetworkAllowlistCatalogError as E;
17191        match error {
17192            E::InvalidPath(path) => Self::InvalidNetworkAllowlistCatalogPath(path),
17193            E::Io(error) => Self::NetworkAllowlistCatalogIo(error),
17194            E::InvalidEntry { line, message } => {
17195                Self::InvalidNetworkAllowlistCatalogEntry { line, message }
17196            }
17197            E::InvalidCatalog(message) => Self::InvalidNetworkAllowlistCatalog(message),
17198            E::Capacity { max } => Self::NetworkAllowlistCatalogCapacity { max },
17199        }
17200    }
17201}
17202
17203#[cfg(test)]
17204mod agent_stream_blocked_tests {
17205    use super::*;
17206    use gate4agent_types::{
17207        AdapterId, AdapterVerification, HostRequestOutcome as ProviderHostRequestOutcome,
17208        ProviderSource,
17209    };
17210
17211    fn provider_source(family: AdapterFamily, adapter: &str) -> ProviderSource {
17212        ProviderSource {
17213            family,
17214            binding: AdapterBinding::new(
17215                AdapterId::new(adapter).unwrap(),
17216                "fixture/v1",
17217                AdapterVerification::SyntheticFixture,
17218            )
17219            .unwrap(),
17220        }
17221    }
17222
17223    fn provider_control_event_at(
17224        family: AdapterFamily,
17225        adapter: &str,
17226        source_sequence: u64,
17227        provider_event: ProviderEvent,
17228    ) -> ControlEvent {
17229        ControlEvent {
17230            sequence: 41,
17231            command_id: None,
17232            instance_id: AgentInstanceId(7),
17233            generation: SessionGeneration(3),
17234            event: ControlEventKind::ProviderEvent {
17235                sequence: 8,
17236                source: provider_source(family, adapter),
17237                source_sequence,
17238                event: provider_event,
17239            },
17240        }
17241    }
17242
17243    fn provider_control_event(family: AdapterFamily, provider_event: ProviderEvent) -> ControlEvent {
17244        provider_control_event_at(family, "fixture", 9, provider_event)
17245    }
17246
17247    fn denied_host_request(
17248        by: ProviderHostDecisionAuthority,
17249        reason: Option<String>,
17250    ) -> ControlEvent {
17251        provider_control_event_at(
17252            AdapterFamily::Acp,
17253            "claude-code",
17254            2,
17255            ProviderEvent::HostRequestObserved {
17256                method: "terminal/create".to_owned(),
17257                params_json: String::new(),
17258                decision: ProviderHostRequestDecision::Denied { by },
17259                outcome: ProviderHostRequestOutcome::Executed,
17260                reason,
17261            },
17262        )
17263    }
17264
17265    fn blocked(
17266        event: &ControlEvent,
17267    ) -> (
17268        Option<String>,
17269        String,
17270        BlockAuthorityV1,
17271        Option<String>,
17272        String,
17273        Option<String>,
17274    ) {
17275        let chunk = agent_stream_chunk(event).expect("expected a Blocked chunk");
17276        chunk.validate().expect("minted chunk must validate");
17277        let AgentStreamChunkKindV1::Blocked {
17278            correlation_id,
17279            tool_class,
17280            authority,
17281            reason_kind,
17282            reason,
17283            help,
17284        } = chunk.kind
17285        else {
17286            panic!("expected AgentStreamChunkKindV1::Blocked");
17287        };
17288        (correlation_id, tool_class, authority, reason_kind, reason, help)
17289    }
17290
17291    /// Pins the fleet and every adapter each member declares: the fleet is
17292    /// exactly what `active_registry` says, and no member declares a hook
17293    /// adapter (lifecycle hooks are retired).
17294    #[test]
17295    fn the_enabled_provider_matrix_is_exact() {
17296        let registry = active_registry().unwrap();
17297        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
17298        assert_eq!(
17299            providers
17300                .iter()
17301                .map(|contract| contract.provider.as_str())
17302                .collect::<Vec<_>>(),
17303            vec!["claude", "codex", "grok", "kimi"],
17304        );
17305        assert!(adapters.iter().all(|contract| {
17306            !matches!(contract.family, AdapterFamily::Hook | AdapterFamily::ManagedHook)
17307        }));
17308    }
17309
17310    /// A gate-denied host request, WITH its reason text carried all the way
17311    /// from the ACP host, mints a `Blocked` chunk naming the gate.
17312    #[test]
17313    fn gate_denied_host_request_mints_a_blocked_chunk() {
17314        let gate_text =
17315            "blocked by dangerous-command gate: rule=filesystem-wipe, argument=rm -rf /";
17316        let event = denied_host_request(
17317            ProviderHostDecisionAuthority::Gate,
17318            Some(gate_text.to_owned()),
17319        );
17320        let chunk = agent_stream_chunk(&event).expect("expected a Blocked chunk");
17321        assert_eq!(chunk.source_sequence, 2);
17322        let (correlation_id, tool_class, authority, reason_kind, reason, help) = blocked(&event);
17323        assert_eq!(authority, BlockAuthorityV1::HostGate);
17324        assert_eq!(reason, gate_text);
17325        assert_eq!(correlation_id, None);
17326        assert_eq!(reason_kind, None);
17327        assert_eq!(help, None);
17328        assert_eq!(tool_class, "Write");
17329    }
17330
17331    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.";
17332
17333    /// The owner's classifier sample, verbatim, arriving as a
17334    /// `tool_call_update`-derived `ProviderEvent::ToolCompleted` with
17335    /// `non_execution_kind: "permission-rule"` -- mints a `Blocked` chunk with
17336    /// `authority: ProviderPermissionRule`, the `Reason:` sentence as `reason`
17337    /// and the guidance tail as `help`.
17338    #[test]
17339    fn classifier_sample_permission_rule_mints_a_blocked_chunk() {
17340        let event = provider_control_event(
17341            AdapterFamily::Acp,
17342            ProviderEvent::ToolCompleted {
17343                id: "toolu_9".to_owned(),
17344                output: CLASSIFIER_SAMPLE.to_owned(),
17345                is_error: true,
17346                duration_ms: None,
17347                agent_id: None,
17348                non_execution_kind: Some("permission-rule".to_owned()),
17349            },
17350        );
17351        let (correlation_id, _, authority, reason_kind, reason, help) = blocked(&event);
17352        assert_eq!(authority, BlockAuthorityV1::ProviderPermissionRule);
17353        assert_eq!(reason, "Blocked by classifier");
17354        assert_eq!(reason_kind, Some("permission-rule".to_owned()));
17355        assert!(help.as_deref().is_some_and(|help| help.starts_with("If you have other tasks")));
17356        assert!(correlation_id.is_some(), "a tool-correlated block must carry a correlation id");
17357    }
17358
17359    /// A `session/prompt` RPC error with `data.codexErrorInfo:
17360    /// "usageLimitExceeded"` (measured live 2026-09-05) mints a `Blocked` chunk
17361    /// with `authority: ProviderQuota`, the message verbatim as `reason` and the
17362    /// vendor code as `reason_kind`.
17363    #[test]
17364    fn codex_quota_rpc_error_mints_a_blocked_chunk() {
17365        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.";
17366        let event = provider_control_event(
17367            AdapterFamily::Acp,
17368            ProviderEvent::SessionEnded {
17369                result: "JSON-RPC error -32603: Internal error".to_owned(),
17370                cost_usd: None,
17371                is_error: true,
17372                stop_reason: Some(ProviderStopReason::ProviderError {
17373                    code: -32603,
17374                    message: message.to_owned(),
17375                    vendor_code: Some("usageLimitExceeded".to_owned()),
17376                }),
17377            },
17378        );
17379        let (_, tool_class, authority, reason_kind, reason, _) = blocked(&event);
17380        assert_eq!(authority, BlockAuthorityV1::ProviderQuota);
17381        assert_eq!(reason, message);
17382        assert_eq!(reason_kind, Some("usageLimitExceeded".to_owned()));
17383        assert_eq!(tool_class, "Session");
17384    }
17385
17386    /// ACP's `stopReason: "refusal"` mints a `Blocked` chunk with
17387    /// `authority: ProviderRefusal`.
17388    #[test]
17389    fn session_end_stop_reason_refusal_mints_a_blocked_chunk() {
17390        let event = provider_control_event(
17391            AdapterFamily::Acp,
17392            ProviderEvent::SessionEnded {
17393                result: "refusal".to_owned(),
17394                cost_usd: None,
17395                is_error: true,
17396                stop_reason: Some(ProviderStopReason::Refusal),
17397            },
17398        );
17399        let (_, _, authority, _, _, _) = blocked(&event);
17400        assert_eq!(authority, BlockAuthorityV1::ProviderRefusal);
17401    }
17402
17403    /// A failed tool completion with no typed `non_execution_kind` -- or one
17404    /// that only says the turn stopped -- is a plain execution failure, never a
17405    /// block; nor is an agent's own narrative claiming one.
17406    #[test]
17407    fn failures_and_narrative_without_a_typed_block_mint_no_blocked_chunk() {
17408        for non_execution_kind in [None, Some("interrupted".to_owned()), Some("cancelled".to_owned())] {
17409            let event = provider_control_event(
17410                AdapterFamily::Acp,
17411                ProviderEvent::ToolCompleted {
17412                    id: "toolu_1".to_owned(),
17413                    output: "command exited with status 1".to_owned(),
17414                    is_error: true,
17415                    duration_ms: Some(12),
17416                    agent_id: None,
17417                    non_execution_kind,
17418                },
17419            );
17420            assert!(agent_stream_chunk(&event).is_none());
17421        }
17422        let narrative = provider_control_event(
17423            AdapterFamily::Acp,
17424            ProviderEvent::ToolCompleted {
17425                id: "toolu_2".to_owned(),
17426                output: "the harness blocked the write, approval needed".to_owned(),
17427                is_error: false,
17428                duration_ms: Some(3),
17429                agent_id: None,
17430                non_execution_kind: None,
17431            },
17432        );
17433        assert!(agent_stream_chunk(&narrative).is_none());
17434    }
17435
17436    /// `Granted`/`Deferred` decisions never mint a `Blocked` chunk, whatever the
17437    /// execution outcome: a granted request that failed while running was
17438    /// still authorized.
17439    #[test]
17440    fn granted_and_deferred_host_requests_never_mint_a_blocked_chunk() {
17441        for (decision, outcome) in [
17442            (
17443                ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
17444                ProviderHostRequestOutcome::Executed,
17445            ),
17446            (ProviderHostRequestDecision::Deferred, ProviderHostRequestOutcome::Executed),
17447            (
17448                ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
17449                ProviderHostRequestOutcome::Failed {
17450                    error: "terminal/create spawn failed: os error 3".to_owned(),
17451                },
17452            ),
17453        ] {
17454            let event = provider_control_event_at(
17455                AdapterFamily::Acp,
17456                "claude-code",
17457                3,
17458                ProviderEvent::HostRequestObserved {
17459                    method: "fs/read_text_file".to_owned(),
17460                    params_json: String::new(),
17461                    decision: decision.clone(),
17462                    outcome,
17463                    reason: None,
17464                },
17465            );
17466            assert!(agent_stream_chunk(&event).is_none(), "{decision:?}");
17467        }
17468    }
17469
17470    /// A deferred `session/request_permission` resolved by an operator or an
17471    /// expired deadline carries no reason text -- the chunk falls back to a
17472    /// categorical sentence naming the authority rather than inventing detail.
17473    #[test]
17474    fn denied_host_request_with_no_reason_falls_back_to_a_categorical_sentence() {
17475        for (by, expected_authority, expected_reason) in [
17476            (
17477                ProviderHostDecisionAuthority::Operator,
17478                BlockAuthorityV1::Operator,
17479                "declined by the operator",
17480            ),
17481            (
17482                ProviderHostDecisionAuthority::DeadlinePolicy,
17483                BlockAuthorityV1::HostDeadline,
17484                "denied by host policy after the operator deadline expired",
17485            ),
17486        ] {
17487            let (_, _, authority, _, reason, _) = blocked(&denied_host_request(by, None));
17488            assert_eq!(authority, expected_authority);
17489            assert_eq!(reason, expected_reason);
17490        }
17491    }
17492
17493    /// An over-long reason is cut to the wire bound BEFORE the chunk is built,
17494    /// at a safe UTF-8 boundary -- `validate` rejects an over-long value
17495    /// outright rather than truncating.
17496    #[test]
17497    fn overlong_blocked_reason_is_truncated_to_the_wire_bound() {
17498        let oversized = "x".repeat(MAX_ACP_BLOCKED_REASON_BYTES + 500);
17499        let (_, _, _, _, reason, _) = blocked(&denied_host_request(
17500            ProviderHostDecisionAuthority::Policy,
17501            Some(oversized),
17502        ));
17503        assert!(reason.len() <= MAX_ACP_BLOCKED_REASON_BYTES);
17504    }
17505}
17506
17507#[cfg(test)]
17508mod standalone_capability_platform_tests {
17509    use super::*;
17510
17511    #[test]
17512    fn standalone_workspace_capability_requires_windows_identity_guards() {
17513        let capabilities = baseline_capabilities().unwrap();
17514        let advertised = capabilities.iter().any(|capability| {
17515            capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
17516        });
17517        assert_eq!(advertised, cfg!(windows));
17518
17519        let request = NodeRequest::CreateStandaloneWorkspace {
17520            workspace_id: WorkspaceId::new("standalone-platform").unwrap(),
17521            root: OpaqueHostPath::utf8("standalone-platform-root".to_owned()).unwrap(),
17522            initial_branch: None,
17523        };
17524        assert_eq!(
17525            request_uses_unnegotiated_capability(&request, &capabilities),
17526            !cfg!(windows),
17527        );
17528    }
17529}
17530
17531
17532#[cfg(test)]
17533mod station_knobs_resolve_tests {
17534    use super::*;
17535    use gate4agent_catalog::EnvMutation;
17536    use gate4agent_runtime_native::{
17537        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
17538        NativeLaunchProfileId,
17539    };
17540    use std::ffi::OsString;
17541    use std::sync::Arc;
17542
17543    struct EmptyEnvironmentResolver;
17544
17545    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
17546        fn resolve_child_environment(
17547            &self,
17548        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
17549            Ok(vec![EnvMutation {
17550                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
17551                value: None,
17552            }])
17553        }
17554    }
17555
17556    fn agent(value: &str) -> AgentId {
17557        AgentId::new(value).unwrap()
17558    }
17559
17560    fn station_knobs_fixture() -> (NodeShared, crate::protocol::SpawnProfileDefaults) {
17561        let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
17562        let profile_revision =
17563            crate::protocol::SpawnEnvironmentProfileRevision::new("local-claude-r1").unwrap();
17564        let spawn_defaults = crate::protocol::SpawnProfileDefaults {
17565            profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
17566            revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
17567            provider: agent("claude"),
17568            mode: SessionMode::Pty,
17569            terminal_size: gate4agent_types::TerminalSize {
17570                rows: 24,
17571                columns: 80,
17572            },
17573            prompt: None,
17574            bundle_id: None,
17575            context_id: None,
17576            environment_profile_id: Some(profile_id.clone()),
17577        };
17578        let spawn_profiles = SpawnProfileRegistry::new([spawn_defaults.clone()]).unwrap();
17579        let catalog = active_registry().unwrap();
17580        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
17581        let control = runtime.native_launch_profile_control();
17582        let node_profile = NodeEnvironmentProfile::new(
17583            profile_id.clone(),
17584            profile_revision,
17585            agent("claude"),
17586            [NativeLaunchProfile::new(
17587                NativeLaunchProfileId::new("local-claude-pty").unwrap(),
17588                agent("claude"),
17589                TransportKind::Pty,
17590                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
17591                Arc::new(EmptyEnvironmentResolver),
17592            )
17593            .unwrap()],
17594        )
17595        .unwrap();
17596        let (binding, native_profiles, _materialization) = node_profile.into_parts();
17597        for native_profile in native_profiles {
17598            runtime.upsert_native_launch_profile(native_profile).unwrap();
17599        }
17600        let workspace = WorkspaceConfig::new(
17601            WorkspaceId::new("primary").unwrap(),
17602            std::env::current_dir().unwrap(),
17603        )
17604        .unwrap();
17605        let shared = NodeShared::new_with_incarnation(
17606            handle,
17607            "fixture-token".to_owned(),
17608            NodeId::new("node-station-knobs-test").unwrap(),
17609            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
17610            vec![workspace],
17611            vec![agent("claude")],
17612            ProviderRuntimeStatuses::default(),
17613            None,
17614            Vec::new(),
17615            Vec::new(),
17616            spawn_profiles,
17617            Some(control),
17618            None,
17619            None,
17620            None,
17621            Vec::new(),
17622            Vec::new(),
17623            Vec::new(),
17624            Vec::new(),
17625            None,
17626            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
17627        );
17628        shared
17629            .environment_profiles
17630            .write()
17631            .unwrap_or_else(|poisoned| poisoned.into_inner())
17632            .insert(binding.id.clone(), binding);
17633        (shared, spawn_defaults)
17634    }
17635
17636    /// Empty-default catalog refuses unknown network_allowlist ids.
17637    /// Feature-off dig2-station-probe keeps browser_profile stub echo.
17638    #[test]
17639    fn resolve_environment_profile_refuses_unknown_network_allowlist() {
17640        let (shared, defaults) = station_knobs_fixture();
17641        let unknown = crate::protocol::SpawnNetworkAllowlistId::new("not-registered").unwrap();
17642        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
17643        let spec = crate::protocol::SpawnSpec {
17644            target: crate::protocol::SpawnTarget {
17645                node_id: shared.node_id.clone(),
17646                workspace_id: WorkspaceId::new("primary").unwrap(),
17647                worktree_id: None,
17648            },
17649            profile_id: defaults.profile_id.clone(),
17650            expected_profile_revision: defaults.revision.clone(),
17651            overrides: crate::protocol::SpawnOverrides {
17652                network_allowlist: Some(unknown),
17653                browser_profile_id: Some(browser),
17654                ..crate::protocol::SpawnOverrides::default()
17655            },
17656            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
17657            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
17658                "station-knobs-unknown-allowlist",
17659            )
17660            .unwrap(),
17661            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
17662        };
17663        let resolved = spec.resolve(&defaults).unwrap();
17664        let failure = shared
17665            .resolve_environment_profile(&resolved)
17666            .expect_err("unknown allowlist must refuse");
17667        assert_eq!(failure.code, NodeFailureCode::UnknownNetworkAllowlist);
17668    }
17669
17670    /// Registered network_allowlist (+ browser_profile_id when feature off)
17671    /// echo onto the env-profile receipt. Empty catalog still allows omit
17672    /// (None). Empty/whitespace refuse is type-level. Feature-off
17673    /// dig2-station-probe keeps stub browser echo; feature-on refuse is
17674    /// covered by `…_refuses_browser_profile_when_station_probe_fails`.
17675    #[test]
17676    fn resolve_environment_profile_echoes_registered_station_network_and_browser_knobs() {
17677        let (shared, defaults) = station_knobs_fixture();
17678        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
17679        #[cfg(not(feature = "dig2-station-probe"))]
17680        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
17681        shared
17682            .network_allowlist_catalog
17683            .write()
17684            .unwrap_or_else(|poisoned| poisoned.into_inner())
17685            .insert_id(allowlist.clone())
17686            .unwrap();
17687        let spec = crate::protocol::SpawnSpec {
17688            target: crate::protocol::SpawnTarget {
17689                node_id: shared.node_id.clone(),
17690                workspace_id: WorkspaceId::new("primary").unwrap(),
17691                worktree_id: None,
17692            },
17693            profile_id: defaults.profile_id.clone(),
17694            expected_profile_revision: defaults.revision.clone(),
17695            overrides: crate::protocol::SpawnOverrides {
17696                network_allowlist: Some(allowlist.clone()),
17697                #[cfg(not(feature = "dig2-station-probe"))]
17698                browser_profile_id: Some(browser.clone()),
17699                ..crate::protocol::SpawnOverrides::default()
17700            },
17701            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
17702            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-echo")
17703                .unwrap(),
17704            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
17705        };
17706        let resolved = spec.resolve(&defaults).unwrap();
17707        assert_eq!(resolved.network_allowlist.as_ref(), Some(&allowlist));
17708        #[cfg(not(feature = "dig2-station-probe"))]
17709        assert_eq!(resolved.browser_profile_id.as_ref(), Some(&browser));
17710        let receipt = shared
17711            .resolve_environment_profile(&resolved)
17712            .expect("resolve ok")
17713            .expect("env profile present");
17714        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
17715        #[cfg(not(feature = "dig2-station-probe"))]
17716        assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
17717        #[cfg(feature = "dig2-station-probe")]
17718        assert_eq!(receipt.browser_profile_id, None);
17719        assert_eq!(receipt.profile_id.as_str(), "local-claude");
17720
17721        let omitted = crate::protocol::SpawnSpec {
17722            overrides: crate::protocol::SpawnOverrides::default(),
17723            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-omit")
17724                .unwrap(),
17725            ..spec
17726        };
17727        let resolved_omit = omitted.resolve(&defaults).unwrap();
17728        let bare = shared
17729            .resolve_environment_profile(&resolved_omit)
17730            .expect("resolve ok")
17731            .expect("env profile present");
17732        assert_eq!(bare.network_allowlist, None);
17733        assert_eq!(bare.browser_profile_id, None);
17734    }
17735
17736    /// Feature `dig2-station-probe`: browser_profile_id set refuses when the
17737    /// cheap station probe cannot succeed. On non-Windows (this CI host) that
17738    /// is `BrowserStationProbeUnavailable`. Never cookies on C2. Lease map is
17739    /// unit-tested separately (probe refuses before lease on Linux).
17740    #[cfg(feature = "dig2-station-probe")]
17741    #[test]
17742    fn resolve_environment_profile_refuses_browser_profile_when_station_probe_fails() {
17743        let (shared, defaults) = station_knobs_fixture();
17744        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
17745        let spec = crate::protocol::SpawnSpec {
17746            target: crate::protocol::SpawnTarget {
17747                node_id: shared.node_id.clone(),
17748                workspace_id: WorkspaceId::new("primary").unwrap(),
17749                worktree_id: None,
17750            },
17751            profile_id: defaults.profile_id.clone(),
17752            expected_profile_revision: defaults.revision.clone(),
17753            overrides: crate::protocol::SpawnOverrides {
17754                browser_profile_id: Some(browser),
17755                ..crate::protocol::SpawnOverrides::default()
17756            },
17757            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
17758            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
17759                "station-knobs-probe-refuse",
17760            )
17761            .unwrap(),
17762            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
17763        };
17764        let resolved = spec.resolve(&defaults).unwrap();
17765        let failure = shared
17766            .resolve_environment_profile(&resolved)
17767            .expect_err("browser_profile with failing probe must refuse");
17768        #[cfg(windows)]
17769        {
17770            assert!(
17771                matches!(
17772                    failure.code,
17773                    NodeFailureCode::BrowserStationUnreachable
17774                        | NodeFailureCode::BrowserStationProbeUnavailable
17775                ),
17776                "unexpected code {:?}",
17777                failure.code
17778            );
17779        }
17780        #[cfg(not(windows))]
17781        {
17782            assert_eq!(
17783                failure.code,
17784                NodeFailureCode::BrowserStationProbeUnavailable
17785            );
17786        }
17787    }
17788
17789    /// Feature on: omitting browser_profile_id still resolves (no probe).
17790    #[cfg(feature = "dig2-station-probe")]
17791    #[test]
17792    fn resolve_environment_profile_skips_station_probe_when_browser_profile_omitted() {
17793        let (shared, defaults) = station_knobs_fixture();
17794        let spec = crate::protocol::SpawnSpec {
17795            target: crate::protocol::SpawnTarget {
17796                node_id: shared.node_id.clone(),
17797                workspace_id: WorkspaceId::new("primary").unwrap(),
17798                worktree_id: None,
17799            },
17800            profile_id: defaults.profile_id.clone(),
17801            expected_profile_revision: defaults.revision.clone(),
17802            overrides: crate::protocol::SpawnOverrides::default(),
17803            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
17804            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
17805                "station-knobs-probe-omit",
17806            )
17807            .unwrap(),
17808            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
17809        };
17810        let resolved = spec.resolve(&defaults).unwrap();
17811        let receipt = shared
17812            .resolve_environment_profile(&resolved)
17813            .expect("omit browser_profile must not probe")
17814            .expect("env profile present");
17815        assert_eq!(receipt.browser_profile_id, None);
17816    }
17817
17818    /// Feature dig2-station-probe: exclusive BrowserStationLease on NodeShared
17819    /// refuses a second holder for the same opaque profile id; release on
17820    /// remove_binding allows reacquire. Never ImportSession/cookies on C2.
17821    #[cfg(feature = "dig2-station-probe")]
17822    #[test]
17823    fn browser_station_lease_exclusive_and_clears_on_remove_binding() {
17824        let (shared, _defaults) = station_knobs_fixture();
17825        let profile = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
17826        let workspace_id = WorkspaceId::new("primary").unwrap();
17827        let address_a = SessionAddress {
17828            workspace_id: workspace_id.clone(),
17829            session: SessionKey {
17830                instance_id: AgentInstanceId(101),
17831                generation: SessionGeneration(1),
17832            },
17833        };
17834        let address_b = SessionAddress {
17835            workspace_id: workspace_id.clone(),
17836            session: SessionKey {
17837                instance_id: AgentInstanceId(102),
17838                generation: SessionGeneration(1),
17839            },
17840        };
17841        let guard_a = crate::dig2_station_lease::acquire_lease(
17842            &shared.browser_station_leases,
17843            profile.clone(),
17844            address_a.session.instance_id,
17845            1,
17846        )
17847        .expect("first lease");
17848        let busy = crate::dig2_station_lease::acquire_lease(
17849            &shared.browser_station_leases,
17850            profile.clone(),
17851            address_b.session.instance_id,
17852            2,
17853        )
17854        .expect_err("second holder must refuse");
17855        assert!(matches!(
17856            busy,
17857            crate::dig2_station_lease::BrowserStationLeaseError::ProfileBusy { .. }
17858        ));
17859        // Simulate a bound session so remove_binding clears the lease.
17860        shared.bind_session_with_policy(
17861            &address_a,
17862            ProviderRuntimePolicy::raw_pty(),
17863            None,
17864        );
17865        guard_a.retain();
17866        assert!(shared.remove_binding(&address_a).is_some());
17867        crate::dig2_station_lease::acquire_lease(
17868            &shared.browser_station_leases,
17869            profile,
17870            address_b.session.instance_id,
17871            3,
17872        )
17873        .expect("reacquire after clear")
17874        .retain();
17875        assert!(
17876            crate::dig2_station_lease::release_lease_for_holder(
17877                &shared.browser_station_leases,
17878                address_b.session.instance_id,
17879            )
17880        );
17881    }
17882
17883    /// Feature-off: no browser_station_leases field; stub echo documented in
17884    /// resolve_environment_profile_echoes_registered_station_network_and_browser_knobs.
17885    #[cfg(not(feature = "dig2-station-probe"))]
17886    #[test]
17887    fn browser_station_lease_feature_off_keeps_stub_without_lease_table() {
17888        let (shared, defaults) = station_knobs_fixture();
17889        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
17890        let spec = crate::protocol::SpawnSpec {
17891            target: crate::protocol::SpawnTarget {
17892                node_id: shared.node_id.clone(),
17893                workspace_id: WorkspaceId::new("primary").unwrap(),
17894                worktree_id: None,
17895            },
17896            profile_id: defaults.profile_id.clone(),
17897            expected_profile_revision: defaults.revision.clone(),
17898            overrides: crate::protocol::SpawnOverrides {
17899                browser_profile_id: Some(browser.clone()),
17900                ..crate::protocol::SpawnOverrides::default()
17901            },
17902            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
17903            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("lease-stub-off")
17904                .unwrap(),
17905            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
17906        };
17907        let resolved = spec.resolve(&defaults).unwrap();
17908        let receipt = shared
17909            .resolve_environment_profile(&resolved)
17910            .expect("stub resolve")
17911            .expect("env profile");
17912        assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
17913    }
17914
17915
17916    /// Startup file loader registers ids into the empty-default catalog.
17917    /// Feature-off dig2 probe stays stub. Never secrets on C2.
17918    #[test]
17919    fn network_allowlist_catalog_loads_from_temp_file() {
17920        let unique = std::time::SystemTime::now()
17921            .duration_since(std::time::UNIX_EPOCH)
17922            .unwrap()
17923            .as_nanos();
17924        let path = std::env::temp_dir().join(format!(
17925            "gate4agent-node-allowlist-config-{}-{unique}.txt",
17926            std::process::id(),
17927        ));
17928        std::fs::write(
17929            &path,
17930            "# opaque station allowlist ids\negress-default\nlab-egress\n",
17931        )
17932        .unwrap();
17933        let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
17934            .expect("temp catalog file must load");
17935        let _ = std::fs::remove_file(&path);
17936        assert_eq!(loaded.len(), 2);
17937        let (shared, defaults) = station_knobs_fixture();
17938        {
17939            let mut catalog = shared
17940                .network_allowlist_catalog
17941                .write()
17942                .unwrap_or_else(|poisoned| poisoned.into_inner());
17943            *catalog = loaded;
17944        }
17945        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
17946        let spec = crate::protocol::SpawnSpec {
17947            target: crate::protocol::SpawnTarget {
17948                node_id: shared.node_id.clone(),
17949                workspace_id: WorkspaceId::new("primary").unwrap(),
17950                worktree_id: None,
17951            },
17952            profile_id: defaults.profile_id.clone(),
17953            expected_profile_revision: defaults.revision.clone(),
17954            overrides: crate::protocol::SpawnOverrides {
17955                network_allowlist: Some(allowlist.clone()),
17956                ..crate::protocol::SpawnOverrides::default()
17957            },
17958            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
17959            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
17960                "station-knobs-catalog-file",
17961            )
17962            .unwrap(),
17963            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
17964        };
17965        let resolved = spec.resolve(&defaults).unwrap();
17966        let receipt = shared
17967            .resolve_environment_profile(&resolved)
17968            .expect("registered via file load")
17969            .expect("env profile present");
17970        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
17971    }
17972
17973    /// Snapshot launch inventory echoes registered catalog ids (opaque).
17974    /// Dig2 station probe is orthogonal (feature `dig2-station-probe`). Never secrets on C2.
17975    #[test]
17976    fn snapshot_launch_inventory_lists_network_allowlist_catalog_ids() {
17977        let (shared, _defaults) = station_knobs_fixture();
17978        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
17979        shared
17980            .network_allowlist_catalog
17981            .write()
17982            .unwrap_or_else(|poisoned| poisoned.into_inner())
17983            .insert_id(allowlist.clone())
17984            .unwrap();
17985        let snapshot = shared.snapshot();
17986        let inventory = snapshot
17987            .launch_inventory
17988            .expect("launch inventory present");
17989        let ids = inventory
17990            .network_allowlists
17991            .expect("non-empty catalog surfaces ids");
17992        assert_eq!(ids, vec![allowlist]);
17993    }
17994
17995    /// Track B: registered id with provider_native codex_network_access refuses
17996    /// clearly for Claude (fixture provider). Unknown id still UnknownNetworkAllowlist.
17997    /// Never secrets on C2. Codex Moderate maps to `-c` overlay; RO/FullAuto refuse.
17998    #[test]
17999    fn resolve_environment_profile_refuses_unsupported_provider_native_mapping() {
18000        let (shared, defaults) = station_knobs_fixture();
18001        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-only").unwrap();
18002        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18003            id: allowlist.clone(),
18004            permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
18005                protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18006                peer: "127.0.0.1:443".to_owned(),
18007            }],
18008            provider_native: Some(
18009                crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
18010                    codex_network_access: Some(true),
18011                },
18012            ),
18013        };
18014        shared
18015            .network_allowlist_catalog
18016            .write()
18017            .unwrap_or_else(|poisoned| poisoned.into_inner())
18018            .insert(entry)
18019            .unwrap();
18020        let spec = crate::protocol::SpawnSpec {
18021            target: crate::protocol::SpawnTarget {
18022                node_id: shared.node_id.clone(),
18023                workspace_id: WorkspaceId::new("primary").unwrap(),
18024                worktree_id: None,
18025            },
18026            profile_id: defaults.profile_id.clone(),
18027            expected_profile_revision: defaults.revision.clone(),
18028            overrides: crate::protocol::SpawnOverrides {
18029                network_allowlist: Some(allowlist),
18030                ..crate::protocol::SpawnOverrides::default()
18031            },
18032            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18033            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18034                "station-knobs-unsupported-mapping",
18035            )
18036            .unwrap(),
18037            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18038        };
18039        let resolved = spec.resolve(&defaults).unwrap();
18040        assert_eq!(resolved.provider.as_str(), "claude");
18041        let failure = shared
18042            .resolve_environment_profile(&resolved)
18043            .expect_err("claude + codex_network_access must refuse");
18044        assert_eq!(
18045            failure.code,
18046            NodeFailureCode::UnsupportedNetworkAllowlistMapping
18047        );
18048    }
18049
18050    /// Codex Moderate + codex_network_access yields `-c` overlay argv.
18051    /// ReadOnly / FullAuto refuse UnsupportedNetworkAllowlistMapping.
18052    /// Never secrets on C2.
18053    #[test]
18054    fn resolve_codex_network_access_overlay_moderate_only() {
18055        let (shared, defaults) = station_knobs_fixture();
18056        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-net").unwrap();
18057        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18058            id: allowlist.clone(),
18059            permits: Vec::new(),
18060            provider_native: Some(
18061                crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
18062                    codex_network_access: Some(true),
18063                },
18064            ),
18065        };
18066        shared
18067            .network_allowlist_catalog
18068            .write()
18069            .unwrap_or_else(|poisoned| poisoned.into_inner())
18070            .insert(entry)
18071            .unwrap();
18072
18073        let overlay = shared
18074            .resolve_codex_network_access_overlay_args(
18075                &agent("codex"),
18076                SessionMode::Pty,
18077                ApprovalLevel::Moderate,
18078                Some(&allowlist),
18079            )
18080            .expect("Moderate Codex maps network_access");
18081        assert_eq!(
18082            overlay,
18083            vec![
18084                std::ffi::OsString::from("-c"),
18085                std::ffi::OsString::from("sandbox_workspace_write.network_access=true"),
18086            ]
18087        );
18088
18089        let acp = shared
18090            .resolve_codex_network_access_overlay_args(
18091                &agent("codex"),
18092                SessionMode::Acp,
18093                ApprovalLevel::Moderate,
18094                Some(&allowlist),
18095            )
18096            .unwrap();
18097        assert_eq!(acp, overlay);
18098
18099        let inline = shared
18100            .resolve_codex_network_access_overlay_args(
18101                &agent("codex"),
18102                SessionMode::Inline,
18103                ApprovalLevel::Moderate,
18104                Some(&allowlist),
18105            )
18106            .unwrap_err();
18107        assert_eq!(
18108            inline.code,
18109            NodeFailureCode::UnsupportedNetworkAllowlistMapping
18110        );
18111
18112        for level in [ApprovalLevel::ReadOnly, ApprovalLevel::FullAuto] {
18113            let spec = crate::protocol::SpawnSpec {
18114                target: crate::protocol::SpawnTarget {
18115                    node_id: shared.node_id.clone(),
18116                    workspace_id: WorkspaceId::new("primary").unwrap(),
18117                    worktree_id: None,
18118                },
18119                profile_id: defaults.profile_id.clone(),
18120                expected_profile_revision: defaults.revision.clone(),
18121                overrides: crate::protocol::SpawnOverrides {
18122                    provider: crate::protocol::SpawnOverride::Set {
18123                        value: agent("codex"),
18124                    },
18125                    approval_level: Some(level),
18126                    network_allowlist: Some(allowlist.clone()),
18127                    ..crate::protocol::SpawnOverrides::default()
18128                },
18129                deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18130                idempotency_key: crate::protocol::SpawnIdempotencyKey::new(format!(
18131                    "codex-net-refuse-{}",
18132                    match level {
18133                        ApprovalLevel::ReadOnly => "ro",
18134                        ApprovalLevel::FullAuto => "fa",
18135                        _ => "other",
18136                    }
18137                ))
18138                .unwrap(),
18139                required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18140            };
18141            let resolved = spec.resolve(&defaults).unwrap();
18142            let failure = shared
18143                .resolve_environment_profile(&resolved)
18144                .expect_err("RO/FullAuto must refuse");
18145            assert_eq!(
18146                failure.code,
18147                NodeFailureCode::UnsupportedNetworkAllowlistMapping
18148            );
18149        }
18150    }
18151
18152    /// Claude PTY + catalog permits → ProviderHome `--settings` sidecar with
18153    /// sandbox.network.allowedDomains. ACP / missing ProviderHome refuse.
18154    /// Never secrets on C2; does not invent provider_native Claude keys.
18155    #[test]
18156    fn resolve_claude_settings_network_overlay_pty_writes_sidecar() {
18157        let (shared, _defaults) = station_knobs_fixture();
18158        let allowlist =
18159            crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-domains").unwrap();
18160        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18161            id: allowlist.clone(),
18162            permits: vec![
18163                crate::network_allowlist_catalog::NetworkPermitSketch {
18164                    protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18165                    peer: "github.com:443".to_owned(),
18166                },
18167                crate::network_allowlist_catalog::NetworkPermitSketch {
18168                    protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18169                    peer: "*.npmjs.org".to_owned(),
18170                },
18171            ],
18172            provider_native: None,
18173        };
18174        shared
18175            .network_allowlist_catalog
18176            .write()
18177            .unwrap_or_else(|poisoned| poisoned.into_inner())
18178            .insert(entry)
18179            .unwrap();
18180
18181        let unique = std::time::SystemTime::now()
18182            .duration_since(std::time::UNIX_EPOCH)
18183            .unwrap()
18184            .as_nanos();
18185        let provider_home = std::env::temp_dir().join(format!(
18186            "gate4agent-claude-provider-home-{}-{unique}",
18187            std::process::id(),
18188        ));
18189        std::fs::create_dir_all(&provider_home).unwrap();
18190
18191        let overlay = shared
18192            .resolve_network_allowlist_overlay_args(
18193                &agent("claude"),
18194                SessionMode::Pty,
18195                ApprovalLevel::Moderate,
18196                Some(&allowlist),
18197                Some(provider_home.as_path()),
18198            )
18199            .expect("Claude PTY settings overlay on unix");
18200        let settings_path = provider_home.join(
18201            crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
18202        );
18203        assert_eq!(
18204            overlay,
18205            vec![
18206                std::ffi::OsString::from("--settings"),
18207                std::ffi::OsString::from(settings_path.as_os_str()),
18208            ]
18209        );
18210        let body = std::fs::read_to_string(&settings_path).unwrap();
18211        let value: serde_json::Value = serde_json::from_str(&body).unwrap();
18212        assert_eq!(value["sandbox"]["enabled"], true);
18213        assert_eq!(
18214            value["sandbox"]["network"]["allowedDomains"],
18215            serde_json::json!(["github.com", "*.npmjs.org"])
18216        );
18217        assert!(settings_path.starts_with(&provider_home));
18218
18219        let acp = shared
18220            .resolve_network_allowlist_overlay_args(
18221                &agent("claude"),
18222                SessionMode::Acp,
18223                ApprovalLevel::Moderate,
18224                Some(&allowlist),
18225                Some(provider_home.as_path()),
18226            )
18227            .expect_err("Claude ACP must refuse settings overlay");
18228        assert_eq!(
18229            acp.code,
18230            NodeFailureCode::UnsupportedNetworkAllowlistMapping
18231        );
18232
18233        let no_home = shared
18234            .resolve_network_allowlist_overlay_args(
18235                &agent("claude"),
18236                SessionMode::Pty,
18237                ApprovalLevel::Moderate,
18238                Some(&allowlist),
18239                None,
18240            )
18241            .expect_err("Claude overlay requires ProviderHome");
18242        assert_eq!(
18243            no_home.code,
18244            NodeFailureCode::UnsupportedNetworkAllowlistMapping
18245        );
18246
18247        let bare = crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-bare").unwrap();
18248        shared
18249            .network_allowlist_catalog
18250            .write()
18251            .unwrap_or_else(|poisoned| poisoned.into_inner())
18252            .insert_id(bare.clone())
18253            .unwrap();
18254        let empty = shared
18255            .resolve_network_allowlist_overlay_args(
18256                &agent("claude"),
18257                SessionMode::Pty,
18258                ApprovalLevel::Moderate,
18259                Some(&bare),
18260                Some(provider_home.as_path()),
18261            )
18262            .unwrap();
18263        assert!(empty.is_empty());
18264
18265        let _ = std::fs::remove_dir_all(&provider_home);
18266    }
18267
18268    /// Kimi / Grok with permits stay without invented argv overlays.
18269    #[test]
18270    fn resolve_kimi_grok_network_overlay_stays_empty_without_native() {
18271        let (shared, _defaults) = station_knobs_fixture();
18272        let allowlist =
18273            crate::protocol::SpawnNetworkAllowlistId::new("egress-kimi-permits").unwrap();
18274        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18275            id: allowlist.clone(),
18276            permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
18277                protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18278                peer: "example.com:443".to_owned(),
18279            }],
18280            provider_native: None,
18281        };
18282        shared
18283            .network_allowlist_catalog
18284            .write()
18285            .unwrap_or_else(|poisoned| poisoned.into_inner())
18286            .insert(entry)
18287            .unwrap();
18288        for provider in ["kimi", "grok"] {
18289            let overlay = shared
18290                .resolve_network_allowlist_overlay_args(
18291                    &agent(provider),
18292                    SessionMode::Pty,
18293                    ApprovalLevel::Moderate,
18294                    Some(&allowlist),
18295                    None,
18296                )
18297                .unwrap();
18298            assert!(
18299                overlay.is_empty(),
18300                "{provider} must not invent network argv from permits"
18301            );
18302        }
18303    }
18304
18305    /// Track B: JSON v2 catalog file loads permits; inventory still ids only.
18306    #[test]
18307    fn network_allowlist_catalog_loads_json_v2_and_lists_ids_only() {
18308        let unique = std::time::SystemTime::now()
18309            .duration_since(std::time::UNIX_EPOCH)
18310            .unwrap()
18311            .as_nanos();
18312        let path = std::env::temp_dir().join(format!(
18313            "gate4agent-node-allowlist-json-{}-{unique}.json",
18314            std::process::id(),
18315        ));
18316        std::fs::write(
18317            &path,
18318            r#"{
18319              "schema_version": 2,
18320              "entries": [
18321                {
18322                  "id": "egress-default",
18323                  "permits": [{ "protocol": "tcp", "peer": "127.0.0.1:443" }]
18324                },
18325                { "id": "lab-egress" }
18326              ]
18327            }"#,
18328        )
18329        .unwrap();
18330        let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
18331            .expect("json v2 catalog must load");
18332        let _ = std::fs::remove_file(&path);
18333        assert_eq!(loaded.len(), 2);
18334        let entry = loaded
18335            .get(&crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap())
18336            .unwrap();
18337        assert_eq!(entry.permits.len(), 1);
18338        let (shared, defaults) = station_knobs_fixture();
18339        {
18340            let mut catalog = shared
18341                .network_allowlist_catalog
18342                .write()
18343                .unwrap_or_else(|poisoned| poisoned.into_inner());
18344            *catalog = loaded;
18345        }
18346        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18347        let spec = crate::protocol::SpawnSpec {
18348            target: crate::protocol::SpawnTarget {
18349                node_id: shared.node_id.clone(),
18350                workspace_id: WorkspaceId::new("primary").unwrap(),
18351                worktree_id: None,
18352            },
18353            profile_id: defaults.profile_id.clone(),
18354            expected_profile_revision: defaults.revision.clone(),
18355            overrides: crate::protocol::SpawnOverrides {
18356                network_allowlist: Some(allowlist.clone()),
18357                ..crate::protocol::SpawnOverrides::default()
18358            },
18359            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18360            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18361                "station-knobs-catalog-json",
18362            )
18363            .unwrap(),
18364            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18365        };
18366        let resolved = spec.resolve(&defaults).unwrap();
18367        let receipt = shared
18368            .resolve_environment_profile(&resolved)
18369            .expect("permits-only entry ok for claude")
18370            .expect("env profile present");
18371        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18372        let inventory = shared
18373            .snapshot()
18374            .launch_inventory
18375            .expect("launch inventory present");
18376        let ids = inventory.network_allowlists.expect("ids surface");
18377        assert_eq!(
18378            ids,
18379            vec![
18380                crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap(),
18381                crate::protocol::SpawnNetworkAllowlistId::new("lab-egress").unwrap(),
18382            ]
18383        );
18384    }
18385
18386
18387}
18388
18389
18390#[cfg(test)]
18391mod workspace_environment_collision_tests {
18392    use super::*;
18393    use crate::session_environment::{
18394        NodeSessionMaterializationProfile, NodeSessionPathBinding, NodeSessionPathClass,
18395    };
18396    use gate4agent_catalog::EnvMutation;
18397    use gate4agent_runtime_native::{
18398        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
18399        NativeLaunchProfileId,
18400    };
18401    use std::ffi::OsString;
18402    use std::sync::Arc;
18403
18404    struct EmptyEnvironmentResolver;
18405
18406    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
18407        fn resolve_child_environment(
18408            &self,
18409        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
18410            Ok(vec![EnvMutation {
18411                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
18412                value: None,
18413            }])
18414        }
18415    }
18416
18417    fn agent(value: &str) -> AgentId {
18418        AgentId::new(value).unwrap()
18419    }
18420
18421    /// Two Claude ProviderHome profiles (`CLAUDE_CONFIG_DIR` A vs B) on one node.
18422    fn claude_config_dir_collision_fixture() -> (
18423        NodeShared,
18424        ResolvedEnvironmentProfileReceipt,
18425        ResolvedEnvironmentProfileReceipt,
18426    ) {
18427        let catalog = active_registry().unwrap();
18428        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18429        let control = runtime.native_launch_profile_control();
18430        let workspace = WorkspaceConfig::new(
18431            WorkspaceId::new("primary").unwrap(),
18432            std::env::current_dir().unwrap(),
18433        )
18434        .unwrap();
18435        let shared = NodeShared::new_with_incarnation(
18436            handle,
18437            "fixture-token".to_owned(),
18438            NodeId::new("node-env-collision-test").unwrap(),
18439            NodeIncarnationId::from_bytes([3; crate::protocol::NODE_INCARNATION_ID_BYTES]),
18440            vec![workspace],
18441            vec![agent("claude")],
18442            ProviderRuntimeStatuses::default(),
18443            None,
18444            Vec::new(),
18445            Vec::new(),
18446            SpawnProfileRegistry::default(),
18447            Some(control),
18448            None,
18449            None,
18450            None,
18451            Vec::new(),
18452            Vec::new(),
18453            Vec::new(),
18454            Vec::new(),
18455            None,
18456            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
18457        );
18458
18459        let mut receipts = Vec::new();
18460        for (suffix, native_suffix) in [("a", "a-pty"), ("b", "b-pty")] {
18461            let profile_id =
18462                SpawnEnvironmentProfileId::new(format!("claude-config-dir-{suffix}")).unwrap();
18463            let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(format!(
18464                "claude-config-dir-{suffix}-r1"
18465            ))
18466            .unwrap();
18467            let materialization = NodeSessionMaterializationProfile::new(
18468                Vec::new(),
18469                vec![NodeSessionPathBinding::new(
18470                    "CLAUDE_CONFIG_DIR",
18471                    NodeSessionPathClass::ProviderHome,
18472                )
18473                .unwrap()],
18474                Vec::new(),
18475            )
18476            .unwrap();
18477            let node_profile = NodeEnvironmentProfile::new_with_materialization(
18478                profile_id.clone(),
18479                profile_revision.clone(),
18480                agent("claude"),
18481                [NativeLaunchProfile::new(
18482                    NativeLaunchProfileId::new(format!("claude-config-dir-{native_suffix}"))
18483                        .unwrap(),
18484                    agent("claude"),
18485                    TransportKind::Pty,
18486                    vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
18487                    Arc::new(EmptyEnvironmentResolver),
18488                )
18489                .unwrap()],
18490                Some(materialization),
18491            )
18492            .unwrap();
18493            let (binding, native_profiles, materialization) = node_profile.into_parts();
18494            for native_profile in native_profiles {
18495                runtime.upsert_native_launch_profile(native_profile).unwrap();
18496            }
18497            shared
18498                .environment_profiles
18499                .write()
18500                .unwrap_or_else(|poisoned| poisoned.into_inner())
18501                .insert(binding.id.clone(), binding);
18502            shared
18503                .environment_materialization_profiles
18504                .write()
18505                .unwrap_or_else(|poisoned| poisoned.into_inner())
18506                .insert(profile_id.clone(), materialization.unwrap());
18507            receipts.push(ResolvedEnvironmentProfileReceipt {
18508                profile_id,
18509                profile_revision,
18510                network_allowlist: None,
18511                browser_profile_id: None,
18512            });
18513        }
18514        let profile_b = receipts.pop().unwrap();
18515        let profile_a = receipts.pop().unwrap();
18516        (shared, profile_a, profile_b)
18517    }
18518
18519    #[test]
18520    fn refuse_distinct_claude_config_dir_profiles_on_same_workspace_cwd() {
18521        let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
18522        let primary = WorkspaceId::new("primary").unwrap();
18523        let worktree = WorkspaceId::new("worktree-b").unwrap();
18524        let address = SessionAddress {
18525            workspace_id: primary.clone(),
18526            session: SessionKey {
18527                instance_id: AgentInstanceId(41),
18528                generation: SessionGeneration(1),
18529            },
18530        };
18531        shared.bind_session_with_policy(
18532            &address,
18533            ProviderRuntimePolicy::raw_pty(),
18534            Some(profile_a.clone()),
18535        );
18536
18537        let refused = shared
18538            .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_b))
18539            .unwrap_err();
18540        assert_eq!(refused.code, NodeFailureCode::WorkspaceBusy);
18541
18542        // Some vs None on the same cwd is also a collision.
18543        let refused_none = shared
18544            .ensure_workspace_environment_profile_compatible(&primary, None)
18545            .unwrap_err();
18546        assert_eq!(refused_none.code, NodeFailureCode::WorkspaceBusy);
18547
18548        // Same profile on the same workspace remains allowed.
18549        shared
18550            .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_a))
18551            .unwrap();
18552
18553        // Distinct workspace id (managed-worktree parallel path) allows profile B.
18554        shared
18555            .ensure_workspace_environment_profile_compatible(&worktree, Some(&profile_b))
18556            .unwrap();
18557    }
18558
18559    #[tokio::test]
18560    async fn spawn_session_refuses_conflicting_environment_profile_before_mutation() {
18561        let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
18562        let primary = WorkspaceId::new("primary").unwrap();
18563        let address = SessionAddress {
18564            workspace_id: primary.clone(),
18565            session: SessionKey {
18566                instance_id: AgentInstanceId(41),
18567                generation: SessionGeneration(1),
18568            },
18569        };
18570        shared.bind_session_with_policy(
18571            &address,
18572            ProviderRuntimePolicy::raw_pty(),
18573            Some(profile_a),
18574        );
18575
18576        let failure = shared
18577            .spawn_session_with_deadline(
18578                primary,
18579                agent("claude"),
18580                SessionMode::Pty,
18581                gate4agent_types::TerminalSize {
18582                    rows: 24,
18583                    columns: 80,
18584                },
18585                None,
18586                Some(profile_b),
18587                None,
18588                None,
18589                None,
18590                None,
18591                SpawnRecordPolicy::ProviderIdentityOnly,
18592                Some(Instant::now() + Duration::from_secs(30)),
18593                &[],
18594                None,
18595                ApprovalLevel::default(),
18596                None,
18597                None,
18598            )
18599            .await
18600            .unwrap_err();
18601        assert_eq!(failure.code, NodeFailureCode::WorkspaceBusy);
18602        // Guard runs before instance id mint / bind.
18603        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
18604        assert!(shared.handle.snapshot().sessions.is_empty());
18605    }
18606}
18607
18608#[cfg(all(test, windows))]
18609mod tests {
18610    use super::*;
18611    use gate4agent_types::ProviderActivity;
18612    use crate::session_environment::{
18613        NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
18614        NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
18615        NodeSessionPathClass,
18616    };
18617    use gate4agent_catalog::EnvMutation;
18618    use gate4agent_runtime_native::{
18619        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
18620        NativeLaunchProfile, NativeLaunchProfileId,
18621    };
18622    use std::ffi::OsString;
18623    use std::sync::Arc;
18624
18625    struct EmptyEnvironmentResolver;
18626
18627    struct FixtureSecretResolver {
18628        deny: Arc<AtomicBool>,
18629    }
18630
18631    impl NodeSecretResolver for FixtureSecretResolver {
18632        fn resolve(
18633            &self,
18634            _reference: &NodeSecretReference,
18635        ) -> Result<NodeSecretValue, NodeSecretResolveError> {
18636            if self.deny.load(Ordering::Acquire) {
18637                Err(NodeSecretResolveError::Denied)
18638            } else {
18639                NodeSecretValue::text("fixture-secret")
18640                    .map_err(|_| NodeSecretResolveError::Unavailable)
18641            }
18642        }
18643    }
18644
18645    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
18646        fn resolve_child_environment(
18647            &self,
18648        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
18649            Ok(vec![EnvMutation {
18650                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
18651                value: None,
18652            }])
18653        }
18654    }
18655
18656    fn agent(value: &str) -> AgentId {
18657        AgentId::new(value).unwrap()
18658    }
18659
18660    fn terminal_test_shared_with_mode(mode: WorktreeServiceMode) -> NodeShared {
18661        let catalog = active_registry().unwrap();
18662        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18663        let workspace = WorkspaceConfig::new(
18664            WorkspaceId::new("primary").unwrap(),
18665            std::env::current_dir().unwrap(),
18666        )
18667        .unwrap()
18668        .with_worktree_service_mode(mode);
18669        NodeShared::new(
18670            handle,
18671            "fixture-token".to_owned(),
18672            NodeId::new("node-terminal-test").unwrap(),
18673            vec![workspace],
18674            vec![agent("claude")],
18675        )
18676    }
18677
18678    fn terminal_test_shared() -> NodeShared {
18679        terminal_test_shared_with_mode(WorktreeServiceMode::Manual)
18680    }
18681
18682    #[cfg(feature = "fixture")]
18683    #[test]
18684    fn managed_worktree_v2_failure_probe_keeps_only_the_latest_code() {
18685        let probe = SpawnManagedWorktreeV2FailureProbe::default();
18686        assert_eq!(probe.latest_code(), None);
18687
18688        probe.record(Some(NodeFailureCode::ManagedWorktreeOwnershipConflict));
18689        assert_eq!(
18690            probe.latest_code(),
18691            Some(NodeFailureCode::ManagedWorktreeOwnershipConflict),
18692        );
18693
18694        probe.record(Some(NodeFailureCode::SpawnDeadlineExceeded));
18695        assert_eq!(
18696            probe.latest_code(),
18697            Some(NodeFailureCode::SpawnDeadlineExceeded),
18698        );
18699
18700        probe.record(None);
18701        assert_eq!(probe.latest_code(), None);
18702    }
18703
18704    #[test]
18705    fn launch_snapshot_is_authoritative_bounded_and_path_free() {
18706        let source = temporary_workspace_root("launch-inventory-source");
18707        let allocation = temporary_workspace_root("launch-inventory-allocation");
18708        std::fs::create_dir_all(&source).unwrap();
18709        std::fs::create_dir_all(&allocation).unwrap();
18710        let workspace_id = WorkspaceId::new("primary").unwrap();
18711        let mut workspace = WorkspaceConfig::new(workspace_id.clone(), &source)
18712            .unwrap()
18713            .with_worktree_service_mode(WorktreeServiceMode::Managed);
18714        for index in 0..MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE {
18715            workspace = workspace
18716                .with_managed_worktree_profile(
18717                    ManagedWorktreeProfile::new(
18718                        WorktreeProfileId::new(format!("profile-{index}")).unwrap(),
18719                        crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
18720                        &allocation,
18721                        "gate4agent",
18722                        "HEAD",
18723                        ManagedWorktreeRetention::RemoveWhenReleased,
18724                    )
18725                    .unwrap(),
18726                )
18727                .unwrap();
18728        }
18729        let overflow = workspace.clone().with_managed_worktree_profile(
18730            ManagedWorktreeProfile::new(
18731                WorktreeProfileId::new("overflow").unwrap(),
18732                crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
18733                &allocation,
18734                "gate4agent",
18735                "HEAD",
18736                ManagedWorktreeRetention::Retain,
18737            )
18738            .unwrap(),
18739        );
18740        assert!(matches!(
18741            overflow,
18742            Err(NodeServerError::ManagedWorktreeProfileCapacity {
18743                max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
18744                ..
18745            }),
18746        ));
18747
18748        let catalog = active_registry().unwrap();
18749        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18750        let shared = NodeShared::new(
18751            handle,
18752            "fixture-token".to_owned(),
18753            NodeId::new("node-launch-inventory").unwrap(),
18754            vec![workspace],
18755            vec![agent("claude")],
18756        );
18757        let snapshot = shared.snapshot();
18758        let launch = snapshot.launch_inventory.as_ref().unwrap();
18759        assert_eq!(launch.spawn_profiles.as_ref().unwrap().len(), 1);
18760        assert!(launch.bundles.as_ref().unwrap().is_empty());
18761        let profiles = snapshot.workspaces[0]
18762            .managed_worktree_profiles
18763            .as_ref()
18764            .unwrap();
18765        assert_eq!(
18766            snapshot.workspaces[0].worktree_service_mode,
18767            Some(WorktreeServiceMode::Managed),
18768        );
18769        assert_eq!(
18770            profiles.profiles.len(),
18771            MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
18772        );
18773        let public_inventory = serde_json::to_string(&(launch, profiles)).unwrap();
18774        for forbidden in ["allocation_root", "branch_prefix", "base", "prompt", "environment", "home"] {
18775            assert!(!public_inventory.contains(forbidden), "launch inventory leaked {forbidden}");
18776        }
18777        let legacy = snapshot_for_wire(
18778            &shared,
18779            true,
18780            true,
18781            false,
18782            false,
18783            true,
18784            false,
18785            true,
18786            false,
18787            true,
18788        );
18789        assert!(legacy.launch_inventory.is_none());
18790        assert!(legacy.workspaces[0].managed_worktree_profiles.is_none());
18791
18792        drop(shared);
18793        std::fs::remove_dir_all(source).unwrap();
18794        std::fs::remove_dir_all(allocation).unwrap();
18795    }
18796
18797    #[test]
18798    fn agent_progress_projection_omits_sensitive_provider_payloads_and_is_capability_gated() {
18799        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
18800            capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
18801        }));
18802        let source = gate4agent_types::ProviderSource {
18803            family: AdapterFamily::PtySemantic,
18804            binding: AdapterBinding::new(
18805                gate4agent_types::AdapterId::new("claude").unwrap(),
18806                "fixture/v1",
18807                gate4agent_types::AdapterVerification::SyntheticFixture,
18808            )
18809            .unwrap(),
18810        };
18811        let provider = ProviderSnapshot {
18812            sequence: 17,
18813            session: Some(ProviderSessionIdentity {
18814                key: ProviderSessionKey::SessionId,
18815                id: "PRIVATE_PROVIDER_SESSION".to_owned(),
18816                transcript_path: Some(r"C:\PRIVATE_PROVIDER_PATH".to_owned()),
18817            }),
18818            model: Some("PRIVATE_MODEL".to_owned()),
18819            tools: vec!["PRIVATE_TOOL_INVENTORY".to_owned()],
18820            completed_turns: 3,
18821            usage: gate4agent_types::TokenUsage {
18822                input_tokens: 10,
18823                output_tokens: 20,
18824                cache_read_tokens: 30,
18825                cache_write_tokens: 40,
18826                reasoning_tokens: 50,
18827                context_window: Some(200_000),
18828            },
18829            lead_activity: ProviderActivity::WaitingForInput,
18830            activity: ProviderActivity::WaitingForInput,
18831            current_prompt: Some("PRIVATE_CURRENT_PROMPT".to_owned()),
18832            active_tools: (0..9)
18833                .map(|index| gate4agent_types::ActiveProviderTool {
18834                    id: format!("private-tool-id-{index}"),
18835                    name: if index == 0 {
18836                        format!("  Read\u{0000}{}", "x".repeat(80))
18837                    } else {
18838                        format!("Tool-{index}")
18839                    },
18840                    input_json: "PRIVATE_TOOL_INPUT_JSON".to_owned(),
18841                })
18842                .collect(),
18843            interactions: vec![gate4agent_types::ProviderInteraction {
18844                id: gate4agent_types::ProviderInteractionId(17),
18845                source: source.clone(),
18846                provider_request_id: Some("PRIVATE_PROVIDER_REQUEST".to_owned()),
18847                interaction_kind: ProviderInteractionKind::Approval,
18848                tool_name: " Approve\u{0007}Tool ".to_owned(),
18849                prompt: "PRIVATE_INTERACTION_PROMPT".to_owned(),
18850                agent_id: Some("PRIVATE_INTERACTION_AGENT".to_owned()),
18851                resume_lead_activity: Some(ProviderActivity::Working),
18852                status: ProviderInteractionStatus::Pending,
18853            }],
18854            subagents: vec![gate4agent_types::ProviderSubagent {
18855                source: source.clone(),
18856                provider_agent_id: "PRIVATE_SUBAGENT_ID".to_owned(),
18857                agent_type: Some("PRIVATE_SUBAGENT_TYPE".to_owned()),
18858                description: Some("PRIVATE_SUBAGENT_DESCRIPTION".to_owned()),
18859            }],
18860            sources: vec![gate4agent_types::ProviderSourceCursor {
18861                source,
18862                sequence: 17,
18863                gap_count: 2,
18864                stale: true,
18865            }],
18866            last_event: Some(ProviderEvent::Error {
18867                message: "PRIVATE_PROVIDER_ERROR".to_owned(),
18868            }),
18869            gap_count: 2,
18870            stale: true,
18871        };
18872        let address = terminal_address(3);
18873        let entry = agent_progress_from_provider_snapshot(address.clone(), &provider).unwrap();
18874        assert_eq!(entry.address, address);
18875        assert_eq!(entry.progress.provider_sequence, 17);
18876        assert_eq!(entry.progress.active_tool_count, 9);
18877        assert_eq!(
18878            entry.progress.active_tool_labels.len(),
18879            MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS,
18880        );
18881        assert_eq!(entry.progress.subagent_count, 1);
18882        assert_eq!(entry.progress.last_event_kind, Some(AgentProgressEventKindV1::Error));
18883        assert!(entry.progress.truncated);
18884        let encoded = serde_json::to_string(&entry).unwrap();
18885        for forbidden in [
18886            "PRIVATE_PROVIDER_SESSION",
18887            "PRIVATE_PROVIDER_PATH",
18888            "PRIVATE_MODEL",
18889            "PRIVATE_TOOL_INVENTORY",
18890            "PRIVATE_CURRENT_PROMPT",
18891            "PRIVATE_TOOL_INPUT_JSON",
18892            "PRIVATE_PROVIDER_REQUEST",
18893            "PRIVATE_INTERACTION_PROMPT",
18894            "PRIVATE_INTERACTION_AGENT",
18895            "PRIVATE_SUBAGENT_ID",
18896            "PRIVATE_SUBAGENT_TYPE",
18897            "PRIVATE_SUBAGENT_DESCRIPTION",
18898            "PRIVATE_PROVIDER_ERROR",
18899            "input_json",
18900            "current_prompt",
18901            "session_id",
18902        ] {
18903            assert!(!encoded.contains(forbidden), "agent progress leaked {forbidden}");
18904        }
18905
18906        let snapshot = NodeSnapshot {
18907            node_id: NodeId::new("node-progress-test").unwrap(),
18908            enabled_providers: vec![agent("claude")],
18909            provider_runtime_statuses: ProviderRuntimeStatuses::default(),
18910            workspaces: Vec::new(),
18911            session_records: Vec::new(),
18912            managed_worktrees: Vec::new(),
18913            launch_inventory: None,
18914            agent_progress: vec![entry],
18915        };
18916        let mut legacy_reply = ResponseEnvelope {
18917            request_id: 1,
18918            result: Ok(NodeResponse::Snapshot {
18919                event_sequence: 0,
18920                controller: None,
18921                snapshot: snapshot.clone(),
18922            }),
18923        };
18924        project_response_without_agent_progress(&mut legacy_reply);
18925        let Ok(NodeResponse::Snapshot { snapshot: legacy, .. }) = legacy_reply.result else {
18926            panic!("agent progress projection changed response kind");
18927        };
18928        assert!(legacy.agent_progress.is_empty());
18929        assert_eq!(snapshot.agent_progress.len(), 1);
18930    }
18931
18932    fn terminal_address(generation: u64) -> SessionAddress {
18933        SessionAddress {
18934            workspace_id: WorkspaceId::new("primary").unwrap(),
18935            session: SessionKey {
18936                instance_id: AgentInstanceId(41),
18937                generation: SessionGeneration(generation),
18938            },
18939        }
18940    }
18941
18942    fn spawn_spec_fixture() -> SpawnSpec {
18943        SpawnSpec {
18944            target: crate::protocol::SpawnTarget {
18945                node_id: NodeId::new("node-terminal-test").unwrap(),
18946                workspace_id: WorkspaceId::new("primary").unwrap(),
18947                worktree_id: None,
18948            },
18949            profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
18950            expected_profile_revision: crate::protocol::SpawnProfileRevision::new("builtin-v1")
18951                .unwrap(),
18952            overrides: crate::protocol::SpawnOverrides::default(),
18953            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18954            idempotency_key: SpawnIdempotencyKey::new("spawn-spec-fixture").unwrap(),
18955            required_capabilities: SpawnRequiredCapabilities::default(),
18956        }
18957    }
18958
18959    fn environment_profile_test_shared(
18960    ) -> (NodeShared, NativeRuntime, ResolvedEnvironmentProfileReceipt) {
18961        let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
18962        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
18963            "local-claude-r1",
18964        )
18965        .unwrap();
18966        let spawn_profiles = SpawnProfileRegistry::new([
18967            crate::protocol::SpawnProfileDefaults {
18968                profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
18969                revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
18970                provider: agent("claude"),
18971                mode: SessionMode::Pty,
18972                terminal_size: gate4agent_types::TerminalSize {
18973                    rows: 24,
18974                    columns: 80,
18975                },
18976                prompt: None,
18977                bundle_id: None,
18978                context_id: None,
18979                environment_profile_id: Some(profile_id.clone()),
18980            },
18981        ])
18982        .unwrap();
18983        let catalog = active_registry().unwrap();
18984        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18985        let control = runtime.native_launch_profile_control();
18986        let node_profile = NodeEnvironmentProfile::new(
18987            profile_id.clone(),
18988            profile_revision.clone(),
18989            agent("claude"),
18990            [NativeLaunchProfile::new(
18991                NativeLaunchProfileId::new("local-claude-pty").unwrap(),
18992                agent("claude"),
18993                TransportKind::Pty,
18994                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
18995                Arc::new(EmptyEnvironmentResolver),
18996            )
18997            .unwrap()],
18998        )
18999        .unwrap();
19000        let (binding, native_profiles, _materialization) = node_profile.into_parts();
19001        for native_profile in native_profiles {
19002            runtime.upsert_native_launch_profile(native_profile).unwrap();
19003        }
19004        let workspace = WorkspaceConfig::new(
19005            WorkspaceId::new("primary").unwrap(),
19006            std::env::current_dir().unwrap(),
19007        )
19008        .unwrap();
19009        let shared = NodeShared::new_with_incarnation(
19010            handle,
19011            "fixture-token".to_owned(),
19012            NodeId::new("node-terminal-test").unwrap(),
19013            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19014            vec![workspace],
19015            vec![agent("claude")],
19016            ProviderRuntimeStatuses::default(),
19017            None,
19018            Vec::new(),
19019            Vec::new(),
19020            spawn_profiles,
19021            Some(control),
19022            None,
19023            None,
19024            None,
19025            Vec::new(),
19026            Vec::new(),
19027            Vec::new(),
19028            Vec::new(),
19029            None,
19030            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19031        );
19032        shared
19033            .environment_profiles
19034            .write()
19035            .unwrap_or_else(|poisoned| poisoned.into_inner())
19036            .insert(binding.id.clone(), binding);
19037        (
19038            shared,
19039            runtime,
19040            ResolvedEnvironmentProfileReceipt {
19041                profile_id,
19042                profile_revision,
19043                network_allowlist: None,
19044                browser_profile_id: None,
19045            },
19046        )
19047    }
19048
19049    fn materialization_test_shared(
19050        deny_secret: bool,
19051    ) -> (
19052        NodeShared,
19053        NativeRuntime,
19054        ResolvedEnvironmentProfileReceipt,
19055        PathBuf,
19056        Arc<AtomicBool>,
19057    ) {
19058        let workspace = WorkspaceConfig::new(
19059            WorkspaceId::new("primary").unwrap(),
19060            std::env::current_dir().unwrap(),
19061        )
19062        .unwrap();
19063        materialization_test_shared_with_workspace(deny_secret, workspace)
19064    }
19065
19066    fn materialization_test_shared_with_workspace(
19067        deny_secret: bool,
19068        workspace: WorkspaceConfig,
19069    ) -> (
19070        NodeShared,
19071        NativeRuntime,
19072        ResolvedEnvironmentProfileReceipt,
19073        PathBuf,
19074        Arc<AtomicBool>,
19075    ) {
19076        let root = temporary_workspace_root(if deny_secret {
19077            "materialization-denied"
19078        } else {
19079            "materialization-ready"
19080        });
19081        std::fs::create_dir_all(&root).unwrap();
19082        let materialization_root = root.join("session-environments");
19083        let deny = Arc::new(AtomicBool::new(deny_secret));
19084        let resolver = Arc::new(FixtureSecretResolver {
19085            deny: Arc::clone(&deny),
19086        });
19087        let materializer = SessionEnvironmentMaterializer::new(
19088            materialization_root,
19089            resolver,
19090        )
19091        .unwrap();
19092        let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
19093        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19094            "materialized-claude-r1",
19095        )
19096        .unwrap();
19097        let materialization = NodeSessionMaterializationProfile::new(
19098            vec![NodeSessionEnvironmentMutation::SetSecret {
19099                key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
19100                reference: NodeSecretReference::new("fixture-token").unwrap(),
19101            }],
19102            vec![NodeSessionPathBinding::new(
19103                "GATE4AGENT_PROVIDER_HOME",
19104                NodeSessionPathClass::ProviderHome,
19105            )
19106            .unwrap()],
19107            vec![NodeSessionFile::secret(
19108                NodeSessionPathClass::Config,
19109                "auth/token",
19110                NodeSecretReference::new("fixture-token").unwrap(),
19111            )
19112            .unwrap()],
19113        )
19114        .unwrap();
19115        let catalog = active_registry().unwrap();
19116        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19117        let control = runtime.native_launch_profile_control();
19118        let node_profile = NodeEnvironmentProfile::new_with_materialization(
19119            profile_id.clone(),
19120            profile_revision.clone(),
19121            agent("claude"),
19122            [NativeLaunchProfile::new(
19123                NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
19124                agent("claude"),
19125                TransportKind::Pty,
19126                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19127                Arc::new(EmptyEnvironmentResolver),
19128            )
19129            .unwrap()],
19130            Some(materialization),
19131        )
19132        .unwrap();
19133        let (binding, native_profiles, materialization) = node_profile.into_parts();
19134        for native_profile in native_profiles {
19135            runtime.upsert_native_launch_profile(native_profile).unwrap();
19136        }
19137        let shared = NodeShared::new_with_incarnation(
19138            handle,
19139            "fixture-token".to_owned(),
19140            NodeId::new("node-materialization-test").unwrap(),
19141            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19142            vec![workspace],
19143            vec![agent("claude")],
19144            ProviderRuntimeStatuses::default(),
19145            None,
19146            Vec::new(),
19147            Vec::new(),
19148            SpawnProfileRegistry::default(),
19149            Some(control),
19150            None,
19151            Some(materializer),
19152            None,
19153            Vec::new(),
19154            Vec::new(),
19155            Vec::new(),
19156            Vec::new(),
19157            None,
19158            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19159        );
19160        shared
19161            .environment_profiles
19162            .write()
19163            .unwrap_or_else(|poisoned| poisoned.into_inner())
19164            .insert(binding.id.clone(), binding);
19165        shared
19166            .environment_materialization_profiles
19167            .write()
19168            .unwrap_or_else(|poisoned| poisoned.into_inner())
19169            .insert(profile_id.clone(), materialization.unwrap());
19170        (
19171            shared,
19172            runtime,
19173            ResolvedEnvironmentProfileReceipt {
19174                profile_id,
19175                profile_revision,
19176                network_allowlist: None,
19177                browser_profile_id: None,
19178            },
19179            root,
19180            deny,
19181        )
19182    }
19183
19184    #[cfg(feature = "fixture")]
19185    #[derive(Clone, Copy)]
19186    enum CodexHomeProfile {
19187        Missing,
19188        WrongClass,
19189        Exact,
19190    }
19191
19192    #[cfg(feature = "fixture")]
19193    fn codex_bundle_test_shared(
19194        home_profile: CodexHomeProfile,
19195    ) -> (
19196        NodeShared,
19197        NativeRuntime,
19198        ResolvedEnvironmentProfileReceipt,
19199        NodeBundle,
19200        PathBuf,
19201    ) {
19202        const ROOT_MANIFEST: &[u8] = br#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"review-tools"}"#;
19203        const CLAUDE_MANIFEST: &[u8] =
19204            br#"{"name":"review-tools","version":"1.0.0","description":"Review helpers"}"#;
19205        const SKILL: &[u8] = b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n";
19206        const DIGEST: &str =
19207            "sha256:941f20868a6ef49b4329bf1bb1368515763f5b64d8279b05cd9700966083d707";
19208
19209        let (shared, mut runtime, _claude_receipt, root, _deny) =
19210            materialization_test_shared(false);
19211        let profile_id = SpawnEnvironmentProfileId::new(match home_profile {
19212            CodexHomeProfile::Missing => "codex-home-missing",
19213            CodexHomeProfile::WrongClass => "codex-home-wrong",
19214            CodexHomeProfile::Exact => "codex-home-exact",
19215        })
19216        .unwrap();
19217        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19218            "codex-home-r1",
19219        )
19220        .unwrap();
19221        let materialization = match home_profile {
19222            CodexHomeProfile::Missing => None,
19223            CodexHomeProfile::WrongClass | CodexHomeProfile::Exact => {
19224                Some(NodeSessionMaterializationProfile::new(
19225                    Vec::new(),
19226                    vec![NodeSessionPathBinding::new(
19227                        "CODEX_HOME",
19228                        if matches!(home_profile, CodexHomeProfile::Exact) {
19229                            NodeSessionPathClass::ProviderHome
19230                        } else {
19231                            NodeSessionPathClass::Config
19232                        },
19233                    )
19234                    .unwrap()],
19235                    Vec::new(),
19236                )
19237                .unwrap())
19238            }
19239        };
19240        let node_profile = NodeEnvironmentProfile::new_with_materialization(
19241            profile_id.clone(),
19242            profile_revision.clone(),
19243            agent("codex"),
19244            [NativeLaunchProfile::new(
19245                NativeLaunchProfileId::new(format!("{profile_id}-pty")).unwrap(),
19246                agent("codex"),
19247                TransportKind::Pty,
19248                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19249                Arc::new(EmptyEnvironmentResolver),
19250            )
19251            .unwrap()],
19252            materialization,
19253        )
19254        .unwrap();
19255        let (binding, native_profiles, materialization) = node_profile.into_parts();
19256        for native_profile in native_profiles {
19257            runtime.upsert_native_launch_profile(native_profile).unwrap();
19258        }
19259        shared
19260            .environment_profiles
19261            .write()
19262            .unwrap_or_else(|poisoned| poisoned.into_inner())
19263            .insert(binding.id.clone(), binding);
19264        if let Some(materialization) = materialization {
19265            shared
19266                .environment_materialization_profiles
19267                .write()
19268                .unwrap_or_else(|poisoned| poisoned.into_inner())
19269                .insert(profile_id.clone(), materialization);
19270        }
19271
19272        let bundle_source = root.join(format!("bundle-source-{profile_id}"));
19273        std::fs::create_dir_all(bundle_source.join(".claude-plugin")).unwrap();
19274        std::fs::create_dir_all(bundle_source.join("skills/review-code")).unwrap();
19275        std::fs::write(bundle_source.join("plugin.json"), ROOT_MANIFEST).unwrap();
19276        std::fs::write(
19277            bundle_source.join(".claude-plugin/plugin.json"),
19278            CLAUDE_MANIFEST,
19279        )
19280        .unwrap();
19281        std::fs::write(bundle_source.join("skills/review-code/SKILL.md"), SKILL).unwrap();
19282        crate::bundle_catalog::protect_bundle_source_tree_fixture(&bundle_source).unwrap();
19283        let bundle = NodeBundle::new(
19284            crate::protocol::SpawnBundleId::new("review-tools").unwrap(),
19285            crate::protocol::SpawnBundleRevision::new("review-tools-r1").unwrap(),
19286            crate::protocol::SpawnBundleDigest::new(DIGEST).unwrap(),
19287            &bundle_source,
19288        )
19289        .unwrap();
19290        *shared
19291            .bundle_catalog
19292            .write()
19293            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
19294            BundleCatalog::new([bundle.clone()]).unwrap();
19295        (
19296            shared,
19297            runtime,
19298            ResolvedEnvironmentProfileReceipt {
19299                profile_id,
19300                profile_revision,
19301                network_allowlist: None,
19302                browser_profile_id: None,
19303            },
19304            bundle,
19305            root,
19306        )
19307    }
19308
19309    #[cfg(feature = "fixture")]
19310    #[test]
19311    fn codex_bundle_preflight_rejects_missing_or_wrong_home_before_materialization() {
19312        for home_profile in [CodexHomeProfile::Missing, CodexHomeProfile::WrongClass] {
19313            let (shared, runtime, receipt, bundle, root) =
19314                codex_bundle_test_shared(home_profile);
19315            let error = shared
19316                .bundle_layout(
19317                    &agent("codex"),
19318                    SessionMode::Pty,
19319                    Some(&receipt),
19320                    &bundle,
19321                )
19322                .unwrap_err();
19323            assert_eq!(error.code, NodeFailureCode::BundleBindingMismatch);
19324            assert!(shared
19325                .materializations
19326                .lock()
19327                .unwrap_or_else(|poisoned| poisoned.into_inner())
19328                .is_empty());
19329            let materialization_root = root.join("session-environments");
19330            assert!(!std::fs::read_dir(&materialization_root)
19331                .unwrap()
19332                .filter_map(Result::ok)
19333                .any(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())));
19334            drop(shared);
19335            drop(runtime);
19336            let _ = std::fs::remove_dir_all(root);
19337        }
19338    }
19339
19340    #[cfg(feature = "fixture")]
19341    #[test]
19342    fn codex_bundle_exact_home_profile_materializes_only_the_skill_tree() {
19343        let (shared, runtime, receipt, bundle, root) =
19344            codex_bundle_test_shared(CodexHomeProfile::Exact);
19345        let address = SessionAddress {
19346            workspace_id: WorkspaceId::new("primary").unwrap(),
19347            session: SessionKey {
19348                instance_id: AgentInstanceId(310),
19349                generation: SessionGeneration(1),
19350            },
19351        };
19352        let (overlay, guard) = shared
19353            .prepare_session_materialization(
19354                &address,
19355                &agent("codex"),
19356                SessionMode::Pty,
19357                Some(&receipt),
19358                Some(&bundle.receipt()),
19359                None,
19360                None,
19361            )
19362            .unwrap()
19363            .unwrap();
19364        assert!(matches!(overlay, Some(PreparedNativeLaunchOverlay::Instance(_))));
19365        let ownership = shared
19366            .materializations
19367            .lock()
19368            .unwrap_or_else(|poisoned| poisoned.into_inner())
19369            .get(guard.id().unwrap())
19370            .unwrap()
19371            .clone();
19372        let (profile, _, _) = shared
19373            .materialization_profile(
19374                &agent("codex"),
19375                SessionMode::Pty,
19376                Some(&receipt),
19377                Some(&bundle.receipt()),
19378                None,
19379            )
19380            .unwrap()
19381            .unwrap();
19382        let environment = shared
19383            .session_environment_materializer
19384            .as_ref()
19385            .unwrap()
19386            .resolve_environment(&ownership, &profile)
19387            .unwrap();
19388        assert!(environment.iter().any(|mutation| {
19389            mutation.key == OsString::from("CODEX_HOME")
19390                && mutation.value.as_deref() == Some(ownership.provider_home().as_os_str())
19391        }));
19392        assert_eq!(
19393            std::fs::read(ownership.provider_home().join("skills/review-code/SKILL.md"))
19394                .unwrap(),
19395            b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n",
19396        );
19397        assert!(std::fs::read_dir(ownership.bundle_root())
19398            .unwrap()
19399            .next()
19400            .is_none());
19401        drop(guard);
19402        drop(shared);
19403        drop(runtime);
19404        let _ = std::fs::remove_dir_all(root);
19405    }
19406
19407    #[cfg(feature = "fixture")]
19408    #[test]
19409    fn codex_cold_record_resume_revalidates_the_isolated_skill_tree() {
19410        let (shared, runtime, receipt, bundle, root) =
19411            codex_bundle_test_shared(CodexHomeProfile::Exact);
19412        let bundle_receipt = bundle.receipt();
19413        let address = SessionAddress {
19414            workspace_id: WorkspaceId::new("primary").unwrap(),
19415            session: SessionKey {
19416                instance_id: AgentInstanceId(311),
19417                generation: SessionGeneration(1),
19418            },
19419        };
19420        let (_overlay, guard) = shared
19421            .prepare_session_materialization(
19422                &address,
19423                &agent("codex"),
19424                SessionMode::Pty,
19425                Some(&receipt),
19426                Some(&bundle_receipt),
19427                None,
19428                None,
19429            )
19430            .unwrap()
19431            .unwrap();
19432        let materialization_id = guard.id().unwrap().clone();
19433        let skill_path = shared
19434            .materializations
19435            .lock()
19436            .unwrap_or_else(|poisoned| poisoned.into_inner())
19437            .get(&materialization_id)
19438            .unwrap()
19439            .provider_home()
19440            .join("skills/review-code/SKILL.md");
19441        let record_id = shared
19442            .bind_spawn_session_with_materialization(
19443                &address,
19444                agent("codex"),
19445                SessionMode::Pty,
19446                ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap(),
19447                SpawnRecordPolicy::ProviderIdentityOnly,
19448                Some(receipt.clone()),
19449                Some(bundle_receipt.clone()),
19450                None,
19451                Some(materialization_id.clone()),
19452            )
19453            .unwrap()
19454            .unwrap();
19455        guard.retain();
19456        std::fs::write(skill_path, b"changed-after-record-bind").unwrap();
19457
19458        let error = match shared.resolve_record_materialization(
19459                &record_id,
19460                &agent("codex"),
19461                SessionMode::Pty,
19462                Some(&receipt),
19463                Some(&bundle_receipt),
19464                None,
19465            ) {
19466                Ok(_) => panic!("changed Codex skill tree unexpectedly revalidated"),
19467                Err(error) => error,
19468            };
19469        assert_eq!(error.code, NodeFailureCode::BundleMaterializationFailed);
19470        assert_eq!(
19471            shared
19472                .materializations
19473                .lock()
19474                .unwrap_or_else(|poisoned| poisoned.into_inner())
19475                .get(&materialization_id)
19476                .unwrap()
19477                .state(),
19478            MaterializationState::RecoveryRequired,
19479        );
19480        drop(shared);
19481        drop(runtime);
19482        let _ = std::fs::remove_dir_all(root);
19483    }
19484
19485    fn bind_record_owned_materialization_fixture(
19486        shared: &NodeShared,
19487        receipt: &ResolvedEnvironmentProfileReceipt,
19488        instance_id: u64,
19489    ) -> (SessionAddress, SessionRecordId, MaterializationId, PathBuf) {
19490        let address = SessionAddress {
19491            workspace_id: WorkspaceId::new("primary").unwrap(),
19492            session: SessionKey {
19493                instance_id: AgentInstanceId(instance_id),
19494                generation: SessionGeneration(1),
19495            },
19496        };
19497        let (overlay, materialization_guard) = shared
19498            .prepare_session_materialization(
19499                &address,
19500                &agent("claude"),
19501                SessionMode::Pty,
19502                Some(receipt),
19503                None,
19504                None,
19505                None,
19506            )
19507            .unwrap()
19508            .unwrap();
19509        let materialization_id = materialization_guard.id().unwrap().clone();
19510        let materialization_root = shared
19511            .materializations
19512            .lock()
19513            .unwrap_or_else(|poisoned| poisoned.into_inner())
19514            .get(&materialization_id)
19515            .unwrap()
19516            .root()
19517            .to_path_buf();
19518        let selection = shared
19519            .select_environment_profile(
19520                address.session.instance_id,
19521                &agent("claude"),
19522                SessionMode::Pty,
19523                Some(receipt),
19524            )
19525            .unwrap()
19526            .unwrap();
19527        assert!(shared
19528            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
19529            .unwrap()
19530            .is_none());
19531        let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
19532        let record_id = shared
19533            .bind_spawn_session_with_materialization(
19534                &address,
19535                agent("claude"),
19536                SessionMode::Pty,
19537                policy,
19538                SpawnRecordPolicy::ProviderIdentityOnly,
19539                Some(receipt.clone()),
19540                None,
19541                None,
19542                Some(materialization_id.clone()),
19543            )
19544            .unwrap()
19545            .unwrap();
19546        selection.retain();
19547        materialization_guard.retain();
19548        (address, record_id, materialization_id, materialization_root)
19549    }
19550
19551    #[test]
19552    fn child_environment_profile_capability_is_state_aware_and_legacy_safe() {
19553        let (shared, _runtime, environment_profile) = environment_profile_test_shared();
19554        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
19555            capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
19556        }));
19557        let support = node_compatibility_support_for_manifest(&[], &[]).unwrap();
19558        assert_eq!(support.state_schema.versions.maximum(), NODE_STATE_SCHEMA_V10);
19559        let mut spec = spawn_spec_fixture();
19560        spec.expected_profile_revision =
19561            crate::protocol::SpawnProfileRevision::new("test-r1").unwrap();
19562        assert!(matches!(
19563            &spec.overrides.environment_profile_id,
19564            crate::protocol::SpawnOverride::Inherit,
19565        ));
19566        assert!(!NodeRequest::SpawnSpec { spec: spec.clone() }
19567            .requires_child_environment_profile_capability());
19568        assert!(request_requires_child_environment_profile(
19569            &shared,
19570            &NodeRequest::SpawnSpec { spec: spec.clone() },
19571        ));
19572
19573        let address = terminal_address(1);
19574        shared.bind_session_with_policy(
19575            &address,
19576            ProviderRuntimePolicy::raw_pty(),
19577            Some(environment_profile.clone()),
19578        );
19579        assert!(request_requires_child_environment_profile(
19580            &shared,
19581            &NodeRequest::Input {
19582                session: address.clone(),
19583                text: "x".to_owned(),
19584            },
19585        ));
19586
19587        let mut record = record("claude", "profile-record");
19588        record.active_session = Some(address.clone());
19589        record.environment_profile = Some(environment_profile.clone());
19590        let record_id = record.record_id.clone();
19591        shared.insert_record(record.clone()).unwrap();
19592        assert!(request_requires_child_environment_profile(
19593            &shared,
19594            &NodeRequest::ResumeSessionRecord {
19595                record_id,
19596                terminal_size: gate4agent_types::TerminalSize {
19597                    rows: 24,
19598                    columns: 80,
19599                },
19600                initial_prompt: None,
19601            },
19602        ));
19603
19604        let snapshot = snapshot_for_wire(
19605            &shared, false, true, true, true, false, false, false, false, true,
19606        );
19607        assert_eq!(snapshot.session_records.len(), 1);
19608        assert!(snapshot.session_records[0].environment_profile.is_none());
19609        let event = project_event_without_child_environment_profile(NodeEventEnvelope {
19610            sequence: 1,
19611            event: NodeEvent::SessionRecordUpserted { record },
19612        });
19613        let NodeEvent::SessionRecordUpserted { record } = event.event else {
19614            panic!("record projection changed the event kind");
19615        };
19616        assert!(record.environment_profile.is_none());
19617
19618        let resolved = shared.resolve_spawn_spec(&spec).unwrap();
19619        let mut reply = ResponseEnvelope {
19620            request_id: 1,
19621            result: Ok(NodeResponse::SpawnSpecAccepted {
19622                receipt: resolved.receipt_with_environment(
19623                    shared.incarnation_id,
19624                    address,
19625                    Some(environment_profile),
19626                ),
19627            }),
19628        };
19629        project_response_without_child_environment_profile(&mut reply);
19630        assert_eq!(
19631            reply.result.unwrap_err().code,
19632            NodeFailureCode::UnsupportedCapability,
19633        );
19634    }
19635
19636    #[test]
19637    fn spawn_profile_environment_authority_is_exact_and_legacy_safe() {
19638        let (shared, _runtime, environment_profile) = environment_profile_test_shared();
19639        let snapshot = shared.snapshot();
19640        let profiles = snapshot
19641            .launch_inventory
19642            .as_ref()
19643            .and_then(|inventory| inventory.spawn_profiles.as_ref())
19644            .unwrap();
19645        assert_eq!(profiles.len(), 1);
19646        assert_eq!(profiles[0].environment_profile, Some(environment_profile));
19647        assert!(snapshot.requires_child_environment_profile_capability());
19648
19649        let mut legacy = snapshot.clone();
19650        clear_snapshot_child_environment_profiles(&mut legacy);
19651        assert!(legacy
19652            .launch_inventory
19653            .as_ref()
19654            .unwrap()
19655            .spawn_profiles
19656            .as_ref()
19657            .unwrap()[0]
19658            .environment_profile
19659            .is_none());
19660        assert!(!legacy.requires_child_environment_profile_capability());
19661
19662        let profile = shared.spawn_profiles.iter().next().unwrap().clone();
19663        let bindings = shared
19664            .environment_profiles
19665            .read()
19666            .unwrap_or_else(|poisoned| poisoned.into_inner())
19667            .clone();
19668        let mut provider_mismatch = bindings.clone();
19669        provider_mismatch
19670            .values_mut()
19671            .next()
19672            .unwrap()
19673            .provider = agent("codex");
19674        assert!(resolved_spawn_profile_summary(&profile, &provider_mismatch).is_none());
19675        let mut mode_mismatch = profile;
19676        mode_mismatch.mode = SessionMode::Inline;
19677        assert!(resolved_spawn_profile_summary(&mode_mismatch, &bindings).is_none());
19678
19679        shared
19680            .environment_profiles
19681            .write()
19682            .unwrap_or_else(|poisoned| poisoned.into_inner())
19683            .clear();
19684        assert!(shared
19685            .snapshot()
19686            .launch_inventory
19687            .unwrap()
19688            .spawn_profiles
19689            .unwrap()
19690            .is_empty());
19691    }
19692
19693    #[test]
19694    fn session_bundle_capability_is_state_aware_and_legacy_safe() {
19695        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
19696            capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
19697        }));
19698        let shared = terminal_test_shared();
19699        let bundle = ResolvedBundleReceipt {
19700            id: crate::protocol::SpawnBundleId::new("review-bundle").unwrap(),
19701            revision: crate::protocol::SpawnBundleRevision::new("r1").unwrap(),
19702            digest: crate::protocol::SpawnBundleDigest::new(format!(
19703                "sha256:{}",
19704                "0".repeat(64),
19705            ))
19706            .unwrap(),
19707        };
19708        let mut bundled = record("claude", "bundle-record");
19709        bundled.bundle = Some(bundle.clone());
19710        let record_id = bundled.record_id.clone();
19711        shared.insert_record(bundled.clone()).unwrap();
19712        assert!(request_requires_session_bundle(
19713            &shared,
19714            &NodeRequest::ForgetSessionRecord {
19715                record_id: record_id.clone(),
19716            },
19717        ));
19718
19719        let snapshot = snapshot_for_wire(
19720            &shared, false, true, true, true, true, false, false, false, true,
19721        );
19722        assert!(snapshot.session_records[0].bundle.is_none());
19723        let launch = snapshot.launch_inventory.as_ref().unwrap();
19724        assert!(launch.spawn_profiles.is_some());
19725        assert!(launch.bundles.is_none());
19726        let event = project_event_without_session_bundle(NodeEventEnvelope {
19727            sequence: 1,
19728            event: NodeEvent::SessionRecordUpserted { record: bundled },
19729        });
19730        let NodeEvent::SessionRecordUpserted { record } = event.event else {
19731            panic!("record projection changed the event kind");
19732        };
19733        assert!(record.bundle.is_none());
19734
19735        let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
19736        let mut reply = ResponseEnvelope {
19737            request_id: 1,
19738            result: Ok(NodeResponse::SpawnSpecAccepted {
19739                receipt: resolved.receipt_with_materialization(
19740                    shared.incarnation_id,
19741                    terminal_address(17),
19742                    None,
19743                    Some(bundle),
19744                    None,
19745                ),
19746            }),
19747        };
19748        project_response_without_session_bundle(&mut reply);
19749        assert_eq!(
19750            reply.result.unwrap_err().code,
19751            NodeFailureCode::UnsupportedCapability,
19752        );
19753    }
19754
19755    #[test]
19756    fn history_context_pack_capability_redacts_loaded_messages_and_legacy_metadata() {
19757        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
19758            capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
19759        }));
19760        let context_id = SpawnContextId::new("context-review").unwrap();
19761        let context = ResolvedContextPackReceipt {
19762            id: context_id.clone(),
19763            digest: crate::protocol::SpawnContextDigest::new(format!(
19764                "sha256:{}",
19765                "c".repeat(64),
19766            ))
19767            .unwrap(),
19768            lineage: ContextPackLineageReceipt {
19769                source_node_id: NodeId::new("node-source").unwrap(),
19770                source_session: terminal_address(1),
19771                source_provider: agent("kimi"),
19772            },
19773            source_message_count: 2,
19774            retained_message_count: 2,
19775            byte_len: 256,
19776            truncated: false,
19777        };
19778        assert!(context.is_valid());
19779        let mut bound_record = record("codex", "context-record");
19780        bound_record.context_id = Some(context_id);
19781        bound_record.context = Some(context.clone());
19782        let session = gate4agent_types::SessionSnapshot {
19783            instance_id: AgentInstanceId(91),
19784            agent_id: agent("kimi"),
19785            transport: TransportKind::Pty,
19786            generation: SessionGeneration(3),
19787            status: SessionStatus::Running,
19788            pending_operation: None,
19789            pending_input: None,
19790            process_id: Some(9001),
19791            terminal_size: None,
19792            terminal_frame: None,
19793            terminal_stale: None,
19794            session_options: None,
19795            capabilities: gate4agent_types::CapabilitySnapshot::default(),
19796            history: gate4agent_types::HistorySnapshot {
19797                pending: None,
19798                candidates: vec![HistoryCandidateSummary {
19799                    id: "candidate-opaque".to_owned(),
19800                    session_id_hint: "session-hint".to_owned(),
19801                    modified_at_unix_ms: Some(1),
19802                }],
19803                loaded_candidate_id: Some("candidate-opaque".to_owned()),
19804                loaded: Some(HistorySessionRecord {
19805                    session_id: "private-vendor-session".to_owned(),
19806                    title: Some("private title".to_owned()),
19807                    cwd: Some(r"C:\private\history-root".to_owned()),
19808                    model: Some("private-model".to_owned()),
19809                    message_count: 2,
19810                    completed_turn_count: None,
19811                    total_tokens: 19,
19812                    messages: vec![
19813                        gate4agent_types::HistoryMessageRecord {
19814                            role: gate4agent_types::HistoryMessageRole::User,
19815                            text: "F7_PRIVATE_HISTORY_MESSAGE".to_owned(),
19816                        },
19817                        gate4agent_types::HistoryMessageRecord {
19818                            role: gate4agent_types::HistoryMessageRole::Assistant,
19819                            text: "private answer".to_owned(),
19820                        },
19821                    ],
19822                }),
19823                last_error: None,
19824            },
19825            resume: gate4agent_types::ResumeSnapshot::default(),
19826            foreground: gate4agent_types::ForegroundSnapshot::default(),
19827            provider: gate4agent_types::ProviderSnapshot::default(),
19828            screen_state: gate4agent_types::PtyScreenState::default(),
19829        };
19830        let original = NodeSnapshot {
19831            node_id: NodeId::new("node-terminal-test").unwrap(),
19832            enabled_providers: vec![agent("kimi"), agent("codex")],
19833            provider_runtime_statuses: ProviderRuntimeStatuses::default(),
19834            workspaces: vec![WorkspaceSnapshot {
19835                workspace_id: WorkspaceId::new("primary").unwrap(),
19836                canonical_root: opaque_windows_path(
19837                    std::env::current_dir().unwrap().to_string_lossy().into_owned(),
19838                ),
19839                sessions: vec![session],
19840                worktree_service_mode: None,
19841                managed_worktree_profiles: None,
19842            }],
19843            session_records: vec![bound_record],
19844            managed_worktrees: Vec::new(),
19845            launch_inventory: None,
19846            agent_progress: Vec::new(),
19847        };
19848
19849        let mut negotiated = original.clone();
19850        project_snapshot_history_for_wire(&mut negotiated, true);
19851        assert_eq!(negotiated.workspaces[0].sessions[0].history.candidates.len(), 1);
19852        assert!(negotiated.workspaces[0].sessions[0].history.loaded.is_none());
19853        assert_eq!(negotiated.session_records[0].context.as_ref(), Some(&context));
19854        assert!(!serde_json::to_string(&negotiated)
19855            .unwrap()
19856            .contains("F7_PRIVATE_HISTORY_MESSAGE"));
19857
19858        let mut legacy = original;
19859        project_snapshot_history_for_wire(&mut legacy, false);
19860        clear_snapshot_context_packs(&mut legacy);
19861        assert_eq!(
19862            legacy.workspaces[0].sessions[0].history,
19863            gate4agent_types::HistorySnapshot::default(),
19864        );
19865        assert!(legacy.session_records[0].context_id.is_none());
19866        assert!(legacy.session_records[0].context.is_none());
19867        let encoded = serde_json::to_string(&legacy).unwrap();
19868        assert!(!encoded.contains("F7_PRIVATE_HISTORY_MESSAGE"));
19869        assert!(!encoded.contains("context-review"));
19870
19871        let mut reply = ResponseEnvelope {
19872            request_id: 7,
19873            result: Ok(NodeResponse::ContextPackExported { context }),
19874        };
19875        project_response_without_context_pack(&mut reply);
19876        assert_eq!(
19877            reply.result.unwrap_err().code,
19878            NodeFailureCode::UnsupportedCapability,
19879        );
19880    }
19881
19882    #[test]
19883    fn forgetting_unbound_context_pack_evicts_replay_but_retained_record_is_busy() {
19884        let shared = terminal_test_shared();
19885        let history = HistorySessionRecord {
19886            session_id: "fixture-history".to_owned(),
19887            title: None,
19888            cwd: None,
19889            model: None,
19890            message_count: 1,
19891            completed_turn_count: None,
19892            total_tokens: 0,
19893            messages: vec![gate4agent_types::HistoryMessageRecord {
19894                role: gate4agent_types::HistoryMessageRole::User,
19895                text: "bounded handoff".to_owned(),
19896            }],
19897        };
19898        let pack = NodeContextPack::export(
19899            ContextPackLineageReceipt {
19900                source_node_id: shared.node_id.clone(),
19901                source_session: terminal_address(1),
19902                source_provider: agent("claude"),
19903            },
19904            &history,
19905        )
19906        .unwrap();
19907        let context = shared
19908            .context_catalog
19909            .write()
19910            .unwrap_or_else(|poisoned| poisoned.into_inner())
19911            .insert(pack)
19912            .unwrap();
19913        let mut retained = record("codex", "context-retained-record");
19914        retained.context_id = Some(context.id.clone());
19915        retained.context = Some(context.clone());
19916        let record_id = retained.record_id.clone();
19917        shared.insert_record(retained).unwrap();
19918        assert_eq!(
19919            shared.forget_context_pack(&context.id).unwrap_err().code,
19920            NodeFailureCode::ContextPackBusy,
19921        );
19922        assert!(shared
19923            .context_catalog
19924            .read()
19925            .unwrap_or_else(|poisoned| poisoned.into_inner())
19926            .get(&context.id)
19927            .is_some());
19928        shared
19929            .session_records
19930            .lock()
19931            .unwrap_or_else(|poisoned| poisoned.into_inner())
19932            .records
19933            .remove(&record_id);
19934
19935        let mut spec = spawn_spec_fixture();
19936        spec.overrides.context_id = crate::protocol::SpawnOverride::Set {
19937            value: context.id.clone(),
19938        };
19939        let resolved = shared.resolve_spawn_spec(&spec).unwrap();
19940        let receipt = resolved.receipt_with_materialization(
19941            shared.incarnation_id,
19942            terminal_address(2),
19943            None,
19944            None,
19945            Some(context.clone()),
19946        );
19947        assert!(receipt.context_binding_is_valid());
19948        shared.remember_spawn_spec(spec.clone(), Ok(receipt), Instant::now());
19949
19950        shared.forget_context_pack(&context.id).unwrap();
19951        assert!(shared
19952            .context_catalog
19953            .read()
19954            .unwrap_or_else(|poisoned| poisoned.into_inner())
19955            .get(&context.id)
19956            .is_none());
19957        assert!(shared
19958            .replay_spawn_spec(&spec, Instant::now())
19959            .unwrap()
19960            .is_none());
19961    }
19962
19963    #[test]
19964    fn environment_profile_selection_is_owned_by_the_exact_session_binding() {
19965        let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
19966        let address = terminal_address(1);
19967        let selection = shared
19968            .select_environment_profile(
19969                address.session.instance_id,
19970                &agent("claude"),
19971                SessionMode::Pty,
19972                Some(&environment_profile),
19973            )
19974            .unwrap()
19975            .unwrap();
19976        shared.bind_session_with_policy(
19977            &address,
19978            ProviderRuntimePolicy::raw_pty(),
19979            Some(environment_profile.clone()),
19980        );
19981        selection.retain();
19982        let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
19983        assert!(matches!(
19984            runtime.remove_native_launch_profile(&native_id),
19985            Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
19986        ));
19987        assert!(shared.remove_binding(&address).is_some());
19988        assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
19989
19990        let mismatched = ResolvedEnvironmentProfileReceipt {
19991            profile_id: environment_profile.profile_id,
19992            profile_revision: crate::protocol::SpawnEnvironmentProfileRevision::new(
19993                "local-claude-r2",
19994            )
19995            .unwrap(),
19996            network_allowlist: None,
19997            browser_profile_id: None,
19998        };
19999        let error = match shared.select_environment_profile(
20000                AgentInstanceId(42),
20001                &agent("claude"),
20002                SessionMode::Pty,
20003                Some(&mismatched),
20004            ) {
20005            Ok(_) => panic!("mismatched environment profile revision was selected"),
20006            Err(error) => error,
20007        };
20008        assert_eq!(
20009            error.code,
20010            NodeFailureCode::EnvironmentProfileBindingMismatch,
20011        );
20012        assert!(!shared
20013            .session_bindings
20014            .lock()
20015            .unwrap_or_else(|poisoned| poisoned.into_inner())
20016            .contains_key(&AgentInstanceId(42)));
20017    }
20018
20019    #[test]
20020    fn combined_environment_and_bundle_overlay_clears_with_its_binding() {
20021        let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
20022        let address = terminal_address(1);
20023        let selection = shared
20024            .select_environment_profile(
20025                address.session.instance_id,
20026                &agent("claude"),
20027                SessionMode::Pty,
20028                Some(&environment_profile),
20029            )
20030            .unwrap()
20031            .unwrap();
20032        let overlay = NativeInstanceLaunchOverlay::new(
20033            agent("claude"),
20034            TransportKind::Pty,
20035            vec![EnvMutation {
20036                key: OsString::from("GATE4AGENT_SESSION_TOKEN"),
20037                value: Some(OsString::from("fixture-secret")),
20038            }],
20039            vec![
20040                OsString::from("--plugin-dir"),
20041                std::env::current_dir().unwrap().into_os_string(),
20042            ],
20043        )
20044        .unwrap();
20045        let instance_overlay = shared
20046            .install_prepared_launch_overlay(
20047                address.session.instance_id,
20048                PreparedNativeLaunchOverlay::Instance(overlay),
20049            )
20050            .unwrap()
20051            .unwrap();
20052        let bundle = ResolvedBundleReceipt {
20053            id: crate::protocol::SpawnBundleId::new("combined-bundle").unwrap(),
20054            revision: crate::protocol::SpawnBundleRevision::new("combined-r1").unwrap(),
20055            digest: crate::protocol::SpawnBundleDigest::new(format!(
20056                "sha256:{}",
20057                "0".repeat(64),
20058            ))
20059            .unwrap(),
20060        };
20061        shared.bind_session_with_materialization(
20062            &address,
20063            ProviderRuntimePolicy::raw_pty(),
20064            Some(environment_profile),
20065            Some(bundle),
20066            None,
20067            None,
20068        );
20069        selection.retain();
20070        instance_overlay.retain();
20071
20072        let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
20073        assert!(matches!(
20074            runtime.remove_native_launch_profile(&native_id),
20075            Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
20076        ));
20077        assert!(shared.remove_binding(&address).is_some());
20078        assert!(!shared
20079            .native_launch_profile_control
20080            .as_ref()
20081            .unwrap()
20082            .clear_native_instance_launch_overlay(address.session.instance_id));
20083        assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
20084    }
20085
20086    #[test]
20087    fn session_environment_materialization_failure_is_pre_child_and_durable() {
20088        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
20089        let address = SessionAddress {
20090            workspace_id: WorkspaceId::new("primary").unwrap(),
20091            session: SessionKey {
20092                instance_id: AgentInstanceId(201),
20093                generation: SessionGeneration(1),
20094            },
20095        };
20096        let error = match shared.prepare_session_materialization(
20097            &address,
20098            &agent("claude"),
20099            SessionMode::Pty,
20100            Some(&receipt),
20101            None,
20102            None,
20103            None,
20104        ) {
20105            Ok(_) => panic!("denied local reference unexpectedly materialized"),
20106            Err(error) => error,
20107        };
20108        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
20109        assert!(shared.handle.snapshot().sessions.is_empty());
20110        assert!(shared
20111            .session_bindings
20112            .lock()
20113            .unwrap_or_else(|poisoned| poisoned.into_inner())
20114            .is_empty());
20115        assert!(shared
20116            .materializations
20117            .lock()
20118            .unwrap_or_else(|poisoned| poisoned.into_inner())
20119            .is_empty());
20120        drop(shared);
20121        drop(runtime);
20122        std::fs::remove_dir_all(root).unwrap();
20123    }
20124
20125    #[test]
20126    fn session_environment_binding_retains_on_stop_and_cleans_after_remove_reap() {
20127        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20128        let address = SessionAddress {
20129            workspace_id: WorkspaceId::new("primary").unwrap(),
20130            session: SessionKey {
20131                instance_id: AgentInstanceId(202),
20132                generation: SessionGeneration(1),
20133            },
20134        };
20135        let (overlay, materialization_guard) = shared
20136            .prepare_session_materialization(
20137                &address,
20138                &agent("claude"),
20139                SessionMode::Pty,
20140                Some(&receipt),
20141                None,
20142                None,
20143                None,
20144            )
20145            .unwrap()
20146            .unwrap();
20147        let materialization_id = materialization_guard.id().unwrap().clone();
20148        let materialization_root = shared
20149            .materializations
20150            .lock()
20151            .unwrap_or_else(|poisoned| poisoned.into_inner())
20152            .get(&materialization_id)
20153            .unwrap()
20154            .root()
20155            .to_path_buf();
20156        let selection = shared
20157            .select_environment_profile(
20158                address.session.instance_id,
20159                &agent("claude"),
20160                SessionMode::Pty,
20161                Some(&receipt),
20162            )
20163            .unwrap()
20164            .unwrap();
20165        assert!(shared
20166            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
20167            .unwrap()
20168            .is_none());
20169        shared.bind_session_with_materialization(
20170            &address,
20171            ProviderRuntimePolicy::raw_pty(),
20172            Some(receipt),
20173            None,
20174            None,
20175            Some(materialization_id),
20176        );
20177        selection.retain();
20178        materialization_guard.retain();
20179
20180        assert!(materialization_root.is_dir());
20181        assert!(shared
20182            .session_bindings
20183            .lock()
20184            .unwrap_or_else(|poisoned| poisoned.into_inner())
20185            .contains_key(&address.session.instance_id));
20186
20187        let removed = shared.remove_binding(&address).unwrap();
20188        shared
20189            .cleanup_session_owned_materialization(&address, &removed)
20190            .unwrap();
20191        assert!(!materialization_root.exists());
20192        assert!(shared
20193            .materializations
20194            .lock()
20195            .unwrap_or_else(|poisoned| poisoned.into_inner())
20196            .is_empty());
20197        drop(shared);
20198        drop(runtime);
20199        std::fs::remove_dir_all(root).unwrap();
20200    }
20201
20202    #[tokio::test]
20203    async fn record_owned_session_environment_reuses_on_resume_and_forget_cleans() {
20204        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20205        let address = SessionAddress {
20206            workspace_id: WorkspaceId::new("primary").unwrap(),
20207            session: SessionKey {
20208                instance_id: AgentInstanceId(203),
20209                generation: SessionGeneration(1),
20210            },
20211        };
20212        let (overlay, materialization_guard) = shared
20213            .prepare_session_materialization(
20214                &address,
20215                &agent("claude"),
20216                SessionMode::Pty,
20217                Some(&receipt),
20218                None,
20219                None,
20220                None,
20221            )
20222            .unwrap()
20223            .unwrap();
20224        let materialization_id = materialization_guard.id().unwrap().clone();
20225        let materialization_root = shared
20226            .materializations
20227            .lock()
20228            .unwrap_or_else(|poisoned| poisoned.into_inner())
20229            .get(&materialization_id)
20230            .unwrap()
20231            .root()
20232            .to_path_buf();
20233        let selection = shared
20234            .select_environment_profile(
20235                address.session.instance_id,
20236                &agent("claude"),
20237                SessionMode::Pty,
20238                Some(&receipt),
20239            )
20240            .unwrap()
20241            .unwrap();
20242        assert!(shared
20243            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
20244            .unwrap()
20245            .is_none());
20246        let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
20247        let record_id = shared
20248            .bind_spawn_session_with_materialization(
20249                &address,
20250                agent("claude"),
20251                SessionMode::Pty,
20252                policy,
20253                SpawnRecordPolicy::ProviderIdentityOnly,
20254                Some(receipt.clone()),
20255                None,
20256                None,
20257                Some(materialization_id.clone()),
20258            )
20259            .unwrap()
20260            .unwrap();
20261        selection.retain();
20262        materialization_guard.retain();
20263        assert_eq!(
20264            shared
20265                .materializations
20266                .lock()
20267                .unwrap_or_else(|poisoned| poisoned.into_inner())
20268                .get(&materialization_id)
20269                .unwrap()
20270                .owner(),
20271            &MaterializationOwner::Record {
20272                record_id: record_id.clone(),
20273            },
20274        );
20275
20276        let removed = shared.remove_binding(&address).unwrap();
20277        shared
20278            .cleanup_session_owned_materialization(&address, &removed)
20279            .unwrap();
20280        assert!(materialization_root.is_dir());
20281        assert!(shared
20282            .resolve_record_materialization(
20283                &record_id,
20284                &agent("claude"),
20285                SessionMode::Pty,
20286                Some(&receipt),
20287                None,
20288                None,
20289            )
20290            .unwrap()
20291            .is_some());
20292        {
20293            let mut records = shared
20294                .session_records
20295                .lock()
20296                .unwrap_or_else(|poisoned| poisoned.into_inner());
20297            let record = records.records.get_mut(&record_id).unwrap();
20298            record.active_session = None;
20299            record.state = ManagedSessionState::Unavailable;
20300        }
20301        shared.forget_session_record(&record_id).await.unwrap();
20302        assert!(!materialization_root.exists());
20303        assert!(shared
20304            .materializations
20305            .lock()
20306            .unwrap_or_else(|poisoned| poisoned.into_inner())
20307            .is_empty());
20308        drop(shared);
20309        drop(runtime);
20310        std::fs::remove_dir_all(root).unwrap();
20311    }
20312
20313    #[test]
20314    fn record_materialization_revalidation_failure_persists_recovery_required() {
20315        let (mut shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20316        let state_path = root.join("node-state-v6.json");
20317        shared.state_path = Some(state_path.clone());
20318        let (_address, _record_id, materialization_id, materialization_root) =
20319            bind_record_owned_materialization_fixture(&shared, &receipt, 205);
20320        std::fs::write(
20321            materialization_root.join(".gate4agent-materialization-owner"),
20322            b"tampered",
20323        )
20324        .unwrap();
20325
20326        let error = match shared.resolve_record_materialization(
20327            &_record_id,
20328            &agent("claude"),
20329            SessionMode::Pty,
20330            Some(&receipt),
20331            None,
20332            None,
20333        ) {
20334            Ok(_) => panic!("tampered owner marker unexpectedly revalidated"),
20335            Err(error) => error,
20336        };
20337        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
20338        assert_eq!(
20339            shared
20340                .materializations
20341                .lock()
20342                .unwrap_or_else(|poisoned| poisoned.into_inner())
20343                .get(&materialization_id)
20344                .unwrap()
20345                .state(),
20346            MaterializationState::RecoveryRequired,
20347        );
20348        let loaded = session_registry::load(Some(&state_path), &shared.node_id).unwrap();
20349        assert_eq!(loaded.materializations.len(), 1);
20350        assert_eq!(
20351            loaded.materializations[0].state(),
20352            MaterializationState::RecoveryRequired,
20353        );
20354        drop(shared);
20355        drop(runtime);
20356        std::fs::remove_dir_all(root).unwrap();
20357    }
20358
20359    #[test]
20360    fn transient_record_materialization_resolution_failure_remains_ready() {
20361        let (shared, runtime, receipt, root, deny) = materialization_test_shared(false);
20362        let (_address, record_id, materialization_id, _materialization_root) =
20363            bind_record_owned_materialization_fixture(&shared, &receipt, 206);
20364        deny.store(true, Ordering::Release);
20365
20366        let error = match shared.resolve_record_materialization(
20367            &record_id,
20368            &agent("claude"),
20369            SessionMode::Pty,
20370            Some(&receipt),
20371            None,
20372            None,
20373        ) {
20374            Ok(_) => panic!("denied transient resolver unexpectedly succeeded"),
20375            Err(error) => error,
20376        };
20377        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
20378        assert_eq!(
20379            shared
20380                .materializations
20381                .lock()
20382                .unwrap_or_else(|poisoned| poisoned.into_inner())
20383                .get(&materialization_id)
20384                .unwrap()
20385                .state(),
20386            MaterializationState::Ready,
20387        );
20388        drop(shared);
20389        drop(runtime);
20390        std::fs::remove_dir_all(root).unwrap();
20391    }
20392
20393    #[test]
20394    fn startup_missing_record_materialization_is_not_resumable() {
20395        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20396        let record_id = SessionRecordId::new("sr-missing-materialization").unwrap();
20397        shared
20398            .insert_record(ManagedSessionRecord {
20399                record_id: record_id.clone(),
20400                display_name: "missing materialization".to_owned(),
20401                provider: agent("claude"),
20402                mode: SessionMode::Pty,
20403                state: ManagedSessionState::Dormant,
20404                workspace_id: WorkspaceId::new("primary").unwrap(),
20405                canonical_root: opaque_windows_path(
20406                    std::env::current_dir().unwrap().to_string_lossy().into_owned(),
20407                ),
20408                provider_session: Some(gate4agent_types::ProviderSessionIdentity {
20409                    key: gate4agent_types::ProviderSessionKey::SessionId,
20410                    id: "provider-session-missing-materialization".to_owned(),
20411                    transcript_path: None,
20412                }),
20413                active_session: None,
20414                environment_profile: Some(receipt),
20415                bundle: None,
20416                context_id: None,
20417                context: None,
20418                exported_context: None,
20419                task_binding: None,
20420                created_at_unix_ms: 1,
20421                updated_at_unix_ms: 1,
20422                last_error: None,
20423            })
20424            .unwrap();
20425
20426        shared.reconcile_materializations();
20427        let record = shared.record(&record_id).unwrap();
20428        assert_eq!(record.state, ManagedSessionState::Unavailable);
20429        assert!(record.provider_session.is_none());
20430        assert_eq!(
20431            record.last_error.as_deref(),
20432            Some("environment-profile-unavailable"),
20433        );
20434        drop(shared);
20435        drop(runtime);
20436        std::fs::remove_dir_all(root).unwrap();
20437    }
20438
20439    #[tokio::test]
20440    async fn managed_worktree_cleanup_waits_for_session_environment_cleanup() {
20441        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20442        let lease = install_managed_lease(&shared);
20443        let address = SessionAddress {
20444            workspace_id: WorkspaceId::new("primary").unwrap(),
20445            session: SessionKey {
20446                instance_id: AgentInstanceId(204),
20447                generation: SessionGeneration(1),
20448            },
20449        };
20450        let (overlay, materialization_guard) = shared
20451            .prepare_session_materialization(
20452                &address,
20453                &agent("claude"),
20454                SessionMode::Pty,
20455                Some(&receipt),
20456                None,
20457                None,
20458                Some(lease.lease_id.clone()),
20459            )
20460            .unwrap()
20461            .unwrap();
20462        let materialization_id = materialization_guard.id().unwrap().clone();
20463        let materialization_root = shared
20464            .materializations
20465            .lock()
20466            .unwrap_or_else(|poisoned| poisoned.into_inner())
20467            .get(&materialization_id)
20468            .unwrap()
20469            .root()
20470            .to_path_buf();
20471        let selection = shared
20472            .select_environment_profile(
20473                address.session.instance_id,
20474                &agent("claude"),
20475                SessionMode::Pty,
20476                Some(&receipt),
20477            )
20478            .unwrap()
20479            .unwrap();
20480        assert!(shared
20481            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
20482            .unwrap()
20483            .is_none());
20484        shared.bind_session_with_materialization(
20485            &address,
20486            ProviderRuntimePolicy::raw_pty(),
20487            Some(receipt),
20488            None,
20489            None,
20490            Some(materialization_id),
20491        );
20492        selection.retain();
20493        materialization_guard.retain();
20494
20495        let blocked = shared
20496            .cleanup_managed_worktree(&lease.lease_id, false)
20497            .await
20498            .unwrap_err();
20499        assert_eq!(blocked.code, NodeFailureCode::ManagedWorktreeRecoveryRequired);
20500        assert!(materialization_root.is_dir());
20501        let removed = shared.remove_binding(&address).unwrap();
20502        shared
20503            .cleanup_session_owned_materialization(&address, &removed)
20504            .unwrap();
20505        assert!(!materialization_root.exists());
20506        drop(shared);
20507        drop(runtime);
20508        std::fs::remove_dir_all(root).unwrap();
20509    }
20510
20511    #[tokio::test]
20512    async fn raw_managed_record_is_inventory_only_and_does_not_block_session_cleanup() {
20513        let git_root = temporary_workspace_root("raw-managed-inventory");
20514        let source = git_root.join("source");
20515        let allocation = git_root.join("allocation");
20516        std::fs::create_dir_all(&source).unwrap();
20517        std::fs::create_dir_all(&allocation).unwrap();
20518        run_test_git(&source, &["init"]);
20519        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
20520        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
20521        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
20522        run_test_git(&source, &["add", "seed.txt"]);
20523        run_test_git(&source, &["commit", "-m", "seed"]);
20524        let profile = ManagedWorktreeProfile::new(
20525            WorktreeProfileId::new("default").unwrap(),
20526            crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
20527            &allocation,
20528            "gate4agent",
20529            "HEAD",
20530            ManagedWorktreeRetention::RemoveWhenReleased,
20531        )
20532        .unwrap();
20533        let workspace = WorkspaceConfig::new(
20534            WorkspaceId::new("primary").unwrap(),
20535            &source,
20536        )
20537        .unwrap()
20538        .with_worktree_service_mode(WorktreeServiceMode::Managed)
20539        .with_managed_worktree_profile(profile.clone())
20540        .unwrap();
20541        let (shared, mut runtime, receipt, materialization_fixture_root, _deny) =
20542            materialization_test_shared_with_workspace(false, workspace);
20543        let target = PathBuf::from(profile.allocation_root()).join("mw-raw-inventory");
20544        let source_text = source.to_string_lossy().into_owned();
20545        let target_text = target.to_string_lossy().into_owned();
20546        let created = create_git_worktree(
20547            &source_text,
20548            &target_text,
20549            "gate4agent/mw-raw-inventory",
20550            Some("HEAD"),
20551        )
20552        .await
20553        .unwrap();
20554        let lease = ManagedWorktreeLeaseRecord {
20555            lease_id: ManagedWorktreeLeaseId::new("mw-raw-inventory").unwrap(),
20556            source_workspace_id: WorkspaceId::new("primary").unwrap(),
20557            workspace_id: WorkspaceId::new("managed-raw-inventory").unwrap(),
20558            profile_id: profile.profile_id().clone(),
20559            profile_revision: profile.revision().clone(),
20560            target_root: created.path.clone(),
20561            branch: "gate4agent/mw-raw-inventory".to_owned(),
20562            base_commit: created.head.clone(),
20563            expected_head: Some(created.head.clone()),
20564            retention: ManagedWorktreeRetention::RemoveWhenReleased,
20565            state: ManagedWorktreeLeaseState::Ready,
20566            session_holders: Vec::new(),
20567            record_holders: Vec::new(),
20568            cleanup_failure: None,
20569            created_at_unix_ms: 1,
20570            updated_at_unix_ms: 1,
20571        };
20572        shared
20573            .workspaces
20574            .write()
20575            .unwrap_or_else(|poisoned| poisoned.into_inner())
20576            .insert(lease.workspace_id.clone(), created.path.clone());
20577        *shared
20578            .managed_worktrees
20579            .lock()
20580            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
20581            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
20582        let address = SessionAddress {
20583            workspace_id: lease.workspace_id.clone(),
20584            session: SessionKey {
20585                instance_id: AgentInstanceId(205),
20586                generation: SessionGeneration(1),
20587            },
20588        };
20589        let (overlay, materialization_guard) = shared
20590            .prepare_session_materialization(
20591                &address,
20592                &agent("claude"),
20593                SessionMode::Pty,
20594                Some(&receipt),
20595                None,
20596                None,
20597                Some(lease.lease_id.clone()),
20598            )
20599            .unwrap()
20600            .unwrap();
20601        let materialization_id = materialization_guard.id().unwrap().clone();
20602        let materialization_root = shared
20603            .materializations
20604            .lock()
20605            .unwrap_or_else(|poisoned| poisoned.into_inner())
20606            .get(&materialization_id)
20607            .unwrap()
20608            .root()
20609            .to_path_buf();
20610        let selection = shared
20611            .select_environment_profile(
20612                address.session.instance_id,
20613                &agent("claude"),
20614                SessionMode::Pty,
20615                Some(&receipt),
20616            )
20617            .unwrap()
20618            .unwrap();
20619        assert!(shared
20620            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
20621            .unwrap()
20622            .is_none());
20623        let record_id = shared
20624            .bind_spawn_session_with_materialization(
20625                &address,
20626                agent("claude"),
20627                SessionMode::Pty,
20628                ProviderRuntimePolicy::raw_pty(),
20629                SpawnRecordPolicy::Always,
20630                Some(receipt.clone()),
20631                None,
20632                None,
20633                Some(materialization_id.clone()),
20634            )
20635            .unwrap()
20636            .unwrap();
20637        selection.retain();
20638        materialization_guard.retain();
20639        let record = shared.record(&record_id).unwrap();
20640        assert_eq!(record.state, ManagedSessionState::Live);
20641        assert!(record.provider_session.is_none());
20642        assert!(record.environment_profile.is_none());
20643        assert!(record.bundle.is_none());
20644        assert!(record.context_id.is_none());
20645        assert!(record.context.is_none());
20646        assert_eq!(record.active_session.as_ref(), Some(&address));
20647        assert_eq!(
20648            shared
20649                .materializations
20650                .lock()
20651                .unwrap_or_else(|poisoned| poisoned.into_inner())
20652                .get(&materialization_id)
20653                .unwrap()
20654                .owner(),
20655            &MaterializationOwner::Session {
20656                incarnation_id: shared.incarnation_id,
20657                instance_id: address.session.instance_id,
20658                generation: address.session.generation,
20659            },
20660        );
20661        {
20662            let mut bindings = shared
20663                .session_bindings
20664                .lock()
20665                .unwrap_or_else(|poisoned| poisoned.into_inner());
20666            bindings
20667                .get_mut(&address.session.instance_id)
20668                .unwrap()
20669                .managed_worktree_lease_id = Some(lease.lease_id.clone());
20670        }
20671        let lease_snapshot = shared
20672            .managed_worktrees
20673            .lock()
20674            .unwrap_or_else(|poisoned| poisoned.into_inner())
20675            .bind_session(
20676                &lease.lease_id,
20677                ManagedWorktreeSessionHolder {
20678                    incarnation_id: shared.incarnation_id,
20679                    instance_id: address.session.instance_id,
20680                    generation: address.session.generation,
20681                },
20682                None,
20683                unix_time_ms(),
20684            )
20685            .unwrap();
20686        assert_eq!(lease_snapshot.active_session_count, 1);
20687        assert_eq!(lease_snapshot.managed_record_count, 0);
20688        shared.persist_state().unwrap();
20689        assert!(shared
20690            .managed_worktrees
20691            .lock()
20692            .unwrap_or_else(|poisoned| poisoned.into_inner())
20693            .get(&lease.lease_id)
20694            .unwrap()
20695            .record_holders
20696            .is_empty());
20697        shared
20698            .handle
20699            .dispatch(shared.prepare_command(ControlCommand::Register {
20700                instance_id: address.session.instance_id,
20701                agent_id: agent("claude"),
20702                transport: TransportKind::Pty,
20703            }))
20704            .unwrap();
20705        runtime.tick().await;
20706        shared.reconcile_managed_record(
20707            &record_id,
20708            &address,
20709            &ControlEvent {
20710                sequence: 1,
20711                command_id: None,
20712                instance_id: address.session.instance_id,
20713                generation: address.session.generation,
20714                event: ControlEventKind::Removed,
20715            },
20716            false,
20717            false,
20718        );
20719        let remove = shared.remove_session(&address);
20720        let drive_runtime = async {
20721            runtime.tick().await;
20722        };
20723        let (removed, ()) = tokio::join!(remove, drive_runtime);
20724        removed.unwrap();
20725
20726        assert!(!materialization_root.exists());
20727        assert!(!Path::new(&created.path).exists());
20728        assert_eq!(
20729            shared
20730                .managed_worktrees
20731                .lock()
20732                .unwrap_or_else(|poisoned| poisoned.into_inner())
20733                .get(&lease.lease_id)
20734                .unwrap()
20735                .state,
20736            ManagedWorktreeLeaseState::Removed,
20737        );
20738        let renamed = shared
20739            .rename_session_record(&record_id, "raw managed inventory".to_owned())
20740            .unwrap();
20741        assert_eq!(renamed.record_id, record_id);
20742        assert_eq!(renamed.display_name, "raw managed inventory");
20743        assert_eq!(renamed.state, ManagedSessionState::Unavailable);
20744        assert!(renamed.active_session.is_none());
20745
20746        drop(shared);
20747        drop(runtime);
20748        std::fs::remove_dir_all(materialization_fixture_root).unwrap();
20749        std::fs::remove_dir_all(git_root).unwrap();
20750    }
20751
20752    fn install_managed_lease(shared: &NodeShared) -> ManagedWorktreeLeaseRecord {
20753        let root = std::env::current_dir().unwrap()
20754            .join("managed-test-target")
20755            .to_string_lossy()
20756            .into_owned();
20757        let lease = ManagedWorktreeLeaseRecord {
20758            lease_id: ManagedWorktreeLeaseId::new("mw-test").unwrap(),
20759            source_workspace_id: WorkspaceId::new("primary").unwrap(),
20760            workspace_id: WorkspaceId::new("managed-test").unwrap(),
20761            profile_id: WorktreeProfileId::new("default").unwrap(),
20762            profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
20763            target_root: root.clone(),
20764            branch: "gate4agent/mw-test".to_owned(),
20765            base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
20766            expected_head: Some("0123456789abcdef0123456789abcdef01234567".to_owned()),
20767            retention: ManagedWorktreeRetention::RemoveWhenReleased,
20768            state: ManagedWorktreeLeaseState::Ready,
20769            session_holders: Vec::new(),
20770            record_holders: Vec::new(),
20771            cleanup_failure: None,
20772            created_at_unix_ms: 1,
20773            updated_at_unix_ms: 1,
20774        };
20775        shared.workspaces.write()
20776            .unwrap_or_else(|poisoned| poisoned.into_inner())
20777            .insert(lease.workspace_id.clone(), root);
20778        *shared.managed_worktrees.lock()
20779            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
20780            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
20781        lease
20782    }
20783
20784    #[cfg(feature = "fixture")]
20785    #[test]
20786    fn monitoring_fixture_managed_spawn_retains_effective_record_holder_and_converges_live() {
20787        let mut shared = terminal_test_shared();
20788        shared.fixture_semantic_hook_policy = true;
20789        let lease = install_managed_lease(&shared);
20790        let address = SessionAddress {
20791            workspace_id: lease.workspace_id.clone(),
20792            session: SessionKey {
20793                instance_id: AgentInstanceId(206),
20794                generation: SessionGeneration(1),
20795            },
20796        };
20797        let pre_admission_policy = ProviderRuntimePolicy::raw_pty();
20798        let effective_runtime_policy =
20799            shared.effective_spawn_runtime_policy(pre_admission_policy);
20800        assert_ne!(effective_runtime_policy, pre_admission_policy);
20801        assert!(effective_runtime_policy.provider_session_identity);
20802
20803        let record_id = shared
20804            .bind_spawn_session(
20805                &address,
20806                agent("claude"),
20807                SessionMode::Pty,
20808                effective_runtime_policy,
20809                SpawnRecordPolicy::Always,
20810                None,
20811            )
20812            .unwrap()
20813            .unwrap();
20814        assert_eq!(
20815            shared.record(&record_id).unwrap().state,
20816            ManagedSessionState::IdentityPending,
20817        );
20818        let (record_holder, raw_inventory_record_id) = {
20819            let mut bindings = shared
20820                .session_bindings
20821                .lock()
20822                .unwrap_or_else(|poisoned| poisoned.into_inner());
20823            let binding = bindings.get_mut(&address.session.instance_id).unwrap();
20824            binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
20825            managed_spawn_record_ownership(binding, effective_runtime_policy)
20826        };
20827        assert_eq!(record_holder.as_ref(), Some(&record_id));
20828        assert!(raw_inventory_record_id.is_none());
20829        let snapshot = shared
20830            .managed_worktrees
20831            .lock()
20832            .unwrap_or_else(|poisoned| poisoned.into_inner())
20833            .bind_session(
20834                &lease.lease_id,
20835                ManagedWorktreeSessionHolder {
20836                    incarnation_id: shared.incarnation_id,
20837                    instance_id: address.session.instance_id,
20838                    generation: address.session.generation,
20839                },
20840                record_holder,
20841                unix_time_ms(),
20842            )
20843            .unwrap();
20844        assert_eq!(snapshot.managed_record_count, 1);
20845        assert_eq!(
20846            shared
20847                .managed_worktrees
20848                .lock()
20849                .unwrap_or_else(|poisoned| poisoned.into_inner())
20850                .get(&lease.lease_id)
20851                .unwrap()
20852                .record_holders,
20853            vec![record_id.clone()],
20854        );
20855
20856        let identity = ProviderSessionIdentity {
20857            key: ProviderSessionKey::SessionId,
20858            id: "monitoring-fixture-managed-session".to_owned(),
20859            transcript_path: None,
20860        };
20861        shared.publish_control(ControlEvent {
20862            sequence: 1,
20863            command_id: None,
20864            instance_id: address.session.instance_id,
20865            generation: address.session.generation,
20866            event: ControlEventKind::ProviderEvent {
20867                sequence: 1,
20868                source: gate4agent_types::ProviderSource {
20869                    family: AdapterFamily::Hook,
20870                    binding: AdapterBinding::new(
20871                        gate4agent_types::AdapterId::new("claude-code").unwrap(),
20872                        "fixture/v1",
20873                        gate4agent_types::AdapterVerification::SyntheticFixture,
20874                    )
20875                    .unwrap(),
20876                },
20877                source_sequence: 1,
20878                event: ProviderEvent::SessionIdentityObserved {
20879                    identity: identity.clone(),
20880                },
20881            },
20882        });
20883
20884        let live = shared.record(&record_id).unwrap();
20885        assert_eq!(live.state, ManagedSessionState::Live);
20886        assert_eq!(live.provider_session.as_ref(), Some(&identity));
20887        assert_eq!(live.active_session.as_ref(), Some(&address));
20888        assert_eq!(
20889            shared
20890                .managed_worktrees
20891                .lock()
20892                .unwrap_or_else(|poisoned| poisoned.into_inner())
20893                .get(&lease.lease_id)
20894                .unwrap()
20895                .record_holders,
20896            vec![record_id],
20897        );
20898    }
20899
20900    fn managed_v2_test_shared(
20901        allocation_root: &Path,
20902        state_path: Option<PathBuf>,
20903        profile_revision: &str,
20904    ) -> NodeShared {
20905        let profile = ManagedWorktreeProfile::new(
20906            WorktreeProfileId::new("default").unwrap(),
20907            WorktreeProfileRevision::new(profile_revision).unwrap(),
20908            allocation_root,
20909            "gate4agent",
20910            "HEAD",
20911            ManagedWorktreeRetention::RemoveWhenReleased,
20912        )
20913        .unwrap();
20914        let workspace = WorkspaceConfig::new(
20915            WorkspaceId::new("primary").unwrap(),
20916            std::env::current_dir().unwrap(),
20917        )
20918        .unwrap()
20919        .with_worktree_service_mode(WorktreeServiceMode::Managed)
20920        .with_managed_worktree_profile(profile)
20921        .unwrap();
20922        let catalog = active_registry().unwrap();
20923        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20924        let mut shared = NodeShared::new(
20925            handle,
20926            "fixture-token".to_owned(),
20927            NodeId::new("node-terminal-test").unwrap(),
20928            vec![workspace],
20929            vec![agent("claude")],
20930        );
20931        shared.state_path = state_path;
20932        shared
20933    }
20934
20935    fn managed_v2_request(idempotency_key: &str) -> ManagedWorktreeSpawnRequestV2 {
20936        let mut spawn_spec = spawn_spec_fixture();
20937        spawn_spec.idempotency_key = SpawnIdempotencyKey::new(idempotency_key).unwrap();
20938        ManagedWorktreeSpawnRequestV2 {
20939            spawn_spec,
20940            worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
20941            expected_profile_revision: WorktreeProfileRevision::new("v1").unwrap(),
20942        }
20943    }
20944
20945    #[test]
20946    fn managed_spawn_receipt_rebinds_allocated_worktree_and_roundtrips_wire() {
20947        let shared = terminal_test_shared();
20948        let mut lease = install_managed_lease(&shared);
20949        let session = SessionAddress {
20950            workspace_id: lease.workspace_id.clone(),
20951            session: SessionKey {
20952                instance_id: AgentInstanceId(73),
20953                generation: SessionGeneration(4),
20954            },
20955        };
20956        lease.state = ManagedWorktreeLeaseState::InUse;
20957        lease.session_holders.push(ManagedWorktreeSessionHolder {
20958            incarnation_id: shared.incarnation_id,
20959            instance_id: session.session.instance_id,
20960            generation: session.session.generation,
20961        });
20962        let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
20963        assert!(resolved.target.worktree_id.is_none());
20964
20965        let receipt = managed_spawn_receipt(
20966            &resolved,
20967            shared.incarnation_id,
20968            session.clone(),
20969            lease.snapshot(),
20970            None,
20971            None,
20972            None,
20973        );
20974        assert!(resolved.target.worktree_id.is_none());
20975        assert_eq!(receipt.spawn.target.workspace_id, lease.source_workspace_id);
20976        assert_eq!(receipt.spawn.target.worktree_id.as_ref(), Some(&lease.workspace_id));
20977        assert_eq!(receipt.spawn.session, session);
20978        assert_eq!(receipt.lease.workspace_id, lease.workspace_id);
20979        let encoded = serde_json::to_vec(&receipt).unwrap();
20980        let decoded: ManagedWorktreeSpawnReceipt = serde_json::from_slice(&encoded).unwrap();
20981        assert_eq!(decoded, receipt);
20982    }
20983
20984    #[tokio::test]
20985    async fn managed_spawn_v2_rejects_profile_revision_before_allocation() {
20986        let root = temporary_workspace_root("managed-v2-revision");
20987        let allocation = root.join("allocation");
20988        std::fs::create_dir_all(&allocation).unwrap();
20989        let shared = managed_v2_test_shared(
20990            &allocation,
20991            Some(root.join("node-state.json")),
20992            "v1",
20993        );
20994        let mut request = managed_v2_request("managed-v2-revision");
20995        request.expected_profile_revision = WorktreeProfileRevision::new("v2").unwrap();
20996
20997        let failure = shared.spawn_managed_worktree_v2(request).await.unwrap_err();
20998
20999        assert_eq!(
21000            failure.code,
21001            NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
21002        );
21003        assert!(shared
21004            .managed_worktrees
21005            .lock()
21006            .unwrap_or_else(|poisoned| poisoned.into_inner())
21007            .records()
21008            .is_empty());
21009        assert!(shared
21010            .managed_spawn_replays
21011            .lock()
21012            .unwrap_or_else(|poisoned| poisoned.into_inner())
21013            .is_empty());
21014        std::fs::remove_dir_all(root).unwrap();
21015    }
21016
21017    #[test]
21018    fn managed_spawn_v2_concurrent_same_key_has_one_durable_reservation() {
21019        let root = temporary_workspace_root("managed-v2-concurrent");
21020        let allocation = root.join("allocation");
21021        std::fs::create_dir_all(&allocation).unwrap();
21022        let shared = Arc::new(managed_v2_test_shared(
21023            &allocation,
21024            Some(root.join("node-state.json")),
21025            "v1",
21026        ));
21027        let request = managed_v2_request("managed-v2-concurrent");
21028        let barrier = Arc::new(std::sync::Barrier::new(3));
21029        let mut joins = Vec::new();
21030        for _ in 0..2 {
21031            let shared = Arc::clone(&shared);
21032            let request = request.clone();
21033            let barrier = Arc::clone(&barrier);
21034            joins.push(std::thread::spawn(move || {
21035                let digest = managed_spawn_request_digest_v2(&request).unwrap();
21036                barrier.wait();
21037                shared.acquire_durable_managed_spawn_v2(&request, digest)
21038            }));
21039        }
21040        barrier.wait();
21041        let results = joins
21042            .into_iter()
21043            .map(|join| join.join().unwrap())
21044            .collect::<Vec<_>>();
21045        assert_eq!(
21046            results
21047                .iter()
21048                .filter(|result| {
21049                    matches!(result, Ok(DurableManagedSpawnReplayDecision::Reserved))
21050                })
21051                .count(),
21052            1,
21053        );
21054        assert_eq!(
21055            results
21056                .iter()
21057                .filter(|result| {
21058                    matches!(result, Err(error) if error.code == NodeFailureCode::BackendBusy)
21059                })
21060                .count(),
21061            1,
21062        );
21063        assert_eq!(
21064            shared
21065                .managed_spawn_replays
21066                .lock()
21067                .unwrap_or_else(|poisoned| poisoned.into_inner())
21068                .len(),
21069            1,
21070        );
21071        assert!(shared
21072            .managed_worktrees
21073            .lock()
21074            .unwrap_or_else(|poisoned| poisoned.into_inner())
21075            .records()
21076            .is_empty());
21077        drop(shared);
21078        std::fs::remove_dir_all(root).unwrap();
21079    }
21080
21081    #[tokio::test]
21082    async fn managed_spawn_v2_linked_pending_reopen_fails_closed_without_reallocation() {
21083        let root = temporary_workspace_root("managed-v2-linked-pending");
21084        let allocation = root.join("allocation");
21085        std::fs::create_dir_all(&allocation).unwrap();
21086        let state_path = root.join("node-state.json");
21087        let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
21088        let lease = install_managed_lease(&shared);
21089        let request = managed_v2_request("managed-v2-linked-pending");
21090        assert!(matches!(
21091            shared
21092                .acquire_durable_managed_spawn_v2(
21093                    &request,
21094                    managed_spawn_request_digest_v2(&request).unwrap(),
21095                )
21096                .unwrap(),
21097            DurableManagedSpawnReplayDecision::Reserved,
21098        ));
21099        {
21100            let _transaction = shared
21101                .state_transaction
21102                .lock()
21103                .unwrap_or_else(|poisoned| poisoned.into_inner());
21104            shared
21105                .link_durable_managed_spawn_v2_lease_locked(
21106                    &request.spawn_spec.idempotency_key,
21107                    &lease.lease_id,
21108                )
21109                .unwrap();
21110            shared.persist_state_locked().unwrap();
21111        }
21112        drop(shared);
21113
21114        let mut loaded = session_registry::load(
21115            Some(&state_path),
21116            &NodeId::new("node-terminal-test").unwrap(),
21117        )
21118        .unwrap();
21119        let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
21120        *reopened
21121            .managed_worktrees
21122            .lock()
21123            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21124            ManagedWorktreeRegistry::from_records(
21125                std::mem::take(&mut loaded.managed_worktrees),
21126                std::mem::take(&mut loaded.managed_worktree_tombstones),
21127            )
21128            .unwrap();
21129        *reopened
21130            .managed_spawn_replays
21131            .lock()
21132            .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
21133            .managed_spawn_replays
21134            .into_iter()
21135            .map(|record| (record.idempotency_key.clone(), record))
21136            .collect();
21137        let lease_count = reopened
21138            .managed_worktrees
21139            .lock()
21140            .unwrap_or_else(|poisoned| poisoned.into_inner())
21141            .records()
21142            .len();
21143
21144        let failure = reopened
21145            .spawn_managed_worktree_v2(request)
21146            .await
21147            .unwrap_err();
21148
21149        assert_eq!(
21150            failure.code,
21151            NodeFailureCode::ManagedWorktreeRecoveryRequired,
21152        );
21153        assert_eq!(
21154            reopened
21155                .managed_worktrees
21156                .lock()
21157                .unwrap_or_else(|poisoned| poisoned.into_inner())
21158                .records()
21159                .len(),
21160            lease_count,
21161        );
21162        std::fs::remove_dir_all(root).unwrap();
21163    }
21164
21165    #[tokio::test]
21166    async fn managed_spawn_v2_replays_exact_receipt_after_durable_reopen_and_conflicts() {
21167        let root = temporary_workspace_root("managed-v2-reopen");
21168        let allocation = root.join("allocation");
21169        std::fs::create_dir_all(&allocation).unwrap();
21170        let state_path = root.join("node-state.json");
21171        let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
21172        let lease = install_managed_lease(&shared);
21173        let request = managed_v2_request("managed-v2-reopen");
21174        let session = SessionAddress {
21175            workspace_id: lease.workspace_id.clone(),
21176            session: SessionKey {
21177                instance_id: AgentInstanceId(91),
21178                generation: SessionGeneration(1),
21179            },
21180        };
21181        let snapshot = {
21182            let mut registry = shared
21183                .managed_worktrees
21184                .lock()
21185                .unwrap_or_else(|poisoned| poisoned.into_inner());
21186            let current = registry.get_mut(&lease.lease_id).unwrap();
21187            current.state = ManagedWorktreeLeaseState::InUse;
21188            current.session_holders.push(ManagedWorktreeSessionHolder {
21189                incarnation_id: shared.incarnation_id,
21190                instance_id: session.session.instance_id,
21191                generation: session.session.generation,
21192            });
21193            current.snapshot()
21194        };
21195        let resolved = shared.resolve_spawn_spec(&request.spawn_spec).unwrap();
21196        let receipt = managed_spawn_receipt(
21197            &resolved,
21198            shared.incarnation_id,
21199            session,
21200            snapshot,
21201            None,
21202            None,
21203            None,
21204        );
21205        assert!(matches!(
21206            shared
21207                .acquire_durable_managed_spawn_v2(
21208                    &request,
21209                    managed_spawn_request_digest_v2(&request).unwrap(),
21210                )
21211                .unwrap(),
21212            DurableManagedSpawnReplayDecision::Reserved,
21213        ));
21214        {
21215            let _transaction = shared
21216                .state_transaction
21217                .lock()
21218                .unwrap_or_else(|poisoned| poisoned.into_inner());
21219            shared
21220                .link_durable_managed_spawn_v2_lease_locked(
21221                    &request.spawn_spec.idempotency_key,
21222                    &lease.lease_id,
21223                )
21224                .unwrap();
21225            shared.persist_state_locked().unwrap();
21226        }
21227        shared
21228            .commit_durable_managed_spawn_v2(&request, receipt.clone())
21229            .unwrap();
21230        let legacy_same_key = ManagedWorktreeSpawnRequest {
21231            spawn_spec: request.spawn_spec.clone(),
21232            worktree_profile_id: request.worktree_profile_id.clone(),
21233        };
21234        shared.remember_managed_spawn_attempt(
21235            Some(&request.spawn_spec.idempotency_key),
21236            legacy_same_key.clone(),
21237            Ok(receipt.clone()),
21238            Instant::now(),
21239        );
21240        assert!(shared
21241            .replay_managed_spawn(&legacy_same_key, Instant::now())
21242            .unwrap()
21243            .is_none());
21244        drop(shared);
21245
21246        let mut loaded = session_registry::load(
21247            Some(&state_path),
21248            &NodeId::new("node-terminal-test").unwrap(),
21249        )
21250        .unwrap();
21251        assert_eq!(loaded.managed_spawn_replays.len(), 1);
21252        let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
21253        *reopened
21254            .managed_worktrees
21255            .lock()
21256            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21257            ManagedWorktreeRegistry::from_records(
21258                std::mem::take(&mut loaded.managed_worktrees),
21259                std::mem::take(&mut loaded.managed_worktree_tombstones),
21260            )
21261            .unwrap();
21262        *reopened
21263            .managed_spawn_replays
21264            .lock()
21265            .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
21266            .managed_spawn_replays
21267            .into_iter()
21268            .map(|record| (record.idempotency_key.clone(), record))
21269            .collect();
21270        let lease_count_before = reopened
21271            .managed_worktrees
21272            .lock()
21273            .unwrap_or_else(|poisoned| poisoned.into_inner())
21274            .records()
21275            .len();
21276
21277        assert_eq!(
21278            reopened
21279                .spawn_managed_worktree_v2(request.clone())
21280                .await
21281                .unwrap(),
21282            receipt,
21283        );
21284        assert_eq!(
21285            reopened
21286                .managed_worktrees
21287                .lock()
21288                .unwrap_or_else(|poisoned| poisoned.into_inner())
21289                .records()
21290                .len(),
21291            lease_count_before,
21292        );
21293        let mut changed = request;
21294        changed.spawn_spec.deadline_ms = crate::protocol::SpawnDeadlineMs::new(29_000).unwrap();
21295        let failure = reopened
21296            .spawn_managed_worktree_v2(changed)
21297            .await
21298            .unwrap_err();
21299        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
21300        assert_eq!(
21301            reopened
21302                .managed_worktrees
21303                .lock()
21304                .unwrap_or_else(|poisoned| poisoned.into_inner())
21305                .records()
21306                .len(),
21307            lease_count_before,
21308        );
21309        let mut new_key = managed_v2_request("managed-v2-new-after-profile-change");
21310        new_key.expected_profile_revision = WorktreeProfileRevision::new("v1").unwrap();
21311        let failure = reopened
21312            .spawn_managed_worktree_v2(new_key)
21313            .await
21314            .unwrap_err();
21315        assert_eq!(
21316            failure.code,
21317            NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
21318        );
21319        assert_eq!(
21320            reopened
21321                .managed_worktrees
21322                .lock()
21323                .unwrap_or_else(|poisoned| poisoned.into_inner())
21324                .records()
21325                .len(),
21326            lease_count_before,
21327        );
21328        assert_eq!(
21329            reopened
21330                .managed_spawn_replays
21331                .lock()
21332                .unwrap_or_else(|poisoned| poisoned.into_inner())
21333                .len(),
21334            1,
21335        );
21336        std::fs::remove_dir_all(root).unwrap();
21337    }
21338
21339    #[test]
21340    fn legacy_managed_spawn_idempotency_remains_transient_and_exact() {
21341        let shared = terminal_test_shared();
21342        let request = ManagedWorktreeSpawnRequest {
21343            spawn_spec: spawn_spec_fixture(),
21344            worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
21345        };
21346        let expected = failure(NodeFailureCode::BackendBusy, "legacy fixture failure");
21347        shared.remember_managed_spawn(
21348            request.clone(),
21349            Err(expected.clone()),
21350            Instant::now(),
21351        );
21352
21353        assert_eq!(
21354            shared
21355                .replay_managed_spawn(&request, Instant::now())
21356                .unwrap()
21357                .unwrap(),
21358            Err(expected),
21359        );
21360        assert!(shared
21361            .managed_spawn_replays
21362            .lock()
21363            .unwrap_or_else(|poisoned| poisoned.into_inner())
21364            .is_empty());
21365    }
21366
21367    #[tokio::test]
21368    async fn ordinary_spawn_and_spawn_spec_cannot_bypass_managed_lease_holder_fence() {
21369        let shared = terminal_test_shared();
21370        let lease = install_managed_lease(&shared);
21371        let raw = shared.spawn_session(
21372            lease.workspace_id.clone(),
21373            agent("claude"),
21374            SessionMode::Pty,
21375            gate4agent_types::TerminalSize { rows: 24, columns: 80 },
21376            None,
21377        ).await.unwrap_err();
21378        assert_eq!(raw.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
21379        let mut spec = spawn_spec_fixture();
21380        spec.target.workspace_id = lease.workspace_id.clone();
21381        let specified = shared.spawn_from_spec(spec).await.unwrap_err();
21382        assert_eq!(specified.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
21383        let mut selected = spawn_spec_fixture();
21384        selected.idempotency_key = SpawnIdempotencyKey::new("managed-selected-bypass").unwrap();
21385        selected.target.worktree_id = Some(lease.workspace_id);
21386        let selected = shared.spawn_from_spec(selected).await.unwrap_err();
21387        assert_eq!(selected.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
21388        assert!(shared.handle.snapshot().sessions.is_empty());
21389    }
21390
21391    #[test]
21392    fn legacy_projection_omits_managed_snapshot_events_and_path_bearing_diagnostics() {
21393        let shared = terminal_test_shared();
21394        let lease = install_managed_lease(&shared);
21395        let snapshot = snapshot_for_wire(
21396            &shared, true, true, true, false, true, true, false, false, true,
21397        );
21398        assert!(snapshot.managed_worktrees.is_empty());
21399        assert!(snapshot.workspaces.iter().all(|workspace| {
21400            workspace.managed_worktree_profiles.is_none()
21401        }));
21402        let mut reply = ResponseEnvelope {
21403            request_id: 1,
21404            result: Ok(NodeResponse::Resync {
21405                event_sequence: 1,
21406                oldest_available_sequence: 1,
21407                snapshot: shared.snapshot(),
21408                events: vec![NodeEventEnvelope {
21409                    sequence: 1,
21410                    event: NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() },
21411                }],
21412            }),
21413        };
21414        project_response_without_managed_worktrees(&mut reply);
21415        let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
21416            panic!("projection changed the response kind");
21417        };
21418        assert!(snapshot.managed_worktrees.is_empty());
21419        assert!(events.is_empty());
21420        let mut git = git_snapshot_for_parser();
21421        git.branch = Some(lease.branch.clone());
21422        git.managed_worktree = Some(crate::protocol::ManagedWorktreeGitScope {
21423            lease_id: lease.lease_id.clone(),
21424            source_workspace_id: lease.source_workspace_id.clone(),
21425            branch: lease.branch.clone(),
21426            base_commit: GitObjectId::new(lease.base_commit.clone()).unwrap(),
21427            active_session_count: 1,
21428            managed_record_count: 0,
21429        });
21430        let mut inspection_reply = ResponseEnvelope {
21431            request_id: 2,
21432            result: Ok(NodeResponse::WorkspaceInspected {
21433                inspection: WorkspaceInspection {
21434                    workspace_id: lease.workspace_id.clone(),
21435                    entries: Vec::new(),
21436                    tree_truncated: false,
21437                    git,
21438                    truncation: None,
21439                },
21440            }),
21441        };
21442        project_response_without_managed_worktrees(&mut inspection_reply);
21443        let Ok(NodeResponse::WorkspaceInspected { inspection }) = inspection_reply.result else {
21444            panic!("projection changed the inspection response kind");
21445        };
21446        assert!(inspection.git.managed_worktree.is_none());
21447        let failure = managed_git_worktree_failure(GitWorktreeError {
21448            kind: GitWorktreeErrorKind::Failed,
21449            message: r"git failed at C:\private\managed-target".to_owned(),
21450        });
21451        assert!(!failure.message.contains("private"));
21452        assert!(!failure.message.contains("managed-target"));
21453    }
21454
21455    #[tokio::test]
21456    async fn restart_reconciliation_retains_exact_owned_worktree_and_explicit_cleanup_removes_it() {
21457        let root = temporary_workspace_root("managed-restart");
21458        let source = root.join("source");
21459        let allocation = root.join("allocation");
21460        std::fs::create_dir_all(&source).unwrap();
21461        std::fs::create_dir_all(&allocation).unwrap();
21462        run_test_git(&source, &["init"]);
21463        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
21464        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
21465        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
21466        run_test_git(&source, &["add", "seed.txt"]);
21467        run_test_git(&source, &["commit", "-m", "seed"]);
21468        let profile = ManagedWorktreeProfile::new(
21469            WorktreeProfileId::new("default").unwrap(),
21470            crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
21471            &allocation,
21472            "gate4agent",
21473            "HEAD",
21474            ManagedWorktreeRetention::Retain,
21475        ).unwrap();
21476        let workspace = WorkspaceConfig::new(
21477            WorkspaceId::new("primary").unwrap(),
21478            &source,
21479        ).unwrap()
21480            .with_worktree_service_mode(WorktreeServiceMode::Managed)
21481            .with_managed_worktree_profile(profile.clone()).unwrap();
21482        let target = PathBuf::from(profile.allocation_root()).join("mw-restart");
21483        let source_text = workspace.canonical_root().to_owned();
21484        let target_text = target.to_string_lossy().into_owned();
21485        let created = create_git_worktree(
21486            &source_text,
21487            &target_text,
21488            "gate4agent/mw-restart",
21489            Some("HEAD"),
21490        ).await.unwrap();
21491        let lease = ManagedWorktreeLeaseRecord {
21492            lease_id: ManagedWorktreeLeaseId::new("mw-restart").unwrap(),
21493            source_workspace_id: WorkspaceId::new("primary").unwrap(),
21494            workspace_id: WorkspaceId::new("managed-restart").unwrap(),
21495            profile_id: profile.profile_id().clone(),
21496            profile_revision: profile.revision().clone(),
21497            target_root: created.path.clone(),
21498            branch: "gate4agent/mw-restart".to_owned(),
21499            base_commit: created.head.clone(),
21500            expected_head: None,
21501            retention: ManagedWorktreeRetention::Retain,
21502            state: ManagedWorktreeLeaseState::Allocating,
21503            session_holders: Vec::new(),
21504            record_holders: Vec::new(),
21505            cleanup_failure: None,
21506            created_at_unix_ms: 1,
21507            updated_at_unix_ms: 1,
21508        };
21509        let catalog = active_registry().unwrap();
21510        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
21511        let shared = NodeShared::new_with_incarnation(
21512            handle,
21513            "fixture-token".to_owned(),
21514            NodeId::new("node-managed-restart").unwrap(),
21515            NodeIncarnationId::from_bytes([7; crate::protocol::NODE_INCARNATION_ID_BYTES]),
21516            vec![workspace],
21517            vec![agent("claude")],
21518            ProviderRuntimeStatuses::default(),
21519            None,
21520            Vec::new(),
21521            Vec::new(),
21522            SpawnProfileRegistry::default(),
21523            None,
21524            None,
21525            None,
21526            None,
21527            Vec::new(),
21528            vec![lease.clone()],
21529            Vec::new(),
21530            Vec::new(),
21531            None,
21532            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
21533        );
21534        shared.reconcile_managed_worktrees().await;
21535        let retained = shared.managed_worktrees.lock()
21536            .unwrap_or_else(|poisoned| poisoned.into_inner())
21537            .get(&lease.lease_id).unwrap().clone();
21538        assert_eq!(retained.state, ManagedWorktreeLeaseState::Retained);
21539        assert!(shared.workspaces.read()
21540            .unwrap_or_else(|poisoned| poisoned.into_inner())
21541            .contains_key(&lease.workspace_id));
21542        let removed = shared.cleanup_managed_worktree(&lease.lease_id, true).await.unwrap();
21543        assert_eq!(removed.state, ManagedWorktreeLeaseState::Removed);
21544        assert!(!Path::new(&created.path).exists());
21545        std::fs::remove_dir_all(root).unwrap();
21546    }
21547
21548    fn run_test_git(root: &Path, arguments: &[&str]) {
21549        let output = std::process::Command::new("git")
21550            .arg("-C").arg(root).args(arguments).output().unwrap();
21551        assert!(
21552            output.status.success(),
21553            "git {:?} failed: {}",
21554            arguments,
21555            String::from_utf8_lossy(&output.stderr),
21556        );
21557    }
21558
21559    #[tokio::test]
21560    async fn spawn_spec_validation_fails_before_session_mutation() {
21561        let shared = terminal_test_shared();
21562        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21563            capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
21564        }));
21565        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21566            capability.as_str() == NODE_SPAWN_PROFILE_REVISION_CAPABILITY
21567        }));
21568        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21569            capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
21570        }));
21571
21572        let mut target_mismatch = spawn_spec_fixture();
21573        target_mismatch.target.node_id = NodeId::new("other-node").unwrap();
21574        let failure = shared.spawn_from_spec(target_mismatch).await.unwrap_err();
21575        assert_eq!(failure.code, NodeFailureCode::SpawnTargetMismatch);
21576
21577        let mut unknown_profile = spawn_spec_fixture();
21578        unknown_profile.idempotency_key = SpawnIdempotencyKey::new("unknown-profile").unwrap();
21579        unknown_profile.profile_id = crate::protocol::SpawnProfileId::new("missing").unwrap();
21580        let failure = shared.spawn_from_spec(unknown_profile).await.unwrap_err();
21581        assert_eq!(failure.code, NodeFailureCode::UnknownSpawnProfile);
21582
21583        let mut revision_mismatch = spawn_spec_fixture();
21584        revision_mismatch.idempotency_key =
21585            SpawnIdempotencyKey::new("profile-revision-mismatch").unwrap();
21586        revision_mismatch.expected_profile_revision =
21587            crate::protocol::SpawnProfileRevision::new("test-r2").unwrap();
21588        let failure = shared.spawn_from_spec(revision_mismatch).await.unwrap_err();
21589        assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
21590
21591        let mut unsupported_materializer = spawn_spec_fixture();
21592        unsupported_materializer.idempotency_key =
21593            SpawnIdempotencyKey::new("unsupported-materializer").unwrap();
21594        unsupported_materializer.overrides.bundle_id = crate::protocol::SpawnOverride::Set {
21595            value: crate::protocol::SpawnBundleId::new("bundle-v1").unwrap(),
21596        };
21597        let failure = shared
21598            .spawn_from_spec(unsupported_materializer)
21599            .await
21600            .unwrap_err();
21601        assert_eq!(failure.code, NodeFailureCode::UnknownBundle);
21602
21603        let failure = shared
21604            .spawn_session_with_deadline(
21605                WorkspaceId::new("primary").unwrap(),
21606                agent("claude"),
21607                SessionMode::Pty,
21608                gate4agent_types::TerminalSize {
21609                    rows: 24,
21610                    columns: 80,
21611                },
21612                None,
21613                None,
21614                None,
21615                None,
21616                None,
21617                None,
21618                SpawnRecordPolicy::ProviderIdentityOnly,
21619                Some(Instant::now()),
21620                &[],
21621                None,
21622                ApprovalLevel::default(),
21623                None,
21624                None,
21625            )
21626            .await
21627            .unwrap_err();
21628        assert_eq!(failure.code, NodeFailureCode::SpawnDeadlineExceeded);
21629        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
21630        assert!(shared.handle.snapshot().sessions.is_empty());
21631    }
21632
21633    #[test]
21634    fn worktree_selection_requires_its_separate_negotiated_capability() {
21635        let mut spec = spawn_spec_fixture();
21636        spec.target.worktree_id = Some(WorkspaceId::new("selected-worktree").unwrap());
21637        let request = NodeRequest::SpawnSpec { spec };
21638        let spawn_spec_capability =
21639            CapabilityId::new(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY).unwrap();
21640        assert!(request_uses_unnegotiated_capability(
21641            &request,
21642            &[spawn_spec_capability.clone()],
21643        ));
21644        let worktree_capability =
21645            CapabilityId::new(NODE_WORKTREE_SELECTION_CAPABILITY).unwrap();
21646        assert!(request_uses_unnegotiated_capability(
21647            &request,
21648            &[spawn_spec_capability.clone(), worktree_capability.clone()],
21649        ));
21650        let profile_revision_capability =
21651            CapabilityId::new(NODE_SPAWN_PROFILE_REVISION_CAPABILITY).unwrap();
21652        assert!(request_uses_unnegotiated_capability(
21653            &request,
21654            &[
21655                spawn_spec_capability.clone(),
21656                worktree_capability.clone(),
21657                profile_revision_capability.clone(),
21658            ],
21659        ));
21660        // Default `SpawnOverrides` leaves `bundle_id`/`context_id` at
21661        // `Inherit`, not `Clear` — the profile's own defaults might still
21662        // materialize a bundle or a context pack, so both capabilities stay
21663        // conservatively required until negotiated (see
21664        // `requires_session_bundle_materialization_capability` /
21665        // `requires_history_context_pack_capability`).
21666        let bundle_materialization_capability =
21667            CapabilityId::new(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY).unwrap();
21668        assert!(request_uses_unnegotiated_capability(
21669            &request,
21670            &[
21671                spawn_spec_capability.clone(),
21672                worktree_capability.clone(),
21673                profile_revision_capability.clone(),
21674                bundle_materialization_capability.clone(),
21675            ],
21676        ));
21677        let history_context_pack_capability =
21678            CapabilityId::new(NODE_HISTORY_CONTEXT_PACK_CAPABILITY).unwrap();
21679        assert!(!request_uses_unnegotiated_capability(
21680            &request,
21681            &[
21682                spawn_spec_capability,
21683                worktree_capability,
21684                profile_revision_capability,
21685                bundle_materialization_capability,
21686                history_context_pack_capability,
21687            ],
21688        ));
21689    }
21690
21691    #[tokio::test]
21692    async fn spawn_spec_idempotency_replays_receipt_and_conflicts_before_session_mutation() {
21693        let mut shared = terminal_test_shared();
21694        let spec = spawn_spec_fixture();
21695        let receipt = shared
21696            .resolve_spawn_spec(&spec)
21697            .unwrap()
21698            .receipt(shared.incarnation_id, terminal_address(1));
21699        shared.remember_spawn_spec(spec.clone(), Ok(receipt.clone()), Instant::now());
21700
21701        let mut revised = shared.spawn_profiles.get(&spec.profile_id).unwrap().clone();
21702        revised.revision = crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
21703        shared.spawn_profiles = SpawnProfileRegistry::new([revised]).unwrap();
21704
21705        assert_eq!(shared.spawn_from_spec(spec.clone()).await.unwrap(), receipt);
21706
21707        let mut revision_conflicting = spec.clone();
21708        revision_conflicting.expected_profile_revision =
21709            crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
21710        let failure = shared.spawn_from_spec(revision_conflicting).await.unwrap_err();
21711        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
21712
21713        let mut stale_cache_miss = spec.clone();
21714        stale_cache_miss.idempotency_key =
21715            SpawnIdempotencyKey::new("stale-profile-cache-miss").unwrap();
21716        let failure = shared.spawn_from_spec(stale_cache_miss).await.unwrap_err();
21717        assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
21718
21719        let mut conflicting = spec;
21720        conflicting.deadline_ms = crate::protocol::SpawnDeadlineMs::new(31_000).unwrap();
21721        let failure = shared.spawn_from_spec(conflicting).await.unwrap_err();
21722        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
21723        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
21724        assert!(shared.handle.snapshot().sessions.is_empty());
21725    }
21726
21727    #[tokio::test]
21728    async fn off_worktree_service_rejects_selection_and_mutation_before_git() {
21729        let shared = terminal_test_shared_with_mode(WorktreeServiceMode::Off);
21730        let mut spec = spawn_spec_fixture();
21731        spec.target.worktree_id = Some(WorkspaceId::new("unregistered-worktree").unwrap());
21732        let failure = shared.spawn_from_spec(spec).await.unwrap_err();
21733        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
21734
21735        let failure = shared
21736            .create_worktree(
21737                WorkspaceId::new("primary").unwrap(),
21738                WorkspaceId::new("new-worktree").unwrap(),
21739                "not-an-absolute-path".to_owned(),
21740                "invalid branch".to_owned(),
21741                None,
21742            )
21743            .await
21744            .unwrap_err();
21745        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
21746
21747        let failure = shared
21748            .remove_worktree(
21749                WorkspaceId::new("primary").unwrap(),
21750                "not-an-absolute-path".to_owned(),
21751            )
21752            .await
21753            .unwrap_err();
21754        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
21755        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
21756        assert!(shared.handle.snapshot().sessions.is_empty());
21757    }
21758
21759    #[tokio::test]
21760    async fn manual_worktree_registration_failure_removes_the_created_worktree() {
21761        let root = temporary_workspace_root("manual-worktree-compensation");
21762        let source = root.join("source");
21763        let target = root.join("target");
21764        let invalid_state_parent = root.join("state-parent-is-a-file");
21765        let invalid_state_path = invalid_state_parent.join("state.json");
21766        std::fs::create_dir_all(&source).unwrap();
21767        std::fs::write(&invalid_state_parent, "not a directory").unwrap();
21768        run_test_git(&source, &["init"]);
21769        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
21770        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
21771        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
21772        run_test_git(&source, &["add", "seed.txt"]);
21773        run_test_git(&source, &["commit", "-m", "seed"]);
21774
21775        let catalog = active_registry().unwrap();
21776        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
21777        let workspace = WorkspaceConfig::new(
21778            WorkspaceId::new("primary").unwrap(),
21779            &source,
21780        ).unwrap();
21781        let mut shared = NodeShared::new(
21782            handle,
21783            "fixture-token".to_owned(),
21784            NodeId::new("node-worktree-compensation").unwrap(),
21785            vec![workspace],
21786            vec![agent("claude")],
21787        );
21788        shared.state_path = Some(invalid_state_path);
21789
21790        let error = shared.create_worktree(
21791            WorkspaceId::new("primary").unwrap(),
21792            WorkspaceId::new("compensated").unwrap(),
21793            target.to_string_lossy().into_owned(),
21794            "gate4agent/compensated".to_owned(),
21795            Some("HEAD".to_owned()),
21796        ).await.unwrap_err();
21797
21798        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21799        assert!(!target.exists());
21800        assert!(list_git_worktrees(source.to_str().unwrap()).await.unwrap()
21801            .iter()
21802            .all(|worktree| !worktree_paths_equal(&worktree.path, &target.to_string_lossy())));
21803        std::fs::remove_dir_all(root).unwrap();
21804    }
21805
21806    #[test]
21807    fn selected_worktree_requires_authoritative_non_main_non_bare_entry() {
21808        fn worktree(path: &str, is_main: bool, is_bare: bool) -> NativeGitWorktreeSnapshot {
21809            NativeGitWorktreeSnapshot {
21810                path: path.to_owned(),
21811                head: "0123456789abcdef".to_owned(),
21812                branch: Some("refs/heads/test".to_owned()),
21813                is_bare,
21814                is_main,
21815                locked: false,
21816                lock_reason: None,
21817                prunable: false,
21818                prunable_reason: None,
21819                workspace_id: None,
21820            }
21821        }
21822
21823        let source = r"C:\repo";
21824        let selected = r"C:\repo-worktree";
21825        let listed = vec![
21826            worktree(source, true, false),
21827            worktree(selected, false, false),
21828        ];
21829        assert!(validate_selected_worktree(source, selected, &listed).is_ok());
21830
21831        let main_failure = validate_selected_worktree(source, source, &listed).unwrap_err();
21832        assert_eq!(main_failure.code, NodeFailureCode::WorktreeProtected);
21833
21834        let bare = vec![
21835            worktree(source, true, false),
21836            worktree(selected, false, true),
21837        ];
21838        let bare_failure = validate_selected_worktree(source, selected, &bare).unwrap_err();
21839        assert_eq!(bare_failure.code, NodeFailureCode::WorktreeProtected);
21840
21841        let foreign_failure =
21842            validate_selected_worktree(source, r"C:\foreign", &listed).unwrap_err();
21843        assert_eq!(foreign_failure.code, NodeFailureCode::WorktreeProtected);
21844    }
21845
21846    fn terminal_frame(sequence: u64) -> TerminalFrame {
21847        TerminalFrame {
21848            sequence,
21849            size: gate4agent_types::TerminalSize {
21850                rows: 24,
21851                columns: 80,
21852            },
21853            cursor_row: 2,
21854            cursor_column: 3,
21855            contents: format!("frame-{sequence}"),
21856            formatted: vec![1, 2, 3],
21857            scrollback_formatted: Vec::new(),
21858            alternate_screen: false,
21859            mouse_protocol_enabled: false,
21860            mouse_protocol_encoding: Default::default(),
21861            produced_at_unix_ms: 0,
21862            screen_state: gate4agent_types::PtyScreenState::default(),
21863            bracketed_paste: None,
21864        }
21865    }
21866
21867    #[test]
21868    fn terminal_frame_watermark_coalesces_and_resets_for_exact_session_address() {
21869        let shared = terminal_test_shared();
21870        let mut receiver = shared.terminal_event_tx.subscribe();
21871        let original = terminal_address(1);
21872
21873        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
21874        let first = receiver.try_recv().unwrap();
21875        assert_eq!(first.len(), 1);
21876        assert!(matches!(
21877            &first[0].event,
21878            NodeEvent::TerminalFrame { address, frame }
21879                if address == &original && frame.sequence == 7
21880        ));
21881
21882        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
21883        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(6))]);
21884        assert!(matches!(
21885            receiver.try_recv(),
21886            Err(broadcast::error::TryRecvError::Empty)
21887        ));
21888
21889        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(8))]);
21890        let advanced = receiver.try_recv().unwrap();
21891        assert_eq!(advanced[0].sequence, 2);
21892
21893        let resumed = terminal_address(2);
21894        shared.publish_terminal_frame_candidates(vec![(resumed.clone(), terminal_frame(1))]);
21895        let reset = receiver.try_recv().unwrap();
21896        assert_eq!(reset[0].sequence, 3);
21897        assert!(matches!(
21898            &reset[0].event,
21899            NodeEvent::TerminalFrame { address, frame }
21900                if address == &resumed && frame.sequence == 1
21901        ));
21902        shared.clear_terminal_frame_watermark(&resumed);
21903        assert!(!shared
21904            .terminal_frame_watermarks
21905            .lock()
21906            .unwrap_or_else(|poisoned| poisoned.into_inner())
21907            .contains_key(&resumed.session.instance_id));
21908    }
21909
21910    #[test]
21911    fn terminal_frame_batches_replace_without_entering_node_event_history() {
21912        let shared = terminal_test_shared();
21913        let mut receiver = shared.terminal_event_tx.subscribe();
21914        let address = terminal_address(1);
21915        let count = NODE_EVENT_HISTORY_MAX as u64 + 17;
21916
21917        for sequence in 1..=count {
21918            shared.publish_terminal_frame_candidates(vec![(
21919                address.clone(),
21920                terminal_frame(sequence),
21921            )]);
21922        }
21923
21924        let history = shared
21925            .history
21926            .lock()
21927            .unwrap_or_else(|poisoned| poisoned.into_inner());
21928        assert!(history.events.is_empty());
21929        assert_eq!(history.last_sequence, count);
21930        assert_eq!(history.replay_floor_sequence, 1);
21931        drop(history);
21932        let NodeResponse::Resync {
21933            event_sequence,
21934            oldest_available_sequence,
21935            events,
21936            ..
21937        } = shared.resync(0) else {
21938            unreachable!("resync helper returned another response")
21939        };
21940        assert_eq!(event_sequence, count);
21941        assert_eq!(oldest_available_sequence, 1);
21942        assert!(events.is_empty());
21943        assert!(matches!(
21944            resync_required_event(&shared).event,
21945            NodeEvent::ResyncRequired {
21946                oldest_available_sequence: 1,
21947            }
21948        ));
21949        assert!(matches!(
21950            receiver.try_recv(),
21951            Err(broadcast::error::TryRecvError::Lagged(skipped)) if skipped == count - 1
21952        ));
21953        let latest = receiver.try_recv().unwrap();
21954        assert_eq!(latest.len(), 1);
21955        assert_eq!(latest[0].sequence, count);
21956    }
21957
21958    #[test]
21959    fn real_durable_eviction_advances_authoritative_replay_floor() {
21960        let shared = terminal_test_shared();
21961        for _ in 0..=NODE_EVENT_HISTORY_MAX {
21962            shared.publish(NodeEvent::ControllerChanged { controller: None });
21963        }
21964
21965        let NodeResponse::Resync {
21966            event_sequence,
21967            oldest_available_sequence,
21968            events,
21969            ..
21970        } = shared.resync(0) else {
21971            unreachable!("resync helper returned another response")
21972        };
21973        assert_eq!(event_sequence, NODE_EVENT_HISTORY_MAX as u64 + 1);
21974        assert_eq!(oldest_available_sequence, 2);
21975        assert_eq!(events.first().map(|event| event.sequence), Some(2));
21976        assert_eq!(events.len(), NODE_EVENT_HISTORY_MAX);
21977        assert!(matches!(
21978            resync_required_event(&shared).event,
21979            NodeEvent::ResyncRequired {
21980                oldest_available_sequence: 2,
21981            }
21982        ));
21983    }
21984
21985    #[test]
21986    fn legacy_terminal_frame_connection_has_no_live_subscription() {
21987        let shared = terminal_test_shared();
21988        let without_capability = terminal_event_subscription(&shared, false);
21989        let with_capability = terminal_event_subscription(&shared, true);
21990        assert!(without_capability.is_none());
21991        assert!(with_capability.is_some());
21992        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21993            capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
21994        }));
21995    }
21996
21997    #[test]
21998    fn continuous_terminal_output_leaves_request_service_after_bounded_event_burst() {
21999        let mut pending = Vec::new();
22000        let mut event_sequence = 0_u64;
22001        for frame_sequence in 1..=4 {
22002            for instance in 1..=(NODE_CONNECTION_EVENT_BURST_MAX as u64 * 2) {
22003                event_sequence += 1;
22004                let mut address = terminal_address(1);
22005                address.session.instance_id = AgentInstanceId(instance);
22006                queue_connection_event(
22007                    &mut pending,
22008                    NodeEventEnvelope {
22009                        sequence: event_sequence,
22010                        event: NodeEvent::TerminalFrame {
22011                            address,
22012                            frame: terminal_frame(frame_sequence),
22013                        },
22014                    },
22015                    0,
22016                );
22017            }
22018        }
22019        assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX * 2);
22020        pending.sort_unstable_by_key(|event| event.sequence);
22021
22022        let mut uninterrupted = pending.clone();
22023        let first_burst = connection_event_burst_len(uninterrupted.len());
22024        uninterrupted.drain(..first_burst);
22025        let second_burst = connection_event_burst_len(uninterrupted.len());
22026        uninterrupted.drain(..second_burst);
22027        assert!(uninterrupted.is_empty());
22028
22029        let (frame_tx, mut frame_rx) =
22030            mpsc::channel::<Result<ClientFrame, FrameError>>(1);
22031        frame_tx
22032            .try_send(Ok(ClientFrame::Request(RequestEnvelope {
22033                request_id: 77,
22034                request: NodeRequest::Snapshot,
22035            })))
22036            .unwrap();
22037        let burst_len = connection_event_burst_len(pending.len());
22038        assert_eq!(burst_len, NODE_CONNECTION_EVENT_BURST_MAX);
22039        pending.drain(..burst_len);
22040        assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX);
22041        assert!(matches!(
22042            frame_rx.try_recv(),
22043            Ok(Ok(ClientFrame::Request(RequestEnvelope {
22044                request_id: 77,
22045                request: NodeRequest::Snapshot,
22046            })))
22047        ));
22048    }
22049
22050    #[test]
22051    fn terminal_lag_watermark_discards_retained_and_replayed_batches() {
22052        let mut pending = Vec::new();
22053        for sequence in [7_u64, 8] {
22054            let mut address = terminal_address(1);
22055            address.session.instance_id = AgentInstanceId(sequence);
22056            queue_connection_event(
22057                &mut pending,
22058                NodeEventEnvelope {
22059                    sequence,
22060                    event: NodeEvent::TerminalFrame {
22061                        address,
22062                        frame: terminal_frame(sequence),
22063                    },
22064                },
22065                0,
22066            );
22067        }
22068        let mut discard_through = 0;
22069        apply_connection_resync_watermark(&mut pending, &mut discard_through, 8);
22070        assert!(pending.is_empty());
22071
22072        let replayed = NodeEventEnvelope {
22073            sequence: 8,
22074            event: NodeEvent::TerminalFrame {
22075                address: terminal_address(1),
22076                frame: terminal_frame(8),
22077            },
22078        };
22079        let advanced = NodeEventEnvelope {
22080            sequence: 9,
22081            event: NodeEvent::TerminalFrame {
22082                address: terminal_address(2),
22083                frame: terminal_frame(1),
22084            },
22085        };
22086        queue_connection_event_batch(
22087            &mut pending,
22088            &[replayed, advanced.clone()],
22089            discard_through,
22090        );
22091        assert_eq!(pending, vec![advanced]);
22092    }
22093
22094    fn record(provider: &str, record_id: &str) -> ManagedSessionRecord {
22095        ManagedSessionRecord {
22096            record_id: SessionRecordId::new(record_id).unwrap(),
22097            display_name: format!("{provider} record"),
22098            provider: agent(provider),
22099            mode: SessionMode::Pty,
22100            state: ManagedSessionState::Unavailable,
22101            workspace_id: WorkspaceId::new("primary").unwrap(),
22102            canonical_root: opaque_windows_path(
22103                std::env::current_dir().unwrap().to_string_lossy().into_owned(),
22104            ),
22105            provider_session: None,
22106            active_session: None,
22107            environment_profile: None,
22108            bundle: None,
22109            context_id: None,
22110            context: None,
22111            exported_context: None,
22112            task_binding: None,
22113            created_at_unix_ms: 1,
22114            updated_at_unix_ms: 1,
22115            last_error: None,
22116        }
22117    }
22118
22119    fn retention_test_record(
22120        workspace_id: &str,
22121        record_id: &str,
22122        state: ManagedSessionState,
22123        updated_at_unix_ms: u64,
22124    ) -> ManagedSessionRecord {
22125        ManagedSessionRecord {
22126            record_id: SessionRecordId::new(record_id).unwrap(),
22127            display_name: format!("{record_id} display"),
22128            provider: agent("claude"),
22129            mode: SessionMode::Pty,
22130            state,
22131            workspace_id: WorkspaceId::new(workspace_id).unwrap(),
22132            canonical_root: opaque_windows_path(
22133                std::env::current_dir().unwrap().to_string_lossy().into_owned(),
22134            ),
22135            provider_session: None,
22136            active_session: None,
22137            environment_profile: None,
22138            bundle: None,
22139            context_id: None,
22140            context: None,
22141            exported_context: None,
22142            task_binding: None,
22143            created_at_unix_ms: updated_at_unix_ms,
22144            updated_at_unix_ms,
22145            last_error: None,
22146        }
22147    }
22148
22149    /// Two workspaces of three `Unavailable` records each, staggered by
22150    /// age, plus one never-touchable decoy per non-`Unavailable` state in
22151    /// `workspace-a` (all as old as that workspace's oldest real record --
22152    /// if the sweep's state gate ever leaked, these would be the first
22153    /// thing it wrongly retired). Ages, oldest to newest:
22154    /// `workspace-a`: a1 (500s), a2 (300s), a3 (100s).
22155    /// `workspace-b`: b1 (400s), b2 (200s), b3 (50s).
22156    fn retention_fixture_records(now: u64) -> Vec<ManagedSessionRecord> {
22157        vec![
22158            retention_test_record("workspace-a", "a1", ManagedSessionState::Unavailable, now - 500_000),
22159            retention_test_record("workspace-a", "a2", ManagedSessionState::Unavailable, now - 300_000),
22160            retention_test_record("workspace-a", "a3", ManagedSessionState::Unavailable, now - 100_000),
22161            retention_test_record("workspace-b", "b1", ManagedSessionState::Unavailable, now - 400_000),
22162            retention_test_record("workspace-b", "b2", ManagedSessionState::Unavailable, now - 200_000),
22163            retention_test_record("workspace-b", "b3", ManagedSessionState::Unavailable, now - 50_000),
22164            retention_test_record("workspace-a", "decoy-live", ManagedSessionState::Live, now - 500_000),
22165            retention_test_record(
22166                "workspace-a",
22167                "decoy-pending",
22168                ManagedSessionState::IdentityPending,
22169                now - 500_000,
22170            ),
22171            retention_test_record(
22172                "workspace-a",
22173                "decoy-dormant",
22174                ManagedSessionState::Dormant,
22175                now - 500_000,
22176            ),
22177        ]
22178    }
22179
22180    fn retention_fixture_shared() -> (NodeShared, NativeRuntime, PathBuf, u64) {
22181        let root = temporary_workspace_root("session-record-retention");
22182        std::fs::create_dir_all(&root).unwrap();
22183        let catalog = active_registry().unwrap();
22184        let (handle, runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22185        let workspace_a =
22186            WorkspaceConfig::new(WorkspaceId::new("workspace-a").unwrap(), &root).unwrap();
22187        let workspace_b =
22188            WorkspaceConfig::new(WorkspaceId::new("workspace-b").unwrap(), &root).unwrap();
22189        let mut shared = NodeShared::new(
22190            handle,
22191            "fixture-token".to_owned(),
22192            NodeId::new("node-retention-test").unwrap(),
22193            vec![workspace_a, workspace_b],
22194            vec![agent("claude")],
22195        );
22196        shared.state_path = Some(root.join("node-state-v10.json"));
22197        let now = unix_time_ms();
22198        for record in retention_fixture_records(now) {
22199            shared.insert_record(record).unwrap();
22200        }
22201        shared
22202            .persist_state_locked()
22203            .expect("fixture-only, empty-history persist against a fresh temp state path");
22204        (shared, runtime, root, now)
22205    }
22206
22207    fn retention_removed_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
22208        shared
22209            .history
22210            .lock()
22211            .unwrap_or_else(|poisoned| poisoned.into_inner())
22212            .events
22213            .iter()
22214            .filter_map(|envelope| match &envelope.event {
22215                NodeEvent::SessionRecordRemoved { record_id } => {
22216                    Some(record_id.as_str().to_owned())
22217                }
22218                _ => None,
22219            })
22220            .collect()
22221    }
22222
22223    fn retention_remaining_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
22224        shared
22225            .session_records
22226            .lock()
22227            .unwrap_or_else(|poisoned| poisoned.into_inner())
22228            .records
22229            .keys()
22230            .map(|record_id| record_id.as_str().to_owned())
22231            .collect()
22232    }
22233
22234    fn owned(values: &[&str]) -> std::collections::BTreeSet<String> {
22235        values.iter().map(|value| (*value).to_owned()).collect()
22236    }
22237
22238    #[tokio::test]
22239    async fn session_record_retention_keep_n_only_keeps_the_newest_per_workspace() {
22240        let (mut shared, runtime, root, _now) = retention_fixture_shared();
22241        shared.session_record_retention = SessionRecordRetentionConfig {
22242            age_ms: 0,
22243            keep_per_workspace: 1,
22244        };
22245        shared.retire_unavailable_session_records().await;
22246
22247        assert_eq!(
22248            retention_remaining_record_ids(&shared),
22249            owned(&["a3", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
22250        );
22251        assert_eq!(
22252            retention_removed_record_ids(&shared),
22253            owned(&["a1", "a2", "b1", "b2"]),
22254        );
22255        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 4);
22256
22257        drop(shared);
22258        drop(runtime);
22259        std::fs::remove_dir_all(root).unwrap();
22260    }
22261
22262    /// Age-only would retire {a1, a2, b1}; keep-N-only would retire
22263    /// {a1, a2, b1, b2}. With both flags set the sweep must retire exactly
22264    /// the intersection {a1, a2, b1} -- `b2` fails the age test (it is only
22265    /// 200s old against a 250s threshold) even though it fails the keep-N
22266    /// test too, proving retirement needs BOTH, not either.
22267    #[tokio::test]
22268    async fn session_record_retention_both_set_retires_only_the_intersection() {
22269        let (mut shared, runtime, root, _now) = retention_fixture_shared();
22270        shared.session_record_retention = SessionRecordRetentionConfig {
22271            age_ms: 250_000,
22272            keep_per_workspace: 1,
22273        };
22274        shared.retire_unavailable_session_records().await;
22275
22276        assert_eq!(
22277            retention_remaining_record_ids(&shared),
22278            owned(&["a3", "b2", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
22279        );
22280        assert_eq!(retention_removed_record_ids(&shared), owned(&["a1", "a2", "b1"]));
22281        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 3);
22282
22283        drop(shared);
22284        drop(runtime);
22285        std::fs::remove_dir_all(root).unwrap();
22286    }
22287
22288    #[tokio::test]
22289    async fn session_record_retention_both_disabled_is_a_no_op() {
22290        let (mut shared, runtime, root, _now) = retention_fixture_shared();
22291        shared.session_record_retention = SessionRecordRetentionConfig::default();
22292        shared.retire_unavailable_session_records().await;
22293
22294        assert_eq!(
22295            retention_remaining_record_ids(&shared),
22296            owned(&[
22297                "a1", "a2", "a3", "b1", "b2", "b3",
22298                "decoy-live", "decoy-pending", "decoy-dormant",
22299            ]),
22300        );
22301        assert!(retention_removed_record_ids(&shared).is_empty());
22302        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 0);
22303
22304        drop(shared);
22305        drop(runtime);
22306        std::fs::remove_dir_all(root).unwrap();
22307    }
22308
22309    #[test]
22310    fn session_record_context_export_selects_one_exact_history_identity() {
22311        let candidates = vec![
22312            HistoryCandidateSummary {
22313                id: "candidate-other".to_owned(),
22314                session_id_hint: "provider-other".to_owned(),
22315                modified_at_unix_ms: Some(1),
22316            },
22317            HistoryCandidateSummary {
22318                id: "candidate-exact".to_owned(),
22319                session_id_hint: "provider-exact".to_owned(),
22320                modified_at_unix_ms: Some(2),
22321            },
22322        ];
22323        assert_eq!(
22324            select_history_candidate(&candidates, Some("provider-exact")).unwrap(),
22325            "candidate-exact",
22326        );
22327    }
22328
22329    #[test]
22330    fn session_record_context_export_rejects_missing_and_ambiguous_history_identity() {
22331        let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
22332            id: id.to_owned(),
22333            session_id_hint: hint.to_owned(),
22334            modified_at_unix_ms: None,
22335        };
22336        let missing = select_history_candidate(
22337            &[candidate("candidate-other", "provider-other")],
22338            Some("provider-exact"),
22339        )
22340        .unwrap_err();
22341        assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
22342        let ambiguous = select_history_candidate(
22343            &[
22344                candidate("candidate-a", "provider-exact"),
22345                candidate("candidate-b", "provider-exact"),
22346            ],
22347            Some("provider-exact"),
22348        )
22349        .unwrap_err();
22350        assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
22351    }
22352
22353    /// The PTY-sidecar-observation shape (no fleet provider today, but
22354    /// capability-keyed rather than vendor-locked) never carries a verified
22355    /// `provider_session`; its record-bound export instead falls back to
22356    /// "exactly one discovered candidate" (see
22357    /// `export_context_pack_for_session_record_inner`). This is the same
22358    /// ambiguity refusal as the identity-filtered path, just without an
22359    /// identity to filter by.
22360    #[test]
22361    fn session_record_context_export_selects_sole_history_candidate_without_identity() {
22362        let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
22363            id: id.to_owned(),
22364            session_id_hint: hint.to_owned(),
22365            modified_at_unix_ms: None,
22366        };
22367        assert_eq!(
22368            select_history_candidate(&[candidate("candidate-sole", "sidecar-session")], None)
22369                .unwrap(),
22370            "candidate-sole",
22371        );
22372        let missing = select_history_candidate(&[], None).unwrap_err();
22373        assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
22374        let ambiguous = select_history_candidate(
22375            &[
22376                candidate("candidate-a", "sidecar-session-a"),
22377                candidate("candidate-b", "sidecar-session-b"),
22378            ],
22379            None,
22380        )
22381        .unwrap_err();
22382        assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
22383    }
22384
22385    #[test]
22386    fn session_record_context_export_revalidation_rejects_record_change() {
22387        let session = terminal_address(1);
22388        let identity = ProviderSessionIdentity {
22389            key: ProviderSessionKey::SessionId,
22390            id: "provider-exact".to_owned(),
22391            transcript_path: Some(r"C:\private\provider.jsonl".to_owned()),
22392        };
22393        let mut expected = record("claude", "record-context-export");
22394        expected.state = ManagedSessionState::Live;
22395        expected.provider_session = Some(identity.clone());
22396        expected.active_session = Some(session.clone());
22397        let binding = SessionBinding {
22398            workspace_id: session.workspace_id.clone(),
22399            generation: session.session.generation,
22400            runtime_policy: ProviderRuntimePolicy::raw_pty(),
22401            pending_resume: None,
22402            record_id: Some(expected.record_id.clone()),
22403            managed_worktree_lease_id: None,
22404            environment_profile: None,
22405            bundle: None,
22406            context: None,
22407            materialization_id: None,
22408        };
22409        let current_root = windows_path_text(&expected.canonical_root).to_owned();
22410        assert!(session_record_context_export_binding_is_exact(
22411            &expected,
22412            &expected,
22413            Some(&identity),
22414            &session,
22415            &binding,
22416            &expected.provider,
22417            &current_root,
22418            false,
22419        ));
22420        let mut changed = expected.clone();
22421        changed.updated_at_unix_ms += 1;
22422        assert!(!session_record_context_export_binding_is_exact(
22423            &expected,
22424            &changed,
22425            Some(&identity),
22426            &session,
22427            &binding,
22428            &expected.provider,
22429            &current_root,
22430            false,
22431        ));
22432        // A same-instant timestamp bump alone (state and active_session
22433        // otherwise unchanged) is not the clean-detachment transition
22434        // either, even with the reactive flag on: only the exact Live ->
22435        // Dormant + active_session cleared shape qualifies.
22436        assert!(!session_record_context_export_binding_is_exact(
22437            &expected,
22438            &changed,
22439            Some(&identity),
22440            &session,
22441            &binding,
22442            &expected.provider,
22443            &current_root,
22444            true,
22445        ));
22446        let mut detached = expected.clone();
22447        detached.state = ManagedSessionState::Dormant;
22448        detached.active_session = None;
22449        detached.updated_at_unix_ms += 1;
22450        assert!(!session_record_context_export_binding_is_exact(
22451            &expected,
22452            &detached,
22453            Some(&identity),
22454            &session,
22455            &binding,
22456            &expected.provider,
22457            &current_root,
22458            false,
22459        ));
22460        assert!(session_record_context_export_binding_is_exact(
22461            &expected,
22462            &detached,
22463            Some(&identity),
22464            &session,
22465            &binding,
22466            &expected.provider,
22467            &current_root,
22468            true,
22469        ));
22470    }
22471
22472    /// Mirrors `session_record_context_export_revalidation_rejects_record_change`
22473    /// for a record whose provider never carries a verified identity (the
22474    /// PTY-sidecar-observation shape -- capability-keyed, no fleet provider
22475    /// declares one today): `identity` is `None` throughout, and the
22476    /// exactness check falls back to comparing `provider_session` itself,
22477    /// which must still be `None` on both sides.
22478    #[test]
22479    fn session_record_context_export_binding_is_exact_without_identity() {
22480        let session = terminal_address(1);
22481        let mut expected = record("pty-sidecar-fixture", "record-context-export-no-identity");
22482        expected.state = ManagedSessionState::Live;
22483        expected.active_session = Some(session.clone());
22484        let binding = SessionBinding {
22485            workspace_id: session.workspace_id.clone(),
22486            generation: session.session.generation,
22487            runtime_policy: ProviderRuntimePolicy::raw_pty(),
22488            pending_resume: None,
22489            record_id: Some(expected.record_id.clone()),
22490            managed_worktree_lease_id: None,
22491            environment_profile: None,
22492            bundle: None,
22493            context: None,
22494            materialization_id: None,
22495        };
22496        let current_root = windows_path_text(&expected.canonical_root).to_owned();
22497        assert!(session_record_context_export_binding_is_exact(
22498            &expected,
22499            &expected,
22500            None,
22501            &session,
22502            &binding,
22503            &expected.provider,
22504            &current_root,
22505            false,
22506        ));
22507        // A record that gained an identity between the initial lookup and
22508        // this recheck no longer matches the `None` this shape expects to
22509        // keep seeing -- treated the same as any other mid-export drift.
22510        let mut drifted = expected.clone();
22511        drifted.provider_session = Some(ProviderSessionIdentity {
22512            key: ProviderSessionKey::SessionId,
22513            id: "unexpected-identity".to_owned(),
22514            transcript_path: None,
22515        });
22516        assert!(!session_record_context_export_binding_is_exact(
22517            &expected,
22518            &drifted,
22519            None,
22520            &session,
22521            &binding,
22522            &expected.provider,
22523            &current_root,
22524            false,
22525        ));
22526    }
22527
22528    #[test]
22529    fn session_record_context_export_preflight_requires_live_usable_source() {
22530        let mut source = record("claude", "record-context-export-preflight");
22531        source.state = ManagedSessionState::Live;
22532        assert!(session_record_context_export_source_is_usable(
22533            &source,
22534            &SessionStatus::Running,
22535            false,
22536        ));
22537        assert!(session_record_context_export_source_is_usable(
22538            &source,
22539            &SessionStatus::Exited { exit_code: Some(0) },
22540            false,
22541        ));
22542
22543        source.state = ManagedSessionState::Dormant;
22544        assert!(!session_record_context_export_source_is_usable(
22545            &source,
22546            &SessionStatus::Running,
22547            false,
22548        ));
22549        // The reactive auto-export-at-exit path (allow_clean_detachment) is
22550        // the only caller that may treat a Dormant, cleanly-exited record as
22551        // still usable — the on-demand path above never does.
22552        assert!(session_record_context_export_source_is_usable(
22553            &source,
22554            &SessionStatus::Exited { exit_code: Some(0) },
22555            true,
22556        ));
22557        assert!(!session_record_context_export_source_is_usable(
22558            &source,
22559            &SessionStatus::Exited { exit_code: Some(0) },
22560            false,
22561        ));
22562        source.state = ManagedSessionState::Live;
22563        assert!(!session_record_context_export_source_is_usable(
22564            &source,
22565            &SessionStatus::Starting,
22566            false,
22567        ));
22568    }
22569
22570    #[test]
22571    fn session_record_context_export_final_conflict_leaves_catalog_empty() {
22572        let catalog = active_registry().unwrap();
22573        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22574        let workspace = WorkspaceConfig::new(
22575            WorkspaceId::new("primary").unwrap(),
22576            std::env::current_dir().unwrap(),
22577        )
22578        .unwrap();
22579        let shared = NodeShared::new(
22580            handle,
22581            "fixture-token".to_owned(),
22582            NodeId::new("node-context-export-conflict").unwrap(),
22583            vec![workspace],
22584            vec![agent("claude")],
22585        );
22586        let history = HistorySessionRecord {
22587            session_id: "provider-exact".to_owned(),
22588            title: None,
22589            cwd: None,
22590            model: None,
22591            message_count: 1,
22592            completed_turn_count: None,
22593            total_tokens: 0,
22594            messages: vec![gate4agent_types::HistoryMessageRecord {
22595                role: gate4agent_types::HistoryMessageRole::User,
22596                text: "bounded context".to_owned(),
22597            }],
22598        };
22599        let pack = NodeContextPack::export(
22600            ContextPackLineageReceipt {
22601                source_node_id: shared.node_id.clone(),
22602                source_session: terminal_address(1),
22603                source_provider: agent("claude"),
22604            },
22605            &history,
22606        )
22607        .unwrap();
22608        let context_id = pack.receipt().id.clone();
22609        let session = terminal_address(1);
22610        let identity = ProviderSessionIdentity {
22611            key: ProviderSessionKey::SessionId,
22612            id: history.session_id.clone(),
22613            transcript_path: None,
22614        };
22615        let mut expected = record("claude", "record-context-export-final-conflict");
22616        expected.state = ManagedSessionState::Live;
22617        expected.provider_session = Some(identity.clone());
22618        expected.active_session = Some(session.clone());
22619        let mut changed = expected.clone();
22620        changed.updated_at_unix_ms += 1;
22621        shared.insert_record(changed).unwrap();
22622
22623        let error = shared
22624            .commit_context_pack_for_session_record(
22625                &expected,
22626                Some(&identity),
22627                &session,
22628                "candidate-exact",
22629                &history,
22630                pack,
22631                false,
22632            )
22633            .unwrap_err();
22634
22635        assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
22636        assert!(shared
22637            .context_catalog
22638            .read()
22639            .unwrap_or_else(|poisoned| poisoned.into_inner())
22640            .get(&context_id)
22641            .is_none());
22642    }
22643
22644    #[tokio::test]
22645    async fn session_task_binding_mints_assigns_clears_with_cas_and_persists_v9() {
22646        let root = temporary_workspace_root("session-task-binding");
22647        std::fs::create_dir_all(&root).unwrap();
22648        let state_path = root.join("node-state.json");
22649        let catalog = active_registry().unwrap();
22650        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22651        let workspace = WorkspaceConfig::new(WorkspaceId::new("primary").unwrap(), &root).unwrap();
22652        let node_id = NodeId::new("node-1").unwrap();
22653        let mut shared = NodeShared::new(
22654            handle,
22655            "fixture-token".to_owned(),
22656            node_id.clone(),
22657            vec![workspace],
22658            vec![agent("claude")],
22659        );
22660        shared.state_path = Some(state_path.clone());
22661        let record = record("claude", "sr-task");
22662        let record_id = record.record_id.clone();
22663        shared.insert_record(record).unwrap();
22664        shared.persist_state().unwrap();
22665        shared
22666            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
22667            .unwrap();
22668
22669        let minted = process_request_inner(
22670            &shared,
22671            77,
22672            ClientRole::Operator,
22673            NodeRequest::SetSessionTask {
22674                record_id: record_id.clone(),
22675                expected_revision: 0,
22676                target: SessionTaskTargetV1::New,
22677            },
22678        )
22679        .await
22680        .unwrap();
22681        let NodeResponse::SessionRecordUpdated { record: minted } = minted else {
22682            panic!("session task mutation returned another response");
22683        };
22684        let minted_binding = minted.task_binding.clone().unwrap();
22685        let minted_task_id = minted_binding.task_id.clone().unwrap();
22686        assert_eq!(minted_binding.revision, 1);
22687        assert!(minted_task_id.as_str().starts_with("task-"));
22688        assert_eq!(minted_task_id.as_str().len(), 29);
22689
22690        let event_count = shared.current_sequence();
22691        let repeated = process_request_inner(
22692            &shared,
22693            77,
22694            ClientRole::Operator,
22695            NodeRequest::SetSessionTask {
22696                record_id: record_id.clone(),
22697                expected_revision: 1,
22698                target: SessionTaskTargetV1::Existing {
22699                    task_id: minted_task_id.clone(),
22700                },
22701            },
22702        )
22703        .await
22704        .unwrap();
22705        assert_eq!(repeated, NodeResponse::SessionRecordUpdated { record: minted.clone() });
22706        assert_eq!(shared.current_sequence(), event_count);
22707
22708        let stale = process_request_inner(
22709            &shared,
22710            77,
22711            ClientRole::Operator,
22712            NodeRequest::SetSessionTask {
22713                record_id: record_id.clone(),
22714                expected_revision: 0,
22715                target: SessionTaskTargetV1::Existing {
22716                    task_id: minted_task_id,
22717                },
22718            },
22719        )
22720        .await
22721        .unwrap_err();
22722        assert_eq!(stale.code, NodeFailureCode::SessionRecordConflict);
22723
22724        let assigned_id: TaskId = "task-ffeeddccbbaa998877665544".parse().unwrap();
22725        let assigned = process_request_inner(
22726            &shared,
22727            77,
22728            ClientRole::Operator,
22729            NodeRequest::SetSessionTask {
22730                record_id: record_id.clone(),
22731                expected_revision: 1,
22732                target: SessionTaskTargetV1::Existing {
22733                    task_id: assigned_id.clone(),
22734                },
22735            },
22736        )
22737        .await
22738        .unwrap();
22739        let NodeResponse::SessionRecordUpdated { record: assigned } = assigned else {
22740            panic!("session task assignment returned another response");
22741        };
22742        assert_eq!(assigned.task_binding.as_ref().unwrap().revision, 2);
22743        assert_eq!(assigned.task_binding.as_ref().unwrap().task_id.as_ref(), Some(&assigned_id));
22744
22745        let cleared = process_request_inner(
22746            &shared,
22747            77,
22748            ClientRole::Operator,
22749            NodeRequest::SetSessionTask {
22750                record_id: record_id.clone(),
22751                expected_revision: 2,
22752                target: SessionTaskTargetV1::Clear,
22753            },
22754        )
22755        .await
22756        .unwrap();
22757        let NodeResponse::SessionRecordUpdated { record: cleared } = cleared else {
22758            panic!("session task clear returned another response");
22759        };
22760        assert_eq!(cleared.task_binding.as_ref().unwrap().revision, 3);
22761        assert!(cleared.task_binding.as_ref().unwrap().task_id.is_none());
22762        assert!(cleared.task_binding.as_ref().unwrap().changed_at_unix_ms > 0);
22763        assert_eq!(
22764            cleared.task_binding.as_ref().unwrap().changed_at_unix_ms,
22765            cleared.updated_at_unix_ms,
22766        );
22767
22768        let persisted = std::fs::read(&state_path).unwrap();
22769        assert_eq!(serde_json::from_slice::<serde_json::Value>(&persisted).unwrap()["version"], NODE_STATE_SCHEMA_V10);
22770        let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
22771        assert_eq!(loaded.records[0].task_binding, cleared.task_binding);
22772        std::fs::remove_dir_all(root).unwrap();
22773    }
22774
22775    #[test]
22776    fn session_task_capability_strips_snapshots_events_and_replies() {
22777        let mut bound = record("claude", "sr-task-projection");
22778        bound.updated_at_unix_ms = 2;
22779        bound.task_binding = Some(SessionTaskBindingV1 {
22780            revision: 1,
22781            task_id: Some("task-00112233445566778899aaff".parse().unwrap()),
22782            changed_at_unix_ms: 2,
22783        });
22784        let request = NodeRequest::SetSessionTask {
22785            record_id: bound.record_id.clone(),
22786            expected_revision: 1,
22787            target: SessionTaskTargetV1::Clear,
22788        };
22789        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22790            capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
22791        }));
22792        assert!(!request_uses_unnegotiated_capability(
22793            &request,
22794            &baseline_capabilities().unwrap(),
22795        ));
22796        assert!(request_uses_unnegotiated_capability(&request, &[]));
22797
22798        let mut snapshot = terminal_test_shared().snapshot();
22799        snapshot.session_records.push(bound.clone());
22800        let mut reply = ResponseEnvelope {
22801            request_id: 1,
22802            result: Ok(NodeResponse::Resync {
22803                event_sequence: 1,
22804                oldest_available_sequence: 1,
22805                snapshot,
22806                events: vec![NodeEventEnvelope {
22807                    sequence: 1,
22808                    event: NodeEvent::SessionRecordUpserted {
22809                        record: bound.clone(),
22810                    },
22811                }],
22812            }),
22813        };
22814        project_response_without_session_task_binding(&mut reply);
22815        let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
22816            panic!("task projection changed the response kind");
22817        };
22818        assert!(snapshot.session_records[0].task_binding.is_none());
22819        let NodeEvent::SessionRecordUpserted { record } = &events[0].event else {
22820            panic!("task projection changed the event kind");
22821        };
22822        assert!(record.task_binding.is_none());
22823
22824        let projected = project_event_without_session_task_binding(NodeEventEnvelope {
22825            sequence: 2,
22826            event: NodeEvent::SessionRecordUpserted { record: bound.clone() },
22827        });
22828        let NodeEvent::SessionRecordUpserted { record } = projected.event else {
22829            panic!("task event projection changed the event kind");
22830        };
22831        assert!(record.task_binding.is_none());
22832
22833        let mut direct = ResponseEnvelope {
22834            request_id: 2,
22835            result: Ok(NodeResponse::SessionRecordUpdated { record: bound }),
22836        };
22837        project_response_without_session_task_binding(&mut direct);
22838        let Ok(NodeResponse::SessionRecordUpdated { record }) = direct.result else {
22839            panic!("task reply projection changed the response kind");
22840        };
22841        assert!(record.task_binding.is_none());
22842    }
22843
22844    #[test]
22845    fn windows_runtime_default_node_endpoint_is_exact_and_valid() {
22846        assert_eq!(DEFAULT_NODE_ENDPOINT, r"\\.\pipe\gate4agent-node");
22847        let workspace = WorkspaceConfig::new(
22848            WorkspaceId::new("primary").unwrap(),
22849            std::env::current_dir().unwrap(),
22850        )
22851        .unwrap();
22852        let config = NodeServerConfig::new(
22853            DEFAULT_NODE_ENDPOINT,
22854            "fixture-token",
22855            NodeId::new("node-1").unwrap(),
22856            [workspace],
22857        )
22858        .unwrap();
22859        assert_eq!(config.endpoint, DEFAULT_NODE_ENDPOINT);
22860    }
22861
22862    #[cfg(all(feature = "fixture", windows))]
22863    #[test]
22864    fn provider_bundle_argv_hold_fixture_is_parent_bounded_and_waits_after_proof() {
22865        let root = temporary_workspace_root("provider-bundle-argv-hold");
22866        let outside = temporary_workspace_root("provider-bundle-argv-hold-outside");
22867        std::fs::create_dir_all(&root).unwrap();
22868        std::fs::create_dir_all(&outside).unwrap();
22869        let proof_path = root.join("provider.proof");
22870        let release_signal = root.join("release.signal");
22871        let config = NodeServerConfig::new(
22872            format!(
22873                r"\\.\pipe\gate4agent-provider-bundle-hold-{}",
22874                std::process::id(),
22875            ),
22876            "fixture-token",
22877            NodeId::new("provider-bundle-hold-node").unwrap(),
22878            [WorkspaceConfig::new(
22879                WorkspaceId::new("primary").unwrap(),
22880                std::env::current_dir().unwrap(),
22881            )
22882            .unwrap()],
22883        )
22884        .unwrap()
22885        .with_spawn_profiles(SpawnProfileRegistry::new([
22886            crate::protocol::SpawnProfileDefaults {
22887                profile_id: crate::protocol::SpawnProfileId::new("kimi-bundle-hold").unwrap(),
22888                revision: crate::protocol::SpawnProfileRevision::new("kimi-bundle-hold-r1")
22889                    .unwrap(),
22890                provider: agent("kimi"),
22891                mode: SessionMode::Pty,
22892                terminal_size: gate4agent_types::TerminalSize {
22893                    rows: 24,
22894                    columns: 80,
22895                },
22896                prompt: None,
22897                bundle_id: None,
22898                context_id: None,
22899                environment_profile_id: None,
22900            },
22901        ])
22902        .unwrap());
22903        let server = NodeServer::new_provider_bundle_argv_hold_fixture(
22904            config,
22905            agent("kimi"),
22906            proof_path.clone(),
22907            release_signal.clone(),
22908        )
22909        .unwrap();
22910        assert_eq!(server.shared.enabled_providers, [agent("kimi")]);
22911
22912        let spec = NodeServer::provider_bundle_argv_fixture_spec(
22913            agent("kimi"),
22914            proof_path.clone(),
22915            Some(release_signal.clone()),
22916        )
22917        .unwrap();
22918        let wrapper = &spec.launch.fixed_args[spec.launch.fixed_args.len() - 3];
22919        let proof_write = wrapper.find("WriteAllLines($proofPath").unwrap();
22920        let release_wait = wrapper
22921            .find("while (-not (Test-Path -LiteralPath $releaseSignal -PathType Leaf))")
22922            .unwrap();
22923        let fixture_ready = wrapper.find("fixture-ready>").unwrap();
22924        assert!(proof_write < release_wait);
22925        assert!(release_wait < fixture_ready);
22926        assert!(wrapper.contains("[DateTime]::UtcNow.AddSeconds(45)"));
22927        assert!(wrapper.contains("Start-Sleep -Milliseconds 20"));
22928        assert_eq!(
22929            &spec.launch.fixed_args[spec.launch.fixed_args.len() - 2..],
22930            &[
22931                proof_path.to_string_lossy().into_owned(),
22932                release_signal.to_string_lossy().into_owned(),
22933            ],
22934        );
22935
22936        let error = NodeServer::provider_bundle_argv_fixture_spec(
22937            agent("kimi"),
22938            proof_path,
22939            Some(outside.join("release.signal")),
22940        )
22941        .unwrap_err();
22942        assert!(matches!(
22943            error,
22944            NodeServerError::Registry(message)
22945                if message == "provider bundle argv release path must share the proof parent"
22946        ));
22947        drop(server);
22948        std::fs::remove_dir_all(root).unwrap();
22949        std::fs::remove_dir_all(outside).unwrap();
22950    }
22951
22952    #[cfg(feature = "fixture")]
22953    #[test]
22954    fn four_provider_context_fixture_registry_is_unambiguous_and_history_bound() {
22955        let root = temporary_workspace_root("context-fixture-registry");
22956        std::fs::create_dir_all(&root).unwrap();
22957        let proof_path = root.join("context.proof");
22958        let config = NodeServerConfig::new(
22959            format!(
22960                r"\\.\pipe\gate4agent-context-fixture-registry-{}",
22961                std::process::id(),
22962            ),
22963            "fixture-token",
22964            NodeId::new("context-fixture-node").unwrap(),
22965            [WorkspaceConfig::new(
22966                WorkspaceId::new("primary").unwrap(),
22967                std::env::current_dir().unwrap(),
22968            )
22969            .unwrap()],
22970        )
22971        .unwrap();
22972        let server = NodeServer::new_context_pack_fixture(config, proof_path).unwrap();
22973        let mut enabled = server
22974            .shared
22975            .enabled_providers
22976            .iter()
22977            .map(AgentId::as_str)
22978            .collect::<Vec<_>>();
22979        enabled.sort_unstable();
22980        assert_eq!(enabled, ["claude", "codex", "grok", "kimi"]);
22981        for provider in &server.shared.enabled_providers {
22982            assert!(server
22983                .shared
22984                .provider_adapter_contracts
22985                .iter()
22986                .any(|contract| {
22987                    &contract.provider == provider
22988                        && contract.family == AdapterFamily::History
22989                }));
22990        }
22991        drop(server);
22992        std::fs::remove_dir_all(root).unwrap();
22993    }
22994
22995    #[cfg(feature = "fixture")]
22996    #[test]
22997    fn monitoring_context_fixture_combines_claude_and_codex_hooks_with_history() {
22998        let root = temporary_workspace_root("monitoring-context-fixture");
22999        std::fs::create_dir_all(&root).unwrap();
23000        let proof_path = root.join("context.proof");
23001        let standard = NodeServer::context_pack_fixture_catalog(
23002            proof_path.clone(),
23003            None,
23004            false,
23005        ).unwrap();
23006        let monitoring = NodeServer::context_pack_fixture_catalog(
23007            proof_path.clone(),
23008            None,
23009            true,
23010        ).unwrap();
23011        let claude = AgentId::new("claude").unwrap();
23012        let standard_claude = standard.get(&claude).unwrap();
23013        let monitoring_claude = monitoring.get(&claude).unwrap();
23014        // Lifecycle hooks are retired (owner ruling 2026-09-25): neither the
23015        // standard nor the monitoring fixture declares a hook adapter
23016        // anymore.
23017        assert!(monitoring_claude.capabilities.adapters.hook.is_none());
23018        assert_eq!(
23019            monitoring_claude.capabilities.adapters.history,
23020            standard_claude.capabilities.adapters.history,
23021        );
23022        assert_ne!(monitoring_claude.launch, standard_claude.launch);
23023        let codex = AgentId::new("codex").unwrap();
23024        let standard_codex = standard.get(&codex).unwrap();
23025        let monitoring_codex = monitoring.get(&codex).unwrap();
23026        assert!(monitoring_codex.capabilities.adapters.hook.is_none());
23027        assert_eq!(
23028            monitoring_codex.capabilities.adapters.history,
23029            standard_codex.capabilities.adapters.history,
23030        );
23031        assert_ne!(monitoring_codex.launch, standard_codex.launch);
23032        let codex_validation = monitoring_codex.launch.fixed_args.iter()
23033            .find(|argument| argument.contains("F7_CODEX_BUNDLE_CONTEXT_VALIDATED"))
23034            .unwrap();
23035        for required in [
23036            "$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')",
23037            "$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')",
23038            "@('role', 'text')",
23039            "g4a-private-provider-session-canary",
23040            "private-provider-login-identity",
23041            "$rawProof",
23042            "$env:GATE4AGENT_HOOK_URL",
23043            "g4a-private-provider-session-canary",
23044        ] {
23045            assert!(codex_validation.contains(required), "missing privacy validation: {required}");
23046        }
23047        for provider in ["grok", "kimi"] {
23048            let provider = AgentId::new(provider).unwrap();
23049            assert_eq!(monitoring.get(&provider), standard.get(&provider));
23050        }
23051
23052        let config = NodeServerConfig::new(
23053            format!(
23054                r"\\.\pipe\gate4agent-monitoring-context-fixture-{}",
23055                std::process::id(),
23056            ),
23057            "fixture-token",
23058            NodeId::new("monitoring-context-fixture-node").unwrap(),
23059            [WorkspaceConfig::new(
23060                WorkspaceId::new("primary").unwrap(),
23061                std::env::current_dir().unwrap(),
23062            )
23063            .unwrap()],
23064        )
23065        .unwrap();
23066        let server = NodeServer::new_monitoring_context_pack_fixture(config, proof_path).unwrap();
23067        assert!(server.shared.fixture_semantic_hook_policy);
23068        drop(server);
23069        std::fs::remove_dir_all(root).unwrap();
23070    }
23071
23072    #[cfg(feature = "fixture")]
23073    #[test]
23074    fn kimi_context_only_fixture_is_bounded_and_preserves_codex_validation() {
23075        let root = temporary_workspace_root("kimi-context-only-fixture");
23076        std::fs::create_dir_all(&root).unwrap();
23077        let proof_path = root.join("context.proof");
23078        let standard = NodeServer::context_pack_fixture_catalog(
23079            proof_path.clone(),
23080            None,
23081            false,
23082        ).unwrap();
23083        let kimi = AgentId::new("kimi").unwrap();
23084        let context_only = NodeServer::context_pack_fixture_catalog(
23085            proof_path,
23086            Some(&kimi),
23087            false,
23088        )
23089        .unwrap();
23090
23091        let codex = AgentId::new("codex").unwrap();
23092        assert_eq!(
23093            standard.get(&codex).unwrap().launch.fixed_args,
23094            context_only.get(&codex).unwrap().launch.fixed_args,
23095        );
23096        let script = context_only
23097            .get(&kimi)
23098            .unwrap()
23099            .launch
23100            .fixed_args
23101            .iter()
23102            .find(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED"))
23103            .expect("Kimi context-only validation script is missing");
23104        for required in [
23105            "$bundleArgs.Count -ne 0",
23106            "GATE4AGENT_CONTEXT_ROOT",
23107            "context-pack.json",
23108            "$entries.Count -ne 1",
23109            "g4a-context-pack-v1",
23110            "$document.PSObject.Properties['cwd']",
23111            "'claude', 'codex', 'grok', 'kimi'",
23112            "$hasUser",
23113            "$hasAssistant",
23114            "SHA256",
23115            "context-only",
23116        ] {
23117            assert!(script.contains(required), "missing validation: {required}");
23118        }
23119        for forbidden in ["CODEX_HOME", "USERPROFILE", "auth", "token", "global config"] {
23120            assert!(!script.contains(forbidden), "forbidden home/auth access: {forbidden}");
23121        }
23122        assert!(!standard
23123            .get(&kimi)
23124            .unwrap()
23125            .launch
23126            .fixed_args
23127            .iter()
23128            .any(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED")));
23129
23130        std::fs::remove_dir_all(root).unwrap();
23131    }
23132
23133    #[cfg(all(feature = "fixture", windows))]
23134    #[tokio::test]
23135    async fn kimi_context_only_fixture_spawns_catalogued_context_pack_through_spawn_spec() {
23136        let root = temporary_workspace_root("kimi-context-only-spawn");
23137        let workspace_root = root.join("workspace");
23138        std::fs::create_dir_all(&workspace_root).unwrap();
23139        let materialization_root = root.join("private-materializations");
23140        let proof_path = root.join("context.proof");
23141        let node_id = NodeId::new("node-kimi-context-only-spawn").unwrap();
23142        let workspace_id = WorkspaceId::new("primary").unwrap();
23143        let profile_id = crate::protocol::SpawnProfileId::new("target-kimi").unwrap();
23144        let endpoint = format!(
23145            r"\\.\pipe\gate4agent-kimi-context-only-spawn-{}-{}",
23146            std::process::id(),
23147            unix_time_ms(),
23148        );
23149        let spawn_profiles = SpawnProfileRegistry::new([
23150            crate::protocol::SpawnProfileDefaults {
23151                profile_id: profile_id.clone(),
23152                revision: crate::protocol::SpawnProfileRevision::new("target-kimi-r1").unwrap(),
23153                provider: agent("kimi"),
23154                mode: SessionMode::Pty,
23155                terminal_size: gate4agent_types::TerminalSize {
23156                    rows: 30,
23157                    columns: 120,
23158                },
23159                prompt: None,
23160                bundle_id: None,
23161                context_id: None,
23162                environment_profile_id: None,
23163            },
23164        ])
23165        .unwrap();
23166        let mut config = NodeServerConfig::new(
23167            &endpoint,
23168            "fixture-token",
23169            node_id.clone(),
23170            [WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap()],
23171        )
23172        .unwrap()
23173        .with_spawn_profiles(spawn_profiles)
23174        .with_session_environment_materialization(
23175            materialization_root,
23176            Arc::new(FixtureSecretResolver {
23177                deny: Arc::new(AtomicBool::new(false)),
23178            }),
23179        )
23180        .unwrap();
23181        config.fixture_raw_pty_runtime = true;
23182        let server = NodeServer::new_context_only_proof_fixture(
23183            config,
23184            agent("kimi"),
23185            proof_path.clone(),
23186        )
23187        .unwrap();
23188        let shared = Arc::clone(&server.shared);
23189        let shutdown = server.shutdown_handle();
23190
23191        let history = HistorySessionRecord {
23192            session_id: "context-only-source-history".to_owned(),
23193            title: None,
23194            cwd: None,
23195            model: None,
23196            message_count: 2,
23197            completed_turn_count: None,
23198            total_tokens: 0,
23199            messages: vec![
23200                gate4agent_types::HistoryMessageRecord {
23201                    role: gate4agent_types::HistoryMessageRole::User,
23202                    text: "continue the bounded parent run through native C2".to_owned(),
23203                },
23204                gate4agent_types::HistoryMessageRecord {
23205                    role: gate4agent_types::HistoryMessageRole::Assistant,
23206                    text: "the exact parent context is ready for the child run".to_owned(),
23207                },
23208            ],
23209        };
23210        let pack = NodeContextPack::export(
23211            ContextPackLineageReceipt {
23212                source_node_id: node_id.clone(),
23213                source_session: SessionAddress {
23214                    workspace_id: workspace_id.clone(),
23215                    session: SessionKey {
23216                        instance_id: AgentInstanceId(71),
23217                        generation: SessionGeneration(1),
23218                    },
23219                },
23220                source_provider: agent("codex"),
23221            },
23222            &history,
23223        )
23224        .unwrap();
23225        let expected_context_hash = digest(&SHA256, pack.bytes())
23226            .as_ref()
23227            .iter()
23228            .map(|byte| format!("{byte:02x}"))
23229            .collect::<String>();
23230        let context = shared
23231            .context_catalog
23232            .write()
23233            .unwrap_or_else(|poisoned| poisoned.into_inner())
23234            .insert(pack)
23235            .unwrap();
23236        let spec = SpawnSpec {
23237            target: crate::protocol::SpawnTarget {
23238                node_id,
23239                workspace_id: workspace_id.clone(),
23240                worktree_id: None,
23241            },
23242            profile_id,
23243            expected_profile_revision: crate::protocol::SpawnProfileRevision::new(
23244                "target-kimi-r1",
23245            ).unwrap(),
23246            overrides: crate::protocol::SpawnOverrides {
23247                context_id: crate::protocol::SpawnOverride::Set {
23248                    value: context.id.clone(),
23249                },
23250                ..crate::protocol::SpawnOverrides::default()
23251            },
23252            deadline_ms: crate::protocol::SpawnDeadlineMs::new(10_000).unwrap(),
23253            idempotency_key: SpawnIdempotencyKey::new("kimi-context-only-spawn").unwrap(),
23254            required_capabilities: SpawnRequiredCapabilities::default(),
23255        };
23256        let resolved = match shared.resolve_spawn_spec(&spec) {
23257            Ok(resolved) => resolved,
23258            Err(error) => panic!(
23259                "Kimi context-only resolve rejected: exact_code={:?} message={}",
23260                error.code,
23261                error.message,
23262            ),
23263        };
23264        let resolved_context = match shared.resolve_context(&resolved) {
23265            Ok(Some(resolved_context)) => resolved_context,
23266            Ok(None) => panic!("Kimi context-only resolve omitted the exact ContextPack"),
23267            Err(error) => panic!(
23268                "Kimi context catalog resolve rejected: exact_code={:?} message={}",
23269                error.code,
23270                error.message,
23271            ),
23272        };
23273        assert_eq!(resolved_context, context);
23274        let runtime_policy = match shared
23275            .admit_provider_runtime(&agent("kimi"), ProviderRuntimeRequirement::RawPty)
23276            .await
23277        {
23278            Ok(runtime_policy) => runtime_policy,
23279            Err(error) => panic!(
23280                "Kimi RawPty admission rejected: exact_code={:?} message={}",
23281                error.code,
23282                error.message,
23283            ),
23284        };
23285        assert!(runtime_policy.raw_pty_lifecycle);
23286
23287        let launch_control = shared.native_launch_profile_control.as_ref().unwrap();
23288        let generic_overlay = NativeLaunchEnvironmentOverlay::new(
23289            agent("kimi"),
23290            TransportKind::Pty,
23291            vec![EnvMutation {
23292                key: OsString::from("GATE4AGENT_UNPROFILED_GENERIC"),
23293                value: Some(OsString::from("must-remain-rejected")),
23294            }],
23295        )
23296        .unwrap();
23297        assert_eq!(
23298            launch_control
23299                .install_native_launch_environment_overlay(
23300                    AgentInstanceId(72),
23301                    generic_overlay,
23302                )
23303                .unwrap_err(),
23304            gate4agent_runtime_native::NativeLaunchProfileError::EnvironmentOverlaySelectionMissing,
23305        );
23306
23307        let diagnostic_address = SessionAddress {
23308            workspace_id: workspace_id.clone(),
23309            session: SessionKey {
23310                instance_id: AgentInstanceId(73),
23311                generation: SessionGeneration(1),
23312            },
23313        };
23314        let (diagnostic_overlay, diagnostic_materialization) = match shared
23315            .prepare_session_materialization(
23316                &diagnostic_address,
23317                &agent("kimi"),
23318                SessionMode::Pty,
23319                None,
23320                None,
23321                Some(&resolved_context),
23322                None,
23323            )
23324        {
23325            Ok(Some(prepared)) => prepared,
23326            Ok(None) => panic!("Kimi context-only materialization produced no launch overlay"),
23327            Err(error) => panic!(
23328                "Kimi context-only materialization rejected: exact_code={:?} message={}",
23329                error.code,
23330                error.message,
23331            ),
23332        };
23333        let diagnostic_overlay = diagnostic_overlay
23334            .expect("Kimi context-only materialization omitted its environment overlay");
23335        assert!(matches!(
23336            &diagnostic_overlay,
23337            PreparedNativeLaunchOverlay::Environment(_),
23338        ));
23339        match shared.install_prepared_launch_overlay(
23340            diagnostic_address.session.instance_id,
23341            diagnostic_overlay,
23342        ) {
23343            Ok(None) => {}
23344            Ok(Some(_)) => panic!("Kimi context-only overlay unexpectedly retained argv authority"),
23345            Err(error) => panic!(
23346                "Kimi context-only overlay installation rejected: exact_code={:?} message={}",
23347                error.code,
23348                error.message,
23349            ),
23350        }
23351        assert!(launch_control.clear_native_instance_launch_overlay(
23352            diagnostic_address.session.instance_id,
23353        ));
23354        drop(diagnostic_materialization);
23355        assert!(shared
23356            .materializations
23357            .lock()
23358            .unwrap_or_else(|poisoned| poisoned.into_inner())
23359            .is_empty());
23360
23361        let node_task = tokio::spawn(server.run());
23362        let ready_client = timeout(Duration::from_secs(5), async {
23363            loop {
23364                match gate4agent_node_wire::LocalNodeClient::connect(
23365                    &endpoint,
23366                    &shared.node_id,
23367                    ClientRole::Observer,
23368                    "fixture-token",
23369                )
23370                .await
23371                {
23372                    Ok(client) => break client,
23373                    Err(_) if !node_task.is_finished() => sleep(Duration::from_millis(10)).await,
23374                    Err(error) => panic!("Kimi context-only Node stopped before readiness: {error}"),
23375                }
23376            }
23377        })
23378        .await
23379        .expect("Kimi context-only Node did not become ready");
23380        assert_eq!(
23381            ready_client
23382                .hello()
23383                .snapshot
23384                .provider_runtime_statuses
23385                .iter()
23386                .find(|status| status.provider().as_str() == "kimi")
23387                .expect("Kimi runtime status is missing")
23388                .mode(),
23389            crate::protocol::ProviderRuntimeMode::RawPassthrough,
23390        );
23391        drop(ready_client);
23392
23393        let receipt = match shared.spawn_from_spec(spec).await {
23394            Ok(receipt) => receipt,
23395            Err(error) => panic!(
23396                "Kimi context-only SpawnSpec rejected: exact_code={:?} message={}",
23397                error.code,
23398                error.message,
23399            ),
23400        };
23401        assert_eq!(receipt.provider, agent("kimi"));
23402        assert_eq!(receipt.context.as_ref(), Some(&context));
23403        assert!(receipt.context_binding_is_valid());
23404        timeout(Duration::from_secs(5), async {
23405            while !proof_path.is_file() {
23406                sleep(Duration::from_millis(10)).await;
23407            }
23408        })
23409        .await
23410        .expect("Kimi context-only fixture did not write its proof");
23411
23412        let proof = std::fs::read_to_string(&proof_path).unwrap();
23413        let lines = proof.lines().collect::<Vec<_>>();
23414        assert_eq!(lines.len(), 7);
23415        assert_eq!(lines[0], "context-only");
23416        assert!(Path::new(lines[1]).is_absolute());
23417        assert_ne!(
23418            std::fs::canonicalize(Path::new(lines[1])).unwrap(),
23419            std::fs::canonicalize(&workspace_root).unwrap(),
23420        );
23421        assert_eq!(
23422            std::fs::canonicalize(Path::new(lines[2])).unwrap(),
23423            std::fs::canonicalize(&workspace_root).unwrap(),
23424        );
23425        assert_eq!(lines[3], expected_context_hash);
23426        assert_eq!(lines[4], "g4a-context-pack-v1");
23427        assert_eq!(lines[5], "codex");
23428        assert_eq!(lines[6], "2");
23429        assert!(shared.handle.snapshot().sessions.iter().any(|session| {
23430            session.instance_id == receipt.session.session.instance_id
23431                && session.agent_id.as_str() == "kimi"
23432        }));
23433
23434        shutdown.request_shutdown().await.unwrap();
23435        timeout(Duration::from_secs(5), node_task)
23436            .await
23437            .unwrap()
23438            .unwrap()
23439            .unwrap();
23440        drop(shutdown);
23441        drop(shared);
23442        std::fs::remove_dir_all(root).unwrap();
23443    }
23444
23445    #[cfg(feature = "fixture")]
23446    #[test]
23447    fn clean_exit_fixture_constructor_is_fixture_scoped_and_path_bounded() {
23448        let root = temporary_workspace_root("clean-exit-constructor");
23449        std::fs::create_dir_all(&root).unwrap();
23450        let config = construction_test_config("clean-exit-constructor")
23451            .with_state_path(root.join("node-state.json"))
23452            .unwrap();
23453        let server = NodeServer::new_clean_exit_fixture(
23454            config,
23455            root.clone(),
23456            root.join("started.marker"),
23457            root.join("release.signal"),
23458        )
23459        .unwrap();
23460        assert_eq!(
23461            server
23462                .shared
23463                .enabled_providers
23464                .iter()
23465                .map(AgentId::as_str)
23466                .collect::<Vec<_>>(),
23467            ["claude"],
23468        );
23469        drop(server);
23470        std::fs::remove_dir_all(root).unwrap();
23471    }
23472
23473    #[test]
23474    fn production_registry_provider_contract_manifest_is_exact() {
23475        let registry = active_registry().unwrap();
23476        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
23477        assert_eq!(
23478            providers
23479                .iter()
23480                .map(|contract| (contract.provider.as_str(), contract.revision.as_str()))
23481                .collect::<Vec<_>>(),
23482            vec![
23483                ("claude", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
23484                ("codex", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
23485                ("grok", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
23486                ("kimi", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
23487            ]
23488        );
23489        assert_eq!(
23490            adapters
23491                .iter()
23492                .map(|contract| (
23493                    contract.provider.as_str(),
23494                    contract.family,
23495                    contract.adapter_id.as_str(),
23496                    contract.revision.as_str(),
23497                ))
23498                .collect::<Vec<_>>(),
23499            vec![
23500                ("claude", AdapterFamily::PtySemantic, "claude-code", "gate4agent-adapter/v1"),
23501                ("claude", AdapterFamily::Pipe, "claude-code", "gate4agent-adapter/v1"),
23502                ("claude", AdapterFamily::Acp, "claude-code", "gate4agent-adapter/v1"),
23503                ("claude", AdapterFamily::OneShot, "claude", "gate4agent-inline/claude-code-2.1/v1"),
23504                ("claude", AdapterFamily::History, "claude-code", "gate4agent-adapter/v1"),
23505                ("claude", AdapterFamily::Resume, "claude-code", "gate4agent-adapter/v1"),
23506                ("claude", AdapterFamily::SessionOptions, "claude-code", "gate4agent-session-options/orca-d8629c4/v1"),
23507                ("codex", AdapterFamily::PtySemantic, "codex", "gate4agent-adapter/v1"),
23508                ("codex", AdapterFamily::Pipe, "codex", "gate4agent-adapter/v1"),
23509                ("codex", AdapterFamily::Acp, "codex", "gate4agent-adapter/v1"),
23510                ("codex", AdapterFamily::OneShot, "codex", "gate4agent-inline/codex-cli-0.144/v1"),
23511                ("codex", AdapterFamily::History, "codex", "gate4agent-adapter/v1"),
23512                ("codex", AdapterFamily::Resume, "codex", "gate4agent-adapter/v1"),
23513                ("codex", AdapterFamily::SessionOptions, "codex", "gate4agent-session-options/orca-d8629c4/v1"),
23514                ("grok", AdapterFamily::Acp, "grok", "gate4agent-adapter/v1"),
23515                ("grok", AdapterFamily::History, "grok", "gate4agent-adapter/v1"),
23516                ("grok", AdapterFamily::Resume, "grok", "gate4agent-adapter/v1"),
23517                ("kimi", AdapterFamily::PtySemantic, "kimi", "gate4agent-adapter/v1"),
23518                ("kimi", AdapterFamily::Pipe, "kimi", "gate4agent-adapter/v1"),
23519                ("kimi", AdapterFamily::Acp, "kimi", "gate4agent-adapter/v1"),
23520                ("kimi", AdapterFamily::OneShot, "kimi", "gate4agent-inline/kimi-code-0.31/v1"),
23521                ("kimi", AdapterFamily::History, "kimi", "gate4agent-adapter/v1"),
23522                ("kimi", AdapterFamily::Resume, "kimi", "gate4agent-adapter/v1"),
23523            ]
23524        );
23525    }
23526
23527    fn construction_test_config(name: &str) -> NodeServerConfig {
23528        let workspace = WorkspaceConfig::new(
23529            WorkspaceId::new("primary").unwrap(),
23530            std::env::current_dir().unwrap(),
23531        )
23532        .unwrap();
23533        NodeServerConfig::new(
23534            format!(r"\\.\pipe\gate4agent-{name}"),
23535            "fixture-token",
23536            NodeId::new(name).unwrap(),
23537            [workspace],
23538        )
23539        .unwrap()
23540    }
23541
23542    fn maximum_size_provider_manifest_registry() -> AgentRegistry {
23543        let adapter = AdapterBinding::new(
23544            gate4agent_types::AdapterId::new("a".repeat(96)).unwrap(),
23545            "r".repeat(crate::protocol::MAX_ADAPTER_CONTRACT_REVISION_BYTES),
23546            gate4agent_types::AdapterVerification::SyntheticFixture,
23547        )
23548        .unwrap();
23549        let agent_ids = ["claude", "codex", "kimi"]
23550            .into_iter()
23551            .map(|agent_id| AgentId::new(agent_id).unwrap())
23552            .collect::<Vec<_>>();
23553        let adapters = gate4agent_catalog::AdapterRegistry::new(
23554            [
23555                AdapterFamily::PtySemantic,
23556                AdapterFamily::Pipe,
23557                AdapterFamily::OneShot,
23558                AdapterFamily::Acp,
23559                AdapterFamily::Hook,
23560                AdapterFamily::ManagedHook,
23561                AdapterFamily::History,
23562                AdapterFamily::Resume,
23563                AdapterFamily::SessionOptions,
23564                AdapterFamily::CapabilityProbe,
23565            ]
23566            .into_iter()
23567            .map(|family| gate4agent_catalog::AdapterDescriptor {
23568                family,
23569                binding: adapter.clone(),
23570                agents: agent_ids.clone(),
23571            }),
23572        )
23573        .unwrap();
23574        let specs = agent_ids
23575            .iter()
23576            .map(|agent_id| {
23577                let agent_id = agent_id.as_str();
23578                let mut spec = builtin_registry().get_by_id(agent_id).unwrap().clone();
23579                spec.revision =
23580                    "r".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES);
23581                spec.capabilities.transports.pty_adapter = Some(adapter.clone());
23582                spec.capabilities.transports.pipe = Some(gate4agent_types::PipeTransportSpec {
23583                    adapter: adapter.clone(),
23584                    protocol: gate4agent_types::PipeProtocol::SemanticNdjson,
23585                    launch_override: None,
23586                    prompt_delivery: gate4agent_types::PipePromptDelivery::None,
23587                });
23588                spec.capabilities.transports.acp = Some(gate4agent_types::AcpTransportSpec {
23589                    adapter: adapter.clone(),
23590                    launch_override: None,
23591                });
23592                spec.capabilities.adapters.hook = Some(adapter.clone());
23593                spec.capabilities.adapters.managed_hook = Some(adapter.clone());
23594                spec.capabilities.adapters.one_shot = Some(adapter.clone());
23595                spec.capabilities.adapters.history = Some(adapter.clone());
23596                spec.capabilities.adapters.resume = Some(adapter.clone());
23597                spec.capabilities.adapters.session_options = Some(adapter.clone());
23598                spec.capabilities.adapters.capability_probe = Some(adapter.clone());
23599                spec
23600            })
23601            .collect::<Vec<_>>();
23602        AgentRegistry::new_with_adapters(specs, &adapters).unwrap()
23603    }
23604
23605    /// The real fleet's manifest is accepted by a real server construction.
23606    ///
23607    /// The count is asserted against `active_registry` itself rather than a
23608    /// literal, so this stays a test that construction succeeds for whatever
23609    /// the fleet is -- which is the property worth guarding -- instead of a
23610    /// second place the fleet size is written down and has to be kept in
23611    /// step with the first.
23612    #[test]
23613    fn node_server_construction_accepts_the_production_manifest() {
23614        let registry = active_registry().unwrap();
23615        let (providers, _) = provider_contract_manifest(&registry).unwrap();
23616        assert_eq!(providers.len(), registry.iter().count());
23617        assert!(!providers.is_empty());
23618        let server = NodeServer::new_with_registry(
23619            construction_test_config("manifest-production-bound"),
23620            registry,
23621        )
23622        .unwrap();
23623        drop(server);
23624    }
23625
23626    #[test]
23627    fn node_server_construction_rejects_count_valid_manifest_over_handshake_capacity() {
23628        let registry = maximum_size_provider_manifest_registry();
23629        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
23630        assert_eq!(providers.len(), 3);
23631        assert_eq!(adapters.len(), 30);
23632        assert!(adapters.len() <= crate::protocol::MAX_PROVIDER_ADAPTER_CONTRACTS);
23633        let error = match NodeServer::new_with_registry(
23634            construction_test_config("manifest-handshake-overflow"),
23635            registry,
23636        ) {
23637            Ok(_) => panic!("oversized negotiated manifest was accepted"),
23638            Err(error) => error,
23639        };
23640        assert!(matches!(
23641            error,
23642            NodeServerError::ProviderContractManifest(message)
23643                if message.contains("authentication binding length")
23644                    && message.contains("8192-byte limit")
23645        ));
23646    }
23647
23648    #[test]
23649    fn provider_contract_manifest_rejects_wire_revision_overflow_before_startup() {
23650        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
23651        spec.revision = "x".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES + 1);
23652        let registry = AgentRegistry::new([spec]).unwrap();
23653        assert!(matches!(
23654            provider_contract_manifest(&registry),
23655            Err(NodeServerError::ProviderContractManifest(message))
23656                if message.contains("exceeds the 128-byte limit")
23657        ));
23658    }
23659
23660    #[test]
23661    fn provider_contract_manifest_rejects_invalid_wire_revision_before_startup() {
23662        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
23663        spec.revision = "invalid revision".to_owned();
23664        let registry = AgentRegistry::new([spec]).unwrap();
23665        assert!(matches!(
23666            provider_contract_manifest(&registry),
23667            Err(NodeServerError::ProviderContractManifest(message))
23668                if message.contains("bounded lowercase ASCII")
23669        ));
23670    }
23671
23672    #[test]
23673    fn provider_contract_manifest_rejects_conflicting_duplicate_family_before_startup() {
23674        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
23675        let transport_binding = spec.capabilities.adapters.one_shot.clone().unwrap();
23676        let conflicting_binding = builtin_registry()
23677            .get_by_id("codex")
23678            .unwrap()
23679            .capabilities
23680            .adapters
23681            .one_shot
23682            .clone()
23683            .unwrap();
23684        let pipe = spec.capabilities.transports.pipe.as_mut().unwrap();
23685        pipe.protocol = gate4agent_types::PipeProtocol::OneShotText;
23686        pipe.adapter = transport_binding;
23687        spec.capabilities.adapters.one_shot = Some(conflicting_binding);
23688        let registry = AgentRegistry::new([spec]).unwrap();
23689        assert!(matches!(
23690            provider_contract_manifest(&registry),
23691            Err(NodeServerError::ProviderContractManifest(message))
23692                if message.contains("conflicting OneShot adapter bindings")
23693        ));
23694    }
23695
23696    #[test]
23697    fn public_node_failures_expose_only_fixed_categories() {
23698        let provider = failure(
23699            NodeFailureCode::BackendOperationFailed,
23700            r"provider bearer-secret failed at C:\private\session.jsonl",
23701        );
23702        assert_eq!(provider.message, "backend-operation-failed");
23703        assert!(!provider.message.contains("bearer-secret"));
23704        assert!(!provider.message.contains(r"C:\private"));
23705        let persistence = persistence_failure(io::Error::new(
23706            io::ErrorKind::Other,
23707            r"state write failed at C:\private\state-v1.json",
23708        ));
23709        assert_eq!(persistence.message, DURABLE_STATE_COMMIT_FAILED_ERROR);
23710        assert!(!persistence.message.contains(r"C:\private"));
23711
23712        for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::Unsupported] {
23713            let generic = durable_state_load_error(io::Error::new(kind, "host I/O failure"));
23714            assert_eq!(
23715                generic.to_string(),
23716                "node durable state failed: durable-state-load-failed",
23717            );
23718        }
23719    }
23720
23721    #[test]
23722    fn terminal_byte_sequences_are_bounded_before_dispatch() {
23723        let sequence = b"\x1b[1;5D".to_vec();
23724        assert_eq!(
23725            terminal_bytes_action(sequence.clone()).unwrap(),
23726            InputAction::TerminalBytes(sequence),
23727        );
23728        assert_eq!(
23729            terminal_bytes_action(Vec::new()).unwrap_err().code,
23730            NodeFailureCode::InvalidRequest,
23731        );
23732        assert_eq!(
23733            terminal_bytes_action(vec![0; MAX_NODE_TERMINAL_BYTES + 1])
23734                .unwrap_err()
23735                .code,
23736            NodeFailureCode::InvalidRequest,
23737        );
23738    }
23739
23740    #[test]
23741    fn node_server_rejects_a_second_owner_of_the_same_durable_state_path() {
23742        let root = temporary_workspace_root("exclusive-state-owner");
23743        std::fs::create_dir_all(&root).unwrap();
23744        let workspace = WorkspaceConfig::new(
23745            WorkspaceId::new("primary").unwrap(),
23746            &root,
23747        )
23748        .unwrap();
23749        let state_path = root.join("state-v1.json");
23750        let endpoint = format!(
23751            r"\\.\pipe\gate4agent-node-state-lock-{}-{}",
23752            std::process::id(),
23753            std::time::SystemTime::now()
23754                .duration_since(std::time::UNIX_EPOCH)
23755                .unwrap()
23756                .as_nanos(),
23757        );
23758        let config = NodeServerConfig::new(
23759            endpoint,
23760            "fixture-token",
23761            NodeId::new("node-1").unwrap(),
23762            [workspace],
23763        )
23764        .unwrap()
23765        .with_state_path(&state_path)
23766        .unwrap();
23767
23768        let first = NodeServer::new(config.clone()).unwrap();
23769        let error = match NodeServer::new(config.clone()) {
23770            Ok(_) => panic!("a second node server acquired the same durable state path"),
23771            Err(error) => error,
23772        };
23773        assert!(matches!(
23774            &error,
23775            NodeServerError::DurableState(error)
23776                if error.kind() == io::ErrorKind::WouldBlock
23777        ));
23778        assert_eq!(
23779            error.to_string(),
23780            "node durable state failed: durable-state-lock-failed",
23781        );
23782        assert!(!error.to_string().contains(&state_path.to_string_lossy().into_owned()));
23783        drop(first);
23784        let second = NodeServer::new(config).unwrap();
23785        drop(second);
23786        std::fs::remove_dir_all(root).unwrap();
23787    }
23788
23789    #[test]
23790    fn session_binding_is_workspace_scoped_and_removed_fail_closed() {
23791        let catalog = active_registry().unwrap();
23792        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23793        let workspace_id = WorkspaceId::new("primary").unwrap();
23794        let workspace = WorkspaceConfig::new(
23795            workspace_id.clone(),
23796            std::env::current_dir().unwrap(),
23797        )
23798        .unwrap();
23799        let shared = NodeShared::new(
23800            handle,
23801            "fixture-token".to_owned(),
23802            NodeId::new("node-1").unwrap(),
23803            vec![workspace.clone()],
23804            vec![agent("claude"), agent("codex"), agent("kimi")],
23805        );
23806        let instance_id = AgentInstanceId(17);
23807        let generation = SessionGeneration(3);
23808        assert!(shared.address_for(instance_id, generation).is_none());
23809        shared.bind_session(instance_id, workspace_id.clone(), generation);
23810        let address = SessionAddress {
23811            workspace_id,
23812            session: SessionKey { instance_id, generation },
23813        };
23814        assert_eq!(
23815            shared.workspace_root(&address.workspace_id).unwrap(),
23816            workspace.canonical_root,
23817        );
23818        assert_eq!(shared.address_for(instance_id, generation), Some(address.clone()));
23819        shared.remove_binding(&address);
23820        assert!(shared.address_for(instance_id, generation).is_none());
23821    }
23822
23823    #[test]
23824    fn open_provider_request_gate_is_state_aware_and_fail_closed() {
23825        let address = SessionAddress {
23826            workspace_id: WorkspaceId::new("primary").unwrap(),
23827            session: SessionKey {
23828                instance_id: AgentInstanceId(7),
23829                generation: SessionGeneration(2),
23830            },
23831        };
23832        let record_id = SessionRecordId::new("record-a").unwrap();
23833        let size = gate4agent_types::TerminalSize {
23834            rows: 24,
23835            columns: 80,
23836        };
23837        let address_requests = [
23838            NodeRequest::Resume {
23839                session: address.clone(),
23840                terminal_size: size,
23841                initial_prompt: None,
23842            },
23843            NodeRequest::Prompt { session: address.clone(), text: "p".to_owned() },
23844            NodeRequest::Paste { session: address.clone(), text: "p".to_owned() },
23845            NodeRequest::Input { session: address.clone(), text: "p".to_owned() },
23846            NodeRequest::TerminalBytes { session: address.clone(), bytes: vec![1] },
23847            NodeRequest::TerminalControl {
23848                session: address.clone(),
23849                control: TerminalControl::Enter,
23850            },
23851            NodeRequest::Resize { session: address.clone(), size },
23852            NodeRequest::Interrupt { session: address.clone() },
23853            NodeRequest::Stop { session: address.clone(), force: false },
23854            NodeRequest::Remove { session: address },
23855        ];
23856        for request in &address_requests {
23857            assert!(request_requires_open_provider_ids_with(
23858                request,
23859                |_| Some(agent("third-party-agent")),
23860                |_| Some(agent("claude")),
23861            ));
23862            assert!(!request_requires_open_provider_ids_with(
23863                request,
23864                |_| Some(agent("claude")),
23865                |_| Some(agent("claude")),
23866            ));
23867            assert!(request_requires_open_provider_ids_with(
23868                request,
23869                |_| None,
23870                |_| Some(agent("claude")),
23871            ));
23872        }
23873
23874        let record_requests = [
23875            NodeRequest::RenameSessionRecord {
23876                record_id: record_id.clone(),
23877                display_name: "renamed".to_owned(),
23878            },
23879            NodeRequest::ResumeSessionRecord {
23880                record_id: record_id.clone(),
23881                terminal_size: size,
23882                initial_prompt: None,
23883            },
23884            NodeRequest::ForgetSessionRecord { record_id },
23885        ];
23886        for request in &record_requests {
23887            assert!(request_requires_open_provider_ids_with(
23888                request,
23889                |_| Some(agent("claude")),
23890                |_| Some(agent("grok")),
23891            ));
23892            assert!(!request_requires_open_provider_ids_with(
23893                request,
23894                |_| Some(agent("claude")),
23895                |_| Some(agent("kimi")),
23896            ));
23897            assert!(request_requires_open_provider_ids_with(
23898                request,
23899                |_| Some(agent("claude")),
23900                |_| None,
23901            ));
23902        }
23903
23904        assert!(request_requires_open_provider_ids_with(
23905            &NodeRequest::Spawn {
23906                workspace_id: WorkspaceId::new("primary").unwrap(),
23907                provider: agent("third-party-agent"),
23908                mode: SessionMode::Pty,
23909                terminal_size: size,
23910                initial_prompt: None,
23911            },
23912            |_| None,
23913            |_| None,
23914        ));
23915        assert!(!request_requires_open_provider_ids_with(
23916            &NodeRequest::Snapshot,
23917            |_| None,
23918            |_| None,
23919        ));
23920        assert!(request_requires_open_provider_ids_with(
23921            &NodeRequest::ForgetContextPack {
23922                context_id: SpawnContextId::new("ctx-forget").unwrap(),
23923            },
23924            |_| None,
23925            |_| None,
23926        ));
23927    }
23928
23929    #[test]
23930    fn legacy_projection_filters_open_record_replies_and_event_history() {
23931        let catalog = active_registry().unwrap();
23932        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23933        let workspace = WorkspaceConfig::new(
23934            WorkspaceId::new("primary").unwrap(),
23935            std::env::current_dir().unwrap(),
23936        )
23937        .unwrap();
23938        let shared = NodeShared::new(
23939            handle,
23940            "fixture-token".to_owned(),
23941            NodeId::new("node-1").unwrap(),
23942            vec![workspace],
23943            vec![agent("claude"), agent("grok")],
23944        );
23945        let legacy = record("claude", "legacy-record");
23946        let open = record("grok", "open-record");
23947        let legacy_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
23948            record: legacy.clone(),
23949        });
23950        let open_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
23951            record: open.clone(),
23952        });
23953        let legacy_removed = shared.publish(NodeEvent::SessionRecordRemoved {
23954            record_id: legacy.record_id.clone(),
23955        });
23956        let open_removed = shared.publish(NodeEvent::SessionRecordRemoved {
23957            record_id: open.record_id.clone(),
23958        });
23959        assert!(project_event_legacy_provider_ids(&shared, legacy_upsert.clone()).is_some());
23960        assert!(project_event_legacy_provider_ids(&shared, open_upsert.clone()).is_none());
23961        assert!(project_event_legacy_provider_ids(&shared, legacy_removed.clone()).is_some());
23962        assert!(project_event_legacy_provider_ids(&shared, open_removed.clone()).is_none());
23963
23964        let mut record_reply = ResponseEnvelope {
23965            request_id: 1,
23966            result: Ok(NodeResponse::SessionRecordUpdated { record: open }),
23967        };
23968        project_response_legacy_provider_ids(&shared, &mut record_reply);
23969        assert!(matches!(
23970            record_reply.result,
23971            Err(NodeFailure { code: NodeFailureCode::UnsupportedCapability, .. })
23972        ));
23973
23974        let mut resync = ResponseEnvelope {
23975            request_id: 2,
23976            result: Ok(NodeResponse::Resync {
23977                event_sequence: open_removed.sequence,
23978                oldest_available_sequence: 1,
23979                snapshot: shared.snapshot(),
23980                events: vec![legacy_upsert, open_upsert, legacy_removed, open_removed],
23981            }),
23982        };
23983        project_response_legacy_provider_ids(&shared, &mut resync);
23984        let Ok(NodeResponse::Resync { snapshot, events, .. }) = resync.result else {
23985            panic!("legacy projection changed the resync response kind");
23986        };
23987        assert!(snapshot.session_records.iter().all(|item| {
23988            provider_id_is_legacy(&item.provider)
23989        }));
23990        assert_eq!(events.len(), 2);
23991        assert!(events.iter().all(|event| matches!(
23992            &event.event,
23993            NodeEvent::SessionRecordUpserted { record }
23994                if provider_id_is_legacy(&record.provider)
23995        ) || matches!(&event.event, NodeEvent::SessionRecordRemoved { .. })));
23996    }
23997
23998    #[test]
23999    fn session_binding_capacity_is_bounded_by_the_control_session_limit() {
24000        let catalog = active_registry().unwrap();
24001        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24002        let workspace_id = WorkspaceId::new("primary").unwrap();
24003        let workspace = WorkspaceConfig::new(
24004            workspace_id.clone(),
24005            std::env::current_dir().unwrap(),
24006        )
24007        .unwrap();
24008        let shared = NodeShared::new(
24009            handle,
24010            "fixture-token".to_owned(),
24011            NodeId::new("node-1").unwrap(),
24012            vec![workspace],
24013            vec![agent("claude")],
24014        );
24015        for index in 0..CONTROL_SESSIONS_MAX {
24016            shared.bind_session(
24017                AgentInstanceId((index + 1) as u64),
24018                workspace_id.clone(),
24019                SessionGeneration(1),
24020            );
24021        }
24022        let error = shared.ensure_binding_capacity().unwrap_err();
24023        assert_eq!(error.code, NodeFailureCode::BackendBusy);
24024    }
24025
24026    #[tokio::test]
24027    async fn provider_spawn_admission_rejects_before_session_mutation() {
24028        let catalog = active_registry().unwrap();
24029        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24030        let workspace = WorkspaceConfig::new(
24031            WorkspaceId::new("primary").unwrap(),
24032            std::env::current_dir().unwrap(),
24033        )
24034        .unwrap();
24035        let mut shared = NodeShared::new(
24036            handle,
24037            "fixture-token".to_owned(),
24038            NodeId::new("node-provider-admission").unwrap(),
24039            vec![workspace],
24040            vec![agent("claude")],
24041        );
24042        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
24043            crate::protocol::ProviderRuntimeStatus::unavailable(agent("claude")),
24044        ])
24045        .unwrap();
24046        let terminal_size = gate4agent_types::TerminalSize {
24047            rows: 24,
24048            columns: 80,
24049        };
24050
24051        let unavailable = shared
24052            .spawn_session(
24053                WorkspaceId::new("primary").unwrap(),
24054                agent("claude"),
24055                SessionMode::Pty,
24056                terminal_size,
24057                None,
24058            )
24059            .await
24060            .unwrap_err();
24061        assert_eq!(unavailable.code, NodeFailureCode::BackendOperationFailed);
24062        assert_eq!(unavailable.message, "backend-operation-failed");
24063
24064        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
24065            crate::protocol::ProviderRuntimeStatus::raw_passthrough(
24066                agent("claude"),
24067                Some(crate::protocol::ProviderRuntimeVersion::new("999.0.0").unwrap()),
24068            ),
24069        ])
24070        .unwrap();
24071        let semantic = shared
24072            .spawn_session(
24073                WorkspaceId::new("primary").unwrap(),
24074                agent("claude"),
24075                SessionMode::Inline,
24076                terminal_size,
24077                None,
24078            )
24079            .await
24080            .unwrap_err();
24081        assert_eq!(semantic.code, NodeFailureCode::UnsupportedCapability);
24082        assert_eq!(semantic.message, "unsupported-capability");
24083
24084        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
24085        assert!(shared
24086            .session_bindings
24087            .lock()
24088            .unwrap_or_else(|poisoned| poisoned.into_inner())
24089            .is_empty());
24090        assert!(shared
24091            .session_records
24092            .lock()
24093            .unwrap_or_else(|poisoned| poisoned.into_inner())
24094            .records
24095            .is_empty());
24096        assert!(shared.handle.snapshot().sessions.is_empty());
24097    }
24098
24099    #[tokio::test]
24100    async fn raw_spawn_binding_is_visible_with_a_durable_live_record() {
24101        let catalog = active_registry().unwrap();
24102        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24103        let workspace_id = WorkspaceId::new("primary").unwrap();
24104        let workspace = WorkspaceConfig::new(
24105            workspace_id.clone(),
24106            std::env::current_dir().unwrap(),
24107        )
24108        .unwrap();
24109        let shared = NodeShared::new(
24110            handle,
24111            "fixture-token".to_owned(),
24112            NodeId::new("node-raw-spawn-record").unwrap(),
24113            vec![workspace],
24114            vec![agent("claude")],
24115        );
24116        let address = SessionAddress {
24117            workspace_id,
24118            session: SessionKey {
24119                instance_id: AgentInstanceId(71),
24120                generation: SessionGeneration::default(),
24121            },
24122        };
24123
24124        let record_id = shared
24125            .bind_spawn_session(
24126                &address,
24127                agent("claude"),
24128                SessionMode::Pty,
24129                ProviderRuntimePolicy::raw_pty(),
24130                SpawnRecordPolicy::Always,
24131                None,
24132            )
24133            .unwrap()
24134            .unwrap();
24135        shared
24136            .handle
24137            .dispatch(shared.prepare_command(ControlCommand::Register {
24138                instance_id: address.session.instance_id,
24139                agent_id: agent("claude"),
24140                transport: TransportKind::Pty,
24141            }))
24142            .unwrap();
24143        runtime.tick().await;
24144
24145        assert!(shared.handle.snapshot().sessions.iter().any(|session| {
24146            session.instance_id == address.session.instance_id
24147                && session.generation == address.session.generation
24148        }));
24149        let record = shared.record(&record_id).unwrap();
24150        assert_eq!(record.display_name, "claude #1");
24151        assert_eq!(record.state, ManagedSessionState::Live);
24152        assert_eq!(record.active_session.as_ref(), Some(&address));
24153        assert!(record.provider_session.is_none());
24154        let binding_record_id = shared
24155            .session_bindings
24156            .lock()
24157            .unwrap_or_else(|poisoned| poisoned.into_inner())
24158            .get(&address.session.instance_id)
24159            .and_then(|binding| binding.record_id.clone());
24160        assert_eq!(binding_record_id.as_ref(), Some(&record_id));
24161
24162        let renamed = shared
24163            .rename_session_record(&record_id, "raw review".to_owned())
24164            .unwrap();
24165        assert_eq!(renamed.record_id, record_id);
24166        assert_eq!(renamed.display_name, "raw review");
24167        shared.reconcile_managed_record(
24168            &record_id,
24169            &address,
24170            &ControlEvent {
24171                sequence: 1,
24172                command_id: None,
24173                instance_id: address.session.instance_id,
24174                generation: address.session.generation,
24175                event: ControlEventKind::Running { process_id: None },
24176            },
24177            false,
24178            false,
24179        );
24180        let running = shared.record(&record_id).unwrap();
24181        assert_eq!(running.state, ManagedSessionState::Live);
24182        assert_eq!(running.active_session.as_ref(), Some(&address));
24183        assert_eq!(running.display_name, "raw review");
24184
24185        shared.reconcile_managed_record(
24186            &record_id,
24187            &address,
24188            &ControlEvent {
24189                sequence: 2,
24190                command_id: None,
24191                instance_id: address.session.instance_id,
24192                generation: address.session.generation,
24193                event: ControlEventKind::Exited {
24194                    exit_code: Some(0),
24195                    forced: false,
24196                },
24197            },
24198            false,
24199            false,
24200        );
24201        let detached = shared.record(&record_id).unwrap();
24202        assert_eq!(detached.state, ManagedSessionState::Unavailable);
24203        assert!(detached.active_session.is_none());
24204        assert_eq!(detached.display_name, "raw review");
24205        let renamed_detached = shared
24206            .rename_session_record(&record_id, "raw review detached".to_owned())
24207            .unwrap();
24208        assert_eq!(renamed_detached.record_id, record_id);
24209        assert_eq!(renamed_detached.state, ManagedSessionState::Unavailable);
24210        assert!(renamed_detached.active_session.is_none());
24211        assert_eq!(renamed_detached.display_name, "raw review detached");
24212        assert_eq!(shared.snapshot().session_records, vec![renamed_detached]);
24213    }
24214
24215    #[test]
24216    fn legacy_raw_spawn_binding_remains_untracked() {
24217        let catalog = active_registry().unwrap();
24218        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24219        let workspace_id = WorkspaceId::new("primary").unwrap();
24220        let workspace = WorkspaceConfig::new(
24221            workspace_id.clone(),
24222            std::env::current_dir().unwrap(),
24223        )
24224        .unwrap();
24225        let shared = NodeShared::new(
24226            handle,
24227            "fixture-token".to_owned(),
24228            NodeId::new("node-legacy-raw-spawn").unwrap(),
24229            vec![workspace],
24230            vec![agent("claude")],
24231        );
24232        let address = SessionAddress {
24233            workspace_id,
24234            session: SessionKey {
24235                instance_id: AgentInstanceId(72),
24236                generation: SessionGeneration::default(),
24237            },
24238        };
24239
24240        let record_id = shared
24241            .bind_spawn_session(
24242                &address,
24243                agent("claude"),
24244                SessionMode::Pty,
24245                ProviderRuntimePolicy::raw_pty(),
24246                SpawnRecordPolicy::ProviderIdentityOnly,
24247                None,
24248            )
24249            .unwrap();
24250
24251        assert!(record_id.is_none());
24252        let binding_record_id = shared
24253            .session_bindings
24254            .lock()
24255            .unwrap_or_else(|poisoned| poisoned.into_inner())
24256            .get(&address.session.instance_id)
24257            .and_then(|binding| binding.record_id.clone());
24258        assert!(binding_record_id.is_none());
24259        assert!(shared.snapshot().session_records.is_empty());
24260    }
24261
24262    #[tokio::test]
24263    async fn raw_runtime_policy_rejects_prompt_paste_and_resume_before_mutation() {
24264        let catalog = active_registry().unwrap();
24265        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24266        let workspace_id = WorkspaceId::new("primary").unwrap();
24267        let workspace = WorkspaceConfig::new(
24268            workspace_id.clone(),
24269            std::env::current_dir().unwrap(),
24270        )
24271        .unwrap();
24272        let mut shared = NodeShared::new(
24273            handle,
24274            "fixture-token".to_owned(),
24275            NodeId::new("node-raw-semantic-gate").unwrap(),
24276            vec![workspace],
24277            vec![agent("claude")],
24278        );
24279        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
24280            crate::protocol::ProviderRuntimeStatus::raw_passthrough(agent("claude"), None),
24281        ])
24282        .unwrap();
24283        shared
24284            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
24285            .unwrap();
24286        let address = SessionAddress {
24287            workspace_id,
24288            session: SessionKey {
24289                instance_id: AgentInstanceId(72),
24290                generation: SessionGeneration::default(),
24291            },
24292        };
24293        shared.bind_session_with_policy(
24294            &address,
24295            ProviderRuntimePolicy::raw_pty(),
24296            None,
24297        );
24298        shared
24299            .handle
24300            .dispatch(shared.prepare_command(ControlCommand::Register {
24301                instance_id: address.session.instance_id,
24302                agent_id: agent("claude"),
24303                transport: TransportKind::Pty,
24304            }))
24305            .unwrap();
24306        runtime.tick().await;
24307        let command_id_before = shared.next_command_id.load(Ordering::Acquire);
24308        let revision_before = shared.handle.snapshot().revision;
24309        let history_sequence_before = shared.current_sequence();
24310        let terminal_size = gate4agent_types::TerminalSize {
24311            rows: 24,
24312            columns: 80,
24313        };
24314
24315        for request in [
24316            NodeRequest::Prompt {
24317                session: address.clone(),
24318                text: "prompt".to_owned(),
24319            },
24320            NodeRequest::Paste {
24321                session: address.clone(),
24322                text: "paste".to_owned(),
24323            },
24324            // `initial_prompt: Some(_)` selects `ResumeWithPrompt`, which needs
24325            // the same semantic capabilities as `Prompt`/`Paste`. A bare
24326            // (promptless) resume is a raw PTY relaunch and IS admitted under
24327            // `raw_pty()` — see `raw_pty_admits_provider_native_resume_without_prompt_only`
24328            // in `provider_runtime.rs`; it is deliberately not exercised here.
24329            NodeRequest::Resume {
24330                session: address.clone(),
24331                terminal_size,
24332                initial_prompt: Some("resume".to_owned()),
24333            },
24334        ] {
24335            let error = process_request_inner(
24336                &shared,
24337                77,
24338                ClientRole::Operator,
24339                request,
24340            )
24341            .await
24342            .unwrap_err();
24343            assert_eq!(error.code, NodeFailureCode::UnsupportedCapability);
24344        }
24345
24346        assert_eq!(shared.next_command_id.load(Ordering::Acquire), command_id_before);
24347        assert_eq!(shared.handle.snapshot().revision, revision_before);
24348        assert_eq!(shared.current_sequence(), history_sequence_before);
24349        let bindings = shared
24350            .session_bindings
24351            .lock()
24352            .unwrap_or_else(|poisoned| poisoned.into_inner());
24353        let binding = bindings.get(&address.session.instance_id).unwrap();
24354        assert_eq!(binding.generation, address.session.generation);
24355        assert!(binding.pending_resume.is_none());
24356        assert!(binding.record_id.is_none());
24357    }
24358
24359    #[tokio::test]
24360    async fn windows_rejects_unix_bytes_workspace_path_before_filesystem_access() {
24361        let catalog = active_registry().unwrap();
24362        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24363        let primary = WorkspaceConfig::new(
24364            WorkspaceId::new("primary").unwrap(),
24365            std::env::current_dir().unwrap(),
24366        )
24367        .unwrap();
24368        let shared = NodeShared::new(
24369            handle,
24370            "fixture-token".to_owned(),
24371            NodeId::new("node-1").unwrap(),
24372            vec![primary],
24373            vec![agent("claude")],
24374        );
24375        shared
24376            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
24377            .unwrap();
24378        let before = shared.snapshot();
24379        let error = process_request_inner(
24380            &shared,
24381            77,
24382            ClientRole::Operator,
24383            NodeRequest::RegisterWorkspace {
24384                workspace_id: WorkspaceId::new("foreign").unwrap(),
24385                root: OpaqueHostPath::unix_bytes(b"/srv/repo".to_vec()).unwrap(),
24386            },
24387        )
24388        .await
24389        .unwrap_err();
24390        assert_eq!(error.code, NodeFailureCode::InvalidRequest);
24391        assert_eq!(error.message, "invalid-request");
24392        assert_eq!(shared.snapshot().workspaces, before.workspaces);
24393    }
24394
24395    #[tokio::test]
24396    async fn dynamic_workspaces_reject_duplicates_busy_and_last_then_publish_ordered_events() {
24397        let catalog = active_registry().unwrap();
24398        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24399        let primary_id = WorkspaceId::new("primary").unwrap();
24400        let primary = WorkspaceConfig::new(
24401            primary_id.clone(),
24402            std::env::current_dir().unwrap(),
24403        )
24404        .unwrap();
24405        let shared = NodeShared::new(
24406            handle,
24407            "fixture-token".to_owned(),
24408            NodeId::new("node-1").unwrap(),
24409            vec![primary.clone()],
24410            vec![agent("claude")],
24411        );
24412        let secondary_id = WorkspaceId::new("secondary").unwrap();
24413        let secondary = shared
24414            .register_workspace(
24415                secondary_id.clone(),
24416                std::env::temp_dir().to_string_lossy().into_owned(),
24417            )
24418            .await
24419            .unwrap();
24420        assert_eq!(secondary.workspace_id, secondary_id);
24421        assert!(secondary.sessions.is_empty());
24422        assert_eq!(shared.snapshot().workspaces.len(), 2);
24423
24424        let duplicate_id = shared
24425            .register_workspace(
24426                secondary_id.clone(),
24427                primary.canonical_root().to_owned(),
24428            )
24429            .await
24430            .unwrap_err();
24431        assert_eq!(duplicate_id.code, NodeFailureCode::DuplicateWorkspaceId);
24432        let duplicate_root = shared
24433            .register_workspace(
24434                WorkspaceId::new("duplicate-root").unwrap(),
24435                windows_path_text(&secondary.canonical_root).to_owned(),
24436            )
24437            .await
24438            .unwrap_err();
24439        assert_eq!(duplicate_root.code, NodeFailureCode::DuplicateWorkspaceRoot);
24440
24441        let address = SessionAddress {
24442            workspace_id: secondary_id.clone(),
24443            session: SessionKey {
24444                instance_id: AgentInstanceId(41),
24445                generation: SessionGeneration(1),
24446            },
24447        };
24448        shared.bind_session(
24449            address.session.instance_id,
24450            secondary_id.clone(),
24451            address.session.generation,
24452        );
24453        let busy = shared.unregister_workspace(&secondary_id).unwrap_err();
24454        assert_eq!(busy.code, NodeFailureCode::WorkspaceBusy);
24455        shared.remove_binding(&address);
24456        shared.unregister_workspace(&secondary_id).unwrap();
24457        assert_eq!(shared.snapshot().workspaces.len(), 1);
24458
24459        let last = shared.unregister_workspace(&primary_id).unwrap_err();
24460        assert_eq!(last.code, NodeFailureCode::LastWorkspace);
24461        let history = shared
24462            .history
24463            .lock()
24464            .unwrap_or_else(|poisoned| poisoned.into_inner());
24465        assert!(matches!(
24466            &history.events[0],
24467            NodeEventEnvelope {
24468                sequence: 1,
24469                event: NodeEvent::WorkspaceAdded { workspace },
24470            } if workspace.workspace_id == secondary_id
24471        ));
24472        assert!(matches!(
24473            &history.events[1],
24474            NodeEventEnvelope {
24475                sequence: 2,
24476                event: NodeEvent::WorkspaceRemoved { workspace_id },
24477            } if workspace_id == &secondary_id
24478        ));
24479    }
24480
24481    #[tokio::test]
24482    async fn provider_session_reference_index_is_controller_gated_and_idempotent() {
24483        let secondary_root = temporary_workspace_root("provider-session-reference-index");
24484        std::fs::create_dir_all(&secondary_root).unwrap();
24485        let catalog = active_registry().unwrap();
24486        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24487        let primary_id = WorkspaceId::new("primary").unwrap();
24488        let secondary_id = WorkspaceId::new("secondary").unwrap();
24489        let shared = NodeShared::new(
24490            handle,
24491            "fixture-token".to_owned(),
24492            NodeId::new("node-session-reference-index").unwrap(),
24493            vec![
24494                WorkspaceConfig::new(primary_id.clone(), std::env::current_dir().unwrap())
24495                    .unwrap(),
24496                WorkspaceConfig::new(secondary_id.clone(), &secondary_root).unwrap(),
24497            ],
24498            vec![agent("claude"), agent("codex")],
24499        );
24500        let identity = ProviderSessionIdentity {
24501            key: gate4agent_types::ProviderSessionKey::SessionId,
24502            id: "provider-session-42".to_owned(),
24503            transcript_path: None,
24504        };
24505        let index_request = |workspace_id: WorkspaceId,
24506                             provider: AgentId,
24507                             identity: ProviderSessionIdentity,
24508                             display_name: &str| {
24509            NodeRequest::IndexProviderSession {
24510                workspace_id,
24511                provider,
24512                identity,
24513                display_name: display_name.to_owned(),
24514            }
24515        };
24516        let request = index_request(
24517            primary_id.clone(),
24518            agent("claude"),
24519            identity.clone(),
24520            "release shepherd",
24521        );
24522        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
24523            capability.as_str() == NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY
24524        }));
24525        let observer = process_request_inner(
24526            &shared,
24527            77,
24528            ClientRole::Observer,
24529            request.clone(),
24530        )
24531        .await
24532        .unwrap_err();
24533        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
24534        let uncontrolled = process_request_inner(
24535            &shared,
24536            77,
24537            ClientRole::Operator,
24538            request.clone(),
24539        )
24540        .await
24541        .unwrap_err();
24542        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
24543        shared
24544            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
24545            .unwrap();
24546
24547        let indexed = process_request_inner(
24548            &shared,
24549            77,
24550            ClientRole::Operator,
24551            request.clone(),
24552        )
24553        .await
24554        .unwrap();
24555        let NodeResponse::ProviderSessionIndexed { record } = indexed else {
24556            panic!("provider session reference index returned another response");
24557        };
24558        assert_eq!(record.state, ManagedSessionState::Dormant);
24559        assert_eq!(record.mode, SessionMode::Pty);
24560        assert_eq!(record.workspace_id, primary_id);
24561        assert_eq!(record.provider_session.as_ref(), Some(&identity));
24562        assert!(record.active_session.is_none());
24563        assert!(record.environment_profile.is_none());
24564        assert!(record.bundle.is_none());
24565        assert!(record.context.is_none());
24566
24567        let repeated = process_request_inner(
24568            &shared,
24569            77,
24570            ClientRole::Operator,
24571            index_request(
24572                primary_id.clone(),
24573                agent("claude"),
24574                identity.clone(),
24575                "ignored replacement",
24576            ),
24577        )
24578        .await
24579        .unwrap();
24580        assert!(matches!(
24581            repeated,
24582            NodeResponse::ProviderSessionIndexed { record: ref existing }
24583                if existing.record_id == record.record_id
24584                    && existing.display_name == "release shepherd"
24585        ));
24586        let conflicting_workspace = process_request_inner(
24587            &shared,
24588            77,
24589            ClientRole::Operator,
24590            index_request(
24591                secondary_id,
24592                agent("claude"),
24593                identity.clone(),
24594                "release shepherd",
24595            ),
24596        )
24597        .await
24598        .unwrap_err();
24599        assert_eq!(conflicting_workspace.code, NodeFailureCode::SessionRecordConflict);
24600        let conflicting_provider = process_request_inner(
24601            &shared,
24602            77,
24603            ClientRole::Operator,
24604            index_request(
24605                primary_id.clone(),
24606                agent("codex"),
24607                identity.clone(),
24608                "release shepherd",
24609            ),
24610        )
24611        .await
24612        .unwrap_err();
24613        assert_eq!(conflicting_provider.code, NodeFailureCode::SessionRecordConflict);
24614        let transcript_rejected = process_request_inner(
24615            &shared,
24616            77,
24617            ClientRole::Operator,
24618            index_request(
24619                primary_id,
24620                agent("claude"),
24621                ProviderSessionIdentity {
24622                    transcript_path: Some("C:/provider/transcript.jsonl".to_owned()),
24623                    ..identity
24624                },
24625                "release shepherd",
24626            ),
24627        )
24628        .await
24629        .unwrap_err();
24630        assert_eq!(transcript_rejected.code, NodeFailureCode::InvalidRequest);
24631        let upserts = shared
24632            .history
24633            .lock()
24634            .unwrap_or_else(|poisoned| poisoned.into_inner())
24635            .events
24636            .iter()
24637            .filter(|event| matches!(
24638                &event.event,
24639                NodeEvent::SessionRecordUpserted { record: published }
24640                    if published.record_id == record.record_id
24641            ))
24642            .count();
24643        assert_eq!(upserts, 1);
24644        std::fs::remove_dir_all(secondary_root).unwrap();
24645    }
24646
24647    #[tokio::test]
24648    async fn standalone_workspace_is_independent_registered_and_controller_gated() {
24649        let fixture_root = temporary_workspace_root("standalone-independent");
24650        let target = fixture_root.join("independent");
24651        std::fs::create_dir_all(&fixture_root).unwrap();
24652        let catalog = active_registry().unwrap();
24653        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24654        let selected = WorkspaceConfig::new(
24655            WorkspaceId::new("primary").unwrap(),
24656            std::env::current_dir().unwrap(),
24657        )
24658        .unwrap();
24659        let selected_root = selected.canonical_root().to_owned();
24660        let shared = NodeShared::new(
24661            handle,
24662            "fixture-token".to_owned(),
24663            NodeId::new("node-standalone").unwrap(),
24664            vec![selected],
24665            vec![agent("claude")],
24666        );
24667        let workspace_id = WorkspaceId::new("independent").unwrap();
24668        let request = NodeRequest::CreateStandaloneWorkspace {
24669            workspace_id: workspace_id.clone(),
24670            root: opaque_windows_path(target.to_string_lossy().into_owned()),
24671            initial_branch: Some("main".to_owned()),
24672        };
24673        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
24674            capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
24675        }));
24676        assert!(!request_uses_unnegotiated_capability(
24677            &request,
24678            &baseline_capabilities().unwrap(),
24679        ));
24680        assert!(request_uses_unnegotiated_capability(&request, &[]));
24681
24682        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
24683            .await
24684            .unwrap_err();
24685        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
24686        assert!(!target.exists());
24687        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
24688            .await
24689            .unwrap_err();
24690        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
24691        assert!(!target.exists());
24692        shared
24693            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
24694            .unwrap();
24695
24696        let response = process_request_inner(&shared, 77, ClientRole::Operator, request)
24697            .await
24698            .unwrap();
24699        let NodeResponse::StandaloneWorkspaceCreated { workspace } = response else {
24700            panic!("standalone workspace creation returned another response");
24701        };
24702        assert_eq!(workspace.workspace_id, workspace_id);
24703        assert!(target.join(".git").is_dir());
24704        let branch = run_git_bounded(
24705            target.to_str().unwrap(),
24706            &["branch", "--show-current"],
24707            GIT_OUTPUT_MAX_BYTES,
24708        )
24709        .await
24710        .unwrap();
24711        assert!(branch.success);
24712        assert_eq!(String::from_utf8(branch.stdout).unwrap().trim(), "main");
24713        let worktrees = list_git_worktrees(target.to_str().unwrap()).await.unwrap();
24714        assert_eq!(worktrees.len(), 1);
24715        let listed_root = WorkspaceConfig::new(
24716            WorkspaceId::new("listed-independent").unwrap(),
24717            &worktrees[0].path,
24718        )
24719        .unwrap();
24720        assert!(platform::roots_equal(
24721            listed_root.canonical_root(),
24722            windows_path_text(&workspace.canonical_root),
24723        ));
24724        assert!(!platform::roots_equal(listed_root.canonical_root(), &selected_root));
24725        assert!(shared.snapshot().workspaces.iter().any(|snapshot| {
24726            snapshot.workspace_id == workspace_id
24727                && snapshot.canonical_root == workspace.canonical_root
24728        }));
24729        assert!(shared
24730            .history
24731            .lock()
24732            .unwrap_or_else(|poisoned| poisoned.into_inner())
24733            .events
24734            .iter()
24735            .any(|event| matches!(
24736                &event.event,
24737                NodeEvent::WorkspaceAdded { workspace }
24738                    if workspace.workspace_id == workspace_id
24739            )));
24740        std::fs::remove_dir_all(fixture_root).unwrap();
24741    }
24742
24743    #[tokio::test]
24744    async fn standalone_workspace_registration_failure_preserves_guarded_repository_for_recovery() {
24745        let fixture_root = temporary_workspace_root("standalone-compensation");
24746        let created_target = fixture_root.join("created-target");
24747        let existing_target = fixture_root.join("existing-target");
24748        let invalid_state_parent = fixture_root.join("state-parent-is-a-file");
24749        let invalid_state_path = invalid_state_parent.join("state.json");
24750        std::fs::create_dir_all(&existing_target).unwrap();
24751        std::fs::write(&invalid_state_parent, "not a directory").unwrap();
24752        let catalog = active_registry().unwrap();
24753        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24754        let primary = WorkspaceConfig::new(
24755            WorkspaceId::new("primary").unwrap(),
24756            std::env::current_dir().unwrap(),
24757        )
24758        .unwrap();
24759        let mut shared = NodeShared::new(
24760            handle,
24761            "fixture-token".to_owned(),
24762            NodeId::new("node-standalone-compensation").unwrap(),
24763            vec![primary],
24764            vec![agent("claude")],
24765        );
24766        shared.state_path = Some(invalid_state_path);
24767
24768        let created_recovery = shared
24769            .create_standalone_workspace(
24770                WorkspaceId::new("compensated").unwrap(),
24771                created_target.to_string_lossy().into_owned(),
24772                Some("main".to_owned()),
24773            )
24774            .await
24775            .unwrap_err();
24776        assert_eq!(
24777            created_recovery.code,
24778            NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
24779        );
24780        assert!(created_target.join(".git").is_dir());
24781        assert!(shared.snapshot().workspaces.iter().all(|workspace| {
24782            workspace.workspace_id.as_str() != "compensated"
24783        }));
24784
24785        let existing_recovery = shared
24786            .create_standalone_workspace(
24787                WorkspaceId::new("restored").unwrap(),
24788                existing_target.to_string_lossy().into_owned(),
24789                None,
24790            )
24791            .await
24792            .unwrap_err();
24793        assert_eq!(
24794            existing_recovery.code,
24795            NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
24796        );
24797        assert!(existing_target.is_dir());
24798        assert!(existing_target.join(".git").is_dir());
24799        assert!(shared.snapshot().workspaces.iter().all(|workspace| {
24800            workspace.workspace_id.as_str() != "restored"
24801        }));
24802
24803        let registered_empty = fixture_root.join("registered-empty");
24804        std::fs::create_dir(&registered_empty).unwrap();
24805        let catalog = active_registry().unwrap();
24806        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24807        let registered = WorkspaceConfig::new(
24808            WorkspaceId::new("registered-empty").unwrap(),
24809            &registered_empty,
24810        )
24811        .unwrap();
24812        let duplicate_shared = NodeShared::new(
24813            handle,
24814            "fixture-token".to_owned(),
24815            NodeId::new("node-standalone-duplicate-root").unwrap(),
24816            vec![registered],
24817            vec![agent("claude")],
24818        );
24819        let duplicate = duplicate_shared
24820            .create_standalone_workspace(
24821                WorkspaceId::new("duplicate-root").unwrap(),
24822                registered_empty.to_string_lossy().into_owned(),
24823                None,
24824            )
24825            .await
24826            .unwrap_err();
24827        assert_eq!(duplicate.code, NodeFailureCode::DuplicateWorkspaceRoot);
24828        assert!(std::fs::read_dir(&registered_empty).unwrap().next().is_none());
24829        std::fs::remove_dir_all(fixture_root).unwrap();
24830    }
24831
24832    #[test]
24833    fn managed_record_id_collision_preserves_the_existing_record() {
24834        let catalog = active_registry().unwrap();
24835        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24836        let workspace = WorkspaceConfig::new(
24837            WorkspaceId::new("primary").unwrap(),
24838            std::env::current_dir().unwrap(),
24839        )
24840        .unwrap();
24841        let shared = NodeShared::new(
24842            handle,
24843            "fixture-token".to_owned(),
24844            NodeId::new("node-1").unwrap(),
24845            vec![workspace.clone()],
24846            vec![agent("claude")],
24847        );
24848        let record_id = SessionRecordId::new("sr-collision").unwrap();
24849        let original = ManagedSessionRecord {
24850            record_id: record_id.clone(),
24851            display_name: "original".to_owned(),
24852            provider: agent("claude"),
24853            mode: SessionMode::Pty,
24854            state: ManagedSessionState::Dormant,
24855            workspace_id: workspace.workspace_id,
24856            canonical_root: opaque_windows_path(workspace.canonical_root),
24857            provider_session: None,
24858            active_session: None,
24859            environment_profile: None,
24860            bundle: None,
24861            context_id: None,
24862            context: None,
24863            exported_context: None,
24864            task_binding: None,
24865            created_at_unix_ms: 1,
24866            updated_at_unix_ms: 1,
24867            last_error: None,
24868        };
24869        shared.insert_record(original.clone()).unwrap();
24870        let mut collision = original.clone();
24871        collision.display_name = "replacement".to_owned();
24872        let error = shared.insert_record(collision).unwrap_err();
24873        assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
24874        assert_eq!(shared.record(&record_id).unwrap(), original);
24875    }
24876
24877    #[test]
24878    fn managed_record_runtime_errors_are_exposed_only_as_allowlisted_categories() {
24879        let catalog = active_registry().unwrap();
24880        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24881        let workspace = WorkspaceConfig::new(
24882            WorkspaceId::new("primary").unwrap(),
24883            std::env::current_dir().unwrap(),
24884        )
24885        .unwrap();
24886        let shared = NodeShared::new(
24887            handle,
24888            "fixture-token".to_owned(),
24889            NodeId::new("node-1").unwrap(),
24890            vec![workspace.clone()],
24891            vec![agent("claude")],
24892        );
24893        let record_id = SessionRecordId::new("sr-runtime-error").unwrap();
24894        shared
24895            .insert_record(ManagedSessionRecord {
24896                record_id: record_id.clone(),
24897                display_name: "runtime error".to_owned(),
24898                provider: agent("claude"),
24899                mode: SessionMode::Pty,
24900                state: ManagedSessionState::IdentityPending,
24901                workspace_id: workspace.workspace_id,
24902                canonical_root: opaque_windows_path(workspace.canonical_root),
24903                provider_session: None,
24904                active_session: None,
24905                environment_profile: None,
24906                bundle: None,
24907                context_id: None,
24908                context: None,
24909                exported_context: None,
24910                task_binding: None,
24911                created_at_unix_ms: 1,
24912                updated_at_unix_ms: 1,
24913                last_error: None,
24914            })
24915            .unwrap();
24916
24917        shared
24918            .mark_record_error(
24919                &record_id,
24920                r"provider failed with bearer-secret-123 at C:\private\session.jsonl",
24921            )
24922            .unwrap();
24923        let record = shared.record(&record_id).unwrap();
24924        assert_eq!(record.state, ManagedSessionState::Unavailable);
24925        assert_eq!(
24926            record.last_error.as_deref(),
24927            Some("provider-runtime-failed"),
24928        );
24929        let snapshot = shared.snapshot();
24930        assert_eq!(snapshot.session_records, vec![record]);
24931    }
24932
24933    /// Regression coverage for the "operator sees `deadline` instead of the
24934    /// kernel's own named rejection" defect: a spawn-dispatch waiter must be
24935    /// able to learn a command's asynchronous kernel rejection (here,
24936    /// specifically classified as `UnsupportedTransport`) exactly once, and
24937    /// must not still find it after that -- proving the fast path this
24938    /// mechanism exists for (`spawn_session_with_deadline`'s commit-wait
24939    /// loop) drains the reason instead of leaving it to be rediscovered (or
24940    /// missed) on a later poll.
24941    #[test]
24942    fn rejected_command_reason_is_classified_and_consumed_exactly_once() {
24943        let catalog = active_registry().unwrap();
24944        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24945        let workspace = WorkspaceConfig::new(
24946            WorkspaceId::new("primary").unwrap(),
24947            std::env::current_dir().unwrap(),
24948        )
24949        .unwrap();
24950        let shared = NodeShared::new(
24951            handle,
24952            "fixture-token".to_owned(),
24953            NodeId::new("node-1").unwrap(),
24954            vec![workspace],
24955            vec![agent("claude")],
24956        );
24957
24958        assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
24959
24960        shared.record_rejected_command(
24961            CommandId(1),
24962            "agent 'claude' does not support transport Acp".to_owned(),
24963            true,
24964        );
24965        // An unrelated command_id must not see another command's reason.
24966        assert!(shared.take_rejected_command_reason(CommandId(2)).is_none());
24967
24968        let reason = shared.take_rejected_command_reason(CommandId(1)).unwrap();
24969        assert_eq!(reason.message, "agent 'claude' does not support transport Acp");
24970        assert!(reason.unsupported_transport);
24971
24972        // Consumed: a second read for the same command_id finds nothing.
24973        assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
24974
24975        // A rejection unrelated to transport support keeps its own
24976        // classification distinct, so the reader can choose the generic
24977        // `BackendOperationFailed` code instead of misreporting
24978        // `UnsupportedTransport`.
24979        shared.record_rejected_command(
24980            CommandId(3),
24981            "backend busy".to_owned(),
24982            false,
24983        );
24984        let unclassified = shared.take_rejected_command_reason(CommandId(3)).unwrap();
24985        assert!(!unclassified.unsupported_transport);
24986    }
24987
24988    #[test]
24989    fn provider_identity_update_is_ignored_without_exact_session_policy() {
24990        let catalog = active_registry().unwrap();
24991        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24992        let workspace = WorkspaceConfig::new(
24993            WorkspaceId::new("primary").unwrap(),
24994            std::env::current_dir().unwrap(),
24995        )
24996        .unwrap();
24997        let shared = NodeShared::new(
24998            handle,
24999            "fixture-token".to_owned(),
25000            NodeId::new("node-identity-policy").unwrap(),
25001            vec![workspace.clone()],
25002            vec![agent("claude")],
25003        );
25004        let address = SessionAddress {
25005            workspace_id: workspace.workspace_id.clone(),
25006            session: SessionKey {
25007                instance_id: AgentInstanceId(90),
25008                generation: SessionGeneration(1),
25009            },
25010        };
25011        let record_id = SessionRecordId::new("sr-policy-denied").unwrap();
25012        shared
25013            .insert_record(ManagedSessionRecord {
25014                record_id: record_id.clone(),
25015                display_name: "policy denied".to_owned(),
25016                provider: agent("claude"),
25017                mode: SessionMode::Pty,
25018                state: ManagedSessionState::IdentityPending,
25019                workspace_id: workspace.workspace_id,
25020                canonical_root: opaque_windows_path(workspace.canonical_root),
25021                provider_session: None,
25022                active_session: Some(address.clone()),
25023                environment_profile: None,
25024                bundle: None,
25025                context_id: None,
25026                context: None,
25027                exported_context: None,
25028                task_binding: None,
25029                created_at_unix_ms: 1,
25030                updated_at_unix_ms: 1,
25031                last_error: None,
25032            })
25033            .unwrap();
25034        let event = ControlEvent {
25035            sequence: 1,
25036            command_id: None,
25037            instance_id: address.session.instance_id,
25038            generation: address.session.generation,
25039            event: ControlEventKind::ProviderEvent {
25040                sequence: 1,
25041                source: gate4agent_types::ProviderSource {
25042                    family: AdapterFamily::PtySemantic,
25043                    binding: AdapterBinding::new(
25044                        gate4agent_types::AdapterId::new("claude").unwrap(),
25045                        "fixture/v1",
25046                        gate4agent_types::AdapterVerification::SyntheticFixture,
25047                    )
25048                    .unwrap(),
25049                },
25050                source_sequence: 1,
25051                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
25052                    identity: gate4agent_types::ProviderSessionIdentity {
25053                        key: gate4agent_types::ProviderSessionKey::SessionId,
25054                        id: "provider-session".to_owned(),
25055                        transcript_path: None,
25056                    },
25057                },
25058            },
25059        };
25060
25061        shared.reconcile_managed_record(&record_id, &address, &event, false, false);
25062
25063        let record = shared.record(&record_id).unwrap();
25064        assert!(record.provider_session.is_none());
25065        assert_eq!(record.state, ManagedSessionState::IdentityPending);
25066        assert_eq!(record.active_session.as_ref(), Some(&address));
25067    }
25068
25069    #[test]
25070    fn provider_identity_cannot_rebind_a_record_across_workspaces() {
25071        let secondary_root = temporary_workspace_root("identity-scope");
25072        std::fs::create_dir_all(&secondary_root).unwrap();
25073        let catalog = active_registry().unwrap();
25074        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25075        let primary = WorkspaceConfig::new(
25076            WorkspaceId::new("primary").unwrap(),
25077            std::env::current_dir().unwrap(),
25078        )
25079        .unwrap();
25080        let secondary = WorkspaceConfig::new(
25081            WorkspaceId::new("secondary").unwrap(),
25082            &secondary_root,
25083        )
25084        .unwrap();
25085        let shared = NodeShared::new(
25086            handle,
25087            "fixture-token".to_owned(),
25088            NodeId::new("node-1").unwrap(),
25089            vec![primary.clone(), secondary.clone()],
25090            vec![agent("claude")],
25091        );
25092        let identity = gate4agent_types::ProviderSessionIdentity {
25093            key: gate4agent_types::ProviderSessionKey::SessionId,
25094            id: "provider-session".to_owned(),
25095            transcript_path: None,
25096        };
25097        let dormant_id = SessionRecordId::new("sr-dormant").unwrap();
25098        shared
25099            .insert_record(ManagedSessionRecord {
25100                record_id: dormant_id.clone(),
25101                display_name: "dormant".to_owned(),
25102                provider: agent("claude"),
25103                mode: SessionMode::Pty,
25104                state: ManagedSessionState::Dormant,
25105                workspace_id: primary.workspace_id,
25106                canonical_root: opaque_windows_path(primary.canonical_root),
25107                provider_session: Some(identity.clone()),
25108                active_session: None,
25109                environment_profile: None,
25110                bundle: None,
25111                context_id: None,
25112                context: None,
25113                exported_context: None,
25114                task_binding: None,
25115                created_at_unix_ms: 1,
25116                updated_at_unix_ms: 1,
25117                last_error: None,
25118            })
25119            .unwrap();
25120        let address = SessionAddress {
25121            workspace_id: secondary.workspace_id.clone(),
25122            session: SessionKey {
25123                instance_id: AgentInstanceId(91),
25124                generation: SessionGeneration(1),
25125            },
25126        };
25127        let current_id = SessionRecordId::new("sr-current").unwrap();
25128        shared
25129            .insert_record(ManagedSessionRecord {
25130                record_id: current_id.clone(),
25131                display_name: "current".to_owned(),
25132                provider: agent("claude"),
25133                mode: SessionMode::Pty,
25134                state: ManagedSessionState::IdentityPending,
25135                workspace_id: secondary.workspace_id,
25136                canonical_root: opaque_windows_path(secondary.canonical_root),
25137                provider_session: None,
25138                active_session: Some(address.clone()),
25139                environment_profile: None,
25140                bundle: None,
25141                context_id: None,
25142                context: None,
25143                exported_context: None,
25144                task_binding: None,
25145                created_at_unix_ms: 2,
25146                updated_at_unix_ms: 2,
25147                last_error: None,
25148            })
25149            .unwrap();
25150        shared.bind_managed_session(
25151            &address,
25152            current_id.clone(),
25153            ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
25154            None,
25155        );
25156        let event = ControlEvent {
25157            sequence: 1,
25158            command_id: None,
25159            instance_id: address.session.instance_id,
25160            generation: address.session.generation,
25161            event: ControlEventKind::ProviderEvent {
25162                sequence: 1,
25163                source: gate4agent_types::ProviderSource {
25164                    family: gate4agent_types::AdapterFamily::PtySemantic,
25165                    binding: gate4agent_types::AdapterBinding::new(
25166                        gate4agent_types::AdapterId::new("claude").unwrap(),
25167                        "fixture/v1",
25168                        gate4agent_types::AdapterVerification::SyntheticFixture,
25169                    )
25170                    .unwrap(),
25171                },
25172                source_sequence: 1,
25173                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
25174                    identity,
25175                },
25176            },
25177        };
25178        shared.reconcile_managed_record(&current_id, &address, &event, true, false);
25179
25180        let binding = shared
25181            .session_bindings
25182            .lock()
25183            .unwrap_or_else(|poisoned| poisoned.into_inner())
25184            .get(&address.session.instance_id)
25185            .cloned()
25186            .unwrap();
25187        assert_eq!(binding.record_id.as_ref(), Some(&current_id));
25188        assert_eq!(shared.record(&dormant_id).unwrap().state, ManagedSessionState::Dormant);
25189        let current = shared.record(&current_id).unwrap();
25190        assert_eq!(current.state, ManagedSessionState::Unavailable);
25191        assert_eq!(current.active_session.as_ref(), Some(&address));
25192        assert_eq!(
25193            current.last_error.as_deref(),
25194            Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR),
25195        );
25196        std::fs::remove_dir_all(secondary_root).unwrap();
25197    }
25198
25199    #[tokio::test]
25200    async fn semantic_identity_with_transcript_path_reuses_record_and_survives_reload() {
25201        let root = temporary_workspace_root("semantic-provider-session");
25202        std::fs::create_dir_all(&root).unwrap();
25203        let state_path = root.join("state-v1.json");
25204        let catalog = active_registry().unwrap();
25205        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25206        let workspace = WorkspaceConfig::new(
25207            WorkspaceId::new("primary").unwrap(),
25208            &root,
25209        )
25210        .unwrap();
25211        let node_id = NodeId::new("node-semantic-identity").unwrap();
25212        let shared = NodeShared::new_with_incarnation(
25213            handle,
25214            "fixture-token".to_owned(),
25215            node_id.clone(),
25216            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
25217            vec![workspace.clone()],
25218            vec![agent("claude")],
25219            ProviderRuntimeStatuses::default(),
25220            None,
25221            Vec::new(),
25222            Vec::new(),
25223            SpawnProfileRegistry::default(),
25224            None,
25225            None,
25226            None,
25227            Some(state_path.clone()),
25228            Vec::new(),
25229            Vec::new(),
25230            Vec::new(),
25231            Vec::new(),
25232            None,
25233            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
25234        );
25235        let persisted_identity = gate4agent_types::ProviderSessionIdentity {
25236            key: gate4agent_types::ProviderSessionKey::SessionId,
25237            id: "provider-session".to_owned(),
25238            transcript_path: None,
25239        };
25240        let durable_id = SessionRecordId::new("sr-durable").unwrap();
25241        shared
25242            .insert_record(ManagedSessionRecord {
25243                record_id: durable_id.clone(),
25244                display_name: "durable".to_owned(),
25245                provider: agent("claude"),
25246                mode: SessionMode::Pty,
25247                state: ManagedSessionState::Dormant,
25248                workspace_id: workspace.workspace_id.clone(),
25249                canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
25250                provider_session: Some(persisted_identity.clone()),
25251                active_session: None,
25252                environment_profile: None,
25253                bundle: None,
25254                context_id: None,
25255                context: None,
25256                exported_context: None,
25257                task_binding: None,
25258                created_at_unix_ms: 1,
25259                updated_at_unix_ms: 1,
25260                last_error: None,
25261            })
25262            .unwrap();
25263        let address = SessionAddress {
25264            workspace_id: workspace.workspace_id.clone(),
25265            session: SessionKey {
25266                instance_id: AgentInstanceId(92),
25267                generation: SessionGeneration(2),
25268            },
25269        };
25270        let pending_id = SessionRecordId::new("sr-pending").unwrap();
25271        shared
25272            .insert_record(ManagedSessionRecord {
25273                record_id: pending_id.clone(),
25274                display_name: "pending".to_owned(),
25275                provider: agent("claude"),
25276                mode: SessionMode::Pty,
25277                state: ManagedSessionState::IdentityPending,
25278                workspace_id: workspace.workspace_id.clone(),
25279                canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
25280                provider_session: None,
25281                active_session: Some(address.clone()),
25282                environment_profile: None,
25283                bundle: None,
25284                context_id: None,
25285                context: None,
25286                exported_context: None,
25287                task_binding: None,
25288                created_at_unix_ms: 2,
25289                updated_at_unix_ms: 2,
25290                last_error: None,
25291            })
25292            .unwrap();
25293        shared.bind_managed_session(
25294            &address,
25295            pending_id.clone(),
25296            ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
25297            None,
25298        );
25299        let observed_identity = gate4agent_types::ProviderSessionIdentity {
25300            transcript_path: Some(r"C:\private\provider-transcript.jsonl".to_owned()),
25301            ..persisted_identity
25302        };
25303        let event = ControlEvent {
25304            sequence: 1,
25305            command_id: None,
25306            instance_id: address.session.instance_id,
25307            generation: address.session.generation,
25308            event: ControlEventKind::ProviderEvent {
25309                sequence: 1,
25310                source: gate4agent_types::ProviderSource {
25311                    family: gate4agent_types::AdapterFamily::PtySemantic,
25312                    binding: gate4agent_types::AdapterBinding::new(
25313                        gate4agent_types::AdapterId::new("claude").unwrap(),
25314                        "fixture/v1",
25315                        gate4agent_types::AdapterVerification::SyntheticFixture,
25316                    )
25317                    .unwrap(),
25318                },
25319                source_sequence: 1,
25320                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
25321                    identity: observed_identity.clone(),
25322                },
25323            },
25324        };
25325        shared.reconcile_managed_record(&pending_id, &address, &event, true, false);
25326
25327        let snapshot = shared.snapshot();
25328        assert_eq!(snapshot.session_records.len(), 1);
25329        let live = &snapshot.session_records[0];
25330        assert_eq!(live.record_id, durable_id);
25331        assert_eq!(live.state, ManagedSessionState::Live);
25332        assert_eq!(live.active_session.as_ref(), Some(&address));
25333        assert_eq!(live.provider_session.as_ref(), Some(&observed_identity));
25334        let binding = shared
25335            .session_bindings
25336            .lock()
25337            .unwrap_or_else(|poisoned| poisoned.into_inner())
25338            .get(&address.session.instance_id)
25339            .cloned()
25340            .unwrap();
25341        assert_eq!(binding.record_id.as_ref(), Some(&durable_id));
25342
25343        let mut duplicate = live.clone();
25344        duplicate.record_id = SessionRecordId::new("sr-duplicate").unwrap();
25345        duplicate.display_name = "duplicate".to_owned();
25346        duplicate.state = ManagedSessionState::Dormant;
25347        duplicate.active_session = None;
25348        duplicate.provider_session.as_mut().unwrap().transcript_path = None;
25349        let duplicate_error = shared.insert_record(duplicate).unwrap_err();
25350        assert_eq!(duplicate_error.code, NodeFailureCode::SessionRecordConflict);
25351        assert_eq!(shared.snapshot().session_records.len(), 1);
25352
25353        let terminal_size = gate4agent_types::TerminalSize {
25354            rows: 24,
25355            columns: 80,
25356        };
25357        let (first_busy, second_busy) = tokio::join!(
25358            shared.resume_session_record(&durable_id, terminal_size, None),
25359            shared.resume_session_record(&durable_id, terminal_size, None),
25360        );
25361        for busy in [first_busy.unwrap_err(), second_busy.unwrap_err()] {
25362            assert_eq!(busy.code, NodeFailureCode::SessionRecordBusy);
25363            assert_eq!(busy.message, "session-record-busy");
25364        }
25365
25366        let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
25367        assert_eq!(loaded.records.len(), 1);
25368        assert_eq!(loaded.records[0].record_id, durable_id);
25369        assert_eq!(loaded.records[0].state, ManagedSessionState::Dormant);
25370        assert_eq!(
25371            loaded.records[0]
25372                .provider_session
25373                .as_ref()
25374                .unwrap()
25375                .transcript_path,
25376            None,
25377        );
25378        let catalog = active_registry().unwrap();
25379        let (reloaded_handle, _reloaded_runtime) =
25380            NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25381        let reloaded = NodeShared::new_with_incarnation(
25382            reloaded_handle,
25383            "fixture-token".to_owned(),
25384            node_id,
25385            NodeIncarnationId::from_bytes([1; crate::protocol::NODE_INCARNATION_ID_BYTES]),
25386            vec![workspace],
25387            vec![agent("claude")],
25388            ProviderRuntimeStatuses::default(),
25389            None,
25390            Vec::new(),
25391            Vec::new(),
25392            SpawnProfileRegistry::default(),
25393            None,
25394            None,
25395            None,
25396            None,
25397            loaded.records,
25398            loaded.managed_worktrees,
25399            loaded.managed_worktree_tombstones,
25400            loaded.materializations,
25401            loaded.warning,
25402            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
25403        );
25404        assert_eq!(reloaded.snapshot().session_records.len(), 1);
25405        assert_eq!(reloaded.snapshot().session_records[0].record_id, durable_id);
25406        std::fs::remove_dir_all(root).unwrap();
25407    }
25408
25409    #[tokio::test]
25410    async fn workspace_reregistration_requires_the_recorded_root_and_restores_records() {
25411        let secondary_root = temporary_workspace_root("workspace-restore");
25412        let other_root = temporary_workspace_root("workspace-conflict");
25413        std::fs::create_dir_all(&secondary_root).unwrap();
25414        std::fs::create_dir_all(&other_root).unwrap();
25415        let catalog = active_registry().unwrap();
25416        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25417        let primary = WorkspaceConfig::new(
25418            WorkspaceId::new("primary").unwrap(),
25419            std::env::current_dir().unwrap(),
25420        )
25421        .unwrap();
25422        let secondary = WorkspaceConfig::new(
25423            WorkspaceId::new("secondary").unwrap(),
25424            &secondary_root,
25425        )
25426        .unwrap();
25427        let shared = NodeShared::new(
25428            handle,
25429            "fixture-token".to_owned(),
25430            NodeId::new("node-1").unwrap(),
25431            vec![primary, secondary.clone()],
25432            vec![agent("claude")],
25433        );
25434        let record_id = SessionRecordId::new("sr-workspace").unwrap();
25435        shared
25436            .insert_record(ManagedSessionRecord {
25437                record_id: record_id.clone(),
25438                display_name: "workspace record".to_owned(),
25439                provider: agent("claude"),
25440                mode: SessionMode::Pty,
25441                state: ManagedSessionState::Dormant,
25442                workspace_id: secondary.workspace_id.clone(),
25443                canonical_root: opaque_windows_path(secondary.canonical_root.clone()),
25444                provider_session: Some(gate4agent_types::ProviderSessionIdentity {
25445                    key: gate4agent_types::ProviderSessionKey::SessionId,
25446                    id: "provider-session".to_owned(),
25447                    transcript_path: None,
25448                }),
25449                active_session: None,
25450                environment_profile: None,
25451                bundle: None,
25452                context_id: None,
25453                context: None,
25454                exported_context: None,
25455                task_binding: None,
25456                created_at_unix_ms: 1,
25457                updated_at_unix_ms: 1,
25458                last_error: None,
25459            })
25460            .unwrap();
25461        shared.unregister_workspace(&secondary.workspace_id).unwrap();
25462        let unavailable = shared.record(&record_id).unwrap();
25463        assert_eq!(unavailable.state, ManagedSessionState::Unavailable);
25464        assert_eq!(windows_path_text(&unavailable.canonical_root), secondary.canonical_root);
25465
25466        let conflict = shared
25467            .register_workspace(
25468                secondary.workspace_id.clone(),
25469                other_root.to_string_lossy().into_owned(),
25470            )
25471            .await
25472            .unwrap_err();
25473        assert_eq!(conflict.code, NodeFailureCode::SessionRecordConflict);
25474        shared
25475            .register_workspace(
25476                secondary.workspace_id,
25477                secondary_root.to_string_lossy().into_owned(),
25478            )
25479            .await
25480            .unwrap();
25481        let restored = shared.record(&record_id).unwrap();
25482        assert_eq!(restored.state, ManagedSessionState::Dormant);
25483        assert!(restored.last_error.is_none());
25484        std::fs::remove_dir_all(secondary_root).unwrap();
25485        std::fs::remove_dir_all(other_root).unwrap();
25486    }
25487
25488    #[test]
25489    fn dynamic_workspace_root_is_rejected_before_filesystem_canonicalization() {
25490        let error = validate_workspace_request_root(
25491            &"x".repeat(MAX_WORKSPACE_ROOT_BYTES + 1),
25492        )
25493        .unwrap_err();
25494        assert_eq!(error.code, NodeFailureCode::InvalidWorkspaceRoot);
25495        let control = validate_workspace_request_root("C:\\repo\nchild").unwrap_err();
25496        assert_eq!(control.code, NodeFailureCode::InvalidWorkspaceRoot);
25497    }
25498
25499    #[test]
25500    fn concurrent_publish_and_resync_never_acknowledge_an_unappended_event() {
25501        let catalog = active_registry().unwrap();
25502        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25503        let workspace = WorkspaceConfig::new(
25504            WorkspaceId::new("primary").unwrap(),
25505            std::env::current_dir().unwrap(),
25506        )
25507        .unwrap();
25508        let shared = NodeShared::new(
25509            handle,
25510            "fixture-token".to_owned(),
25511            NodeId::new("node-1").unwrap(),
25512            vec![workspace],
25513            vec![agent("claude")],
25514        );
25515        let publishers = 4_u64;
25516        let events_per_publisher = 256_u64;
25517        let start = std::sync::Barrier::new(publishers as usize + 1);
25518        let finished = AtomicU64::new(0);
25519
25520        std::thread::scope(|scope| {
25521            for _ in 0..publishers {
25522                scope.spawn(|| {
25523                    start.wait();
25524                    for _ in 0..events_per_publisher {
25525                        shared.publish(NodeEvent::ControllerChanged { controller: None });
25526                    }
25527                    finished.fetch_add(1, Ordering::Release);
25528                });
25529            }
25530            start.wait();
25531            while finished.load(Ordering::Acquire) != publishers {
25532                assert_resync_history_is_linearized(shared.resync(0));
25533                std::thread::yield_now();
25534            }
25535        });
25536        let response = shared.resync(0);
25537        assert_resync_history_is_linearized(response.clone());
25538        let NodeResponse::Resync { event_sequence, events, .. } = response else {
25539            unreachable!("resync helper returned another response");
25540        };
25541        assert_eq!(event_sequence, publishers * events_per_publisher);
25542        assert_eq!(events.len() as u64, event_sequence);
25543    }
25544
25545    fn assert_resync_history_is_linearized(response: NodeResponse) {
25546        let NodeResponse::Resync { event_sequence, events, .. } = response else {
25547            unreachable!("resync helper returned another response");
25548        };
25549        assert!(events.windows(2).all(|pair| {
25550            pair[0].sequence.checked_add(1) == Some(pair[1].sequence)
25551        }));
25552        assert_eq!(events.last().map_or(0, |event| event.sequence), event_sequence);
25553    }
25554
25555    #[test]
25556    fn canonical_workspace_roots_are_safe_for_cmd_exe() {
25557        assert_eq!(
25558            platform::normalize_canonical_root(r"\\?\C:\repo\worktree".to_owned()),
25559            r"C:\repo\worktree",
25560        );
25561        assert_eq!(
25562            platform::normalize_canonical_root(r"\\?\UNC\server\share\repo".to_owned()),
25563            r"\\server\share\repo",
25564        );
25565        assert!(validate_workspace_root(
25566            &WorkspaceId::new("network").unwrap(),
25567            r"\\server\share\repo",
25568        )
25569        .is_err());
25570        let workspace = WorkspaceConfig::new(
25571            WorkspaceId::new("primary").unwrap(),
25572            std::env::current_dir().unwrap(),
25573        )
25574        .unwrap();
25575        assert!(!workspace.canonical_root.starts_with(r"\\?\"));
25576    }
25577
25578    #[test]
25579    fn resume_rebinds_only_on_the_expected_authorized_generation() {
25580        let catalog = active_registry().unwrap();
25581        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25582        let workspace_id = WorkspaceId::new("primary").unwrap();
25583        let workspace = WorkspaceConfig::new(
25584            workspace_id.clone(),
25585            std::env::current_dir().unwrap(),
25586        )
25587        .unwrap();
25588        let shared = NodeShared::new(
25589            handle,
25590            "fixture-token".to_owned(),
25591            NodeId::new("node-1").unwrap(),
25592            vec![workspace],
25593            vec![agent("claude")],
25594        );
25595        let instance_id = AgentInstanceId(17);
25596        let original = SessionAddress {
25597            workspace_id,
25598            session: SessionKey {
25599                instance_id,
25600                generation: SessionGeneration(3),
25601            },
25602        };
25603        shared.bind_session(
25604            instance_id,
25605            original.workspace_id.clone(),
25606            original.session.generation,
25607        );
25608        shared
25609            .arm_resume(
25610                &original,
25611                CommandId(41),
25612                ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
25613            )
25614            .unwrap();
25615
25616        shared.publish_control(ControlEvent {
25617            sequence: 1,
25618            command_id: None,
25619            instance_id,
25620            generation: SessionGeneration(4),
25621            event: ControlEventKind::Running { process_id: None },
25622        });
25623        assert_eq!(
25624            shared.address_for(instance_id, SessionGeneration(3)),
25625            Some(original.clone()),
25626        );
25627        assert!(shared.address_for(instance_id, SessionGeneration(4)).is_none());
25628
25629        shared.publish_control(ControlEvent {
25630            sequence: 2,
25631            command_id: Some(CommandId(42)),
25632            instance_id,
25633            generation: SessionGeneration(3),
25634            event: ControlEventKind::CommandRejected {
25635                message: "unrelated command".to_owned(),
25636            },
25637        });
25638
25639        shared.publish_control(ControlEvent {
25640            sequence: 3,
25641            command_id: None,
25642            instance_id,
25643            generation: SessionGeneration(4),
25644            event: ControlEventKind::ResumeAuthorized {
25645                session: gate4agent_types::ResumeSessionSummary {
25646                    key: gate4agent_types::ProviderSessionKey::SessionId,
25647                    id: "fixture-session".to_owned(),
25648                },
25649            },
25650        });
25651        assert!(shared.address_for(instance_id, SessionGeneration(3)).is_none());
25652        let rebound = shared.address_for(instance_id, SessionGeneration(4)).unwrap();
25653        assert_eq!(rebound.workspace_id, original.workspace_id);
25654        let history = shared
25655            .history
25656            .lock()
25657            .unwrap_or_else(|poisoned| poisoned.into_inner());
25658        assert!(history.events.iter().any(|envelope| matches!(
25659            &envelope.event,
25660            NodeEvent::Control { address, event }
25661                if address == &rebound
25662                    && matches!(&event.event, ControlEventKind::ResumeAuthorized { .. })
25663        )));
25664    }
25665
25666    #[test]
25667    fn all_node_pty_prompts_use_bracketed_paste_framing() {
25668        for agent in ["claude", "codex", "kimi"] {
25669            assert_eq!(
25670                prompt_framing(&AgentId::new(agent).unwrap()),
25671                PromptFraming::BracketedPaste
25672            );
25673        }
25674    }
25675
25676    fn generous_walk_deadline() -> Instant {
25677        Instant::now() + Duration::from_secs(30)
25678    }
25679
25680    #[test]
25681    fn workspace_tree_is_relative_and_skips_heavy_directories() {
25682        let root = temporary_workspace_root("tree");
25683        std::fs::create_dir_all(root.join("src")).unwrap();
25684        std::fs::create_dir_all(root.join(".git/objects")).unwrap();
25685        std::fs::create_dir_all(root.join("target/debug")).unwrap();
25686        std::fs::create_dir_all(root.join("node_modules/package")).unwrap();
25687        std::fs::write(root.join("src/lib.rs"), b"pub fn fixture() {}\n").unwrap();
25688        std::fs::write(root.join("README.md"), b"fixture\n").unwrap();
25689        // A renamed/agent-scoped build directory (`target-a2`-style, per the
25690        // real repository's own build layout) that carries the Cache
25691        // Directory Tagging Specification marker cargo writes into every
25692        // `--target-dir` it creates: must be skipped even though its name
25693        // does not exactly match `target`.
25694        std::fs::create_dir_all(root.join("target-a2/debug")).unwrap();
25695        std::fs::write(root.join("target-a2/CACHEDIR.TAG"), b"Signature: 8a477f597d28d172789f06886806bc55\n").unwrap();
25696        // A directory that merely *starts with* `target` but carries no
25697        // CACHEDIR.TAG: the new prefix-adjacent check must not false-match
25698        // on the name alone, so this stays in the response.
25699        std::fs::create_dir_all(root.join("target-notes")).unwrap();
25700        std::fs::write(root.join("target-notes/plan.md"), b"not a build directory\n").unwrap();
25701
25702        let (entries, truncated, _budget) =
25703            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
25704        assert!(!truncated);
25705        assert!(entries.iter().any(|entry| {
25706            entry.relative_path.as_utf8() == Some("src")
25707                && entry.kind == WorkspaceEntryKind::Directory
25708        }));
25709        assert!(entries.iter().any(|entry| {
25710            entry.relative_path.as_utf8() == Some("src/lib.rs")
25711                && entry.kind == WorkspaceEntryKind::File
25712        }));
25713        assert!(entries.iter().any(|entry| {
25714            entry.relative_path.as_utf8() == Some("target-notes")
25715                && entry.kind == WorkspaceEntryKind::Directory
25716        }));
25717        assert!(entries.iter().any(|entry| {
25718            entry.relative_path.as_utf8() == Some("target-notes/plan.md")
25719                && entry.kind == WorkspaceEntryKind::File
25720        }));
25721        assert!(entries.iter().all(|entry| {
25722            let path = entry.relative_path.as_utf8().unwrap();
25723            !path.starts_with(".git")
25724                && !(path == "target" || path.starts_with("target/"))
25725                && !(path == "target-a2" || path.starts_with("target-a2/"))
25726                && !path.starts_with("node_modules")
25727        }));
25728
25729        std::fs::remove_dir_all(&root).unwrap();
25730    }
25731
25732    #[test]
25733    fn workspace_walk_stops_at_its_entry_cap_and_marks_a_partial_result() {
25734        let root = temporary_workspace_root("tree-entry-cap");
25735        std::fs::create_dir_all(&root).unwrap();
25736        for index in 0..20 {
25737            std::fs::write(root.join(format!("file-{index:02}.txt")), b"fixture\n").unwrap();
25738        }
25739
25740        let (entries, truncated, budget) =
25741            collect_workspace_entries(&root, generous_walk_deadline(), 5);
25742        assert!(truncated);
25743        assert!(budget.entry_cap_exceeded);
25744        assert!(!budget.time_budget_exceeded);
25745        assert!(entries.len() <= 5);
25746
25747        let inspection_truncation = workspace_inspection_truncation(&budget, false, Duration::from_millis(1))
25748            .expect("an entry-cap-truncated walk must report a truncation marker");
25749        assert!(inspection_truncation.walk_entry_cap_exceeded);
25750        assert!(!inspection_truncation.walk_time_budget_exceeded);
25751        assert!(!inspection_truncation.git_time_budget_exceeded);
25752        assert!(inspection_truncation.entries_visited >= 5);
25753
25754        std::fs::remove_dir_all(&root).unwrap();
25755    }
25756
25757    #[test]
25758    fn workspace_walk_within_budget_reports_no_truncation() {
25759        let root = temporary_workspace_root("tree-no-truncation");
25760        std::fs::create_dir_all(&root).unwrap();
25761        std::fs::write(root.join("only-file.txt"), b"fixture\n").unwrap();
25762
25763        let (_entries, truncated, budget) =
25764            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
25765        assert!(!truncated);
25766        assert!(workspace_inspection_truncation(&budget, false, Duration::from_millis(1)).is_none());
25767
25768        std::fs::remove_dir_all(&root).unwrap();
25769    }
25770
25771    #[test]
25772    fn workspace_walk_emits_strict_global_byte_order_for_prefix_named_siblings() {
25773        let root = temporary_workspace_root("tree-prefix-siblings");
25774        std::fs::create_dir_all(root.join("gate4agent-c2/src")).unwrap();
25775        std::fs::write(root.join("gate4agent-c2/src/lib.rs"), b"fixture\n").unwrap();
25776        std::fs::create_dir_all(root.join("gate4agent-c2-client")).unwrap();
25777        std::fs::write(root.join("gate4agent-c2-client/lib.rs"), b"fixture\n").unwrap();
25778
25779        let (entries, truncated, _budget) =
25780            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
25781        assert!(!truncated);
25782        let paths = entries
25783            .iter()
25784            .map(|entry| entry.relative_path.as_utf8().unwrap().to_owned())
25785            .collect::<Vec<_>>();
25786        assert!(paths.windows(2).all(|pair| pair[0] < pair[1]));
25787        assert_eq!(
25788            paths,
25789            vec![
25790                "gate4agent-c2".to_owned(),
25791                "gate4agent-c2-client".to_owned(),
25792                "gate4agent-c2-client/lib.rs".to_owned(),
25793                "gate4agent-c2/src".to_owned(),
25794                "gate4agent-c2/src/lib.rs".to_owned(),
25795            ],
25796        );
25797
25798        std::fs::remove_dir_all(&root).unwrap();
25799    }
25800
25801    #[test]
25802    fn git_parsers_cap_status_and_commit_results() {
25803        let mut status_output = Vec::new();
25804        for index in 0..=GIT_STATUS_MAX_ENTRIES {
25805            status_output.extend_from_slice(format!(" M src/file-{index}.rs\0").as_bytes());
25806        }
25807        let mut snapshot = git_snapshot_for_parser();
25808        parse_git_status(&status_output, &mut snapshot);
25809        assert_eq!(snapshot.status.len(), GIT_STATUS_MAX_ENTRIES);
25810        assert!(snapshot.truncated);
25811        assert_eq!(snapshot.status[0].index_status, " ");
25812        assert_eq!(snapshot.status[0].worktree_status, "M");
25813
25814        snapshot.truncated = false;
25815        let commit_output = (0..=GIT_COMMIT_MAX_ENTRIES)
25816            .map(|index| format!("abc{index}\u{1f}commit {index}\n"))
25817            .collect::<String>();
25818        parse_git_commits(commit_output.as_bytes(), &mut snapshot);
25819        assert_eq!(snapshot.recent_commits.len(), GIT_COMMIT_MAX_ENTRIES);
25820        assert!(snapshot.truncated);
25821    }
25822
25823    #[test]
25824    fn porcelain_v1_z_parser_preserves_current_then_previous_rename_and_copy_order() {
25825        let mut snapshot = git_snapshot_for_parser();
25826        parse_git_status(
25827            b"R  src/current name.rs\0src/previous name.rs\0 C src/copy.rs\0src/original.rs\0?? src/line\nname.rs\0",
25828            &mut snapshot,
25829        );
25830
25831        assert!(!snapshot.truncated);
25832        assert_eq!(snapshot.status.len(), 3);
25833        assert_eq!(snapshot.status[0].index_status, "R");
25834        assert_eq!(snapshot.status[0].worktree_status, " ");
25835        assert_eq!(snapshot.status[0].path.as_utf8(), Some("src/current name.rs"));
25836        assert_eq!(
25837            snapshot.status[0]
25838                .previous_path
25839                .as_ref()
25840                .and_then(RepositoryPath::as_utf8),
25841            Some("src/previous name.rs"),
25842        );
25843        assert_eq!(snapshot.status[1].index_status, " ");
25844        assert_eq!(snapshot.status[1].worktree_status, "C");
25845        assert_eq!(snapshot.status[1].path.as_utf8(), Some("src/copy.rs"));
25846        assert_eq!(
25847            snapshot.status[1]
25848                .previous_path
25849                .as_ref()
25850                .and_then(RepositoryPath::as_utf8),
25851            Some("src/original.rs"),
25852        );
25853        assert_eq!(snapshot.status[2].path.as_utf8(), Some("src/line\nname.rs"));
25854        assert!(snapshot.status[2].previous_path.is_none());
25855    }
25856
25857    #[test]
25858    fn porcelain_v1_z_parser_never_invents_invalid_or_incomplete_path_identity() {
25859        let mut snapshot = git_snapshot_for_parser();
25860        parse_git_status(
25861            b"?? valid.rs\0?? invalid-\xff.rs\0?? invalid\\windows.rs\0R  current.rs\0",
25862            &mut snapshot,
25863        );
25864
25865        assert!(snapshot.truncated);
25866        assert_eq!(snapshot.status.len(), 1);
25867        assert_eq!(snapshot.status[0].path.as_utf8(), Some("valid.rs"));
25868        assert!(snapshot.status[0].previous_path.is_none());
25869    }
25870
25871    #[tokio::test]
25872    async fn observer_can_browse_host_directories_without_controller() {
25873        let root = temporary_workspace_root("host-directory-observer");
25874        std::fs::create_dir_all(root.join("directory-b")).unwrap();
25875        std::fs::create_dir_all(root.join("directory-a")).unwrap();
25876        std::fs::write(root.join("ordinary-file"), b"not a directory").unwrap();
25877        let workspace_id = WorkspaceId::new("primary").unwrap();
25878        let workspace = WorkspaceConfig::new(workspace_id, &root).unwrap();
25879        let catalog = active_registry().unwrap();
25880        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25881        let shared = NodeShared::new(
25882            handle,
25883            "fixture-token".to_owned(),
25884            NodeId::new("node-1").unwrap(),
25885            vec![workspace],
25886            vec![agent("claude")],
25887        );
25888        let request = NodeRequest::BrowseHostDirectories {
25889            directory: Some(OpaqueHostPath::utf8(root.to_string_lossy().into_owned()).unwrap()),
25890            after: None,
25891        };
25892        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
25893            capability.as_str() == CAPABILITY_HOST_DIRECTORY_BROWSE_V1
25894        }));
25895        assert!(!request_uses_unnegotiated_capability(
25896            &request,
25897            &baseline_capabilities().unwrap(),
25898        ));
25899        assert!(request_uses_unnegotiated_capability(&request, &[]));
25900
25901        let response = process_request_inner(&shared, 77, ClientRole::Observer, request)
25902            .await
25903            .unwrap();
25904        let NodeResponse::HostDirectoriesBrowsed { listing } = response else {
25905            panic!("host directory browse returned another response");
25906        };
25907        assert_eq!(
25908            listing
25909                .entries
25910                .iter()
25911                .map(|entry| entry.display_name.as_str())
25912                .collect::<Vec<_>>(),
25913            vec!["directory-a", "directory-b"],
25914        );
25915        assert!(listing.entries.iter().all(|entry| !entry.is_link));
25916        assert!(!listing.incomplete);
25917        std::fs::remove_dir_all(root).unwrap();
25918    }
25919
25920    #[tokio::test]
25921    async fn observer_can_inspect_only_a_registered_workspace() {
25922        let root = temporary_workspace_root("observer");
25923        std::fs::create_dir_all(root.join("src")).unwrap();
25924        std::fs::write(root.join("src/main.rs"), b"fn main() {}\n").unwrap();
25925        let workspace_id = WorkspaceId::new("primary").unwrap();
25926        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
25927        let catalog = active_registry().unwrap();
25928        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25929        let shared = NodeShared::new(
25930            handle,
25931            "fixture-token".to_owned(),
25932            NodeId::new("node-1").unwrap(),
25933            vec![workspace],
25934            vec![agent("claude")],
25935        );
25936
25937        let response = process_request_inner(
25938            &shared,
25939            77,
25940            ClientRole::Observer,
25941            NodeRequest::InspectWorkspace {
25942                workspace_id: workspace_id.clone(),
25943            },
25944        )
25945        .await
25946        .unwrap();
25947        let NodeResponse::WorkspaceInspected { inspection } = response else {
25948            panic!("workspace inspection returned another response");
25949        };
25950        assert_eq!(inspection.workspace_id, workspace_id);
25951        assert!(inspection.entries.iter().any(|entry| {
25952            entry.relative_path.as_utf8() == Some("src/main.rs")
25953        }));
25954
25955        let unknown = process_request_inner(
25956            &shared,
25957            77,
25958            ClientRole::Observer,
25959            NodeRequest::InspectWorkspace {
25960                workspace_id: WorkspaceId::new("unknown").unwrap(),
25961            },
25962        )
25963        .await
25964        .unwrap_err();
25965        assert_eq!(unknown.code, NodeFailureCode::UnknownWorkspace);
25966        std::fs::remove_dir_all(&root).unwrap();
25967    }
25968
25969    #[test]
25970    fn context_pack_source_accepts_only_running_or_successful_exit() {
25971        assert!(context_pack_source_status_is_usable(&SessionStatus::Running));
25972        assert!(context_pack_source_status_is_usable(&SessionStatus::Exited {
25973            exit_code: Some(0),
25974        }));
25975        assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
25976            exit_code: Some(1),
25977        }));
25978        assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
25979            exit_code: None,
25980        }));
25981        assert!(!context_pack_source_status_is_usable(&SessionStatus::Failed {
25982            message: "provider failed".to_owned(),
25983        }));
25984        assert!(!context_pack_source_status_is_usable(&SessionStatus::Registered));
25985        assert!(!context_pack_source_status_is_usable(&SessionStatus::Starting));
25986        assert!(!context_pack_source_status_is_usable(&SessionStatus::Stopping));
25987    }
25988
25989    #[test]
25990    fn context_pack_repository_rejects_changed_head_with_same_git_snapshot() {
25991        let git = git_snapshot_for_parser();
25992        let first = ContextPackRepositoryObservation {
25993            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
25994            git: git.clone(),
25995            files: Vec::new(),
25996        };
25997        let second = ContextPackRepositoryObservation {
25998            head: ContextPackRepositoryHead::Commit("b".repeat(40)),
25999            git,
26000            files: Vec::new(),
26001        };
26002
26003        let error = stable_context_pack_repository(first, second).unwrap_err();
26004
26005        assert_eq!(
26006            error.code,
26007            NodeFailureCode::ContextPackMaterializationFailed,
26008        );
26009    }
26010
26011    #[test]
26012    fn context_pack_repository_rejects_changed_file_with_same_git_snapshot() {
26013        let git = git_snapshot_for_parser();
26014        let first = ContextPackRepositoryObservation {
26015            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
26016            git: git.clone(),
26017            files: vec![ContextPackRepositoryFileSource::utf8(
26018                "README.md",
26019                "first\n".to_owned(),
26020                6,
26021            )],
26022        };
26023        let second = ContextPackRepositoryObservation {
26024            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
26025            git,
26026            files: vec![ContextPackRepositoryFileSource::utf8(
26027                "README.md",
26028                "other\n".to_owned(),
26029                6,
26030            )],
26031        };
26032
26033        let error = stable_context_pack_repository(first, second).unwrap_err();
26034
26035        assert_eq!(
26036            error.code,
26037            NodeFailureCode::ContextPackMaterializationFailed,
26038        );
26039    }
26040
26041    #[tokio::test]
26042    async fn native_session_catalog_zero_active_session_fixture() {
26043        let workspace_root = temporary_workspace_root("native-catalog-workspace");
26044        let history_root = temporary_workspace_root("native-catalog-history");
26045        let outside_root = temporary_workspace_root("native-catalog-outside");
26046        std::fs::create_dir_all(&workspace_root).unwrap();
26047        std::fs::create_dir_all(&history_root).unwrap();
26048        std::fs::create_dir_all(&outside_root).unwrap();
26049        let transcript = |session_id: &str, cwd: &Path, title: &str| {
26050            [
26051                serde_json::json!({
26052                    "type": "user",
26053                    "sessionId": session_id,
26054                    "cwd": cwd.to_str().unwrap(),
26055                    "message": { "content": "raw user text must not cross the catalog wire" }
26056                }),
26057                serde_json::json!({
26058                    "type": "assistant",
26059                    "sessionId": session_id,
26060                    "cwd": cwd.to_str().unwrap(),
26061                    "message": { "model": "claude-sonnet", "content": "raw answer" }
26062                }),
26063                serde_json::json!({
26064                    "type": "ai-title",
26065                    "sessionId": session_id,
26066                    "aiTitle": title
26067                }),
26068            ]
26069            .into_iter()
26070            .map(|record| record.to_string())
26071            .collect::<Vec<_>>()
26072            .join("\n")
26073        };
26074        std::fs::write(
26075            history_root.join("stable-session.jsonl"),
26076            transcript("stable-session", &workspace_root, "Workspace review"),
26077        )
26078        .unwrap();
26079        std::fs::write(
26080            history_root.join("outside-session.jsonl"),
26081            transcript("outside-session", &outside_root, "Outside review"),
26082        )
26083        .unwrap();
26084        let history = NativeHistoryConfig::new(vec![
26085            NativeHistoryRoot::new(
26086                gate4agent_types::AdapterId::new("claude-code").unwrap(),
26087                HistorySourceLayout::SingleNdjson,
26088                history_root.clone(),
26089            )
26090            .unwrap(),
26091        ])
26092        .unwrap();
26093        let catalog = active_registry().unwrap();
26094        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26095        assert!(handle.snapshot().sessions.is_empty());
26096        let workspace_id = WorkspaceId::new("primary").unwrap();
26097        let workspace = WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap();
26098        let mut shared = NodeShared::new(
26099            handle,
26100            "fixture-token".to_owned(),
26101            NodeId::new("node-native-catalog").unwrap(),
26102            vec![workspace],
26103            vec![agent("claude")],
26104        );
26105        shared.native_session_catalog = Some(Arc::new(Mutex::new(
26106            NativeSessionCatalogAuthority::new(history),
26107        )));
26108        let route = NativeSessionCatalogRoute::workspace(
26109            workspace_id.clone(),
26110            agent("claude"),
26111        );
26112        let request = NodeRequest::CatalogNativeSessions {
26113            route: route.clone(),
26114            limit: 2,
26115        };
26116        let observer = process_request_inner(&shared, 7, ClientRole::Observer, request.clone())
26117            .await
26118            .unwrap_err();
26119        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26120        let response = process_request_inner(&shared, 7, ClientRole::Operator, request)
26121            .await
26122            .unwrap();
26123        let NodeResponse::NativeSessionsCataloged { entries, summary, .. } = response else {
26124            panic!("native catalog returned a different response");
26125        };
26126        let summary = summary.expect("node must emit native session catalog summary metadata");
26127        assert_eq!(entries.len(), 1);
26128        assert!(gate4agent_types::validate_candidate_id(&entries[0].selection_id).is_ok());
26129        assert_eq!(entries[0].external_group, None);
26130        assert_eq!(entries[0].record_id, None);
26131        assert_eq!(entries[0].title, None);
26132        assert_eq!(entries[0].model, None);
26133        assert_eq!(entries[0].message_count, 0);
26134        let encoded_entries = serde_json::to_string(&entries).unwrap();
26135        assert!(!encoded_entries.contains("stable-session"));
26136        assert!(!encoded_entries.contains(history_root.to_string_lossy().as_ref()));
26137        let page_request = NodeRequest::PageNativeSessions {
26138            route: route.clone(),
26139            window: crate::protocol::NativeSessionCatalogWindow::Recent,
26140            catalog_revision: summary.catalog_revision,
26141            recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
26142            after_selection_id: None,
26143            limit: 2,
26144        };
26145        let observer = process_request_inner(
26146            &shared,
26147            7,
26148            ClientRole::Observer,
26149            page_request.clone(),
26150        )
26151        .await
26152        .unwrap_err();
26153        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26154        let page_response = process_request_inner(
26155            &shared,
26156            7,
26157            ClientRole::Operator,
26158            page_request.clone(),
26159        )
26160        .await
26161        .unwrap();
26162        assert!(matches!(
26163            page_response,
26164            NodeResponse::NativeSessionsPaged { page, .. }
26165                if page.entries.len() == 1 && !page.has_more && page.remaining_count == 0
26166        ));
26167        let stale = process_request_inner(
26168            &shared,
26169            7,
26170            ClientRole::Operator,
26171            NodeRequest::PageNativeSessions {
26172                route: route.clone(),
26173                window: crate::protocol::NativeSessionCatalogWindow::Recent,
26174                catalog_revision: summary.catalog_revision.wrapping_add(1),
26175                recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
26176                after_selection_id: None,
26177                limit: 2,
26178            },
26179        )
26180        .await
26181        .unwrap_err();
26182        assert_eq!(stale.code, NodeFailureCode::StaleNativeSessionCatalog);
26183        let selection = NativeSessionSelection {
26184            route: route.clone(),
26185            catalog_revision: summary.catalog_revision,
26186            recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
26187            selection_id: entries[0].selection_id.clone(),
26188        };
26189        let preview_response = process_request_inner(
26190            &shared,
26191            7,
26192            ClientRole::Operator,
26193            NodeRequest::PreviewNativeSession {
26194                selection: selection.clone(),
26195                message_limit: 1,
26196            },
26197        )
26198        .await
26199        .unwrap();
26200        let NodeResponse::NativeSessionPreviewed { preview, .. } = preview_response else {
26201            panic!("native preview returned a different response");
26202        };
26203        assert_eq!(preview.messages.len(), 1);
26204        assert!(preview.message_count_exact);
26205        assert_eq!(preview.messages[0].text, "raw answer");
26206        assert!(preview.truncated);
26207
26208        let index_request = NodeRequest::IndexNativeSession {
26209            selection: selection.clone(),
26210            display_name: "Saved Claude".to_owned(),
26211        };
26212        let observer = process_request_inner(
26213            &shared,
26214            7,
26215            ClientRole::Observer,
26216            index_request.clone(),
26217        )
26218        .await
26219        .unwrap_err();
26220        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26221        let without_controller = process_request_inner(
26222            &shared,
26223            7,
26224            ClientRole::Operator,
26225            index_request.clone(),
26226        )
26227        .await
26228        .unwrap_err();
26229        assert_eq!(without_controller.code, NodeFailureCode::ControllerRequired);
26230        let acquired = process_request_inner(
26231            &shared,
26232            7,
26233            ClientRole::Operator,
26234            NodeRequest::AcquireController { lease_ms: 5_000 },
26235        )
26236        .await
26237        .unwrap();
26238        assert!(matches!(acquired, NodeResponse::Controller { controller: Some(_) }));
26239        let indexed = process_request_inner(
26240            &shared,
26241            7,
26242            ClientRole::Operator,
26243            index_request,
26244        )
26245        .await
26246        .unwrap();
26247        assert!(indexed.native_session_index_contract_is_valid());
26248        let NodeResponse::NativeSessionIndexed {
26249            selection: echoed_selection,
26250            record,
26251        } = indexed else {
26252            panic!("native session index returned a different response");
26253        };
26254        assert_eq!(echoed_selection, selection);
26255        let recataloged = process_request_inner(
26256            &shared,
26257            7,
26258            ClientRole::Operator,
26259            NodeRequest::CatalogNativeSessions {
26260                route,
26261                limit: 2,
26262            },
26263        )
26264        .await
26265        .unwrap();
26266        let NodeResponse::NativeSessionsCataloged { entries, .. } = recataloged else {
26267            panic!("native catalog returned a different response after indexing");
26268        };
26269        assert_eq!(entries[0].record_id.as_ref(), Some(&record.record_id));
26270
26271        let unregistered_route = NativeSessionCatalogRoute::unregistered(agent("claude"));
26272        let external_catalog = process_request_inner(
26273            &shared,
26274            7,
26275            ClientRole::Operator,
26276            NodeRequest::CatalogNativeSessions {
26277                route: unregistered_route.clone(),
26278                limit: 2,
26279            },
26280        )
26281        .await
26282        .unwrap();
26283        let external_json = serde_json::to_string(&external_catalog).unwrap();
26284        assert!(!external_json.contains("outside-session"));
26285        assert!(!external_json.contains(outside_root.to_string_lossy().as_ref()));
26286        assert!(!external_json.contains(history_root.to_string_lossy().as_ref()));
26287        assert!(!external_json.contains("\"session_id\""));
26288        assert!(!external_json.contains("\"cwd\""));
26289        assert!(!external_json.contains("\"path\""));
26290        let NodeResponse::NativeSessionsCataloged {
26291            route: echoed_route,
26292            entries: external_entries,
26293            summary: Some(external_summary),
26294        } = external_catalog
26295        else {
26296            panic!("unregistered native catalog returned a different response");
26297        };
26298        assert_eq!(echoed_route, unregistered_route);
26299        assert_eq!(external_entries.len(), 1);
26300        assert_eq!(external_entries[0].record_id, None);
26301        let external_group = external_entries[0]
26302            .external_group
26303            .as_ref()
26304            .expect("unregistered session must have an external group");
26305        assert_eq!(
26306            external_group.kind,
26307            gate4agent_types::NativeSessionExternalGroupKind::Project,
26308        );
26309        assert!(external_group.group_id.starts_with("external-"));
26310        assert_eq!(
26311            external_group.display_name,
26312            outside_root.file_name().unwrap().to_string_lossy(),
26313        );
26314        let external_selection = NativeSessionSelection {
26315            route: unregistered_route,
26316            catalog_revision: external_summary.catalog_revision,
26317            recent_cutoff_unix_ms: external_summary.recent_cutoff_unix_ms,
26318            selection_id: external_entries[0].selection_id.clone(),
26319        };
26320        let external_preview = process_request_inner(
26321            &shared,
26322            7,
26323            ClientRole::Operator,
26324            NodeRequest::PreviewNativeSession {
26325                selection: external_selection.clone(),
26326                message_limit: 1,
26327            },
26328        )
26329        .await
26330        .unwrap();
26331        let external_preview_json = serde_json::to_string(&external_preview).unwrap();
26332        assert!(!external_preview_json.contains("outside-session"));
26333        assert!(!external_preview_json.contains(outside_root.to_string_lossy().as_ref()));
26334        assert!(!external_preview_json.contains("\"session_id\""));
26335        let NodeResponse::NativeSessionPreviewed { preview, .. } = external_preview else {
26336            panic!("unregistered native preview returned a different response");
26337        };
26338        assert_eq!(preview.messages.len(), 1);
26339        let external_index = process_request_inner(
26340            &shared,
26341            7,
26342            ClientRole::Operator,
26343            NodeRequest::IndexNativeSession {
26344                selection: external_selection,
26345                display_name: "External Claude".to_owned(),
26346            },
26347        )
26348        .await
26349        .unwrap_err();
26350        assert_eq!(
26351            external_index.code,
26352            NodeFailureCode::WorkspaceRegistrationRequired,
26353        );
26354        let record_response = process_request_inner(
26355            &shared,
26356            7,
26357            ClientRole::Operator,
26358            NodeRequest::PreviewSessionRecord {
26359                record_id: record.record_id.clone(),
26360                message_limit: 2,
26361            },
26362        )
26363        .await
26364        .unwrap();
26365        let NodeResponse::SessionRecordPreviewed { record_id, preview } = record_response else {
26366            panic!("record preview returned a different response");
26367        };
26368        assert_eq!(record_id, record.record_id);
26369        assert_eq!(preview.messages.len(), 2);
26370        let encoded = serde_json::to_string(&preview).unwrap();
26371        assert!(!encoded.contains("stable-session"));
26372        assert!(!encoded.contains(history_root.to_string_lossy().as_ref()));
26373        let NodeResponse::Resync { events, .. } = shared.resync(0) else {
26374            panic!("record preview history summary returned a different response");
26375        };
26376        let summaries = events
26377            .iter()
26378            .filter_map(|envelope| match &envelope.event {
26379                NodeEvent::SessionRecordHistorySummarized { record_id, summary }
26380                    if record_id == &record.record_id =>
26381                {
26382                    Some(*summary)
26383                }
26384                _ => None,
26385            })
26386            .collect::<Vec<_>>();
26387        assert_eq!(summaries, vec![SessionHistorySummaryV1::from(&preview)]);
26388        let summary_json = serde_json::to_string(&summaries).unwrap();
26389        for private in [
26390            "stable-session",
26391            "raw user text must not cross the catalog wire",
26392            "raw answer",
26393            "Workspace review",
26394            "claude-sonnet",
26395        ] {
26396            assert!(!summary_json.contains(private), "{private}");
26397        }
26398        let identity = record.provider_session.clone().unwrap();
26399        assert_eq!(
26400            shared.revalidate_session_record_preview(&record, &identity),
26401            Ok(())
26402        );
26403        shared.remove_record_memory(&record.record_id);
26404        assert_eq!(
26405            shared
26406                .revalidate_session_record_preview(&record, &identity)
26407                .unwrap_err()
26408                .code,
26409            NodeFailureCode::UnknownSessionRecord
26410        );
26411        let released = process_request_inner(
26412            &shared,
26413            7,
26414            ClientRole::Operator,
26415            NodeRequest::ReleaseController,
26416        )
26417        .await
26418        .unwrap();
26419        assert!(matches!(released, NodeResponse::Controller { controller: None }));
26420        assert!(shared.controller_state().is_none());
26421        std::fs::remove_dir_all(workspace_root).unwrap();
26422        std::fs::remove_dir_all(history_root).unwrap();
26423        std::fs::remove_dir_all(outside_root).unwrap();
26424    }
26425
26426    #[tokio::test]
26427    async fn native_session_catalog_concurrent_operation_fails_fast_without_waiting_on_mutex() {
26428        let history_root = temporary_workspace_root("native-catalog-concurrent-operation");
26429        std::fs::create_dir_all(&history_root).unwrap();
26430        let history = NativeHistoryConfig::new(vec![
26431            NativeHistoryRoot::new(
26432                gate4agent_types::AdapterId::new("claude-code").unwrap(),
26433                HistorySourceLayout::SingleNdjson,
26434                history_root.clone(),
26435            )
26436            .unwrap(),
26437        ])
26438        .unwrap();
26439        let catalog = Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)));
26440        let held = catalog.lock().unwrap();
26441        let result = timeout(
26442            Duration::from_secs(1),
26443            run_native_session_catalog_operation(
26444                Arc::clone(&catalog),
26445                "native session catalog",
26446                |_| Ok::<(), std::convert::Infallible>(()),
26447            ),
26448        )
26449        .await
26450        .expect("concurrent native catalog request must not wait for the operation deadline")
26451        .unwrap_err();
26452        assert_eq!(result.code, NodeFailureCode::BackendBusy);
26453        drop(held);
26454        std::fs::remove_dir_all(history_root).unwrap();
26455    }
26456
26457    fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
26458        let unique = std::time::SystemTime::now()
26459            .duration_since(std::time::UNIX_EPOCH)
26460            .unwrap()
26461            .as_nanos();
26462        std::env::temp_dir().join(format!(
26463            "gate4agent-node-{label}-{}-{unique}",
26464            std::process::id(),
26465        ))
26466    }
26467
26468    fn git_snapshot_for_parser() -> GitSnapshot {
26469        GitSnapshot {
26470            is_repository: true,
26471            branch: Some("main".to_owned()),
26472            status: Vec::new(),
26473            recent_commits: Vec::new(),
26474            worktrees: Vec::new(),
26475            managed_worktree: None,
26476            truncated: false,
26477            diagnostic: None,
26478        }
26479    }
26480
26481    #[tokio::test]
26482    async fn workspace_inspection_scopes_only_exact_active_managed_branch() {
26483        let root = temporary_workspace_root("managed-git-scope");
26484        std::fs::create_dir_all(&root).unwrap();
26485        run_test_git(&root, &["init"]);
26486        run_test_git(&root, &["config", "user.email", "gate4agent@example.invalid"]);
26487        run_test_git(&root, &["config", "user.name", "Gate4Agent Test"]);
26488        std::fs::write(root.join("seed.txt"), "seed\n").unwrap();
26489        run_test_git(&root, &["add", "seed.txt"]);
26490        run_test_git(&root, &["commit", "-m", "seed"]);
26491        run_test_git(&root, &["checkout", "-b", "gate4agent/a"]);
26492
26493        let workspace_id = WorkspaceId::new("managed-a").unwrap();
26494        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
26495        let catalog = active_registry().unwrap();
26496        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26497        let shared = NodeShared::new(
26498            handle,
26499            "fixture-token".to_owned(),
26500            NodeId::new("node-1").unwrap(),
26501            vec![workspace],
26502            vec![agent("claude")],
26503        );
26504        let mut lease = ManagedWorktreeLeaseRecord {
26505            lease_id: ManagedWorktreeLeaseId::new("mw-a").unwrap(),
26506            source_workspace_id: WorkspaceId::new("primary").unwrap(),
26507            workspace_id: workspace_id.clone(),
26508            profile_id: WorktreeProfileId::new("default").unwrap(),
26509            profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
26510            target_root: root.to_string_lossy().into_owned(),
26511            branch: "gate4agent/a".to_owned(),
26512            base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
26513            expected_head: None,
26514            retention: ManagedWorktreeRetention::Retain,
26515            state: ManagedWorktreeLeaseState::InUse,
26516            session_holders: vec![ManagedWorktreeSessionHolder {
26517                incarnation_id: shared.incarnation_id,
26518                instance_id: AgentInstanceId(7),
26519                generation: SessionGeneration(1),
26520            }],
26521            record_holders: Vec::new(),
26522            cleanup_failure: None,
26523            created_at_unix_ms: 1,
26524            updated_at_unix_ms: 1,
26525        };
26526        *shared
26527            .managed_worktrees
26528            .lock()
26529            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
26530            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
26531
26532        let inspection = shared.inspect_workspace(workspace_id.clone()).await.unwrap();
26533        let scope = inspection.git.managed_worktree.unwrap();
26534        assert_eq!(scope.lease_id, lease.lease_id);
26535        assert_eq!(scope.branch, "gate4agent/a");
26536        assert_eq!(scope.active_session_count, 1);
26537
26538        lease.branch = "gate4agent/b".to_owned();
26539        *shared
26540            .managed_worktrees
26541            .lock()
26542            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
26543            ManagedWorktreeRegistry::from_records(vec![lease], Vec::new()).unwrap();
26544        let mismatch = shared.inspect_workspace(workspace_id).await.unwrap();
26545        assert!(mismatch.git.managed_worktree.is_none());
26546        std::fs::remove_dir_all(root).unwrap();
26547    }
26548
26549    #[tokio::test]
26550    async fn workspace_file_write_requires_controller_and_rejects_stale_revision() {
26551        let root = temporary_workspace_root("workspace-file-write-controller");
26552        std::fs::create_dir_all(root.join("src")).unwrap();
26553        std::fs::write(root.join("src/lib.rs"), b"old\n").unwrap();
26554        let workspace_id = WorkspaceId::new("primary").unwrap();
26555        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
26556        let catalog = active_registry().unwrap();
26557        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26558        let shared = NodeShared::new(
26559            handle,
26560            "fixture-token".to_owned(),
26561            NodeId::new("node-1").unwrap(),
26562            vec![workspace],
26563            vec![agent("claude")],
26564        );
26565        let path = RepositoryPath::utf8("src/lib.rs".to_owned()).unwrap();
26566        let expected_revision = workspace_file_revision(b"old\n");
26567        let request = NodeRequest::WriteWorkspaceFile {
26568            workspace_id: workspace_id.clone(),
26569            path: path.clone(),
26570            expected_revision: expected_revision.clone(),
26571            text: "new\n".to_owned(),
26572        };
26573        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
26574            .await
26575            .unwrap_err();
26576        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26577        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
26578            .await
26579            .unwrap_err();
26580        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
26581        shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
26582        let response = process_request_inner(&shared, 77, ClientRole::Operator, request).await.unwrap();
26583        let NodeResponse::WorkspaceFileWritten { file } = response else {
26584            panic!("workspace file write returned another response");
26585        };
26586        assert_eq!(file.revision, Some(workspace_file_revision(b"new\n")));
26587        assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
26588        let stale = process_request_inner(
26589            &shared,
26590            77,
26591            ClientRole::Operator,
26592            NodeRequest::WriteWorkspaceFile {
26593                workspace_id,
26594                path,
26595                expected_revision,
26596                text: "lost update\n".to_owned(),
26597            },
26598        )
26599        .await
26600        .unwrap_err();
26601        assert_eq!(stale.code, NodeFailureCode::RepositoryFileRevisionConflict);
26602        assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
26603        std::fs::remove_dir_all(root).unwrap();
26604    }
26605
26606    #[tokio::test]
26607    async fn workspace_entry_create_requires_controller_and_never_overwrites() {
26608        let root = temporary_workspace_root("workspace-entry-create-controller");
26609        std::fs::create_dir_all(root.join("src")).unwrap();
26610        let workspace_id = WorkspaceId::new("primary").unwrap();
26611        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
26612        let catalog = active_registry().unwrap();
26613        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26614        let shared = NodeShared::new(
26615            handle,
26616            "fixture-token".to_owned(),
26617            NodeId::new("node-1").unwrap(),
26618            vec![workspace],
26619            vec![agent("claude")],
26620        );
26621        let path = RepositoryPath::utf8("src/new.rs".to_owned()).unwrap();
26622        let request = NodeRequest::CreateWorkspaceFile {
26623            workspace_id: workspace_id.clone(),
26624            path: path.clone(),
26625        };
26626        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
26627            capability.as_str() == NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY
26628        }));
26629        assert!(!request_uses_unnegotiated_capability(
26630            &request,
26631            &baseline_capabilities().unwrap(),
26632        ));
26633        assert!(request_uses_unnegotiated_capability(&request, &[]));
26634        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
26635            .await
26636            .unwrap_err();
26637        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26638        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
26639            .await
26640            .unwrap_err();
26641        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
26642        shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
26643
26644        let response = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
26645            .await
26646            .unwrap();
26647        let NodeResponse::WorkspaceFileCreated { file } = response else {
26648            panic!("workspace file create returned another response");
26649        };
26650        assert_eq!(file.workspace_id, workspace_id);
26651        assert_eq!(file.path, path);
26652        assert_eq!(file.revision, Some(workspace_file_revision(&[])));
26653        assert_eq!(std::fs::read(root.join("src/new.rs")).unwrap(), Vec::<u8>::new());
26654
26655        let duplicate = process_request_inner(&shared, 77, ClientRole::Operator, request)
26656            .await
26657            .unwrap_err();
26658        assert_eq!(duplicate.code, NodeFailureCode::RepositoryEntryAlreadyExists);
26659
26660        let directory_path = RepositoryPath::utf8("src/nested".to_owned()).unwrap();
26661        let response = process_request_inner(
26662            &shared,
26663            77,
26664            ClientRole::Operator,
26665            NodeRequest::CreateWorkspaceDirectory {
26666                workspace_id: workspace_id.clone(),
26667                path: directory_path.clone(),
26668            },
26669        )
26670        .await
26671        .unwrap();
26672        assert_eq!(
26673            response,
26674            NodeResponse::WorkspaceDirectoryCreated {
26675                workspace_id: workspace_id.clone(),
26676                entry: WorkspaceEntry {
26677                    relative_path: directory_path,
26678                    kind: WorkspaceEntryKind::Directory,
26679                },
26680            },
26681        );
26682        assert!(root.join("src/nested").is_dir());
26683
26684        let missing_parent = process_request_inner(
26685            &shared,
26686            77,
26687            ClientRole::Operator,
26688            NodeRequest::CreateWorkspaceFile {
26689                workspace_id: workspace_id.clone(),
26690                path: RepositoryPath::utf8("missing/new.rs".to_owned()).unwrap(),
26691            },
26692        )
26693        .await
26694        .unwrap_err();
26695        assert_eq!(missing_parent.code, NodeFailureCode::RepositoryParentNotFound);
26696        let non_directory_parent = process_request_inner(
26697            &shared,
26698            77,
26699            ClientRole::Operator,
26700            NodeRequest::CreateWorkspaceFile {
26701                workspace_id,
26702                path: RepositoryPath::utf8("src/new.rs/child".to_owned()).unwrap(),
26703            },
26704        )
26705        .await
26706        .unwrap_err();
26707        assert_eq!(
26708            non_directory_parent.code,
26709            NodeFailureCode::RepositoryParentNotDirectory,
26710        );
26711        std::fs::remove_dir_all(root).unwrap();
26712    }
26713
26714    #[tokio::test]
26715    async fn workspace_entry_create_timeout_cancels_before_commit_without_late_mutation() {
26716        let root = temporary_workspace_root("workspace-entry-create-timeout-cancel");
26717        std::fs::create_dir_all(root.join("src")).unwrap();
26718        #[cfg(windows)]
26719        let root = std::fs::canonicalize(root).unwrap();
26720        let path = RepositoryPath::utf8("src/late.rs".to_owned()).unwrap();
26721        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
26722        let barrier = Arc::new(std::sync::Barrier::new(2));
26723        let done = Arc::new(AtomicBool::new(false));
26724        let task_commit_state = Arc::clone(&commit_state);
26725        let task_barrier = Arc::clone(&barrier);
26726        let task_done = Arc::clone(&done);
26727        let task_root = root.clone();
26728        let task = tokio::task::spawn_blocking(move || {
26729            task_barrier.wait();
26730            let result = create_workspace_file_on_disk(
26731                &task_root,
26732                &path,
26733                &task_commit_state,
26734            );
26735            task_done.store(true, Ordering::Release);
26736            result
26737        });
26738
26739        let error = settle_workspace_entry_create(
26740            task,
26741            Arc::clone(&commit_state),
26742            Duration::from_millis(10),
26743            "fixture deadline",
26744            "fixture task failed",
26745        )
26746        .await
26747        .unwrap_err();
26748        assert_eq!(error.code, NodeFailureCode::RepositoryEntryCreateTimedOut);
26749        assert_eq!(
26750            commit_state.load(Ordering::Acquire),
26751            WORKSPACE_ENTRY_CREATE_CANCELED,
26752        );
26753
26754        barrier.wait();
26755        timeout(Duration::from_secs(1), async {
26756            while !done.load(Ordering::Acquire) {
26757                tokio::task::yield_now().await;
26758            }
26759        })
26760        .await
26761        .expect("canceled blocking create did not settle");
26762        assert!(!root.join("src/late.rs").exists());
26763        std::fs::remove_dir_all(root).unwrap();
26764    }
26765
26766}